From 21271134e429836d7dae8a3f08b2391c7df52f40 Mon Sep 17 00:00:00 2001 From: JasonAHendry Date: Tue, 16 Sep 2025 09:24:02 +0200 Subject: [PATCH 01/67] Fix example script The -e flag is not needed. --- scripts/run_realtime.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/run_realtime.sh b/scripts/run_realtime.sh index 3efd8d5..0642eed 100755 --- a/scripts/run_realtime.sh +++ b/scripts/run_realtime.sh @@ -1,8 +1,7 @@ # Example of how to run nomadic realtime # 2023/07/12, J.Hendry -nomadic realtime \ --e 0000-00-00_example \ +nomadic realtime 0000-00-00_example \ -f example_data/minknow/fastq_pass \ -m example_data/metadata/sample_info.csv \ -b example_data/beds/nomads8.amplicons.bed --call From c1bd5eadd14ed2b06d23ea461b1d109c27724327 Mon Sep 17 00:00:00 2001 From: JasonAHendry Date: Tue, 16 Sep 2025 09:24:50 +0200 Subject: [PATCH 02/67] Add functionality for cleaning sample_type column --- src/nomadic/util/metadata.py | 107 +++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/nomadic/util/metadata.py b/src/nomadic/util/metadata.py index 586ecef..2750a4b 100644 --- a/src/nomadic/util/metadata.py +++ b/src/nomadic/util/metadata.py @@ -32,6 +32,12 @@ def get_csv_delimiter(csv_path: str, delimiters: List[str] = [",", ";", "\t"]): return used[0] +# -------------------------------------------------------------------------------- +# Check validity of various columns in the metadata +# +# -------------------------------------------------------------------------------- + + def check_barcode_format(barcode: str, try_to_fix: bool = True) -> str: """ Check that the format of a barcode is as expected, and optionally @@ -79,6 +85,75 @@ def check_barcode_format(barcode: str, try_to_fix: bool = True) -> str: return barcode +def check_sample_type_format(sample_type: str, try_to_fix: bool = False) -> str: + """ + Check that the format of the `sample_type` column is correct, and optionally + try to fix if it is not + + """ + + # Settings + EXPECTED = ["field", "pos", "neg"] + KNOWN_POS_CONTROLS = [ + "3D7", + "Dd2", + "HB3", + "GB4", + "7G8", + "NF54", + "FCR3", + ] # some smaller ones could be field substrings, e.g. K1, W2 + + if pd.isna(sample_type): + raise MetadataFormatError( + "Missing information in the 'sample_type' column. Please ensure it is complete." + ) + + if not isinstance(sample_type, str): + sample_type = str(sample_type) + + sample_type = sample_type.strip() # safe to do this in all cases + if sample_type in EXPECTED: + return sample_type + + if not try_to_fix: + raise MetadataFormatError( + f"Found a sample with type '{sample_type}' which is invalid. Please assign one of: {', '.join(EXPECTED)}." + ) + + # Raise a warning and proceed if we are fixing. + # warnings.warn( + # f"Found a sample with type '{sample_type}' which is invalid. Trying to fix..." + # ) + + for e in EXPECTED: + if sample_type.lower() == e: # capitalisation issue + return e + if sample_type.lower().startswith(e): # added something to the end + return e + + for k in KNOWN_POS_CONTROLS: + if sample_type.lower() == k.lower(): + return "pos" + + for ( + e + ) in EXPECTED: # this can be dangerous, only do if other attempts haven't worked + if sample_type.lower().startswith(e[0]): + return e + + # Raise if couldn't fix + raise MetadataFormatError( + f"Found a sample with type '{sample_type}' which is invalid. Please assign one of: {', '.join(EXPECTED)}." + ) + + +# -------------------------------------------------------------------------------- +# Class(es) to parse metadata table(s) for various use cases, e.g. realtime +# analysis or summarizing +# -------------------------------------------------------------------------------- + + class MetadataTableParser: """ Parse the `metadata_csv` table, and make sure that it is formatted @@ -145,3 +220,35 @@ def _check_entries_unique(self): def _check_all_barcodes(self) -> List[str]: self.df["barcode"] = [check_barcode_format(b) for b in self.df["barcode"]] + + +class ExtendedMetadataTableParser(MetadataTableParser): + """ + Add requirement for sample type, and parse it + + # Should also check we have positive and negative controls in each experiment + + """ + + def __init__(self, metadata_csv: str, include_unclassified: bool = True): + super().__init__(metadata_csv, include_unclassified) + + self._check_sample_type() + + def _check_sample_type(self): + if "sample_type" not in self.df.columns: + raise MetadataFormatError( + "Metadata is missing the column 'sample_type'. " + "Please create this column and populate it with" + " 'field' (field sample), 'pos' (positive control) or 'neg' (negative control)" + " for each sample." + ) + self.df["sample_type"] = [ + check_sample_type_format(s, try_to_fix=True) for s in self.df["sample_type"] + ] + + sample_type_counts = self.df.sample_type.value_counts().to_dict() + if "neg" not in sample_type_counts: + raise MetadataFormatError("No negative control found for experiment!") + # if "pos" not in sample_type_counts: + # raise MetadataFormatError("No positive control found for experiment!") From 1de3bea825f944106b8de2fef8f102ce651dc709 Mon Sep 17 00:00:00 2001 From: JasonAHendry Date: Tue, 16 Sep 2025 09:26:05 +0200 Subject: [PATCH 03/67] Add first-draft of summarise command --- src/nomadic/cli.py | 2 + src/nomadic/summarize/commands.py | 22 + .../dashboard/assets/calling-style.css | 153 +++++ .../dashboard/assets/nomadic_logo.png | Bin 0 -> 225424 bytes .../dashboard/assets/summary-style.css | 147 +++++ src/nomadic/summarize/dashboard/builders.py | 215 +++++++ src/nomadic/summarize/dashboard/components.py | 316 +++++++++ src/nomadic/summarize/main.py | 603 ++++++++++++++++++ 8 files changed, 1458 insertions(+) create mode 100644 src/nomadic/summarize/commands.py create mode 100644 src/nomadic/summarize/dashboard/assets/calling-style.css create mode 100644 src/nomadic/summarize/dashboard/assets/nomadic_logo.png create mode 100644 src/nomadic/summarize/dashboard/assets/summary-style.css create mode 100644 src/nomadic/summarize/dashboard/builders.py create mode 100644 src/nomadic/summarize/dashboard/components.py create mode 100644 src/nomadic/summarize/main.py diff --git a/src/nomadic/cli.py b/src/nomadic/cli.py index a62b714..9ae3310 100644 --- a/src/nomadic/cli.py +++ b/src/nomadic/cli.py @@ -4,6 +4,7 @@ from nomadic.download.commands import download from nomadic.dashboard.commands import dashboard from nomadic.start.commands import start +from nomadic.summarize.commands import summarize # From: https://stackoverflow.com/questions/47972638/how-can-i-define-the-order-of-click-sub-commands-in-help @@ -31,3 +32,4 @@ def cli(): cli.add_command(download) cli.add_command(realtime) cli.add_command(dashboard) +cli.add_command(summarize) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py new file mode 100644 index 0000000..94819f9 --- /dev/null +++ b/src/nomadic/summarize/commands.py @@ -0,0 +1,22 @@ +import click + + +@click.command( + short_help="Summarize a set of experiments.", +) +@click.argument( + "experiment_dirs", + type=click.Path(exists=True), + nargs=-1, # allow multiple arguments; gets passed as tuple +) +@click.option("-n", "--summary_name", type=str, default="", help="Name of summary") +def summarize(experiment_dirs: tuple[str], summary_name: str): + """ + Summarize a set of experiments to evaluate quality control and + mutation prevalence + + """ + + from .main import main + + main(expt_dirs=experiment_dirs, summary_name=summary_name) diff --git a/src/nomadic/summarize/dashboard/assets/calling-style.css b/src/nomadic/summarize/dashboard/assets/calling-style.css new file mode 100644 index 0000000..07e7b23 --- /dev/null +++ b/src/nomadic/summarize/dashboard/assets/calling-style.css @@ -0,0 +1,153 @@ +/* Overall styling ------------------------------------------------------------------ */ + +:root { + --grey: #ecebeb; +} + + +body { + font-family: Arial, Helvetica, sans-serif; + background: var(--grey); +} + +#overall { + margin: 20px; + padding: 20px; + border-radius: 20px; + background: white; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +/* Banner ------------------------------------------------------------------ */ + +.logo-and-summary { + display: flex; + flex-direction: row; + margin-bottom: 20px; +} + +#logo { + margin-left: 0px; + width: 500px; + flex: 0.1; +} + +#expt-summary { + margin-left: 20px; + flex: 0.9; +} + + +/* Mapping Section ------------------------------------------------------------------ */ + +.mapping-row { + padding: 20px; + margin: 20px; + border-color: var(--grey); + border-width: 2px; + border-style: solid; + border-radius: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.mapping-plots { + display: flex; + flex-direction: row; + gap: 20px; +} + +#mapping-pie { + flex: 0.25; +} + +#mapping-barplot { + flex: 0.75; +} + +/* BED Coverage Section ------------------------------------------------------------------ */ + +.bedcov-row { + padding: 20px; + margin: 20px; + border-color: var(--grey); + border-width: 2px; + border-style: solid; + border-radius: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.bedcov-plots { + display: flex; + flex-direction: row; + gap: 20px; +} + +#bedcov-pie { + flex: 0.25; +} + +#bedcov-strip { + flex: 0.75; +} + + +/* Depth Section ------------------------------------------------------------------ */ + +.depth-row { + padding: 20px; + margin: 20px; + border-color: var(--grey); + border-width: 2px; + border-style: solid; + border-radius: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.depth-plots { + display: flex; + flex-direction: row; + gap: 20px; +} + +#depth-hist { + flex: 0.4; +} + +#depth-line { + flex: 0.6; +} + + +/* Variant Section ------------------------------------------------------------------ */ + +.variant-row { + padding: 20px; + margin: 20px; + border-color: var(--grey); + border-width: 2px; + border-style: solid; + border-radius: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.variant-plots { + display: flex; + flex-direction: row; + gap: 20px; +} + +#variant-heat { + flex: 1.0; +} + +/* Footer ------------------------------------------------------------------ */ + +.footer { + padding: 20px; + margin: 20px; + display: flex; + flex-direction: row; + gap: 20px; + justify-content: right; +} + diff --git a/src/nomadic/summarize/dashboard/assets/nomadic_logo.png b/src/nomadic/summarize/dashboard/assets/nomadic_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..3b6878a19a9eccd975ce98389f50a05e6e4878aa GIT binary patch literal 225424 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
3dO;E@ho@6gx8myEu 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

kWLCHuwOT?2Q~_P^!#~zx&dc)_{W- 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 sF*nV<~lsQ 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�Xr9Um32ua+Cd&@ir$w@zXK7AFe9Nm7x_Fkq=A`G^4%YUB`sQKnX8X?+@1zLa^{Qzpf@ zAvQ><_s9l%qUVJ~X~&bXyQ-A2?t-v$g-kf~ee(wcyCR<+!lJA0=7YxKUrteX43_#~ zRIA;rrVSTrq7Pajbo$5T%pKce+nS_=Go44(cG$H7ukKGDKy+Wb`JU{}Od>SVGFG)P z?o~mPZmNmZgC?sk9~|s1aXDMt1~%u4=qR^+QJ*$tJ)s18a#$=zPUuZ;eZ8CA5?io0 zXqpCC2gJtNe+w;KTX8IyzyghdB&BFBPYulsse`9fecxnms~zH&}(G~)|cP@Olx*V-Gm`S%Ncq9f|lb^{mnF8PPj zW{H)+zkVB-jc5(?2d8bau?q7K>YR2K$pKjdo675}vygRHtRu?1ho+7jE`opWE{n>q z*szWAmRsJmPwu5Ey_V~%*XucrPWVRp^xVXW?#Gj@D|HNip_VPkJjC&>{CY&x*7cm8 zAG#=9g%YS6X#ivHk4rpo`O-F|r(AMm9D#MWM&~^8igjrfP&;bv5a&B=wJuO@(!wUn z>z4sckAxIIv*xEA3=2ab?-WKduhu9{JWy)iT)TTuAJAAlCh)SZE_6`z-rG z{emkspH7g&MHeoL=zJ*9E2EtK;#sfdMXk+8v5ixgv-_VLCPNSub^otdD^hhF<^AHy z>O*=9!u0*8&bB`v>*#+tQhPi%=$EuD8!w^SLe!J%FH9y)l-rLSd?j6@cK%2-Y|VAq zbne!<{S4obz9s{W=D9Hpq8o^&B)Z(s?8vJQpPznJtrj05IQ2alWf;}|C^U^hA{(X~ zoR%2fV{(HnT<1VFlSUX?FE2yb%e$B?O@>|6izPf%6PZ7Z#n;0&jT2n5{{<)Ra1x-jwb*2g@=)^^)$~Bj{PBc8#eRYeXQPs0Kl}&;c$+4%V3B=M`{$#kPd` zcFT*|wbU@>&cj#->L44WEu;o%+zEZ0oVBcj=L`&I!HC;7KaojTAecBmwKaFo7Z2C* zP*my;o(fJt)TBe3#S=vD*4#{Y5e>;~s|FBCSkOygH2DdAfs<-I1Qqyn4glbohO^KL zFwFwoDCPYNqQbSIcqYkRGZMF8f#L^^rXF$t&3>N0LuZ6(FfqglYIg}F-S9#xxlmaW zc1u$$5ukY8fm4v+-wV=A0^mGh<9%~!-U=z0P%$aG{GMT*>^*|FG~Hm(NH8%fyf7To zYug>K0TAf6|0|#M?|8{o`j5F5RsMzFPt%>r;JL{5BA!_JcV%~C+T-% ze@fKtuivFzz>e?)7dM`}vD zO$2a2(7vNQVn6v7DNFn5*Zn&AMgJcF?pL$1>fKzsfS38s;B7Byu3cDu2fBAkT2N0F2Kzh None: + """ + Add a banner which shows the logo and summarise the number of FASTQ files + processed + + """ + + # Create the component + self.expt_summary = ThroughputSummary( + summary_name=self.summary_name, + component_id="thoughput-summary", + throughput_csv=throughput_csv, + ) + + # Define banner layout + banner = html.Div(className="banner", children=self.expt_summary.get_layout()) + + # Add to components and layout + self.components.append(self.expt_summary) + self.layout.append(banner) + + def _add_quality_control(self, coverage_csv: str) -> None: + """ + Add a panel that shows quality control results + + """ + dropdown = dcc.Dropdown( + id="quality-dropdown", + options=QualityControl.STATISTICS, + value=QualityControl.STATISTICS[1], + style=dict(width="300px"), + ) + + self.quality_control = QualityControl( + self.summary_name, + component_id="quality-heat", + dropdown_id="quality-dropdown", + coverage_csv=coverage_csv, + ) + + quality_row = html.Div( + className="quality-row", + children=[ + html.H3("Quality Control Statistics", style=dict(marginTop="0px")), + dropdown, + html.Div( + className="quality-plots", + children=[self.quality_control.get_layout()], + ), + ], + ) + + # Add components and layout + self.components.append(self.quality_control) + self.layout.append(quality_row) + + def _add_prevalence_row(self, prevalence_csv: str) -> None: + """ + Add a panel that shows prevalence calls + + """ + radio = dcc.RadioItems( + id="prevalence-radio", + options=list(PrevalenceBarplot.GENE_SETS.keys()), + value=list(PrevalenceBarplot.GENE_SETS.keys())[0], + ) + + self.prevalence_bars = PrevalenceBarplot( + self.summary_name, + component_id="prevalence-bars", + radio_id="prevalence-radio", + prevalence_csv=prevalence_csv, + ) + + prevalence_row = html.Div( + className="prevalence-row", + children=[ + html.H3("Prevalence", style=dict(marginTop="0px")), + radio, + html.Div( + className="prevalence-plots", + children=[self.prevalence_bars.get_layout()], + ), + ], + ) + + # Add components and layout + self.components.append(self.prevalence_bars) + self.layout.append(prevalence_row) + + +class BasicSummaryDashboard(SummaryDashboardBuilder): + """ + Build a dashboard with a focus on mapping statistics + + """ + + CSS_STYLE = ["assets/summary-style.css"] + + def __init__( + self, + summary_name: str, + throughput_csv: str, + coverage_csv: str, + prevalence_csv: str, + ): + """ + Initialise all of the dashboard components + + """ + + super().__init__(summary_name, self.CSS_STYLE) + self.throughput_csv = throughput_csv + self.coverage_csv = coverage_csv + self.prevalence_csv = prevalence_csv + + def _gen_layout(self): + """ + Generate the layout + + """ + self._add_throughput_banner(self.throughput_csv) + self._add_quality_control(self.coverage_csv) + self._add_prevalence_row(self.prevalence_csv) + # self._add_mapping_row(self.read_mapping_csv) + # self._add_region_coverage_row(self.region_coverage_csv, self.regions) + # self._add_depth_row(self.depth_profiles_csv, self.regions) + # self._add_footer() diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py new file mode 100644 index 0000000..fe8ce73 --- /dev/null +++ b/src/nomadic/summarize/dashboard/components.py @@ -0,0 +1,316 @@ +# import datetime +# import os +from abc import ABC, abstractmethod +# from typing import Optional +# import re + +import numpy as np +import pandas as pd +import plotly.express as px +import plotly.graph_objects as go +import seaborn as sns +from dash import Dash, dcc, html +from dash.dependencies import Input, Output +from matplotlib.colors import rgb2hex + +from nomadic.util.metadata import MetadataTableParser +from nomadic.util.regions import RegionBEDParser + +# -------------------------------------------------------------------------------- +# Interface for a single real-time dashboard component +# +# -------------------------------------------------------------------------------- + + +class SummaryDashboardComponent(ABC): + """ + Interface for summary dashboard components + + """ + + def __init__(self, summary_name: str, component_id: str): + """ + Initialise components of the dashboard component + + """ + + # User + self.summary_name = summary_name + self.component_id = component_id + + # Always + self.layout_obj = self._define_layout() + + @abstractmethod + def _define_layout(self): + pass + + def get_layout(self): + """ + Get the object that will be passed to the overall HTML + layout + + Typically, this is something from `html.` or `dcc.`, e.g. + `dcc.Graph` + + """ + + if self.layout_obj is None: + raise ValueError("Must define `self.layout_component`.") + + return self.layout_obj + + @abstractmethod + def callback(self, app: Dash) -> None: + """ + Define the callback for this componenet, which will cause it to update + in response to the timer, as well (potentially) other inputs + + """ + pass + + +# -------------------------------------------------------------------------------- +# Concrete components +# +# -------------------------------------------------------------------------------- + +# -------------------------------------------------------------------------------- +# OVERALL STATISTICS +# -------------------------------------------------------------------------------- + + +class ThroughputSummary(SummaryDashboardComponent): + """ + Make a pie chart that shows read mapping statistics + + """ + + logo_src_path = "assets/nomadic_logo.png" + + def __init__(self, summary_name: str, throughput_csv: str, component_id: str): + self.throughput_csv = throughput_csv + self.throughput_df = pd.read_csv(throughput_csv, index_col="sample_type") + super().__init__(summary_name, component_id) + + def _define_layout(self): + """ + Define the layout to be a dcc.Graph object with the + appropriate ID + + """ + + layout = html.Div( + className="logo-and-summary", + children=[ + html.Img(id="logo", src=self.logo_src_path), + html.Div( + id="throughput-summary", + children=[ + html.H3("Throughput Details"), + html.P( + [ + f"Experiments: {self.throughput_df.columns.shape[0] - 1}", + html.Br(), + f"Field samples (total): {self.throughput_df.loc['field', 'All']}", + html.Br(), + f"Field samples (unique): {self.throughput_df.loc['field_unique', 'All']}", + html.Br(), + ] + ), + ], + ), + ], + ) + + return layout + + def callback(self, app: Dash) -> None: + """ + Define the update callback for the pie chart + + """ + + +class QualityControl(SummaryDashboardComponent): + STATISTICS = [ + "mean_cov_field", + "per_field_passing", + "per_field_contam", + "per_field_lowcov", + ] + + def __init__( + self, summary_name: str, coverage_csv: str, component_id: str, dropdown_id: str + ) -> None: + """ + Initialisation loads the coverage data and prepares for plotting; + + """ + + self.coverage_csv = coverage_csv + self.coverage_df = pd.read_csv(coverage_csv) + self.plot_df = pd.pivot_table( + index="expt_name", + columns="name", + values=self.STATISTICS, + dropna=False, + observed=False, + data=self.coverage_df, + ) + + self.dropdown_id = dropdown_id + super().__init__(summary_name, component_id) + + def _define_layout(self): + return dcc.Graph(id=self.component_id) + + def callback(self, app: Dash) -> None: + @app.callback( + Output(self.component_id, "figure"), Input(self.dropdown_id, "value") + ) + def _update(focus_stat: str): + """Called whenver the input changes""" + plot_data = [ + go.Heatmap( + x=self.plot_df[focus_stat].columns, + y=self.plot_df[focus_stat].index, + z=self.plot_df[focus_stat], + text=self.plot_df[focus_stat], + # colorscale="Reds", + xgap=1, + ygap=1, + colorbar=dict( + title=focus_stat, outlinecolor="black", outlinewidth=1 + ), + hoverongaps=False, + # **STAT_KWARGS[STAT] + ) + ] + MAR = 40 + fig = go.Figure(plot_data) + fig.update_layout( + width=1200, + height=600, + yaxis_title="Experiments", + hovermode="y unified", + paper_bgcolor="white", # Sets the background color of the paper + plot_bgcolor="white", + title=dict(text=focus_stat), + margin=dict(t=MAR, l=MAR, r=MAR, b=MAR), + xaxis=dict( + showline=True, linecolor="black", linewidth=2, dtick=1, mirror=True + ), + yaxis=dict( + showline=True, linecolor="black", linewidth=2, dtick=1, mirror=True + ), + xaxis_showgrid=False, + yaxis_showgrid=False, + # height=n_mutations*SZ # TOOD: how to adjust dynamically + ) + fig.update_traces( + text=self.plot_df[focus_stat], + texttemplate="%{text:.0f}", + textfont_size=12, + ) + return fig + + +class PrevalenceBarplot(SummaryDashboardComponent): + GENE_SETS = { + "Resistance": ["crt", "dhps", "dhfr", "kelch13", "mdr1"], + "Diversity": ["ama1", "csp"], + } + + def __init__( + self, + summary_name: str, + prevalence_csv: str, + component_id: str, + radio_id: str, + ) -> None: + """ + Initialisation loads the coverage data and prepares for plotting; + + """ + + self.prevalence_csv = prevalence_csv + self.prev_df = pd.read_csv(prevalence_csv) + + self.radio_id = radio_id + super().__init__(summary_name, component_id) + + def _define_layout(self): + return dcc.Graph(id=self.component_id) + + def callback(self, app: Dash) -> None: + @app.callback( + Output(self.component_id, "figure"), Input(self.radio_id, "value") + ) + def _update(gene_set: str): + """Called whenver the input changes""" + + genes = self.GENE_SETS[gene_set] + + # Limit to key genes + plot_df = self.prev_df.query("gene in @genes") + plot_df.sort_values(["gene", "chrom", "pos"], inplace=True) + + # Prepare plotting data + customdata = np.stack( + [ + plot_df["n_samples"], + plot_df["n_passed"], + plot_df["n_mixed"] + plot_df["n_mut"], + ], + axis=-1, + ) + + # Plotting + htemp = "%{y:0.1f}% (%{customdata[2]}/%{customdata[1]})" + plot_data = [ + go.Bar( + x=plot_df["mutation"], + y=plot_df["prevalence"], + customdata=customdata, + hovertemplate=htemp, + name="Prevalence", + error_y=dict( + type="data", + array=plot_df["prevalence_highci"] - plot_df["prevalence"], + arrayminus=plot_df["prevalence"] - plot_df["prevalence_lowci"], + ), + ), + # go.Bar( + # x=plot_df["mutation"], + # y=plot_df["per_mixed"], + # customdata=customdata, + # hovertemplate=htemp, + # name="Mixed", + # ), + ] + fig = go.Figure(plot_data) + fig.update_layout( + yaxis_title="Prevalence (%)", + xaxis=dict(showline=True, linewidth=1, linecolor="black", mirror=True), + yaxis=dict( + showline=True, + linewidth=1, + linecolor="black", + mirror=True, + showgrid=True, + gridcolor="lightgray", + gridwidth=0.5, + griddash="dot", + ), + barmode="stack", + legend=dict( + orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0 + ), + plot_bgcolor="rgba(0,0,0,0)", + hovermode="x unified", + ) + fig.update_yaxes(range=[0, 100]) + fig.update_traces(marker=dict(line=dict(color="black", width=1))) + + return fig diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py new file mode 100644 index 0000000..6bab26e --- /dev/null +++ b/src/nomadic/summarize/main.py @@ -0,0 +1,603 @@ +import os +import glob +import pandas as pd +import webbrowser + +from statsmodels.stats.proportion import proportion_confint +from typing import NamedTuple +from enum import StrEnum, auto +from nomadic.util.logging_config import LoggingFascade +from nomadic.util.experiment import summary_files, legacy_summary_files +from nomadic.dashboard.main import ( + find_metadata, + find_regions, +) +from nomadic.util.dirs import produce_dir +from nomadic.util.metadata import ExtendedMetadataTableParser +from nomadic.summarize.dashboard.builders import BasicSummaryDashboard + + +def get_metadata_csv(expt_dir: str) -> str: + """ + Get the metadata CSV file + TODO: Does this not duplicate with 'find_metadata' ?? + """ + # In most cases, should match experiment name + metadata_csv = f"{expt_dir}/metadata/{os.path.basename(expt_dir)}.csv" + if os.path.exists(metadata_csv): + return metadata_csv + metadata_csv = glob.glob(f"{expt_dir}/metadata/*.csv") + if len(metadata_csv) == 1: + return metadata_csv[0] + raise ValueError( + f"Found {len(metadata_csv)} *.csv files in '{expt_dir}/metadata', cannot determine which is metadata." + ) + + +# -------------------------------------------------------------------------------- +# Check complete experiment +# +# -------------------------------------------------------------------------------- + + +def check_complete_experiment(expt_dir: str) -> None: + """ + Check if an experiment is complete; in reality, it would be nice, at this point, to load an object + that represents all the files I'd want to work with, e.g. the experiment directories class + """ + + if not os.path.isdir(expt_dir): + raise FileNotFoundError(f"Experiment directory {expt_dir} does not exist.") + + # We can use this for now, but of course this is getting messy + _ = find_metadata(expt_dir) + _ = find_regions(expt_dir) + + used_summary_files = None + for file_format in [summary_files, legacy_summary_files]: + if not os.path.exists(f"{expt_dir}/{file_format.fastqs_processed}"): + continue + + used_summary_files = file_format + for file in used_summary_files: + if not os.path.exists(f"{expt_dir}/{file}"): + raise FileNotFoundError(f"Missing '{file}' file in {expt_dir}.") + + if not used_summary_files: + raise FileNotFoundError(f"Could not find any summary files in {expt_dir}.") + + # TODO: for now, using this for VCF + if not os.path.exists(f"{expt_dir}/vcfs"): + raise FileNotFoundError(f"Could not find VCF directory in {expt_dir}.") + + +def check_regions_consistent(expt_dirs: list[str]) -> None: + """ + Check that the regions are consistent across all experiment directories + + TODO: + - Might make sense to *extract* the region that was used and save it; + + """ + region_sets = [find_regions(expt_dir) for expt_dir in expt_dirs] + + base = region_sets[0] + for r in region_sets: + if not (r.df == base.df).all().all(): + raise ValueError( + "Different regions used across experiments, this is not supported. Check region BED files are the same." + ) + + +def compute_throughput(metadata: pd.DataFrame, add_unique: bool = True) -> pd.DataFrame: + """ + Compute a simple throughput crosstable + + Also add information about uniqueness + + """ + + throughput_df = pd.crosstab( + metadata["sample_type"], metadata["expt_name"], margins="All" + ) + + if add_unique: + um = metadata.drop_duplicates("sample_id") + throughput_df.loc["field_unique"] = pd.crosstab( + um["sample_type"], um["expt_name"], margins="All" + ).loc["field"] + + return throughput_df + + +def get_region_coverage_dataframe( + expt_dirs: list[str], metadata: pd.DataFrame +) -> pd.DataFrame: + """ + Here we load a consolidated region coverage dataframe and include information required + for quality control + """ + + # Load coverage data + bed_dfs = [] + for expt_dir in expt_dirs: + # TODO: allow for legacy or modern names + bed_csv = f"{expt_dir}/summary.bedcov.csv" + + bed_df = pd.read_csv(bed_csv) + bed_df.insert(0, "expt_name", os.path.basename(expt_dir)) + bed_df.query("barcode != 'unclassified'", inplace=True) + + # TODO: Do checks + bed_df = pd.merge( + left=metadata[["expt_name", "barcode", "sample_id", "sample_type"]], + right=bed_df, + on=["expt_name", "barcode"], + how="right", + ) + # TODO: Do checks + bed_dfs.append(bed_df) + concat_df = pd.concat(bed_dfs) + + # Get negative control data + neg_df = ( + concat_df.query("sample_type == 'neg'") + .groupby(["expt_name", "name"]) + .mean_cov.mean() + .reset_index() + .rename({"mean_cov": "mean_cov_neg"}, axis=1) + ) + + # TODO: do checks + coverage_df = pd.merge( + left=concat_df[ + ["expt_name", "barcode", "sample_id", "sample_type", "name", "mean_cov"] + ], # sample ID, will want it at some point + right=neg_df, + on=["expt_name", "name"], + how="left", + ) + # TODO: do checks + + return coverage_df + + +def calc_quality_control_columns( + df: pd.DataFrame, *, min_coverage: int = 50, max_contam: float = 0.1 +) -> None: + """ + Calculate columns evaluating whether samples have passed quality control + """ + + # Do we have enough coverage? + df["fail_lowcov"] = df["mean_cov"] < min_coverage + + # Check if coverage of negative control exceeds `max_contam` + df["fail_contam"] = (df["mean_cov_neg"] / (df["mean_cov"] + 0.01) >= max_contam) | ( + df["mean_cov_neg"] >= min_coverage + ) + df["fail_contam"] = ( + df["fail_contam"] & ~df["fail_lowcov"] + ) # If already failed low coverage, don't consider contamination. + + # Finally, define passing + df["passing"] = ~df["fail_contam"] & ~df["fail_lowcov"] + + +# -------------------------------------------------------------------------------- +# Quality Control +# +# -------------------------------------------------------------------------------- + + +class QcStatus(StrEnum): + PASS = auto() + LOWCOV = auto() + CONTAM = auto() + DUPLICATE = auto() + CONTROL = auto() + + +def _add_qc_status_no_duplicates(df: pd.DataFrame) -> list[str]: + status_strs = [] + for _, row in df.iterrows(): + status = [] + if row["sample_type"] in ["pos", "neg"]: + status.append(QcStatus.CONTROL) + if row["fail_contam"]: + status.append(QcStatus.CONTAM) + if row["fail_lowcov"]: + status.append(QcStatus.LOWCOV) + if not status: + status.append(QcStatus.PASS) + status_strs.append(";".join(status)) + df["status"] = status_strs + + +def _mark_duplicates(df: pd.DataFrame) -> None: + def _update_duplicate(status: str, idx: int, keep_idx: int) -> str: + if idx == keep_idx: + return status + return f"{status};{QcStatus.DUPLICATE}" + + for (_, _), data in df.groupby(["sample_id", "name"]): + # Select an index to keep, i.e. the best sample that should + # marked as duplicate + passing = data["status"] == "pass" + if passing.sum() == 1: + keep_idx = passing.idxmax() + elif passing.sum() > 1: + keep_idx = data[passing]["mean_cov"].idxmax() # keep maximum coverage + else: # none are passing, arbrarily keep first + keep_idx = data.index[0] + + # NB: updating in-place + # must be a view, not a slice hence [,] + df.loc[data.index, "status"] = [ + _update_duplicate(status, idx, keep_idx) + for idx, status in data["status"].items() + ] + + +def add_quality_control_status_column(df: pd.DataFrame) -> None: + """ + Add a QC status column in-place + + Note: + - When we mark duplicates; we do it on an AMPLICON x SAMPLE level; + not on a per-sample level. So we could take amplicons from separate + samples to get the best data for that sample. + + """ + _add_qc_status_no_duplicates(df) + _mark_duplicates(df) + + +# -------------------------------------------------------------------------------- +# Variant analysis +# +# -------------------------------------------------------------------------------- + + +def load_variant_summary_csv( + variants_csv: str, define_gene: bool = True +) -> pd.DataFrame: + """ + Load an clean `summary.variants.csv` data produced by `nomadic` + + """ + + # Settings + NUMERIC_COLUMNS = ["gq", "dp", "wsaf"] + UNPHASED_GT_TO_INT = {"./.": -1, "0/0": 0, "0/1": 1, "1/1": 2} + + # Load + variants_df = pd.read_csv(variants_csv) + + # Reformat numeric columns to be floats + for c in NUMERIC_COLUMNS: + variants_df[c] = [float(v) if v != "." else None for v in variants_df[c]] + + # Reformat unphased genotypes as integers + variants_df.insert( + variants_df.columns.tolist().index("gt") + 1, + "gt_int", + variants_df["gt"].map(UNPHASED_GT_TO_INT), + ) + + # Optionally reformat amplicon name to gene; assuming like gene-... + if define_gene: + variants_df.insert( + variants_df.columns.get_loc("amplicon") + 1, + "gene", + [a.split("-")[0] for a in variants_df["amplicon"]], + ) + variants_df.insert( + variants_df.columns.get_loc("gene") + 1, + "mutation", + [ + f"{gene}-{aa_change}" + for gene, aa_change in zip( + variants_df["gene"], variants_df["aa_change"] + ) + ], + ) + + return variants_df + + +def load_and_concat_variants(expt_dirs: list[str]) -> pd.DataFrame: + """ + Load all of the variant calls for a set of experiment dirs + + Note that because we do note have the unfiltered VCF files, we have to do + some additional work in order to ensure all mutations are represented across + all experiments; + """ + + # Load data + variant_dfs = [] + for expt_dir in expt_dirs: + variant_csv = f"{expt_dir}/summary.variants.csv" + variant_df = load_variant_summary_csv(variant_csv) + variant_df.insert(0, "expt_name", os.path.basename(expt_dir)) + variant_df.query("barcode != 'unclassified'", inplace=True) + variant_dfs.append(variant_df) + variant_df = pd.concat(variant_dfs) + + # Get all unique mutations + MUT_COLUMNS = [ + "chrom", + "pos", + "ref", + "alt", + "strand", + "aa_change", + "aa_pos", + "mut_type", + "mutation", + "amplicon", + "gene", + ] + uniq_mutation_df = variant_df[MUT_COLUMNS].drop_duplicates() + + # Now we merge these back in for each barcode + # - By doing a 'right' merge, we make sure all variants are present for each barcode + # - The 'NaNs' that are present when the variant doesn't exist for that barcode + # get filled with zeros, i.e. we assume homozygous reference + # - In reality, it could have been EITHER 0/0 or ./. (i.e. filtered), but we handle + # this afterwards when we merge with QC data; + full_variant_dfs = [] + for (expt_name, barcode), bdf in variant_df.groupby(["expt_name", "barcode"]): + mdf = pd.merge(bdf, uniq_mutation_df, on=MUT_COLUMNS, how="right") + mdf["expt_name"] = expt_name + mdf["barcode"] = barcode + + # Filled by default with hom reference + mdf["gt"] = mdf["gt"].fillna(0.0) + mdf["gt_int"] = mdf["gt_int"].fillna(0.0) + + full_variant_dfs.append(mdf) + + return pd.concat(full_variant_dfs) + + +def compute_variant_prevalence(variants_df: str) -> pd.DataFrame: + """ + Compute the prevalence of each mutation in `variants_df` + + Assumes columns several columns exist; compute across all samples + in data; + + """ + + prev_df = ( + variants_df.groupby( + ["gene", "chrom", "pos", "ref", "alt", "aa_change", "mut_type", "mutation"] + ) + .agg( + n_samples=pd.NamedAgg("gt_int", len), + n_passed=pd.NamedAgg("gt_int", lambda x: sum(x != -1)), + n_wt=pd.NamedAgg("gt_int", lambda x: sum(x == 0)), + n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), + n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), + ) + .reset_index() + ) + + # Compute frequencies + prev_df["per_wt"] = 100 * prev_df["n_wt"] / prev_df["n_passed"] + prev_df["per_mixed"] = 100 * prev_df["n_mixed"] / prev_df["n_passed"] + prev_df["per_mut"] = 100 * prev_df["n_mut"] / prev_df["n_passed"] + + # Compute prevalence + prev_df["prevalence"] = prev_df["per_mixed"] + prev_df["per_mut"] + + # Compute prevalence 95% confidence intervals + low, high = proportion_confint( + prev_df["n_mut"] + prev_df["n_mixed"], + prev_df["n_passed"], + alpha=0.05, + method="beta", + ) + prev_df["prevalence_lowci"] = 100 * low + prev_df["prevalence_highci"] = 100 * high + + return prev_df + + +# -------------------------------------------------------------------------------- +# Main +# +# -------------------------------------------------------------------------------- + + +def main(expt_dirs: str, summary_name: str) -> None: + """ + Define the main function for the summary analysis + + TODO: + - Ideas for location? + - Either force a specific column name; e.g. site + - Or allow for the user to indicate the name + - Easiest is to require either lat/lon; or a file mapping to lat/lon. + - It is nice to allow arbitrary grouping by columns that are valid for prevalence plot + - The best is probably to enable certain panels / analyses IF certain columns are present + in the shared metadata; for example parasitemia + + """ + + output_dir = produce_dir( + "summaries", summary_name + ) # should I ensure we are in a workspace? + + # PARSE EXPERIMENT DIRECTORIES + log = LoggingFascade(logger_name="nomadic") + log.info("Input parameters:") + log.info(f" Summary Name: {summary_name}") + log.info(f" Found {len(expt_dirs)} experiment directories.") + for expt_dir in expt_dirs: + check_complete_experiment(expt_dir) + log.info(" All experiments are complete.") + + # CHECK METADATA IS VALID + # TODO: + # - Should I already interrogate geospatial information? + # - Where should I compute throughput information? + dfs = [] + for expt_dir in expt_dirs: + metadata_csv = get_metadata_csv(expt_dir) + parser = ExtendedMetadataTableParser(metadata_csv) + parser.df.insert(0, "expt_name", os.path.basename(expt_dir)) + if not dfs: + shared_columns = set(parser.df.columns) + shared_columns.intersection_update(parser.df.columns) + dfs.append(parser.df) + # Should I not take all common columns? + log.info(" All metadata tables pass completion checks.") + log.info( + f" Found {len(shared_columns)} shared columns across all metadata files: {', '.join(shared_columns)}" + ) + fixed_columns = ["expt_name", "barcode", "sample_id", "sample_type"] + shared_columns.difference_update(fixed_columns) + shared_columns = fixed_columns + list(shared_columns) + metadata = pd.concat([df[shared_columns] for df in dfs]) + metadata.to_csv(f"{output_dir}/metadata.csv", index=False) + + # Check regions are consistent + check_regions_consistent(expt_dirs) + log.info(" All experiments use the same regions.") + + # Throughput data + # TODO: Need to make a real decision about how to handle duplicated sample IDs + log.info("Overall sequencing throughput:") + throughput_df = compute_throughput(metadata) + log.info(f" Positive controls: {throughput_df.loc['pos', 'All']}") + log.info(f" Negative controls: {throughput_df.loc['neg', 'All']}") + log.info(f" Field samples (total): {throughput_df.loc['field', 'All']}") + log.info(f" Field samples (unique): {throughput_df.loc['field_unique', 'All']}") + throughput_df.to_csv(f"{output_dir}/summary.throughput.csv", index=True) + + # Now let's evaluate coverage + coverage_df = get_region_coverage_dataframe(expt_dirs, metadata) + MIN_COV = 50 + MAX_CONTAM = 0.1 + calc_quality_control_columns( + coverage_df, min_coverage=MIN_COV, max_contam=MAX_CONTAM + ) + + log.info("Amplicon-Sample QC Statistics:") + field_coverage_df = coverage_df.query("sample_type == 'field'") + n = field_coverage_df.shape[0] + n_lowcov = field_coverage_df["fail_lowcov"].sum() + n_contam = field_coverage_df["fail_contam"].sum() + n_pass = field_coverage_df["passing"].sum() + log.info(f" Coverage below <{MIN_COV}x: {n_lowcov} ({100 * n_lowcov / n:.2f}%)") + log.info(f" Contamination >{MAX_CONTAM}: {n_contam} ({100 * n_contam / n:.2f}%)") + log.info(f" Passing QC: {n_pass} ({100 * n_pass / n:.2f}%)") + add_quality_control_status_column(coverage_df) + log.info(coverage_df["status"].value_counts()) + coverage_df.to_csv(f"{output_dir}/summary.coverage.csv", index=False) + + final_df = ( + coverage_df.query("sample_type == 'field'") + .groupby(["expt_name", "name"]) + .agg( + mean_cov_field=pd.NamedAgg("mean_cov", "median"), + mean_cov_neg=pd.NamedAgg("mean_cov_neg", "median"), + n_field=pd.NamedAgg("barcode", len), + n_field_passing=pd.NamedAgg("passing", lambda x: x.sum()), + per_field_contam=pd.NamedAgg("fail_contam", lambda x: 100 * x.mean()), + per_field_lowcov=pd.NamedAgg("fail_lowcov", lambda x: 100 * x.mean()), + per_field_passing=pd.NamedAgg("passing", lambda x: 100 * x.mean()), + ) + .reset_index() + ) + final_df.to_csv(f"{output_dir}/summary.quality_control.csv", index=False) + + # -------------------------------------------------------------------------------- + # Let's move onto to variant calling results + # + # -------------------------------------------------------------------------------- + + log.info("Loading variants...") + variant_df = load_and_concat_variants(expt_dirs) + + # Merge with the quality control results, then we can subset to the analysis set + variant_df = pd.merge( + left=coverage_df.rename({"name": "amplicon"}, axis=1)[ + ["expt_name", "barcode", "sample_id", "sample_type", "amplicon", "status"] + ], + right=variant_df, + on=["expt_name", "barcode", "amplicon"], + ) + + log.info("Filtering to analysis set...") + remove_genes = ["hrp2", "hrp3"] + remove_mutations = ["crt-N75K"] + analysis_df = ( + variant_df.query("status == 'pass'") + .query("mut_type == 'missense'") + .query("gene not in @remove_genes") + .query("mutation not in @remove_mutations") + ) + analysis_df.to_csv(f"{output_dir}/summary.variants.analysis_set.csv", index=False) + + # Then we will compute prevalence + prev_df = compute_variant_prevalence(analysis_df) + prev_df.to_csv(f"{output_dir}/summary.variants.prevalence.csv", index=False) + + # -------------------------------------------------------------------------------- + # Dashboard + # + # -------------------------------------------------------------------------------- + + print(" Variant calling: False") + dashboard = BasicSummaryDashboard( + summary_name, + throughput_csv=f"{output_dir}/summary.throughput.csv", + coverage_csv=f"{output_dir}/summary.quality_control.csv", + prevalence_csv=f"{output_dir}/summary.variants.prevalence.csv", + ) + print("Done.") + + print("") + print("Launching dashboard (press CNTRL+C to exit):") + print("") + webbrowser.open("http://127.0.0.1:8050") + dashboard.run(debug=True) + + # CHECKPOINT 2: + # summary.quality_control.by_amplicon.csv + # summary.quality_control.by_experiment.csv + # -> the .by_amplicon.csv we use... + # -> Some visualisations and statistics on these tables + + # PART 3: Mutation prevalence + # -> In future versions, will change + + # 3a. get the data and filter to passing + # Load the variants for each experiment + + # Get the unique mutations + + # Use this to make sure every sample has all mutations + + # Merge with the sumary.quality_control.by_amplicon.csv table! + # REDUCE to the set of amplicons we use for analysis + # -> Limit to passing + # -> Limit to missense mutations + + # 3b. Nice analysis to compute the prevalence by site + # Idea would be to do country-wide prevalence for each bar; + # but then partition the bar by the SITE + # Then if I pick a site, just show the prevalence there. + + # CHECKPOINT 3: + # summary.variants_prevalence.by_site.csv + # + + # PART 4: Mapping + # -> Simple: input the summary.variants_prevalence.by_site.csv + # -> Load hte site data, if we have it; + # plot From 12dc6a59df240a3f9e6af9c5c5c663ce5d0fda79 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Mon, 13 Oct 2025 13:04:09 +0200 Subject: [PATCH 04/67] Read in master metadata file --- src/nomadic/summarize/commands.py | 58 +++++++++++++++++++++++++++++-- src/nomadic/summarize/main.py | 8 ++++- src/nomadic/util/workspace.py | 18 ++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 94819f9..03e0ce1 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -1,5 +1,9 @@ +from pathlib import Path + import click +from nomadic.util.workspace import Workspace, check_if_workspace + @click.command( short_help="Summarize a set of experiments.", @@ -9,14 +13,62 @@ type=click.Path(exists=True), nargs=-1, # allow multiple arguments; gets passed as tuple ) -@click.option("-n", "--summary_name", type=str, default="", help="Name of summary") -def summarize(experiment_dirs: tuple[str], summary_name: str): +@click.option( + "-w", + "--workspace", + "workspace_path", + default="./", + show_default="current directory", + type=click.Path(exists=True, file_okay=False, dir_okay=True), + help="Path of the workspace where all input/output files (beds, metadata, results) are stored. " + "The workspace directory simplifies the use of nomadic in that many arguments don't need to be listed " + "as they are predefined in the workspace config or can be loaded from the workspace", +) +@click.option( + "-m", + "--metadata_csv", + type=click.Path(exists=True, dir_okay=False, file_okay=True, path_type=Path), + help="Path to the master metadata CSV file.", + show_default="/metadata/.csv", +) +@click.option("-n", "--summary_name", type=str, help="Name of summary") +def summarize( + experiment_dirs: tuple[str], + summary_name: str, + workspace_path: str, + metadata_csv: Path, +): """ Summarize a set of experiments to evaluate quality control and mutation prevalence """ + if not check_if_workspace(workspace_path): + raise click.BadParameter( + param_hint="-w/--workspace", + message=f"'{workspace_path}' is not a workspace.", + ) + workspace = Workspace(workspace_path) + + if summary_name is None: + summary_name = workspace.get_name() + + if metadata_csv is None: + metadata_csv = Path(workspace.get_master_metadata_csv(summary_name)) + + if not metadata_csv.exists(): + raise click.BadParameter( + param_hint="-m/--metadata_csv", + message=f"Master metadata file '{metadata_csv}' does not exist.", + ) + + if len(experiment_dirs) == 0: + experiment_dirs = workspace.get_experiment_dirs() from .main import main - main(expt_dirs=experiment_dirs, summary_name=summary_name) + main( + expt_dirs=experiment_dirs, + summary_name=summary_name, + meta_data_path=metadata_csv, + ) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 6bab26e..69a9316 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -412,7 +412,12 @@ def compute_variant_prevalence(variants_df: str) -> pd.DataFrame: # -------------------------------------------------------------------------------- -def main(expt_dirs: str, summary_name: str) -> None: +def main( + *, + expt_dirs: tuple[str], + summary_name: str, + meta_data_path: Path, +) -> None: """ Define the main function for the summary analysis @@ -435,6 +440,7 @@ def main(expt_dirs: str, summary_name: str) -> None: log = LoggingFascade(logger_name="nomadic") log.info("Input parameters:") log.info(f" Summary Name: {summary_name}") + log.info(f" Master metadata: {meta_data_path}") log.info(f" Found {len(expt_dirs)} experiment directories.") for expt_dir in expt_dirs: check_complete_experiment(expt_dir) diff --git a/src/nomadic/util/workspace.py b/src/nomadic/util/workspace.py index 207fe97..2888729 100644 --- a/src/nomadic/util/workspace.py +++ b/src/nomadic/util/workspace.py @@ -91,6 +91,12 @@ def get_metadata_csv(self, experiment_name: str): """ return os.path.join(self.get_metadata_dir(), f"{experiment_name}.csv") + def get_master_metadata_csv(self, summary_name: str): + """ + Get the path to the master metadata CSV file for summaries. + """ + return os.path.join(self.get_metadata_dir(), f"{summary_name}.csv") + def get_bed_file(self, panel_name: str): """ Get the path to the BED file for a given panel name. @@ -119,3 +125,15 @@ def get_experiment_names(self): for name in os.listdir(self.get_results_dir()) if os.path.isdir(os.path.join(self.get_results_dir(), name)) ] + + def get_experiment_dirs(self): + """ + Get a list of available experiment directories in the workspace. + """ + if not os.path.exists(self.get_results_dir()): + return [] + + return [ + os.path.join(self.get_results_dir(), name) + for name in self.get_experiment_names() + ] From b84a5d302dc5a939e0c042a738562a808a1789ca Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Mon, 13 Oct 2025 13:04:58 +0200 Subject: [PATCH 05/67] Make summarize work with new format and delve --- src/nomadic/summarize/main.py | 27 ++++++++------ src/nomadic/util/dirs.py | 2 +- src/nomadic/util/experiment.py | 64 +++++++++++++++------------------- 3 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 69a9316..8f22f68 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -1,20 +1,25 @@ -import os import glob -import pandas as pd +import os import webbrowser +from enum import StrEnum, auto +from pathlib import Path +import pandas as pd from statsmodels.stats.proportion import proportion_confint -from typing import NamedTuple -from enum import StrEnum, auto -from nomadic.util.logging_config import LoggingFascade -from nomadic.util.experiment import summary_files, legacy_summary_files + from nomadic.dashboard.main import ( find_metadata, find_regions, ) +from nomadic.summarize.dashboard.builders import BasicSummaryDashboard from nomadic.util.dirs import produce_dir +from nomadic.util.experiment import ( + get_summary_files, + legacy_summary_files, + summary_files, +) +from nomadic.util.logging_config import LoggingFascade from nomadic.util.metadata import ExtendedMetadataTableParser -from nomadic.summarize.dashboard.builders import BasicSummaryDashboard def get_metadata_csv(expt_dir: str) -> str: @@ -71,7 +76,7 @@ def check_complete_experiment(expt_dir: str) -> None: raise FileNotFoundError(f"Could not find VCF directory in {expt_dir}.") -def check_regions_consistent(expt_dirs: list[str]) -> None: +def check_regions_consistent(expt_dirs: tuple[str]) -> None: """ Check that the regions are consistent across all experiment directories @@ -122,11 +127,13 @@ def get_region_coverage_dataframe( bed_dfs = [] for expt_dir in expt_dirs: # TODO: allow for legacy or modern names - bed_csv = f"{expt_dir}/summary.bedcov.csv" + bed_csv = get_summary_files(Path(expt_dir)).region_coverage bed_df = pd.read_csv(bed_csv) bed_df.insert(0, "expt_name", os.path.basename(expt_dir)) bed_df.query("barcode != 'unclassified'", inplace=True) + if "sample_id" in bed_df.columns: + bed_df.drop(columns=["sample_id"], inplace=True) # TODO: Do checks bed_df = pd.merge( @@ -268,7 +275,7 @@ def load_variant_summary_csv( """ # Settings - NUMERIC_COLUMNS = ["gq", "dp", "wsaf"] + NUMERIC_COLUMNS = ["dp", "wsaf"] UNPHASED_GT_TO_INT = {"./.": -1, "0/0": 0, "0/1": 1, "1/1": 2} # Load diff --git a/src/nomadic/util/dirs.py b/src/nomadic/util/dirs.py index f4286bc..4e9ded4 100644 --- a/src/nomadic/util/dirs.py +++ b/src/nomadic/util/dirs.py @@ -3,7 +3,7 @@ import platformdirs -def produce_dir(*args): +def produce_dir(*args) -> str: """ Produce a new directory by concatenating `args`, if it does not already exist diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index dfbc618..775ca19 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -1,11 +1,12 @@ -from nomadic.util.dirs import produce_dir -from nomadic.util.metadata import MetadataTableParser -from nomadic.util.regions import RegionBEDParser - import os import shutil +from pathlib import Path from typing import NamedTuple +from nomadic.util.dirs import produce_dir +from nomadic.util.metadata import MetadataTableParser +from nomadic.util.regions import RegionBEDParser + class SummaryFiles(NamedTuple): """ @@ -97,38 +98,7 @@ def get_settings_file(self) -> str: return os.path.join(self.metadata_dir, "settings.json") def get_summary_files(self) -> SummaryFiles: - if os.path.exists( - f"{self.approach_dir}/{legacy_summary_files.fastqs_processed}" - ): - # Use legacy summary files if the old format exists - return SummaryFiles( - fastqs_processed=os.path.join( - self.approach_dir, legacy_summary_files.fastqs_processed - ), - read_mapping=os.path.join( - self.approach_dir, legacy_summary_files.read_mapping - ), - region_coverage=os.path.join( - self.approach_dir, legacy_summary_files.region_coverage - ), - depth_profiles=os.path.join( - self.approach_dir, legacy_summary_files.depth_profiles - ), - variants=os.path.join(self.approach_dir, legacy_summary_files.variants), - ) - return SummaryFiles( - fastqs_processed=os.path.join( - self.approach_dir, summary_files.fastqs_processed - ), - read_mapping=os.path.join(self.approach_dir, summary_files.read_mapping), - region_coverage=os.path.join( - self.approach_dir, summary_files.region_coverage - ), - depth_profiles=os.path.join( - self.approach_dir, summary_files.depth_profiles - ), - variants=os.path.join(self.approach_dir, summary_files.variants), - ) + return get_summary_files(Path(self.approach_dir)) def _setup_metadata_dir( self, metadata: MetadataTableParser, regions: RegionBEDParser @@ -146,3 +116,25 @@ def _setup_metadata_dir( self.regions_bed = f"{self.metadata_dir}/{os.path.basename(regions.path)}" if not os.path.exists(self.regions_bed): shutil.copy(regions.path, self.regions_bed) + + +def get_summary_files(exp_path: Path) -> SummaryFiles: + if not exp_path.exists(): + raise FileNotFoundError(f"Experiment path does not exist: {exp_path}") + if (exp_path / legacy_summary_files.fastqs_processed).exists(): + # Use legacy summary files if the old format exists + return SummaryFiles( + fastqs_processed=str(exp_path / legacy_summary_files.fastqs_processed), + read_mapping=str(exp_path / legacy_summary_files.read_mapping), + region_coverage=str(exp_path / legacy_summary_files.region_coverage), + depth_profiles=str(exp_path / legacy_summary_files.depth_profiles), + variants=str(exp_path / legacy_summary_files.variants), + ) + else: + return SummaryFiles( + fastqs_processed=str(exp_path / summary_files.fastqs_processed), + read_mapping=str(exp_path / summary_files.read_mapping), + region_coverage=str(exp_path / summary_files.region_coverage), + depth_profiles=str(exp_path / summary_files.depth_profiles), + variants=str(exp_path / summary_files.variants), + ) From f075d6918856e7999742ed8fdc710fd19bd4cf41 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Mon, 13 Oct 2025 19:02:34 +0200 Subject: [PATCH 06/67] Add sample summary statistic --- src/nomadic/summarize/commands.py | 8 ++ src/nomadic/summarize/dashboard/builders.py | 35 +++++- src/nomadic/summarize/dashboard/components.py | 46 ++++++++ src/nomadic/summarize/main.py | 103 ++++++++++++++---- 4 files changed, 165 insertions(+), 27 deletions(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 03e0ce1..61e84b9 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -1,6 +1,7 @@ from pathlib import Path import click +import dash from nomadic.util.workspace import Workspace, check_if_workspace @@ -32,11 +33,17 @@ show_default="/metadata/.csv", ) @click.option("-n", "--summary_name", type=str, help="Name of summary") +@click.option( + "--dashboard/--no-dashboard", + default=True, + help="Whether to start the web dashboard to monitor the run.", +) def summarize( experiment_dirs: tuple[str], summary_name: str, workspace_path: str, metadata_csv: Path, + dashboard: bool, ): """ Summarize a set of experiments to evaluate quality control and @@ -71,4 +78,5 @@ def summarize( expt_dirs=experiment_dirs, summary_name=summary_name, meta_data_path=metadata_csv, + dashboard=dashboard, ) diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index b7f9bb0..73ee671 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -7,6 +7,7 @@ # from importlib.resources import files, as_file from nomadic.summarize.dashboard.components import ( + SamplesPie, ThroughputSummary, QualityControl, PrevalenceBarplot, @@ -106,7 +107,32 @@ def _add_throughput_banner(self, throughput_csv: str) -> None: self.components.append(self.expt_summary) self.layout.append(banner) - def _add_quality_control(self, coverage_csv: str) -> None: + def _add_samples(self, samples_csv: str) -> None: + """ + Add a panel that shows progress of samples + + """ + self.samples = SamplesPie( + summary_name=self.summary_name, + component_id="samples-summary", + samples_csv=samples_csv, + ) + quality_row = html.Div( + className="quality-row", + children=[ + html.H3("Samples Statistics", style=dict(marginTop="0px")), + html.Div( + className="samples-plots", + children=[self.samples.get_layout()], + ), + ], + ) + + # Add components and layout + self.components.append(self.samples) + self.layout.append(quality_row) + + def _add_experiment_qc(self, coverage_csv: str) -> None: """ Add a panel that shows quality control results @@ -128,7 +154,7 @@ def _add_quality_control(self, coverage_csv: str) -> None: quality_row = html.Div( className="quality-row", children=[ - html.H3("Quality Control Statistics", style=dict(marginTop="0px")), + html.H3("Experiment QC Statistics", style=dict(marginTop="0px")), dropdown, html.Div( className="quality-plots", @@ -188,6 +214,7 @@ def __init__( self, summary_name: str, throughput_csv: str, + samples_csv: str, coverage_csv: str, prevalence_csv: str, ): @@ -198,6 +225,7 @@ def __init__( super().__init__(summary_name, self.CSS_STYLE) self.throughput_csv = throughput_csv + self.samples_csv = samples_csv self.coverage_csv = coverage_csv self.prevalence_csv = prevalence_csv @@ -207,7 +235,8 @@ def _gen_layout(self): """ self._add_throughput_banner(self.throughput_csv) - self._add_quality_control(self.coverage_csv) + self._add_samples(self.samples_csv) + self._add_experiment_qc(self.coverage_csv) self._add_prevalence_row(self.prevalence_csv) # self._add_mapping_row(self.read_mapping_csv) # self._add_region_coverage_row(self.region_coverage_csv, self.regions) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index fe8ce73..9ffc90d 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -132,6 +132,52 @@ def callback(self, app: Dash) -> None: """ +class SamplesPie(SummaryDashboardComponent): + """ + Make a pie chart that shows read mapping statistics + + """ + + def __init__( + self, + summary_name: str, + samples_csv: str, + component_id: str, + ): + self.samples_csv = samples_csv + self.df = pd.read_csv(samples_csv) + self.df = self.df.groupby("status").count()["sample_id"] + super().__init__(summary_name, component_id) + + def _define_layout(self): + """ + Define the layout to be a dcc.Graph object with the + appropriate ID + + """ + fig = go.Figure( + data=[ + go.Pie( + values=self.df.values, + labels=self.df.index, + sort=False, + hole=0.3, + ) + ] + ) + + MAR = 20 + fig.update_layout(showlegend=False, margin=dict(t=MAR, l=MAR, r=MAR, b=MAR)) + + return dcc.Graph(id=self.component_id, figure=fig) + + def callback(self, app: Dash) -> None: + """ + Define the update callback for the pie chart + + """ + + class QualityControl(SummaryDashboardComponent): STATISTICS = [ "mean_cov_field", diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 8f22f68..9e97d31 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -126,7 +126,6 @@ def get_region_coverage_dataframe( # Load coverage data bed_dfs = [] for expt_dir in expt_dirs: - # TODO: allow for legacy or modern names bed_csv = get_summary_files(Path(expt_dir)).region_coverage bed_df = pd.read_csv(bed_csv) @@ -211,12 +210,13 @@ def _add_qc_status_no_duplicates(df: pd.DataFrame) -> list[str]: status = [] if row["sample_type"] in ["pos", "neg"]: status.append(QcStatus.CONTROL) - if row["fail_contam"]: - status.append(QcStatus.CONTAM) - if row["fail_lowcov"]: - status.append(QcStatus.LOWCOV) - if not status: - status.append(QcStatus.PASS) + else: + if row["fail_contam"]: + status.append(QcStatus.CONTAM) + if row["fail_lowcov"]: + status.append(QcStatus.LOWCOV) + if not status: + status.append(QcStatus.PASS) status_strs.append(";".join(status)) df["status"] = status_strs @@ -257,7 +257,7 @@ def add_quality_control_status_column(df: pd.DataFrame) -> None: """ _add_qc_status_no_duplicates(df) - _mark_duplicates(df) + # _mark_duplicates(df) # -------------------------------------------------------------------------------- @@ -424,6 +424,7 @@ def main( expt_dirs: tuple[str], summary_name: str, meta_data_path: Path, + dashboard: bool = True, ) -> None: """ Define the main function for the summary analysis @@ -438,7 +439,6 @@ def main( in the shared metadata; for example parasitemia """ - output_dir = produce_dir( "summaries", summary_name ) # should I ensure we are in a workspace? @@ -473,8 +473,14 @@ def main( ) fixed_columns = ["expt_name", "barcode", "sample_id", "sample_type"] shared_columns.difference_update(fixed_columns) - shared_columns = fixed_columns + list(shared_columns) + # for now we use the master metadata file + # shared_columns = fixed_columns + list(shared_columns) + shared_columns = fixed_columns metadata = pd.concat([df[shared_columns] for df in dfs]) + master_metadata = pd.read_csv(meta_data_path) + metadata = pd.merge( + left=metadata, right=master_metadata, on=["sample_id"], how="left" + ) metadata.to_csv(f"{output_dir}/metadata.csv", index=False) # Check regions are consistent @@ -512,6 +518,54 @@ def main( log.info(coverage_df["status"].value_counts()) coverage_df.to_csv(f"{output_dir}/summary.coverage.csv", index=False) + REPLICATE_PASSING_THRESHOLD = 0.8 + replicates_qc_df = ( + coverage_df.query("sample_type == 'field'") + .groupby(["expt_name", "barcode", "sample_id"]) + .agg( + n_amplicons=pd.NamedAgg("name", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + n_fail_contam=pd.NamedAgg("fail_contam", "sum"), + n_fail_lowcov=pd.NamedAgg("fail_lowcov", "sum"), + ) + .reset_index() + ) + replicates_qc_df["passing"] = ( + replicates_qc_df["n_passing"] / replicates_qc_df["n_amplicons"] + >= REPLICATE_PASSING_THRESHOLD + ) + replicates_qc_df.to_csv(f"{output_dir}/summary.replicates_qc.csv", index=False) + + samples_summary_df = ( + replicates_qc_df.groupby(["sample_id"]) + .agg( + n_replicates=pd.NamedAgg("barcode", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + ) + .reset_index() + ) + samples_summary_df = ( + samples_summary_df.merge( + master_metadata[["sample_id"]], how="right", on="sample_id" + ) + .fillna({"n_replicates": 0, "n_passing": 0}) + .astype({"n_replicates": int, "n_passing": int}) + ) + samples_summary_df["status"] = samples_summary_df.apply( + lambda row: "passing" + if row["n_passing"] > 0 + else "failing" + if row["n_replicates"] > 0 + else "missing", + axis=1, + ) + samples_summary_df.sort_values( + by=["n_passing", "n_replicates", "sample_id"], + inplace=True, + ascending=[False, False, True], + ) + samples_summary_df.to_csv(f"{output_dir}/summary.samples_qc.csv", index=False) + final_df = ( coverage_df.query("sample_type == 'field'") .groupby(["expt_name", "name"]) @@ -526,7 +580,7 @@ def main( ) .reset_index() ) - final_df.to_csv(f"{output_dir}/summary.quality_control.csv", index=False) + final_df.to_csv(f"{output_dir}/summary.experiments_qc.csv", index=False) # -------------------------------------------------------------------------------- # Let's move onto to variant calling results @@ -565,20 +619,21 @@ def main( # # -------------------------------------------------------------------------------- - print(" Variant calling: False") - dashboard = BasicSummaryDashboard( - summary_name, - throughput_csv=f"{output_dir}/summary.throughput.csv", - coverage_csv=f"{output_dir}/summary.quality_control.csv", - prevalence_csv=f"{output_dir}/summary.variants.prevalence.csv", - ) - print("Done.") + if dashboard: + dashboard = BasicSummaryDashboard( + summary_name, + throughput_csv=f"{output_dir}/summary.throughput.csv", + samples_csv=f"{output_dir}/summary.samples_qc.csv", + coverage_csv=f"{output_dir}/summary.experiments_qc.csv", + prevalence_csv=f"{output_dir}/summary.variants.prevalence.csv", + ) + print("Done.") - print("") - print("Launching dashboard (press CNTRL+C to exit):") - print("") - webbrowser.open("http://127.0.0.1:8050") - dashboard.run(debug=True) + print("") + print("Launching dashboard (press CNTRL+C to exit):") + print("") + webbrowser.open("http://127.0.0.1:8050") + dashboard.run(debug=True) # CHECKPOINT 2: # summary.quality_control.by_amplicon.csv From a892093b469cb8a21ac189f6640f39fc795b4d5b Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 14 Oct 2025 23:50:11 +0200 Subject: [PATCH 07/67] Add prevalence by region plot --- src/nomadic/summarize/dashboard/builders.py | 38 ++++++++ src/nomadic/summarize/dashboard/components.py | 87 ++++++++++++++++++ src/nomadic/summarize/main.py | 89 ++++++++++++++++++- 3 files changed, 213 insertions(+), 1 deletion(-) diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 73ee671..7a46f28 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -7,6 +7,7 @@ # from importlib.resources import files, as_file from nomadic.summarize.dashboard.components import ( + PrevalenceHeatmap, SamplesPie, ThroughputSummary, QualityControl, @@ -201,6 +202,40 @@ def _add_prevalence_row(self, prevalence_csv: str) -> None: self.components.append(self.prevalence_bars) self.layout.append(prevalence_row) + def _add_prevalence_by_region_row(self, prevalence_region_csv: str) -> None: + """ + Add a panel that shows prevalence calls by region + + """ + dropdown = dcc.Dropdown( + id="gene-dropdown", + options=PrevalenceBarplot.GENE_SETS["Resistance"], + value=PrevalenceBarplot.GENE_SETS["Resistance"][0], + style=dict(width="300px"), + ) + + self.prevalence_heatmap = PrevalenceHeatmap( + summary_name=self.summary_name, + prevalence_region_csv=prevalence_region_csv, + component_id="prevalence-heatmap", + gene_dropdown_id="gene-dropdown", + ) + prevalence_row = html.Div( + className="prevalence-region-row", + children=[ + html.H3("Prevalence by region", style=dict(marginTop="0px")), + dropdown, + html.Div( + className="prevalence-region-plots", + children=[self.prevalence_heatmap.get_layout()], + ), + ], + ) + + # Add components and layout + self.components.append(self.prevalence_heatmap) + self.layout.append(prevalence_row) + class BasicSummaryDashboard(SummaryDashboardBuilder): """ @@ -217,6 +252,7 @@ def __init__( samples_csv: str, coverage_csv: str, prevalence_csv: str, + prevalence_region_csv: str, ): """ Initialise all of the dashboard components @@ -228,6 +264,7 @@ def __init__( self.samples_csv = samples_csv self.coverage_csv = coverage_csv self.prevalence_csv = prevalence_csv + self.prevalence_region_csv = prevalence_region_csv def _gen_layout(self): """ @@ -238,6 +275,7 @@ def _gen_layout(self): self._add_samples(self.samples_csv) self._add_experiment_qc(self.coverage_csv) self._add_prevalence_row(self.prevalence_csv) + self._add_prevalence_by_region_row(self.prevalence_region_csv) # self._add_mapping_row(self.read_mapping_csv) # self._add_region_coverage_row(self.region_coverage_csv, self.regions) # self._add_depth_row(self.depth_profiles_csv, self.regions) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 9ffc90d..24f4fdb 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -360,3 +360,90 @@ def _update(gene_set: str): fig.update_traces(marker=dict(line=dict(color="black", width=1))) return fig + + +class PrevalenceHeatmap(SummaryDashboardComponent): + """ + Make a heatmap of prevalences + + """ + + def __init__( + self, + summary_name: str, + prevalence_region_csv: str, + component_id: str, + gene_dropdown_id: str, + ): + self.gene_dropdown_id = gene_dropdown_id + self.df = pd.read_csv(prevalence_region_csv) + super().__init__(summary_name, component_id) + + def _define_layout(self): + """Layout is graph""" + return dcc.Graph(id=self.component_id) + + def callback(self, app: Dash) -> None: + @app.callback( + Output(self.component_id, "figure"), + Input(self.gene_dropdown_id, "value"), + ) + def _update(target_gene): + """Called every time an input changes""" + + df = self.df.query("gene == @target_gene") + plot_df = pd.pivot_table( + index="aa_change", + columns="region", + values=["prevalence", "n_mixed", "n_mut", "n_passed"], + data=df, + ) + + # Hover statment + customdata = np.stack( + [plot_df["n_mixed"], plot_df["n_mut"], plot_df["n_passed"]], axis=-1 + ) + htemp = "%{y} (%{x})
" + htemp += "Prevalence: %{z:.0f}%
" + htemp += "Samples: %{customdata[2]}
" + htemp += "Mixed: %{customdata[0]}
" + htemp += "Clonal: %{customdata[1]}
" + + plot_data = [ + go.Heatmap( + x=plot_df["prevalence"].columns, + y=plot_df["prevalence"].index, + z=plot_df["prevalence"], + texttemplate="%{z:.0f}%", + customdata=customdata, + zmin=0, + zmax=100, + xgap=1, + ygap=1, + colorscale="Spectral_r", + colorbar=dict(title="", outlinecolor="black", outlinewidth=1), + hoverongaps=False, + hovertemplate=htemp, + name="", + ) + ] + MAR = 40 + fig = go.Figure(plot_data) + fig.update_layout( + xaxis_title="Regions", + hovermode="y unified", + paper_bgcolor="white", # Sets the background color of the paper + plot_bgcolor="white", + title=dict(text=target_gene), + margin=dict(t=MAR, l=MAR, r=MAR, b=MAR), + xaxis=dict( + showline=True, linecolor="black", linewidth=2, dtick=1, mirror=True + ), + yaxis=dict( + showline=True, linecolor="black", linewidth=2, dtick=1, mirror=True + ), + xaxis_showgrid=False, + yaxis_showgrid=False, + # height=n_mutations*SZ # TOOD: how to adjust dynamically + ) + return fig diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 9e97d31..8098a90 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -369,6 +369,30 @@ def load_and_concat_variants(expt_dirs: list[str]) -> pd.DataFrame: return pd.concat(full_variant_dfs) +variants_group_columns = [ + "gene", + "chrom", + "pos", + "ref", + "alt", + "aa_change", + "mut_type", + "mutation", +] + + +def filter_false_positives(variants_df): + group_counts = variants_df.groupby(variants_group_columns).agg( + n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), + n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), + mean_wsaf=pd.NamedAgg("wsaf", "mean"), + ) + df = variants_df.merge(group_counts, on=variants_group_columns, how="left") + df_filtered = df[~((df["n_mixed"] + df["n_mut"] == 1) & (df["mean_wsaf"] < 0.1))] + df_filtered = df_filtered.loc[~(df["n_mixed"] + df["n_mut"] == 0)] + return df_filtered.drop(columns=["n_mixed", "n_mut", "mean_wsaf"]) + + def compute_variant_prevalence(variants_df: str) -> pd.DataFrame: """ Compute the prevalence of each mutation in `variants_df` @@ -380,7 +404,7 @@ def compute_variant_prevalence(variants_df: str) -> pd.DataFrame: prev_df = ( variants_df.groupby( - ["gene", "chrom", "pos", "ref", "alt", "aa_change", "mut_type", "mutation"] + variants_group_columns, ) .agg( n_samples=pd.NamedAgg("gt_int", len), @@ -413,6 +437,51 @@ def compute_variant_prevalence(variants_df: str) -> pd.DataFrame: return prev_df +def compute_variant_prevalence_per(variants_df, master_df, field: str) -> pd.DataFrame: + """ + Compute the prevalence of each mutation in `variants_df` + + Assumes columns several columns exist; compute across all samples + in data; + + """ + variants_df = variants_df.merge( + master_df[["sample_id", field]], on="sample_id", how="left" + ) + + prev_df = ( + variants_df.groupby([*variants_group_columns, field]) + .agg( + n_samples=pd.NamedAgg("gt_int", len), + n_passed=pd.NamedAgg("gt_int", lambda x: sum(x != -1)), + n_wt=pd.NamedAgg("gt_int", lambda x: sum(x == 0)), + n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), + n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), + ) + .reset_index() + ) + + # Compute frequencies + prev_df["per_wt"] = 100 * prev_df["n_wt"] / prev_df["n_passed"] + prev_df["per_mixed"] = 100 * prev_df["n_mixed"] / prev_df["n_passed"] + prev_df["per_mut"] = 100 * prev_df["n_mut"] / prev_df["n_passed"] + + # Compute prevalence + prev_df["prevalence"] = prev_df["per_mixed"] + prev_df["per_mut"] + + # Compute prevalence 95% confidence intervals + low, high = proportion_confint( + prev_df["n_mut"] + prev_df["n_mixed"], + prev_df["n_passed"], + alpha=0.05, + method="beta", + ) + prev_df["prevalence_lowci"] = 100 * low + prev_df["prevalence_highci"] = 100 * high + + return prev_df + + # -------------------------------------------------------------------------------- # Main # @@ -590,6 +659,8 @@ def main( log.info("Loading variants...") variant_df = load_and_concat_variants(expt_dirs) + if "sample_id" in variant_df.columns: + variant_df.drop(columns=["sample_id"], inplace=True) # Merge with the quality control results, then we can subset to the analysis set variant_df = pd.merge( left=coverage_df.rename({"name": "amplicon"}, axis=1)[ @@ -608,12 +679,27 @@ def main( .query("gene not in @remove_genes") .query("mutation not in @remove_mutations") ) + + # Filter out false positives + analysis_df = filter_false_positives(analysis_df) analysis_df.to_csv(f"{output_dir}/summary.variants.analysis_set.csv", index=False) # Then we will compute prevalence prev_df = compute_variant_prevalence(analysis_df) prev_df.to_csv(f"{output_dir}/summary.variants.prevalence.csv", index=False) + prev_df_region = compute_variant_prevalence_per( + analysis_df, master_metadata, "region" + ) + prev_df_region.to_csv( + f"{output_dir}/summary.variants.prevalence-region.csv", index=False + ) + + prev_df_year = compute_variant_prevalence_per(analysis_df, master_metadata, "year") + prev_df_year.to_csv( + f"{output_dir}/summary.variants.prevalence-year.csv", index=False + ) + # -------------------------------------------------------------------------------- # Dashboard # @@ -626,6 +712,7 @@ def main( samples_csv=f"{output_dir}/summary.samples_qc.csv", coverage_csv=f"{output_dir}/summary.experiments_qc.csv", prevalence_csv=f"{output_dir}/summary.variants.prevalence.csv", + prevalence_region_csv=f"{output_dir}/summary.variants.prevalence-region.csv", ) print("Done.") From c608abbf5940f0a2671de4ab49316cdbd700cf78 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 15 Oct 2025 01:04:53 +0200 Subject: [PATCH 08/67] Small improvements to summarize Get ready for better text Ensure we mark duplicates and only take the best sample Ensure we exclude samples not in master metadata --- src/nomadic/summarize/dashboard/builders.py | 6 +++- src/nomadic/summarize/dashboard/components.py | 7 ++++- src/nomadic/summarize/main.py | 31 +++++++++++++------ 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 7a46f28..92eabdc 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -5,6 +5,8 @@ from datetime import datetime from typing import Optional +from i18n import t + # from importlib.resources import files, as_file from nomadic.summarize.dashboard.components import ( PrevalenceHeatmap, @@ -140,7 +142,9 @@ def _add_experiment_qc(self, coverage_csv: str) -> None: """ dropdown = dcc.Dropdown( id="quality-dropdown", - options=QualityControl.STATISTICS, + options=[ + {"label": t(option), "value": option, "title": t(f"{option}_tooltip")} + for option in QualityControl.STATISTICS], value=QualityControl.STATISTICS[1], style=dict(width="300px"), ) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 24f4fdb..4a6e7a0 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -8,6 +8,7 @@ import pandas as pd import plotly.express as px import plotly.graph_objects as go +from pyparsing import annotations import seaborn as sns from dash import Dash, dcc, html from dash.dependencies import Input, Output @@ -15,6 +16,7 @@ from nomadic.util.metadata import MetadataTableParser from nomadic.util.regions import RegionBEDParser +from i18n import t # -------------------------------------------------------------------------------- # Interface for a single real-time dashboard component @@ -146,6 +148,7 @@ def __init__( ): self.samples_csv = samples_csv self.df = pd.read_csv(samples_csv) + self.n = len(self.df) self.df = self.df.groupby("status").count()["sample_id"] super().__init__(summary_name, component_id) @@ -162,12 +165,13 @@ def _define_layout(self): labels=self.df.index, sort=False, hole=0.3, + textinfo="label+percent+value" ) ] ) MAR = 20 - fig.update_layout(showlegend=False, margin=dict(t=MAR, l=MAR, r=MAR, b=MAR)) + fig.update_layout(showlegend=False, margin=dict(t=MAR, l=MAR, r=MAR, b=MAR), annotations=[dict(text=f"N={self.n}", font_size=20, showarrow=False, xanchor="center")]) return dcc.Graph(id=self.component_id, figure=fig) @@ -238,6 +242,7 @@ def _update(focus_stat: str): fig.update_layout( width=1200, height=600, + xaxis_title="Amplicons", yaxis_title="Experiments", hovermode="y unified", paper_bgcolor="white", # Sets the background color of the paper diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 8098a90..39dadfa 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -1,5 +1,6 @@ import glob import os +from warnings import warn import webbrowser from enum import StrEnum, auto from pathlib import Path @@ -167,6 +168,11 @@ def get_region_coverage_dataframe( return coverage_df +def calc_unknown_samples(inventory_metadata: pd.DataFrame, master_metadata): + field_samples = inventory_metadata.query("sample_type == 'field'") + unknown_samples = field_samples.loc[~field_samples["sample_id"].isin(master_metadata["sample_id"]), "sample_id"].to_list() + return unknown_samples + def calc_quality_control_columns( df: pd.DataFrame, *, min_coverage: int = 50, max_contam: float = 0.1 @@ -227,7 +233,7 @@ def _update_duplicate(status: str, idx: int, keep_idx: int) -> str: return status return f"{status};{QcStatus.DUPLICATE}" - for (_, _), data in df.groupby(["sample_id", "name"]): + for (_, _), data in df.query("sample_type == 'field'").groupby(["sample_id", "name"]): # Select an index to keep, i.e. the best sample that should # marked as duplicate passing = data["status"] == "pass" @@ -257,7 +263,7 @@ def add_quality_control_status_column(df: pd.DataFrame) -> None: """ _add_qc_status_no_duplicates(df) - # _mark_duplicates(df) + _mark_duplicates(df) # -------------------------------------------------------------------------------- @@ -510,7 +516,7 @@ def main( """ output_dir = produce_dir( "summaries", summary_name - ) # should I ensure we are in a workspace? + ) # TODO allow to change output dir # PARSE EXPERIMENT DIRECTORIES log = LoggingFascade(logger_name="nomadic") @@ -545,12 +551,17 @@ def main( # for now we use the master metadata file # shared_columns = fixed_columns + list(shared_columns) shared_columns = fixed_columns - metadata = pd.concat([df[shared_columns] for df in dfs]) + inventory_metadata = pd.concat([df[shared_columns] for df in dfs]) master_metadata = pd.read_csv(meta_data_path) - metadata = pd.merge( - left=metadata, right=master_metadata, on=["sample_id"], how="left" - ) - metadata.to_csv(f"{output_dir}/metadata.csv", index=False) + # inventory_metadata = pd.merge( + # left=inventory_metadata, right=master_metadata, on=["sample_id"], how="left" + # ) + unknown_samples = calc_unknown_samples(inventory_metadata, master_metadata) + if unknown_samples: + warn(f"Samples in experiments that are not in master metadata: {unknown_samples}") + + inventory_metadata = inventory_metadata[~inventory_metadata["sample_id"].isin(unknown_samples)] + inventory_metadata.to_csv(f"{output_dir}/metadata.csv", index=False) # Check regions are consistent check_regions_consistent(expt_dirs) @@ -559,7 +570,7 @@ def main( # Throughput data # TODO: Need to make a real decision about how to handle duplicated sample IDs log.info("Overall sequencing throughput:") - throughput_df = compute_throughput(metadata) + throughput_df = compute_throughput(inventory_metadata) log.info(f" Positive controls: {throughput_df.loc['pos', 'All']}") log.info(f" Negative controls: {throughput_df.loc['neg', 'All']}") log.info(f" Field samples (total): {throughput_df.loc['field', 'All']}") @@ -567,7 +578,7 @@ def main( throughput_df.to_csv(f"{output_dir}/summary.throughput.csv", index=True) # Now let's evaluate coverage - coverage_df = get_region_coverage_dataframe(expt_dirs, metadata) + coverage_df = get_region_coverage_dataframe(expt_dirs, inventory_metadata) MIN_COV = 50 MAX_CONTAM = 0.1 calc_quality_control_columns( From ccab72b01aaea46bb2c4f2ce512780e98df63c4e Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 15 Oct 2025 03:18:46 +0200 Subject: [PATCH 09/67] Add samples amplicons barplot --- .../dashboard/assets/calling-style.css | 8 +- src/nomadic/summarize/dashboard/builders.py | 23 ++- src/nomadic/summarize/dashboard/components.py | 92 +++++++++- src/nomadic/summarize/main.py | 162 ++++++++++++------ 4 files changed, 224 insertions(+), 61 deletions(-) diff --git a/src/nomadic/summarize/dashboard/assets/calling-style.css b/src/nomadic/summarize/dashboard/assets/calling-style.css index 07e7b23..bd3132d 100644 --- a/src/nomadic/summarize/dashboard/assets/calling-style.css +++ b/src/nomadic/summarize/dashboard/assets/calling-style.css @@ -40,7 +40,7 @@ body { /* Mapping Section ------------------------------------------------------------------ */ -.mapping-row { +.samples-row { padding: 20px; margin: 20px; border-color: var(--grey); @@ -50,17 +50,17 @@ body { box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } -.mapping-plots { +.samples-plots { display: flex; flex-direction: row; gap: 20px; } -#mapping-pie { +#samples-pie { flex: 0.25; } -#mapping-barplot { +#samples-barplot { flex: 0.75; } diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 92eabdc..12f9a23 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -9,6 +9,7 @@ # from importlib.resources import files, as_file from nomadic.summarize.dashboard.components import ( + AmpliconsBarplot, PrevalenceHeatmap, SamplesPie, ThroughputSummary, @@ -110,23 +111,28 @@ def _add_throughput_banner(self, throughput_csv: str) -> None: self.components.append(self.expt_summary) self.layout.append(banner) - def _add_samples(self, samples_csv: str) -> None: + def _add_samples(self, samples_csv: str, samples_amplicons_csv: str) -> None: """ Add a panel that shows progress of samples """ self.samples = SamplesPie( summary_name=self.summary_name, - component_id="samples-summary", + component_id="samples-pie", samples_csv=samples_csv, ) + self.amplicons = AmpliconsBarplot( + summary_name=self.summary_name, + component_id="samples-barplot", + samples_amplicons_csv=samples_amplicons_csv, + ) quality_row = html.Div( - className="quality-row", + className="samples-row", children=[ html.H3("Samples Statistics", style=dict(marginTop="0px")), html.Div( className="samples-plots", - children=[self.samples.get_layout()], + children=[self.samples.get_layout(), self.amplicons.get_layout()], ), ], ) @@ -143,8 +149,9 @@ def _add_experiment_qc(self, coverage_csv: str) -> None: dropdown = dcc.Dropdown( id="quality-dropdown", options=[ - {"label": t(option), "value": option, "title": t(f"{option}_tooltip")} - for option in QualityControl.STATISTICS], + {"label": t(option), "value": option, "title": t(f"{option}_tooltip")} + for option in QualityControl.STATISTICS + ], value=QualityControl.STATISTICS[1], style=dict(width="300px"), ) @@ -254,6 +261,7 @@ def __init__( summary_name: str, throughput_csv: str, samples_csv: str, + samples_amplicons_csv: str, coverage_csv: str, prevalence_csv: str, prevalence_region_csv: str, @@ -266,6 +274,7 @@ def __init__( super().__init__(summary_name, self.CSS_STYLE) self.throughput_csv = throughput_csv self.samples_csv = samples_csv + self.samples_amplicons_csv = samples_amplicons_csv self.coverage_csv = coverage_csv self.prevalence_csv = prevalence_csv self.prevalence_region_csv = prevalence_region_csv @@ -276,7 +285,7 @@ def _gen_layout(self): """ self._add_throughput_banner(self.throughput_csv) - self._add_samples(self.samples_csv) + self._add_samples(self.samples_csv, self.samples_amplicons_csv) self._add_experiment_qc(self.coverage_csv) self._add_prevalence_row(self.prevalence_csv) self._add_prevalence_by_region_row(self.prevalence_region_csv) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 4a6e7a0..27c566b 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -1,9 +1,13 @@ # import datetime # import os from abc import ABC, abstractmethod +from cProfile import label +from calendar import c +from turtle import title # from typing import Optional # import re +from matplotlib.pyplot import colormaps import numpy as np import pandas as pd import plotly.express as px @@ -134,6 +138,13 @@ def callback(self, app: Dash) -> None: """ +SAMPLE_COLORS = { + "missing": "#636EFA", + "failing": "#EF553B", + "passing": "#00CC96", +} + + class SamplesPie(SummaryDashboardComponent): """ Make a pie chart that shows read mapping statistics @@ -165,13 +176,22 @@ def _define_layout(self): labels=self.df.index, sort=False, hole=0.3, - textinfo="label+percent+value" + textinfo="label+percent+value", + marker=dict(colors=[SAMPLE_COLORS[cat] for cat in self.df.index]), ) ] ) MAR = 20 - fig.update_layout(showlegend=False, margin=dict(t=MAR, l=MAR, r=MAR, b=MAR), annotations=[dict(text=f"N={self.n}", font_size=20, showarrow=False, xanchor="center")]) + fig.update_layout( + showlegend=False, + margin=dict(t=MAR, l=MAR, r=MAR, b=MAR), + annotations=[ + dict( + text=f"N={self.n}", font_size=20, showarrow=False, xanchor="center" + ) + ], + ) return dcc.Graph(id=self.component_id, figure=fig) @@ -182,6 +202,74 @@ def callback(self, app: Dash) -> None: """ +class AmpliconsBarplot(SummaryDashboardComponent): + """ + Make a bar chart that shows the Amplicons Statistics + + """ + + def __init__( + self, + summary_name: str, + component_id: str, + samples_amplicons_csv: str, + ): + df = pd.read_csv(samples_amplicons_csv) + # Store inputs + plot_df = pd.crosstab(df["name"], df["status"]) + n_missing = (df["status"] == "missing").sum() + missing = [n_missing] * len(plot_df.index) + # Generate figure + fig = go.Figure( + data=[ + go.Bar( + x=plot_df.index, + y=missing if column == "missing" else plot_df[column], + texttemplate="%{y}", + name=column, + marker=dict(color=SAMPLE_COLORS[column]), + ) + for column in ["passing", "failing", "missing"] + ] + ) + + # Format + fig.update_layout( + xaxis_title="Amplicons", + yaxis_title="Number of Samples", + barmode="stack", + xaxis=dict(showline=True, linewidth=1, linecolor="black", mirror=True), + yaxis=dict( + showline=True, + linewidth=1, + linecolor="black", + mirror=True, + showgrid=True, + gridcolor="lightgray", + gridwidth=0.5, + griddash="dot", + ), + plot_bgcolor="rgba(0,0,0,0)", + hovermode="x unified", + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0), + ) + fig.update_traces(marker=dict(line=dict(color="black", width=1))) + self.fig = fig + super().__init__(summary_name, component_id) + + def _define_layout(self): + """ + Define the layout to be a dcc.Graph object with the + appropriate ID + + """ + + return dcc.Graph(id=self.component_id, figure=self.fig) + + def callback(self, app: Dash) -> None: + pass + + class QualityControl(SummaryDashboardComponent): STATISTICS = [ "mean_cov_field", diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 39dadfa..0c38768 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -168,9 +168,12 @@ def get_region_coverage_dataframe( return coverage_df + def calc_unknown_samples(inventory_metadata: pd.DataFrame, master_metadata): field_samples = inventory_metadata.query("sample_type == 'field'") - unknown_samples = field_samples.loc[~field_samples["sample_id"].isin(master_metadata["sample_id"]), "sample_id"].to_list() + unknown_samples = field_samples.loc[ + ~field_samples["sample_id"].isin(master_metadata["sample_id"]), "sample_id" + ].to_list() return unknown_samples @@ -233,7 +236,9 @@ def _update_duplicate(status: str, idx: int, keep_idx: int) -> str: return status return f"{status};{QcStatus.DUPLICATE}" - for (_, _), data in df.query("sample_type == 'field'").groupby(["sample_id", "name"]): + for (_, _), data in df.query("sample_type == 'field'").groupby( + ["sample_id", "name"] + ): # Select an index to keep, i.e. the best sample that should # marked as duplicate passing = data["status"] == "pass" @@ -516,7 +521,7 @@ def main( """ output_dir = produce_dir( "summaries", summary_name - ) # TODO allow to change output dir + ) # TODO allow to change output dir # PARSE EXPERIMENT DIRECTORIES log = LoggingFascade(logger_name="nomadic") @@ -558,9 +563,13 @@ def main( # ) unknown_samples = calc_unknown_samples(inventory_metadata, master_metadata) if unknown_samples: - warn(f"Samples in experiments that are not in master metadata: {unknown_samples}") + warn( + f"Samples in experiments that are not in master metadata: {unknown_samples}" + ) - inventory_metadata = inventory_metadata[~inventory_metadata["sample_id"].isin(unknown_samples)] + inventory_metadata = inventory_metadata[ + ~inventory_metadata["sample_id"].isin(unknown_samples) + ] inventory_metadata.to_csv(f"{output_dir}/metadata.csv", index=False) # Check regions are consistent @@ -599,52 +608,18 @@ def main( coverage_df.to_csv(f"{output_dir}/summary.coverage.csv", index=False) REPLICATE_PASSING_THRESHOLD = 0.8 - replicates_qc_df = ( - coverage_df.query("sample_type == 'field'") - .groupby(["expt_name", "barcode", "sample_id"]) - .agg( - n_amplicons=pd.NamedAgg("name", "count"), - n_passing=pd.NamedAgg("passing", "sum"), - n_fail_contam=pd.NamedAgg("fail_contam", "sum"), - n_fail_lowcov=pd.NamedAgg("fail_lowcov", "sum"), - ) - .reset_index() - ) - replicates_qc_df["passing"] = ( - replicates_qc_df["n_passing"] / replicates_qc_df["n_amplicons"] - >= REPLICATE_PASSING_THRESHOLD - ) + replicates_qc_df = replicates_qc(coverage_df, REPLICATE_PASSING_THRESHOLD) replicates_qc_df.to_csv(f"{output_dir}/summary.replicates_qc.csv", index=False) - samples_summary_df = ( - replicates_qc_df.groupby(["sample_id"]) - .agg( - n_replicates=pd.NamedAgg("barcode", "count"), - n_passing=pd.NamedAgg("passing", "sum"), - ) - .reset_index() - ) - samples_summary_df = ( - samples_summary_df.merge( - master_metadata[["sample_id"]], how="right", on="sample_id" - ) - .fillna({"n_replicates": 0, "n_passing": 0}) - .astype({"n_replicates": int, "n_passing": int}) - ) - samples_summary_df["status"] = samples_summary_df.apply( - lambda row: "passing" - if row["n_passing"] > 0 - else "failing" - if row["n_replicates"] > 0 - else "missing", - axis=1, + samples_summary_df = calc_samples_summary(master_metadata, replicates_qc_df) + samples_summary_df.to_csv(f"{output_dir}/summary.samples_qc.csv", index=False) + + samples_by_amplicon_summary_df = calc_amplicons_summary( + master_metadata, replicates_amplicon_qc(coverage_df) ) - samples_summary_df.sort_values( - by=["n_passing", "n_replicates", "sample_id"], - inplace=True, - ascending=[False, False, True], + samples_by_amplicon_summary_df.to_csv( + f"{output_dir}/summary.samples_amplicons_qc.csv", index=False ) - samples_summary_df.to_csv(f"{output_dir}/summary.samples_qc.csv", index=False) final_df = ( coverage_df.query("sample_type == 'field'") @@ -721,6 +696,7 @@ def main( summary_name, throughput_csv=f"{output_dir}/summary.throughput.csv", samples_csv=f"{output_dir}/summary.samples_qc.csv", + samples_amplicons_csv=f"{output_dir}/summary.samples_amplicons_qc.csv", coverage_csv=f"{output_dir}/summary.experiments_qc.csv", prevalence_csv=f"{output_dir}/summary.variants.prevalence.csv", prevalence_region_csv=f"{output_dir}/summary.variants.prevalence-region.csv", @@ -730,9 +706,99 @@ def main( print("") print("Launching dashboard (press CNTRL+C to exit):") print("") - webbrowser.open("http://127.0.0.1:8050") + # webbrowser.open("http://127.0.0.1:8050") dashboard.run(debug=True) + +def calc_samples_summary(master_metadata, replicates_qc_df): + samples_summary_df = ( + replicates_qc_df.groupby(["sample_id"]) + .agg( + n_replicates=pd.NamedAgg("barcode", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + ) + .reset_index() + ) + samples_summary_df = ( + samples_summary_df.merge( + master_metadata[["sample_id"]], how="right", on="sample_id" + ) + .fillna({"n_replicates": 0, "n_passing": 0}) + .astype({"n_replicates": int, "n_passing": int}) + ) + samples_summary_df["status"] = samples_summary_df.apply( + lambda row: "passing" + if row["n_passing"] > 0 + else "failing" + if row["n_replicates"] > 0 + else "missing", + axis=1, + ) + samples_summary_df.sort_values( + by=["n_passing", "n_replicates", "sample_id"], + inplace=True, + ascending=[False, False, True], + ) + + return samples_summary_df + + +def calc_amplicons_summary(master_metadata, replicates_amplicon_qc_df): + samples_by_amplicons_summary_df = ( + replicates_amplicon_qc_df.groupby(["sample_id", "name"]) + .agg( + n_replicates=pd.NamedAgg("barcode", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + ) + .reset_index() + ) + samples_by_amplicons_summary_df = ( + samples_by_amplicons_summary_df.merge( + master_metadata[["sample_id"]], how="right", on="sample_id" + ) + .fillna({"n_replicates": 0, "n_passing": 0}) + .astype({"n_replicates": int, "n_passing": int}) + ) + samples_by_amplicons_summary_df["status"] = samples_by_amplicons_summary_df.apply( + lambda row: "passing" + if row["n_passing"] > 0 + else "failing" + if row["n_replicates"] > 0 + else "missing", + axis=1, + ) + samples_by_amplicons_summary_df.sort_values( + by=["n_passing", "n_replicates", "sample_id"], + inplace=True, + ascending=[False, False, True], + ) + + return samples_by_amplicons_summary_df + + +def replicates_qc(coverage_df, REPLICATE_PASSING_THRESHOLD): + replicates_qc_df = ( + coverage_df.query("sample_type == 'field'") + .groupby(["expt_name", "barcode", "sample_id"]) + .agg( + n_amplicons=pd.NamedAgg("name", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + n_fail_contam=pd.NamedAgg("fail_contam", "sum"), + n_fail_lowcov=pd.NamedAgg("fail_lowcov", "sum"), + ) + .reset_index() + ) + replicates_qc_df["passing"] = ( + replicates_qc_df["n_passing"] / replicates_qc_df["n_amplicons"] + >= REPLICATE_PASSING_THRESHOLD + ) + + return replicates_qc_df + + +def replicates_amplicon_qc(coverage_df): + return coverage_df.query("sample_type == 'field'") + # CHECKPOINT 2: # summary.quality_control.by_amplicon.csv # summary.quality_control.by_experiment.csv From 188d28fc7237cf26f26e477cbd6638c73a57e2fa Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 15 Oct 2025 20:00:29 +0200 Subject: [PATCH 10/67] Add prevalence per region/year plot --- src/nomadic/summarize/commands.py | 2 +- src/nomadic/summarize/compute.py | 117 ++++++++++++++ .../dashboard/assets/calling-style.css | 153 ------------------ .../dashboard/assets/summary-style.css | 68 ++++---- src/nomadic/summarize/dashboard/builders.py | 28 +++- src/nomadic/summarize/dashboard/components.py | 125 +++++++++----- src/nomadic/summarize/main.py | 132 ++------------- 7 files changed, 275 insertions(+), 350 deletions(-) create mode 100644 src/nomadic/summarize/compute.py delete mode 100644 src/nomadic/summarize/dashboard/assets/calling-style.css diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 61e84b9..601eb11 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -78,5 +78,5 @@ def summarize( expt_dirs=experiment_dirs, summary_name=summary_name, meta_data_path=metadata_csv, - dashboard=dashboard, + show_dashboard=dashboard, ) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py new file mode 100644 index 0000000..b7518a6 --- /dev/null +++ b/src/nomadic/summarize/compute.py @@ -0,0 +1,117 @@ +import pandas as pd + +from statsmodels.stats.proportion import proportion_confint + +variants_group_columns = [ + "gene", + "chrom", + "pos", + "ref", + "alt", + "aa_change", + "mut_type", + "mutation", +] + + +def filter_false_positives(variants_df: pd.DataFrame): + group_counts = variants_df.groupby(variants_group_columns).agg( + n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), + n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), + mean_wsaf=pd.NamedAgg("wsaf", "mean"), + ) + df = variants_df.merge(group_counts, on=variants_group_columns, how="left") + df_filtered = df[~((df["n_mixed"] + df["n_mut"] == 1) & (df["mean_wsaf"] < 0.1))] + df_filtered = df_filtered.loc[~(df["n_mixed"] + df["n_mut"] == 0)] + return df_filtered.drop(columns=["n_mixed", "n_mut", "mean_wsaf"]) + + +def compute_variant_prevalence(variants_df: str) -> pd.DataFrame: + """ + Compute the prevalence of each mutation in `variants_df` + + Assumes columns several columns exist; compute across all samples + in data; + + """ + + prev_df = ( + variants_df.groupby( + variants_group_columns, + ) + .agg( + n_samples=pd.NamedAgg("gt_int", len), + n_passed=pd.NamedAgg("gt_int", lambda x: sum(x != -1)), + n_wt=pd.NamedAgg("gt_int", lambda x: sum(x == 0)), + n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), + n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), + ) + .reset_index() + ) + + # Compute frequencies + prev_df["per_wt"] = 100 * prev_df["n_wt"] / prev_df["n_passed"] + prev_df["per_mixed"] = 100 * prev_df["n_mixed"] / prev_df["n_passed"] + prev_df["per_mut"] = 100 * prev_df["n_mut"] / prev_df["n_passed"] + + # Compute prevalence + prev_df["prevalence"] = prev_df["per_mixed"] + prev_df["per_mut"] + + # Compute prevalence 95% confidence intervals + low, high = proportion_confint( + prev_df["n_mut"] + prev_df["n_mixed"], + prev_df["n_passed"], + alpha=0.05, + method="beta", + ) + prev_df["prevalence_lowci"] = 100 * low + prev_df["prevalence_highci"] = 100 * high + + return prev_df + + +def compute_variant_prevalence_per( + variants_df, master_df, fields: list[str] +) -> pd.DataFrame: + """ + Compute the prevalence of each mutation in `variants_df` + + Assumes columns several columns exist; compute across all samples + in data; + + """ + variants_df = variants_df.merge( + master_df[["sample_id", *fields]], on="sample_id", how="left" + ) + + prev_df = ( + variants_df.groupby([*variants_group_columns, *fields]) + .agg( + n_samples=pd.NamedAgg("gt_int", len), + n_passed=pd.NamedAgg("gt_int", lambda x: sum(x != -1)), + n_wt=pd.NamedAgg("gt_int", lambda x: sum(x == 0)), + n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), + n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), + ) + .reset_index() + ) + + # Compute frequencies + prev_df["per_wt"] = 100 * prev_df["n_wt"] / prev_df["n_passed"] + prev_df["per_mixed"] = 100 * prev_df["n_mixed"] / prev_df["n_passed"] + prev_df["per_mut"] = 100 * prev_df["n_mut"] / prev_df["n_passed"] + + # Compute prevalence + prev_df["prevalence"] = prev_df["per_mixed"] + prev_df["per_mut"] + + # Compute prevalence 95% confidence intervals + low, high = proportion_confint( + prev_df["n_mut"] + prev_df["n_mixed"], + prev_df["n_passed"], + alpha=0.05, + method="beta", + ) + prev_df["prevalence_lowci"] = 100 * low + prev_df["prevalence_highci"] = 100 * high + + return prev_df diff --git a/src/nomadic/summarize/dashboard/assets/calling-style.css b/src/nomadic/summarize/dashboard/assets/calling-style.css deleted file mode 100644 index bd3132d..0000000 --- a/src/nomadic/summarize/dashboard/assets/calling-style.css +++ /dev/null @@ -1,153 +0,0 @@ -/* Overall styling ------------------------------------------------------------------ */ - -:root { - --grey: #ecebeb; -} - - -body { - font-family: Arial, Helvetica, sans-serif; - background: var(--grey); -} - -#overall { - margin: 20px; - padding: 20px; - border-radius: 20px; - background: white; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); -} - -/* Banner ------------------------------------------------------------------ */ - -.logo-and-summary { - display: flex; - flex-direction: row; - margin-bottom: 20px; -} - -#logo { - margin-left: 0px; - width: 500px; - flex: 0.1; -} - -#expt-summary { - margin-left: 20px; - flex: 0.9; -} - - -/* Mapping Section ------------------------------------------------------------------ */ - -.samples-row { - padding: 20px; - margin: 20px; - border-color: var(--grey); - border-width: 2px; - border-style: solid; - border-radius: 20px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); -} - -.samples-plots { - display: flex; - flex-direction: row; - gap: 20px; -} - -#samples-pie { - flex: 0.25; -} - -#samples-barplot { - flex: 0.75; -} - -/* BED Coverage Section ------------------------------------------------------------------ */ - -.bedcov-row { - padding: 20px; - margin: 20px; - border-color: var(--grey); - border-width: 2px; - border-style: solid; - border-radius: 20px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); -} - -.bedcov-plots { - display: flex; - flex-direction: row; - gap: 20px; -} - -#bedcov-pie { - flex: 0.25; -} - -#bedcov-strip { - flex: 0.75; -} - - -/* Depth Section ------------------------------------------------------------------ */ - -.depth-row { - padding: 20px; - margin: 20px; - border-color: var(--grey); - border-width: 2px; - border-style: solid; - border-radius: 20px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); -} - -.depth-plots { - display: flex; - flex-direction: row; - gap: 20px; -} - -#depth-hist { - flex: 0.4; -} - -#depth-line { - flex: 0.6; -} - - -/* Variant Section ------------------------------------------------------------------ */ - -.variant-row { - padding: 20px; - margin: 20px; - border-color: var(--grey); - border-width: 2px; - border-style: solid; - border-radius: 20px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); -} - -.variant-plots { - display: flex; - flex-direction: row; - gap: 20px; -} - -#variant-heat { - flex: 1.0; -} - -/* Footer ------------------------------------------------------------------ */ - -.footer { - padding: 20px; - margin: 20px; - display: flex; - flex-direction: row; - gap: 20px; - justify-content: right; -} - diff --git a/src/nomadic/summarize/dashboard/assets/summary-style.css b/src/nomadic/summarize/dashboard/assets/summary-style.css index 2b03a77..8724e9d 100644 --- a/src/nomadic/summarize/dashboard/assets/summary-style.css +++ b/src/nomadic/summarize/dashboard/assets/summary-style.css @@ -37,10 +37,8 @@ body { flex: 0.9; } - -/* Mapping Section ------------------------------------------------------------------ */ - -.mapping-row { +/* Samples Section ------------------------------------------------------------------ */ +.samples-row { padding: 20px; margin: 20px; border-color: var(--grey); @@ -50,23 +48,24 @@ body { box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } -.mapping-plots { +.samples-plots { display: flex; flex-direction: row; gap: 20px; } -#mapping-pie { +#samples-pie { flex: 0.25; } -#mapping-barplot { +#samples-barplot { flex: 0.75; } -/* BED Coverage Section ------------------------------------------------------------------ */ -.bedcov-row { +/* Quality Section ------------------------------------------------------------------ */ + +.quality-row { padding: 20px; margin: 20px; border-color: var(--grey); @@ -76,44 +75,47 @@ body { box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } -.bedcov-plots { +.quality-plots { display: flex; flex-direction: row; gap: 20px; } -#bedcov-pie { - flex: 0.25; -} - -#bedcov-strip { - flex: 0.75; +#quality-heat { + flex: 1.0; } +/* Prevalence Section ------------------------------------------------------------------ */ -/* Quality Section ------------------------------------------------------------------ */ +.prevalence-radio-row { + display: flex; + gap: 1rem; +} -.quality-row { - padding: 20px; - margin: 20px; - border-color: var(--grey); - border-width: 2px; - border-style: solid; - border-radius: 20px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +.prevalence-radio-input { + display: none; /* hide the native radio */ } -.quality-plots { - display: flex; - flex-direction: row; - gap: 20px; +.prevalence-radio-label { + padding: 0.2rem 0.6rem; + border: 1px solid #4442b8; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; } -#quality-heat { - flex: 1.0; +/* Highlight when selected */ +.prevalence-radio-label:has(input:checked) { + background-color: #4442b8; + color: white; + border-color: #4442b8; } -/* Prevalence Section ------------------------------------------------------------------ */ +/* Optional hover effect */ +.prevalence-radio-label:hover { + background-color: #e8f5e9; +} .prevalence-row { padding: 20px; @@ -135,6 +137,8 @@ body { flex: 1.0; } + + /* Footer ------------------------------------------------------------------ */ .footer { diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 12f9a23..3002e2d 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -179,7 +179,7 @@ def _add_experiment_qc(self, coverage_csv: str) -> None: self.components.append(self.quality_control) self.layout.append(quality_row) - def _add_prevalence_row(self, prevalence_csv: str) -> None: + def _add_prevalence_row(self, analysis_csv: str, master_csv: str) -> None: """ Add a panel that shows prevalence calls @@ -188,20 +188,34 @@ def _add_prevalence_row(self, prevalence_csv: str) -> None: id="prevalence-radio", options=list(PrevalenceBarplot.GENE_SETS.keys()), value=list(PrevalenceBarplot.GENE_SETS.keys())[0], + inputClassName="prevalence-radio-input", + labelClassName="prevalence-radio-label", + ) + radio_by = dcc.RadioItems( + id="prevalence-radio-by", + options=["All", "region", "year", "region_year"], + value="All", + inputClassName="prevalence-radio-input", + labelClassName="prevalence-radio-label", ) self.prevalence_bars = PrevalenceBarplot( self.summary_name, component_id="prevalence-bars", radio_id="prevalence-radio", - prevalence_csv=prevalence_csv, + radio_id_by="prevalence-radio-by", + analysis_csv=analysis_csv, + master_csv=master_csv, ) prevalence_row = html.Div( className="prevalence-row", children=[ html.H3("Prevalence", style=dict(marginTop="0px")), - radio, + html.Div( + className="prevalence-radio-row", + children=[radio, radio_by], + ), html.Div( className="prevalence-plots", children=[self.prevalence_bars.get_layout()], @@ -263,7 +277,8 @@ def __init__( samples_csv: str, samples_amplicons_csv: str, coverage_csv: str, - prevalence_csv: str, + analysis_csv: str, + master_csv: str, prevalence_region_csv: str, ): """ @@ -276,7 +291,8 @@ def __init__( self.samples_csv = samples_csv self.samples_amplicons_csv = samples_amplicons_csv self.coverage_csv = coverage_csv - self.prevalence_csv = prevalence_csv + self.analysis_csv = analysis_csv + self.master_csv = master_csv self.prevalence_region_csv = prevalence_region_csv def _gen_layout(self): @@ -287,7 +303,7 @@ def _gen_layout(self): self._add_throughput_banner(self.throughput_csv) self._add_samples(self.samples_csv, self.samples_amplicons_csv) self._add_experiment_qc(self.coverage_csv) - self._add_prevalence_row(self.prevalence_csv) + self._add_prevalence_row(self.analysis_csv, self.master_csv) self._add_prevalence_by_region_row(self.prevalence_region_csv) # self._add_mapping_row(self.read_mapping_csv) # self._add_region_coverage_row(self.region_coverage_csv, self.regions) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 27c566b..1b162f8 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -18,6 +18,10 @@ from dash.dependencies import Input, Output from matplotlib.colors import rgb2hex +from nomadic.summarize.compute import ( + compute_variant_prevalence, + compute_variant_prevalence_per, +) from nomadic.util.metadata import MetadataTableParser from nomadic.util.regions import RegionBEDParser from i18n import t @@ -364,19 +368,25 @@ class PrevalenceBarplot(SummaryDashboardComponent): def __init__( self, summary_name: str, - prevalence_csv: str, + analysis_csv: str, + master_csv: str, component_id: str, radio_id: str, + radio_id_by: str, ) -> None: """ Initialisation loads the coverage data and prepares for plotting; """ - self.prevalence_csv = prevalence_csv - self.prev_df = pd.read_csv(prevalence_csv) + self.analysis_csv = analysis_csv + self.analysis_df = pd.read_csv(analysis_csv) + + self.master__csv = master_csv + self.master_df = pd.read_csv(master_csv) self.radio_id = radio_id + self.radio_id_by = radio_id_by super().__init__(summary_name, component_id) def _define_layout(self): @@ -384,51 +394,89 @@ def _define_layout(self): def callback(self, app: Dash) -> None: @app.callback( - Output(self.component_id, "figure"), Input(self.radio_id, "value") + Output(self.component_id, "figure"), + Input(self.radio_id, "value"), + Input(self.radio_id_by, "value"), ) - def _update(gene_set: str): + def _update(gene_set: str, by: str): """Called whenver the input changes""" genes = self.GENE_SETS[gene_set] # Limit to key genes - plot_df = self.prev_df.query("gene in @genes") + analysis_df = self.analysis_df.query("gene in @genes") + if by == "All": + plot_df = compute_variant_prevalence(analysis_df) + else: + plot_df = compute_variant_prevalence_per( + analysis_df, self.master_df, by.split("_") + ) + if "_" in by: + # we need to create this column + plot_df[by] = ( + plot_df[by.split("_")].astype(str).agg("_".join, axis=1) + ) plot_df.sort_values(["gene", "chrom", "pos"], inplace=True) - # Prepare plotting data - customdata = np.stack( - [ - plot_df["n_samples"], - plot_df["n_passed"], - plot_df["n_mixed"] + plot_df["n_mut"], - ], - axis=-1, - ) + data = [] + htemp = "%{y:0.1f}% (%{customdata[2]}/%{customdata[1]})" + + if by == "All": + # Prepare plotting data + customdata = np.stack( + [ + plot_df["n_samples"], + plot_df["n_passed"], + plot_df["n_mixed"] + plot_df["n_mut"], + ], + axis=-1, + ) + data.append( + go.Bar( + x=plot_df["mutation"], + y=plot_df["prevalence"], + customdata=customdata, + hovertemplate=htemp, + name="Prevalence", + error_y=dict( + type="data", + array=plot_df["prevalence_highci"] - plot_df["prevalence"], + arrayminus=plot_df["prevalence"] + - plot_df["prevalence_lowci"], + ), + ) + ) + else: + for group in plot_df[by].unique(): + group_df = plot_df.query(f"{by} == @group") + # Prepare plotting data + customdata = np.stack( + [ + group_df["n_samples"], + group_df["n_passed"], + group_df["n_mixed"] + group_df["n_mut"], + ], + axis=-1, + ) + data.append( + go.Bar( + x=group_df["mutation"], + y=group_df["prevalence"], + customdata=customdata, + hovertemplate=htemp, + name=str(group), + error_y=dict( + type="data", + array=plot_df["prevalence_highci"] + - plot_df["prevalence"], + arrayminus=plot_df["prevalence"] + - plot_df["prevalence_lowci"], + ), + ) + ) # Plotting - htemp = "%{y:0.1f}% (%{customdata[2]}/%{customdata[1]})" - plot_data = [ - go.Bar( - x=plot_df["mutation"], - y=plot_df["prevalence"], - customdata=customdata, - hovertemplate=htemp, - name="Prevalence", - error_y=dict( - type="data", - array=plot_df["prevalence_highci"] - plot_df["prevalence"], - arrayminus=plot_df["prevalence"] - plot_df["prevalence_lowci"], - ), - ), - # go.Bar( - # x=plot_df["mutation"], - # y=plot_df["per_mixed"], - # customdata=customdata, - # hovertemplate=htemp, - # name="Mixed", - # ), - ] - fig = go.Figure(plot_data) + fig = go.Figure(data) fig.update_layout( yaxis_title="Prevalence (%)", xaxis=dict(showline=True, linewidth=1, linecolor="black", mirror=True), @@ -442,7 +490,6 @@ def _update(gene_set: str): gridwidth=0.5, griddash="dot", ), - barmode="stack", legend=dict( orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0 ), diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 0c38768..71985c4 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -6,12 +6,16 @@ from pathlib import Path import pandas as pd -from statsmodels.stats.proportion import proportion_confint from nomadic.dashboard.main import ( find_metadata, find_regions, ) +from nomadic.summarize.compute import ( + compute_variant_prevalence, + compute_variant_prevalence_per, + filter_false_positives, +) from nomadic.summarize.dashboard.builders import BasicSummaryDashboard from nomadic.util.dirs import produce_dir from nomadic.util.experiment import ( @@ -380,119 +384,6 @@ def load_and_concat_variants(expt_dirs: list[str]) -> pd.DataFrame: return pd.concat(full_variant_dfs) -variants_group_columns = [ - "gene", - "chrom", - "pos", - "ref", - "alt", - "aa_change", - "mut_type", - "mutation", -] - - -def filter_false_positives(variants_df): - group_counts = variants_df.groupby(variants_group_columns).agg( - n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), - n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), - mean_wsaf=pd.NamedAgg("wsaf", "mean"), - ) - df = variants_df.merge(group_counts, on=variants_group_columns, how="left") - df_filtered = df[~((df["n_mixed"] + df["n_mut"] == 1) & (df["mean_wsaf"] < 0.1))] - df_filtered = df_filtered.loc[~(df["n_mixed"] + df["n_mut"] == 0)] - return df_filtered.drop(columns=["n_mixed", "n_mut", "mean_wsaf"]) - - -def compute_variant_prevalence(variants_df: str) -> pd.DataFrame: - """ - Compute the prevalence of each mutation in `variants_df` - - Assumes columns several columns exist; compute across all samples - in data; - - """ - - prev_df = ( - variants_df.groupby( - variants_group_columns, - ) - .agg( - n_samples=pd.NamedAgg("gt_int", len), - n_passed=pd.NamedAgg("gt_int", lambda x: sum(x != -1)), - n_wt=pd.NamedAgg("gt_int", lambda x: sum(x == 0)), - n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), - n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), - ) - .reset_index() - ) - - # Compute frequencies - prev_df["per_wt"] = 100 * prev_df["n_wt"] / prev_df["n_passed"] - prev_df["per_mixed"] = 100 * prev_df["n_mixed"] / prev_df["n_passed"] - prev_df["per_mut"] = 100 * prev_df["n_mut"] / prev_df["n_passed"] - - # Compute prevalence - prev_df["prevalence"] = prev_df["per_mixed"] + prev_df["per_mut"] - - # Compute prevalence 95% confidence intervals - low, high = proportion_confint( - prev_df["n_mut"] + prev_df["n_mixed"], - prev_df["n_passed"], - alpha=0.05, - method="beta", - ) - prev_df["prevalence_lowci"] = 100 * low - prev_df["prevalence_highci"] = 100 * high - - return prev_df - - -def compute_variant_prevalence_per(variants_df, master_df, field: str) -> pd.DataFrame: - """ - Compute the prevalence of each mutation in `variants_df` - - Assumes columns several columns exist; compute across all samples - in data; - - """ - variants_df = variants_df.merge( - master_df[["sample_id", field]], on="sample_id", how="left" - ) - - prev_df = ( - variants_df.groupby([*variants_group_columns, field]) - .agg( - n_samples=pd.NamedAgg("gt_int", len), - n_passed=pd.NamedAgg("gt_int", lambda x: sum(x != -1)), - n_wt=pd.NamedAgg("gt_int", lambda x: sum(x == 0)), - n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), - n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), - ) - .reset_index() - ) - - # Compute frequencies - prev_df["per_wt"] = 100 * prev_df["n_wt"] / prev_df["n_passed"] - prev_df["per_mixed"] = 100 * prev_df["n_mixed"] / prev_df["n_passed"] - prev_df["per_mut"] = 100 * prev_df["n_mut"] / prev_df["n_passed"] - - # Compute prevalence - prev_df["prevalence"] = prev_df["per_mixed"] + prev_df["per_mut"] - - # Compute prevalence 95% confidence intervals - low, high = proportion_confint( - prev_df["n_mut"] + prev_df["n_mixed"], - prev_df["n_passed"], - alpha=0.05, - method="beta", - ) - prev_df["prevalence_lowci"] = 100 * low - prev_df["prevalence_highci"] = 100 * high - - return prev_df - - # -------------------------------------------------------------------------------- # Main # @@ -504,7 +395,7 @@ def main( expt_dirs: tuple[str], summary_name: str, meta_data_path: Path, - dashboard: bool = True, + show_dashboard: bool = True, ) -> None: """ Define the main function for the summary analysis @@ -675,13 +566,15 @@ def main( prev_df.to_csv(f"{output_dir}/summary.variants.prevalence.csv", index=False) prev_df_region = compute_variant_prevalence_per( - analysis_df, master_metadata, "region" + analysis_df, master_metadata, ["region"] ) prev_df_region.to_csv( f"{output_dir}/summary.variants.prevalence-region.csv", index=False ) - prev_df_year = compute_variant_prevalence_per(analysis_df, master_metadata, "year") + prev_df_year = compute_variant_prevalence_per( + analysis_df, master_metadata, ["year"] + ) prev_df_year.to_csv( f"{output_dir}/summary.variants.prevalence-year.csv", index=False ) @@ -691,14 +584,15 @@ def main( # # -------------------------------------------------------------------------------- - if dashboard: + if show_dashboard: dashboard = BasicSummaryDashboard( summary_name, throughput_csv=f"{output_dir}/summary.throughput.csv", samples_csv=f"{output_dir}/summary.samples_qc.csv", samples_amplicons_csv=f"{output_dir}/summary.samples_amplicons_qc.csv", coverage_csv=f"{output_dir}/summary.experiments_qc.csv", - prevalence_csv=f"{output_dir}/summary.variants.prevalence.csv", + analysis_csv=f"{output_dir}/summary.variants.analysis_set.csv", + master_csv=str(meta_data_path), prevalence_region_csv=f"{output_dir}/summary.variants.prevalence-region.csv", ) print("Done.") From 044d80c1b60d74d62a9c08b50ed7f1b0305a6991 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 15 Oct 2025 23:35:44 +0200 Subject: [PATCH 11/67] Fix filtering of false positives --- src/nomadic/summarize/compute.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index b7518a6..af07322 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -15,15 +15,10 @@ def filter_false_positives(variants_df: pd.DataFrame): - group_counts = variants_df.groupby(variants_group_columns).agg( - n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), - n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), - mean_wsaf=pd.NamedAgg("wsaf", "mean"), - ) - df = variants_df.merge(group_counts, on=variants_group_columns, how="left") - df_filtered = df[~((df["n_mixed"] + df["n_mut"] == 1) & (df["mean_wsaf"] < 0.1))] - df_filtered = df_filtered.loc[~(df["n_mixed"] + df["n_mut"] == 0)] - return df_filtered.drop(columns=["n_mixed", "n_mut", "mean_wsaf"]) + mut = variants_df[variants_df["gt_int"].isin([1,2])] + df = variants_df.merge(mut.groupby(variants_group_columns).agg(n_mut=pd.NamedAgg("gt_int", len), wsaf_max=pd.NamedAgg("wsaf", "max")), on=variants_group_columns) + df = df[~(df["n_mut"].le(1) & df["wsaf_max"].lt(0.1))].drop(columns=["n_mut", "wsaf_max"]) + return df def compute_variant_prevalence(variants_df: str) -> pd.DataFrame: From b3d7ac1a5a1b5c300d74bb8ae25fd4f134ed4fce Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 15 Oct 2025 23:59:57 +0200 Subject: [PATCH 12/67] Update wsaf false positive threashold --- src/nomadic/summarize/compute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index af07322..d5eb282 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -17,7 +17,7 @@ def filter_false_positives(variants_df: pd.DataFrame): mut = variants_df[variants_df["gt_int"].isin([1,2])] df = variants_df.merge(mut.groupby(variants_group_columns).agg(n_mut=pd.NamedAgg("gt_int", len), wsaf_max=pd.NamedAgg("wsaf", "max")), on=variants_group_columns) - df = df[~(df["n_mut"].le(1) & df["wsaf_max"].lt(0.1))].drop(columns=["n_mut", "wsaf_max"]) + df = df[~(df["n_mut"].le(1) & df["wsaf_max"].lt(0.15))].drop(columns=["n_mut", "wsaf_max"]) return df From 317bd9128fe9c3c0597a7d3f1a6f22bc2e767c8a Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 21 Oct 2025 15:53:16 +0200 Subject: [PATCH 13/67] Move some files to utils experiment --- src/nomadic/dashboard/main.py | 50 +------------------- src/nomadic/summarize/commands.py | 4 +- src/nomadic/summarize/compute.py | 13 ++++-- src/nomadic/summarize/main.py | 36 +-------------- src/nomadic/util/experiment.py | 77 +++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 89 deletions(-) diff --git a/src/nomadic/dashboard/main.py b/src/nomadic/dashboard/main.py index fe14fc1..a14afc9 100644 --- a/src/nomadic/dashboard/main.py +++ b/src/nomadic/dashboard/main.py @@ -1,57 +1,9 @@ import os from nomadic.realtime.dashboard.builders import MappingRTDashboard, CallingRTDashboard -from nomadic.util.metadata import MetadataTableParser -from nomadic.util.experiment import ExperimentDirectories -from nomadic.util.regions import RegionBEDParser +from nomadic.util.experiment import ExperimentDirectories, find_metadata, find_regions from nomadic.util.settings import load_settings -def find_metadata(input_dir: str) -> MetadataTableParser: - """ - Given an experiment directory, search for the metadata CSV file in thee - expected location - - """ - - metadata_dir = os.path.join(input_dir, "metadata") - csvs = [ - f"{metadata_dir}/{file}" - for file in os.listdir(metadata_dir) - if file.endswith(".csv") - and not file.startswith("._") # ignore AppleDouble files - ] # TODO: what about no-suffix files? - - if len(csvs) != 1: # Could alternatively load and LOOK - raise FileNotFoundError( - f"Expected one metadata CSV file (*.csv) at {metadata_dir}, but found {len(csvs)}." - ) - - return MetadataTableParser(csvs[0]) - - -def find_regions(input_dir: str) -> RegionBEDParser: - """ - Given an experiment directory, search for the metadata CSV file in thee - expected location - - TODO: Bad duplication from above, can write inner function - """ - - metadata_dir = os.path.join(input_dir, "metadata") - beds = [ - f"{metadata_dir}/{file}" - for file in os.listdir(metadata_dir) - if file.endswith(".bed") and not file.endswith(".lowcomplexity_mask.bed") - ] # TODO: what about no-suffix files? - - if len(beds) != 1: # Could alternatively load and LOOK - raise FileNotFoundError( - f"Expected one region BED file (*.bed) at {metadata_dir}, but found {len(beds)}." - ) - - return RegionBEDParser(beds[0]) - - def variant_calling_performed(expt_dirs: ExperimentDirectories) -> bool: """ Check if the variant calling TSV is present diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 601eb11..85f7f03 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -1,7 +1,6 @@ from pathlib import Path import click -import dash from nomadic.util.workspace import Workspace, check_if_workspace @@ -47,7 +46,8 @@ def summarize( ): """ Summarize a set of experiments to evaluate quality control and - mutation prevalence + mutation prevalence. You can either provide a list of folders of experiments, + or if none are provided, all experiments of this workspace will be used. """ if not check_if_workspace(workspace_path): diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index d5eb282..7e869e1 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -15,9 +15,16 @@ def filter_false_positives(variants_df: pd.DataFrame): - mut = variants_df[variants_df["gt_int"].isin([1,2])] - df = variants_df.merge(mut.groupby(variants_group_columns).agg(n_mut=pd.NamedAgg("gt_int", len), wsaf_max=pd.NamedAgg("wsaf", "max")), on=variants_group_columns) - df = df[~(df["n_mut"].le(1) & df["wsaf_max"].lt(0.15))].drop(columns=["n_mut", "wsaf_max"]) + mut = variants_df[variants_df["gt_int"].isin([1, 2])] + df = variants_df.merge( + mut.groupby(variants_group_columns).agg( + n_mut=pd.NamedAgg("gt_int", len), wsaf_max=pd.NamedAgg("wsaf", "max") + ), + on=variants_group_columns, + ) + df = df[~(df["n_mut"].le(1) & df["wsaf_max"].lt(0.15))].drop( + columns=["n_mut", "wsaf_max"] + ) return df diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 71985c4..06c8e56 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -19,9 +19,8 @@ from nomadic.summarize.dashboard.builders import BasicSummaryDashboard from nomadic.util.dirs import produce_dir from nomadic.util.experiment import ( + check_complete_experiment, get_summary_files, - legacy_summary_files, - summary_files, ) from nomadic.util.logging_config import LoggingFascade from nomadic.util.metadata import ExtendedMetadataTableParser @@ -48,39 +47,6 @@ def get_metadata_csv(expt_dir: str) -> str: # Check complete experiment # # -------------------------------------------------------------------------------- - - -def check_complete_experiment(expt_dir: str) -> None: - """ - Check if an experiment is complete; in reality, it would be nice, at this point, to load an object - that represents all the files I'd want to work with, e.g. the experiment directories class - """ - - if not os.path.isdir(expt_dir): - raise FileNotFoundError(f"Experiment directory {expt_dir} does not exist.") - - # We can use this for now, but of course this is getting messy - _ = find_metadata(expt_dir) - _ = find_regions(expt_dir) - - used_summary_files = None - for file_format in [summary_files, legacy_summary_files]: - if not os.path.exists(f"{expt_dir}/{file_format.fastqs_processed}"): - continue - - used_summary_files = file_format - for file in used_summary_files: - if not os.path.exists(f"{expt_dir}/{file}"): - raise FileNotFoundError(f"Missing '{file}' file in {expt_dir}.") - - if not used_summary_files: - raise FileNotFoundError(f"Could not find any summary files in {expt_dir}.") - - # TODO: for now, using this for VCF - if not os.path.exists(f"{expt_dir}/vcfs"): - raise FileNotFoundError(f"Could not find VCF directory in {expt_dir}.") - - def check_regions_consistent(expt_dirs: tuple[str]) -> None: """ Check that the regions are consistent across all experiment directories diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index 775ca19..e46e6d1 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -138,3 +138,80 @@ def get_summary_files(exp_path: Path) -> SummaryFiles: depth_profiles=str(exp_path / summary_files.depth_profiles), variants=str(exp_path / summary_files.variants), ) + + +def check_complete_experiment(expt_dir: str) -> None: + """ + Check if an experiment is complete; in reality, it would be nice, at this point, to load an object + that represents all the files I'd want to work with, e.g. the experiment directories class + """ + + if not os.path.isdir(expt_dir): + raise FileNotFoundError(f"Experiment directory {expt_dir} does not exist.") + + # We can use this for now, but of course this is getting messy + _ = find_metadata(expt_dir) + _ = find_regions(expt_dir) + + used_summary_files = None + for file_format in [summary_files, legacy_summary_files]: + if not os.path.exists(f"{expt_dir}/{file_format.fastqs_processed}"): + continue + + used_summary_files = file_format + for file in used_summary_files: + if not os.path.exists(f"{expt_dir}/{file}"): + raise FileNotFoundError(f"Missing '{file}' file in {expt_dir}.") + + if not used_summary_files: + raise FileNotFoundError(f"Could not find any summary files in {expt_dir}.") + + # TODO: for now, using this for VCF + if not os.path.exists(f"{expt_dir}/vcfs"): + raise FileNotFoundError(f"Could not find VCF directory in {expt_dir}.") + + +def find_metadata(input_dir: str) -> MetadataTableParser: + """ + Given an experiment directory, search for the metadata CSV file in thee + expected location + + """ + + metadata_dir = os.path.join(input_dir, "metadata") + csvs = [ + f"{metadata_dir}/{file}" + for file in os.listdir(metadata_dir) + if file.endswith(".csv") + and not file.startswith("._") # ignore AppleDouble files + ] # TODO: what about no-suffix files? + + if len(csvs) != 1: # Could alternatively load and LOOK + raise FileNotFoundError( + f"Expected one metadata CSV file (*.csv) at {metadata_dir}, but found {len(csvs)}." + ) + + return MetadataTableParser(csvs[0]) + + +def find_regions(input_dir: str) -> RegionBEDParser: + """ + Given an experiment directory, search for the metadata CSV file in thee + expected location + + TODO: Bad duplication from above, can write inner function + """ + + metadata_dir = os.path.join(input_dir, "metadata") + beds = [ + f"{metadata_dir}/{file}" + for file in os.listdir(metadata_dir) + if file.endswith(".bed") and not file.endswith(".lowcomplexity_mask.bed") + ] # TODO: what about no-suffix files? + + if len(beds) != 1: # Could alternatively load and LOOK + raise FileNotFoundError( + f"Expected one region BED file (*.bed) at {metadata_dir}, but found {len(beds)}." + ) + + return RegionBEDParser(beds[0]) From 454da0b1745a07f74208f3258aa556ab799d1336 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 21 Oct 2025 16:46:53 +0200 Subject: [PATCH 14/67] Add some more docs and move code in summarize --- src/nomadic/summarize/main.py | 58 ++++++++++++++++++++-------------- src/nomadic/util/experiment.py | 30 +++++++++++------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 06c8e56..8d8433c 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -1,5 +1,6 @@ import glob import os +from typing import Iterable from warnings import warn import webbrowser from enum import StrEnum, auto @@ -8,7 +9,6 @@ import pandas as pd from nomadic.dashboard.main import ( - find_metadata, find_regions, ) from nomadic.summarize.compute import ( @@ -20,29 +20,13 @@ from nomadic.util.dirs import produce_dir from nomadic.util.experiment import ( check_complete_experiment, + get_metadata_csv, get_summary_files, ) from nomadic.util.logging_config import LoggingFascade from nomadic.util.metadata import ExtendedMetadataTableParser -def get_metadata_csv(expt_dir: str) -> str: - """ - Get the metadata CSV file - TODO: Does this not duplicate with 'find_metadata' ?? - """ - # In most cases, should match experiment name - metadata_csv = f"{expt_dir}/metadata/{os.path.basename(expt_dir)}.csv" - if os.path.exists(metadata_csv): - return metadata_csv - metadata_csv = glob.glob(f"{expt_dir}/metadata/*.csv") - if len(metadata_csv) == 1: - return metadata_csv[0] - raise ValueError( - f"Found {len(metadata_csv)} *.csv files in '{expt_dir}/metadata', cannot determine which is metadata." - ) - - # -------------------------------------------------------------------------------- # Check complete experiment # @@ -87,7 +71,7 @@ def compute_throughput(metadata: pd.DataFrame, add_unique: bool = True) -> pd.Da def get_region_coverage_dataframe( - expt_dirs: list[str], metadata: pd.DataFrame + expt_dirs: Iterable[str], metadata: pd.DataFrame ) -> pd.DataFrame: """ Here we load a consolidated region coverage dataframe and include information required @@ -140,6 +124,9 @@ def get_region_coverage_dataframe( def calc_unknown_samples(inventory_metadata: pd.DataFrame, master_metadata): + """ + Returns the sample ids that are not in the masterdata file. + """ field_samples = inventory_metadata.query("sample_type == 'field'") unknown_samples = field_samples.loc[ ~field_samples["sample_id"].isin(master_metadata["sample_id"]), "sample_id" @@ -415,6 +402,7 @@ def main( shared_columns = fixed_columns inventory_metadata = pd.concat([df[shared_columns] for df in dfs]) master_metadata = pd.read_csv(meta_data_path) + # Do we want to have metadata in result files? # inventory_metadata = pd.merge( # left=inventory_metadata, right=master_metadata, on=["sample_id"], how="left" # ) @@ -424,10 +412,11 @@ def main( f"Samples in experiments that are not in master metadata: {unknown_samples}" ) + # Delete unknown samples inventory_metadata = inventory_metadata[ ~inventory_metadata["sample_id"].isin(unknown_samples) ] - inventory_metadata.to_csv(f"{output_dir}/metadata.csv", index=False) + inventory_metadata.to_csv(f"{output_dir}/inventory.csv", index=False) # Check regions are consistent check_regions_consistent(expt_dirs) @@ -461,7 +450,7 @@ def main( log.info(f" Contamination >{MAX_CONTAM}: {n_contam} ({100 * n_contam / n:.2f}%)") log.info(f" Passing QC: {n_pass} ({100 * n_pass / n:.2f}%)") add_quality_control_status_column(coverage_df) - log.info(coverage_df["status"].value_counts()) + log.info(str(coverage_df["status"].value_counts())) coverage_df.to_csv(f"{output_dir}/summary.coverage.csv", index=False) REPLICATE_PASSING_THRESHOLD = 0.8 @@ -570,7 +559,15 @@ def main( dashboard.run(debug=True) -def calc_samples_summary(master_metadata, replicates_qc_df): +def calc_samples_summary( + master_metadata_df: pd.DataFrame, replicates_qc_df: pd.DataFrame +) -> pd.DataFrame: + """ + Calculates a summary of which samples have how many replicates that are passing or failing, + and if it has at least one passing replicate, it is concidered as passing. + + This can be used to get a list of to be resequenced samples. + """ samples_summary_df = ( replicates_qc_df.groupby(["sample_id"]) .agg( @@ -581,7 +578,7 @@ def calc_samples_summary(master_metadata, replicates_qc_df): ) samples_summary_df = ( samples_summary_df.merge( - master_metadata[["sample_id"]], how="right", on="sample_id" + master_metadata_df[["sample_id"]], how="right", on="sample_id" ) .fillna({"n_replicates": 0, "n_passing": 0}) .astype({"n_replicates": int, "n_passing": int}) @@ -604,6 +601,13 @@ def calc_samples_summary(master_metadata, replicates_qc_df): def calc_amplicons_summary(master_metadata, replicates_amplicon_qc_df): + """ + Calculates a summary of which samples have how many replicates per amplicon that are passing or failing, + and if it has at least one passing replicate over that amplicon, it is concidered as passing. + + This can be used to understand if there are certain amplicons of samples that have no coverage yet + and make decisions on resampling, that are more fine granular than per sample. + """ samples_by_amplicons_summary_df = ( replicates_amplicon_qc_df.groupby(["sample_id", "name"]) .agg( @@ -636,7 +640,13 @@ def calc_amplicons_summary(master_metadata, replicates_amplicon_qc_df): return samples_by_amplicons_summary_df -def replicates_qc(coverage_df, REPLICATE_PASSING_THRESHOLD): +def replicates_qc( + coverage_df: pd.DataFrame, REPLICATE_PASSING_THRESHOLD: float +) -> pd.DataFrame: + """ + Calculates which of the replicates (repeated runs of a sample) passed QC as a whole + (more than REPLICATE_PASSING_THRESHOLD passed) + """ replicates_qc_df = ( coverage_df.query("sample_type == 'field'") .groupby(["expt_name", "barcode", "sample_id"]) diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index e46e6d1..68c963c 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -1,3 +1,4 @@ +import glob import os import shutil from pathlib import Path @@ -176,22 +177,27 @@ def find_metadata(input_dir: str) -> MetadataTableParser: Given an experiment directory, search for the metadata CSV file in thee expected location + TODO this function is probably not needed anymore, could combine it with get_metadata_csv """ - metadata_dir = os.path.join(input_dir, "metadata") - csvs = [ - f"{metadata_dir}/{file}" - for file in os.listdir(metadata_dir) - if file.endswith(".csv") - and not file.startswith("._") # ignore AppleDouble files - ] # TODO: what about no-suffix files? + csv = get_metadata_csv(expt_dir=input_dir) + return MetadataTableParser(csv) - if len(csvs) != 1: # Could alternatively load and LOOK - raise FileNotFoundError( - f"Expected one metadata CSV file (*.csv) at {metadata_dir}, but found {len(csvs)}." - ) - return MetadataTableParser(csvs[0]) +def get_metadata_csv(expt_dir: str) -> str: + """ + Get the metadata CSV file + """ + # In most cases, should match experiment name + metadata_csv = f"{expt_dir}/metadata/{os.path.basename(expt_dir)}.csv" + if os.path.exists(metadata_csv): + return metadata_csv + metadata_csv = glob.glob(f"{expt_dir}/metadata/*.csv") + if len(metadata_csv) == 1: + return metadata_csv[0] + raise ValueError( + f"Found {len(metadata_csv)} *.csv files in '{expt_dir}/metadata', cannot determine which is metadata." + ) def find_regions(input_dir: str) -> RegionBEDParser: From d8b8d75602af0c6c9f1ffa27ca1f76aabae34bd5 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 22 Oct 2025 12:31:40 +0200 Subject: [PATCH 15/67] Linter fixes and code structure --- src/nomadic/summarize/dashboard/builders.py | 2 - src/nomadic/summarize/dashboard/components.py | 16 +- src/nomadic/summarize/main.py | 252 +++++++++--------- 3 files changed, 134 insertions(+), 136 deletions(-) diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 3002e2d..c6ccbe3 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -2,8 +2,6 @@ import threading from abc import ABC, abstractmethod from dash import Dash, html, dcc -from datetime import datetime -from typing import Optional from i18n import t diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 1b162f8..c67d18e 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -1,29 +1,15 @@ -# import datetime -# import os from abc import ABC, abstractmethod -from cProfile import label -from calendar import c -from turtle import title -# from typing import Optional -# import re -from matplotlib.pyplot import colormaps import numpy as np import pandas as pd -import plotly.express as px import plotly.graph_objects as go -from pyparsing import annotations -import seaborn as sns from dash import Dash, dcc, html from dash.dependencies import Input, Output -from matplotlib.colors import rgb2hex from nomadic.summarize.compute import ( compute_variant_prevalence, compute_variant_prevalence_per, ) -from nomadic.util.metadata import MetadataTableParser -from nomadic.util.regions import RegionBEDParser from i18n import t # -------------------------------------------------------------------------------- @@ -401,7 +387,7 @@ def callback(self, app: Dash) -> None: def _update(gene_set: str, by: str): """Called whenver the input changes""" - genes = self.GENE_SETS[gene_set] + genes = self.GENE_SETS[gene_set] # noqa: F841 later used in query # Limit to key genes analysis_df = self.analysis_df.query("gene in @genes") diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 8d8433c..3e692f7 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -1,4 +1,3 @@ -import glob import os from typing import Iterable from warnings import warn @@ -171,6 +170,10 @@ class QcStatus(StrEnum): def _add_qc_status_no_duplicates(df: pd.DataFrame) -> list[str]: + """ + Adds a status to each replicate/amplicon to see which ones passed QC + and if they didn't, why. + """ status_strs = [] for _, row in df.iterrows(): status = [] @@ -188,6 +191,11 @@ def _add_qc_status_no_duplicates(df: pd.DataFrame) -> list[str]: def _mark_duplicates(df: pd.DataFrame) -> None: + """ + Mark all field samples as duplicates, if there is a better covered replicate for the same amplicon + Replicates marked as duplicate will not be used for prevalance evaluation. + """ + def _update_duplicate(status: str, idx: int, keep_idx: int) -> str: if idx == keep_idx: return status @@ -244,7 +252,12 @@ def load_variant_summary_csv( # Settings NUMERIC_COLUMNS = ["dp", "wsaf"] - UNPHASED_GT_TO_INT = {"./.": -1, "0/0": 0, "0/1": 1, "1/1": 2} + UNPHASED_GT_TO_INT = { + "./.": -1, + "0/0": 0, + "0/1": 1, + "1/1": 2, + } # TODO What about multiallelic sites # Load variants_df = pd.read_csv(variants_csv) @@ -329,14 +342,126 @@ def load_and_concat_variants(expt_dirs: list[str]) -> pd.DataFrame: mdf["barcode"] = barcode # Filled by default with hom reference - mdf["gt"] = mdf["gt"].fillna(0.0) + mdf["gt"] = mdf["gt"].fillna("0/0") mdf["gt_int"] = mdf["gt_int"].fillna(0.0) + mdf["wsaf"] = mdf["wsaf"].fillna(0.0) full_variant_dfs.append(mdf) return pd.concat(full_variant_dfs) +def calc_samples_summary( + master_metadata_df: pd.DataFrame, replicates_qc_df: pd.DataFrame +) -> pd.DataFrame: + """ + Calculates a summary of which samples have how many replicates that are passing or failing, + and if it has at least one passing replicate, it is concidered as passing. + + This can be used to get a list of to be resequenced samples. + """ + samples_summary_df = ( + replicates_qc_df.groupby(["sample_id"]) + .agg( + n_replicates=pd.NamedAgg("barcode", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + ) + .reset_index() + ) + samples_summary_df = ( + samples_summary_df.merge( + master_metadata_df[["sample_id"]], how="right", on="sample_id" + ) + .fillna({"n_replicates": 0, "n_passing": 0}) + .astype({"n_replicates": int, "n_passing": int}) + ) + samples_summary_df["status"] = samples_summary_df.apply( + lambda row: "passing" + if row["n_passing"] > 0 + else "failing" + if row["n_replicates"] > 0 + else "missing", + axis=1, + ) + samples_summary_df.sort_values( + by=["n_passing", "n_replicates", "sample_id"], + inplace=True, + ascending=[False, False, True], + ) + + return samples_summary_df + + +def calc_amplicons_summary(master_metadata, replicates_amplicon_qc_df): + """ + Calculates a summary of which samples have how many replicates per amplicon that are passing or failing, + and if it has at least one passing replicate over that amplicon, it is concidered as passing. + + This can be used to understand if there are certain amplicons of samples that have no coverage yet + and make decisions on resampling, that are more fine granular than per sample. + """ + samples_by_amplicons_summary_df = ( + replicates_amplicon_qc_df.groupby(["sample_id", "name"]) + .agg( + n_replicates=pd.NamedAgg("barcode", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + ) + .reset_index() + ) + samples_by_amplicons_summary_df = ( + samples_by_amplicons_summary_df.merge( + master_metadata[["sample_id"]], how="right", on="sample_id" + ) + .fillna({"n_replicates": 0, "n_passing": 0}) + .astype({"n_replicates": int, "n_passing": int}) + ) + samples_by_amplicons_summary_df["status"] = samples_by_amplicons_summary_df.apply( + lambda row: "passing" + if row["n_passing"] > 0 + else "failing" + if row["n_replicates"] > 0 + else "missing", + axis=1, + ) + samples_by_amplicons_summary_df.sort_values( + by=["n_passing", "n_replicates", "sample_id"], + inplace=True, + ascending=[False, False, True], + ) + + return samples_by_amplicons_summary_df + + +def replicates_qc( + coverage_df: pd.DataFrame, REPLICATE_PASSING_THRESHOLD: float +) -> pd.DataFrame: + """ + Calculates which of the replicates (repeated runs of a sample) passed QC as a whole + (more than REPLICATE_PASSING_THRESHOLD passed) + """ + replicates_qc_df = ( + coverage_df.query("sample_type == 'field'") + .groupby(["expt_name", "barcode", "sample_id"]) + .agg( + n_amplicons=pd.NamedAgg("name", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + n_fail_contam=pd.NamedAgg("fail_contam", "sum"), + n_fail_lowcov=pd.NamedAgg("fail_lowcov", "sum"), + ) + .reset_index() + ) + replicates_qc_df["passing"] = ( + replicates_qc_df["n_passing"] / replicates_qc_df["n_amplicons"] + >= REPLICATE_PASSING_THRESHOLD + ) + + return replicates_qc_df + + +def replicates_amplicon_qc(coverage_df): + return coverage_df.query("sample_type == 'field'") + + # -------------------------------------------------------------------------------- # Main # @@ -503,13 +628,13 @@ def main( ) log.info("Filtering to analysis set...") - remove_genes = ["hrp2", "hrp3"] - remove_mutations = ["crt-N75K"] + remove_genes = ["hrp2", "hrp3"] # noqa: F841 later used in query + remove_mutations = ["crt-N75K"] # noqa: F841 later used in query analysis_df = ( variant_df.query("status == 'pass'") .query("mut_type == 'missense'") .query("gene not in @remove_genes") - .query("mutation not in @remove_mutations") + # .query("mutation not in @remove_mutations") ) # Filter out false positives @@ -555,119 +680,8 @@ def main( print("") print("Launching dashboard (press CNTRL+C to exit):") print("") - # webbrowser.open("http://127.0.0.1:8050") - dashboard.run(debug=True) - - -def calc_samples_summary( - master_metadata_df: pd.DataFrame, replicates_qc_df: pd.DataFrame -) -> pd.DataFrame: - """ - Calculates a summary of which samples have how many replicates that are passing or failing, - and if it has at least one passing replicate, it is concidered as passing. - - This can be used to get a list of to be resequenced samples. - """ - samples_summary_df = ( - replicates_qc_df.groupby(["sample_id"]) - .agg( - n_replicates=pd.NamedAgg("barcode", "count"), - n_passing=pd.NamedAgg("passing", "sum"), - ) - .reset_index() - ) - samples_summary_df = ( - samples_summary_df.merge( - master_metadata_df[["sample_id"]], how="right", on="sample_id" - ) - .fillna({"n_replicates": 0, "n_passing": 0}) - .astype({"n_replicates": int, "n_passing": int}) - ) - samples_summary_df["status"] = samples_summary_df.apply( - lambda row: "passing" - if row["n_passing"] > 0 - else "failing" - if row["n_replicates"] > 0 - else "missing", - axis=1, - ) - samples_summary_df.sort_values( - by=["n_passing", "n_replicates", "sample_id"], - inplace=True, - ascending=[False, False, True], - ) - - return samples_summary_df - - -def calc_amplicons_summary(master_metadata, replicates_amplicon_qc_df): - """ - Calculates a summary of which samples have how many replicates per amplicon that are passing or failing, - and if it has at least one passing replicate over that amplicon, it is concidered as passing. - - This can be used to understand if there are certain amplicons of samples that have no coverage yet - and make decisions on resampling, that are more fine granular than per sample. - """ - samples_by_amplicons_summary_df = ( - replicates_amplicon_qc_df.groupby(["sample_id", "name"]) - .agg( - n_replicates=pd.NamedAgg("barcode", "count"), - n_passing=pd.NamedAgg("passing", "sum"), - ) - .reset_index() - ) - samples_by_amplicons_summary_df = ( - samples_by_amplicons_summary_df.merge( - master_metadata[["sample_id"]], how="right", on="sample_id" - ) - .fillna({"n_replicates": 0, "n_passing": 0}) - .astype({"n_replicates": int, "n_passing": int}) - ) - samples_by_amplicons_summary_df["status"] = samples_by_amplicons_summary_df.apply( - lambda row: "passing" - if row["n_passing"] > 0 - else "failing" - if row["n_replicates"] > 0 - else "missing", - axis=1, - ) - samples_by_amplicons_summary_df.sort_values( - by=["n_passing", "n_replicates", "sample_id"], - inplace=True, - ascending=[False, False, True], - ) - - return samples_by_amplicons_summary_df - - -def replicates_qc( - coverage_df: pd.DataFrame, REPLICATE_PASSING_THRESHOLD: float -) -> pd.DataFrame: - """ - Calculates which of the replicates (repeated runs of a sample) passed QC as a whole - (more than REPLICATE_PASSING_THRESHOLD passed) - """ - replicates_qc_df = ( - coverage_df.query("sample_type == 'field'") - .groupby(["expt_name", "barcode", "sample_id"]) - .agg( - n_amplicons=pd.NamedAgg("name", "count"), - n_passing=pd.NamedAgg("passing", "sum"), - n_fail_contam=pd.NamedAgg("fail_contam", "sum"), - n_fail_lowcov=pd.NamedAgg("fail_lowcov", "sum"), - ) - .reset_index() - ) - replicates_qc_df["passing"] = ( - replicates_qc_df["n_passing"] / replicates_qc_df["n_amplicons"] - >= REPLICATE_PASSING_THRESHOLD - ) - - return replicates_qc_df - - -def replicates_amplicon_qc(coverage_df): - return coverage_df.query("sample_type == 'field'") + webbrowser.open("http://127.0.0.1:8050") + dashboard.run(debug=False) # CHECKPOINT 2: # summary.quality_control.by_amplicon.csv From a68b0d80c00765c9c26f649e62f366101bd7c47b Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Mon, 27 Oct 2025 16:32:45 +0100 Subject: [PATCH 16/67] Make sample_type mandatory Make sample_type mandatory. We need it for summary analysis and it is best if people just include it when creating the sample sheet. It should not be much extra work. We maybe want to make it optional if we can derive it from the sample name, to be discussed. --- src/nomadic/realtime/dashboard/components.py | 8 ++-- src/nomadic/realtime/pipelines/experiment.py | 10 ++--- src/nomadic/summarize/commands.py | 19 ++++++--- src/nomadic/summarize/main.py | 10 ++++- .../sample_info_badbarcode-format.csv | 13 +++--- .../metadata/sample_info_badbarcode-int.csv | 13 +++--- .../metadata/sample_info_badheader.csv | 13 +++--- .../sample_info_column_correction_case.csv | 13 +++--- .../sample_info_column_correction_other.csv | 13 +++--- .../sample_info_column_correction_plural.csv | 13 +++--- .../sample_info_column_correction_sample.csv | 13 +++--- .../sample_info_column_correction_space.csv | 13 +++--- ...mple_info_column_correction_underscore.csv | 13 +++--- .../metadata/sample_info_dupbarcode.csv | 13 +++--- .../metadata/sample_info_eurosep.csv | 13 +++--- .../_test_data/metadata/sample_info_good.csv | 13 +++--- .../metadata/sample_info_hybridsep-col.csv | 13 +++--- .../metadata/sample_info_nobarcode.csv | 13 +++--- .../metadata/sample_info_onecolumn.csv | 1 + .../metadata/sample_info_semicolon.csv | 13 +++--- src/nomadic/util/experiment.py | 10 ++++- src/nomadic/util/metadata.py | 16 ++++++-- src/nomadic/util/metadata_test.py | 41 ++++++++++++------- 23 files changed, 182 insertions(+), 128 deletions(-) diff --git a/src/nomadic/realtime/dashboard/components.py b/src/nomadic/realtime/dashboard/components.py index cf4190b..1d92eab 100644 --- a/src/nomadic/realtime/dashboard/components.py +++ b/src/nomadic/realtime/dashboard/components.py @@ -394,7 +394,7 @@ def _update(_, selected_categories): if "sample_id" not in df.columns: # for backwards compatibility to be able to show old experiments where this column was not in the data - df = df.join(self.metadata.required_metadata, on="barcode") + df = df.join(self.metadata.sample_ids_df, on="barcode") x = df.apply( sample_string_from_row, @@ -578,7 +578,7 @@ def _update(_, dropdown_stat, shift=0): ) if "sample_id" not in df.columns: # for backwards compatibility to be able to show old experiments where this column was not in the data - df = df.join(self.metadata.required_metadata, on="barcode") + df = df.join(self.metadata.sample_ids_df, on="barcode") # Prepare plotting data # plot_data = [ @@ -1126,9 +1126,7 @@ def _update(_, target_region): target_df = df.query(qry) if "sample_id" not in target_df.columns: # for backwards compatibility to be able to show old experiments where this column was not in the data - target_df = target_df.join( - self.metadata.required_metadata, on="barcode" - ) + target_df = target_df.join(self.metadata.sample_ids_df, on="barcode") # Munge for plot target_df["sample_string"] = pd.Categorical( diff --git a/src/nomadic/realtime/pipelines/experiment.py b/src/nomadic/realtime/pipelines/experiment.py index 608f642..0122787 100644 --- a/src/nomadic/realtime/pipelines/experiment.py +++ b/src/nomadic/realtime/pipelines/experiment.py @@ -74,7 +74,7 @@ def _run_fastq(self): except FileNotFoundError: continue df = pd.DataFrame(fastq_dts) - df = df.join(self.metadata.required_metadata, on="barcode") + df = df.join(self.metadata.sample_ids_df, on="barcode") df_path = self.expt_dirs.get_summary_files().fastqs_processed df.to_csv(df_path, index=False) @@ -97,7 +97,7 @@ def _run_qcbams(self): except FileNotFoundError: continue df = pd.DataFrame(qcbams_dts) - df = df.join(self.metadata.required_metadata, on="barcode") + df = df.join(self.metadata.sample_ids_df, on="barcode") df_path = self.expt_dirs.get_summary_files().read_mapping df.to_csv(df_path, index=False) @@ -117,7 +117,7 @@ def _run_bedcov(self): except FileNotFoundError: continue bedcov_df = pd.concat(bedcov_dfs) - bedcov_df = bedcov_df.join(self.metadata.required_metadata, on="barcode") + bedcov_df = bedcov_df.join(self.metadata.sample_ids_df, on="barcode") df_path = self.expt_dirs.get_summary_files().region_coverage bedcov_df.to_csv(df_path, index=False) @@ -137,7 +137,7 @@ def _run_depth(self): except FileNotFoundError: continue depth_df = pd.concat(depth_dfs) - depth_df = depth_df.join(self.metadata.required_metadata, on="barcode") + depth_df = depth_df.join(self.metadata.sample_ids_df, on="barcode") df_path = self.expt_dirs.get_summary_files().depth_profiles depth_df.to_csv(df_path, index=False) @@ -192,7 +192,7 @@ def _run_variant(self, caller: str): annotator.convert_to_csv(temp_path) df = pd.read_csv(temp_path) - df = df.join(self.metadata.required_metadata, on="barcode") + df = df.join(self.metadata.sample_ids_df, on="barcode") df.to_csv(csv_path, index=False) # Clean-up diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 85f7f03..0ddbe1b 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -2,6 +2,7 @@ import click +from nomadic.util.exceptions import MetadataFormatError from nomadic.util.workspace import Workspace, check_if_workspace @@ -74,9 +75,15 @@ def summarize( from .main import main - main( - expt_dirs=experiment_dirs, - summary_name=summary_name, - meta_data_path=metadata_csv, - show_dashboard=dashboard, - ) + try: + main( + expt_dirs=experiment_dirs, + summary_name=summary_name, + meta_data_path=metadata_csv, + show_dashboard=dashboard, + ) + except MetadataFormatError as e: + raise click.BadParameter( + param_hint="-m/--metadata_csv", + message=f"Metadata format error: {e}", + ) from e diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 3e692f7..67e3383 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -17,6 +17,7 @@ ) from nomadic.summarize.dashboard.builders import BasicSummaryDashboard from nomadic.util.dirs import produce_dir +from nomadic.util.exceptions import MetadataFormatError from nomadic.util.experiment import ( check_complete_experiment, get_metadata_csv, @@ -509,8 +510,13 @@ def main( dfs = [] for expt_dir in expt_dirs: metadata_csv = get_metadata_csv(expt_dir) - parser = ExtendedMetadataTableParser(metadata_csv) - parser.df.insert(0, "expt_name", os.path.basename(expt_dir)) + try: + parser = ExtendedMetadataTableParser(metadata_csv) + parser.df.insert(0, "expt_name", os.path.basename(expt_dir)) + except MetadataFormatError as e: + raise MetadataFormatError( + f"Metadata format issue in experiment directory {expt_dir}: {e}" + ) from e if not dfs: shared_columns = set(parser.df.columns) shared_columns.intersection_update(parser.df.columns) diff --git a/src/nomadic/util/_test_data/metadata/sample_info_badbarcode-format.csv b/src/nomadic/util/_test_data/metadata/sample_info_badbarcode-format.csv index 95bbe63..91fbb0b 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_badbarcode-format.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_badbarcode-format.csv @@ -1,6 +1,7 @@ -barcode,sample_id,parasite_per_ul,location,platform -barcode_01,3D7 (NB01),10000,Oxford,flongle -barcode_02,Dd2 (NB02),10000,Oxford,flongle -barcode_03,CamC580Y (NB03),10000,Oxford,flongle -barcode_04,GB4 (NB04),10000,Oxford,flongle -barcode_05,HB3 (NB05),10000,Oxford,flongle +barcode,sample_id,sample_type,location,parasitemia,postclean_qubit +barcode_01,3D7,pos,berlin,1000,87 +barcode_02,HB3,pos,berlin,100,34 +barcode_03,NTC,neg,berlin,0,0 +barcode_04,DBS-A01,field,berlin,543,44 +barcode_05,DBS-A02,field,berlin,7583,85 +barcode_06,DBS-A03,field,berlin,349,32 diff --git a/src/nomadic/util/_test_data/metadata/sample_info_badbarcode-int.csv b/src/nomadic/util/_test_data/metadata/sample_info_badbarcode-int.csv index a30c2d5..8cf2967 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_badbarcode-int.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_badbarcode-int.csv @@ -1,6 +1,7 @@ -barcode,sample_id,parasite_per_ul,location,platform -1,3D7 (NB01),10000,Oxford,flongle -2,Dd2 (NB02),10000,Oxford,flongle -3,CamC580Y (NB03),10000,Oxford,flongle -4,GB4 (NB04),10000,Oxford,flongle -5,HB3 (NB05),10000,Oxford,flongle +barcode,sample_id,sample_type,location,parasitemia,postclean_qubit +1,3D7,pos,berlin,1000,87 +2,HB3,pos,berlin,100,34 +3,NTC,neg,berlin,0,0 +4,DBS-A01,field,berlin,543,44 +5,DBS-A02,field,berlin,7583,85 +6,DBS-A03,field,berlin,349,32 diff --git a/src/nomadic/util/_test_data/metadata/sample_info_badheader.csv b/src/nomadic/util/_test_data/metadata/sample_info_badheader.csv index 9b24a43..2fa0660 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_badheader.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_badheader.csv @@ -1,6 +1,7 @@ -barcode;sample_id,parasite_per_ul,location;platform;number -barcode01;3D7 (NB01);10000;Oxford;flongle;8,4 -barcode02;Dd2 (NB02);10000;Oxford;flongle;2,0 -barcode03;CamC580Y (NB03);10000;Oxford;flongle;4,3 -barcode04;GB4 (NB04);10000;Oxford;flongle;5,4 -barcode05;HB3 (NB05);10000;Oxford;flongle;6,1 +barcode;sample_id,sample_type,location;parasitemia;postclean_qubit +barcode01;3D7;pos;berlin;1000;87 +barcode02;HB3;pos;berlin;100;34 +barcode03;NTC;neg;berlin;0;0 +barcode04;DBS-A01;field;berlin;543;44 +barcode05;DBS-A02;field;berlin;7583;85 +barcode06;DBS-A03;field;berlin;349;32 diff --git a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_case.csv b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_case.csv index ec8542a..40456f0 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_case.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_case.csv @@ -1,6 +1,7 @@ -BARCODE,SAMPLE_ID,parasite_per_ul,location,platform -barcode01,3D7 (NB01),10000,Oxford,flongle -barcode02,Dd2 (NB02),10000,Oxford,flongle -barcode03,CamC580Y (NB03),10000,Oxford,flongle -barcode04,GB4 (NB04),10000,Oxford,flongle -barcode05,HB3 (NB05),10000,Oxford,flongle \ No newline at end of file +BARCODE,SAMPLE_ID,SAMPLE_TYPE,location,parasitemia,postclean_qubit +barcode01,3D7,pos,berlin,1000,87 +barcode02,HB3,pos,berlin,100,34 +barcode03,NTC,neg,berlin,0,0 +barcode04,DBS-A01,field,berlin,543,44 +barcode05,DBS-A02,field,berlin,7583,85 +barcode06,DBS-A03,field,berlin,349,32 \ No newline at end of file diff --git a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_other.csv b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_other.csv index b42e42d..83630dc 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_other.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_other.csv @@ -1,6 +1,7 @@ -Barcodes,sample-ids,parasite_per_ul,location,platform -barcode01,3D7 (NB01),10000,Oxford,flongle -barcode02,Dd2 (NB02),10000,Oxford,flongle -barcode03,CamC580Y (NB03),10000,Oxford,flongle -barcode04,GB4 (NB04),10000,Oxford,flongle -barcode05,HB3 (NB05),10000,Oxford,flongle \ No newline at end of file +Barcode,sample-ids,sample-types,location,parasitemia,postclean_qubit +barcode01,3D7,pos,berlin,1000,87 +barcode02,HB3,pos,berlin,100,34 +barcode03,NTC,neg,berlin,0,0 +barcode04,DBS-A01,field,berlin,543,44 +barcode05,DBS-A02,field,berlin,7583,85 +barcode06,DBS-A03,field,berlin,349,32 \ No newline at end of file diff --git a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_plural.csv b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_plural.csv index ee1b60a..6b93fb9 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_plural.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_plural.csv @@ -1,6 +1,7 @@ -barcodes,SAMPLE_IDS,parasite_per_ul,location,platform -barcode01,3D7 (NB01),10000,Oxford,flongle -barcode02,Dd2 (NB02),10000,Oxford,flongle -barcode03,CamC580Y (NB03),10000,Oxford,flongle -barcode04,GB4 (NB04),10000,Oxford,flongle -barcode05,HB3 (NB05),10000,Oxford,flongle \ No newline at end of file +barcodes,SAMPLE_IDS,sample types,location,parasitemia,postclean_qubit +barcode01,3D7,pos,berlin,1000,87 +barcode02,HB3,pos,berlin,100,34 +barcode03,NTC,neg,berlin,0,0 +barcode04,DBS-A01,field,berlin,543,44 +barcode05,DBS-A02,field,berlin,7583,85 +barcode06,DBS-A03,field,berlin,349,32 \ No newline at end of file diff --git a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_sample.csv b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_sample.csv index 69b1a18..11338f5 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_sample.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_sample.csv @@ -1,6 +1,7 @@ -Barcode,Sample,parasite_per_ul,location,platform -barcode01,3D7 (NB01),10000,Oxford,flongle -barcode02,Dd2 (NB02),10000,Oxford,flongle -barcode03,CamC580Y (NB03),10000,Oxford,flongle -barcode04,GB4 (NB04),10000,Oxford,flongle -barcode05,HB3 (NB05),10000,Oxford,flongle \ No newline at end of file +Barcode,Sample,type,location,parasitemia,postclean_qubit +barcode01,3D7,pos,berlin,1000,87 +barcode02,HB3,pos,berlin,100,34 +barcode03,NTC,neg,berlin,0,0 +barcode04,DBS-A01,field,berlin,543,44 +barcode05,DBS-A02,field,berlin,7583,85 +barcode06,DBS-A03,field,berlin,349,32 \ No newline at end of file diff --git a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_space.csv b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_space.csv index 2c36468..7e69597 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_space.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_space.csv @@ -1,6 +1,7 @@ -Barcode,Sample ID -Barcode1,3D7 (NB01) -Barcode2,Dd2 (NB02) -Barcode3,CamC580Y (NB03) -Barcode4,GB4 (NB04) -Barcode5,HB3 (NB05) \ No newline at end of file +barcode,sample id,sample type,location,parasitemia,postclean_qubit +barcode01,3D7,pos,berlin,1000,87 +barcode02,HB3,pos,berlin,100,34 +barcode03,NTC,neg,berlin,0,0 +barcode04,DBS-A01,field,berlin,543,44 +barcode05,DBS-A02,field,berlin,7583,85 +barcode06,DBS-A03,field,berlin,349,32 \ No newline at end of file diff --git a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_underscore.csv b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_underscore.csv index daa1046..c61390a 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_column_correction_underscore.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_column_correction_underscore.csv @@ -1,6 +1,7 @@ -Barcode,sample_ID,parasite_per_ul,location,platform -Barcode1,3D7 (NB01),10000,Oxford,flongle -Barcode2,Dd2 (NB02),10000,Oxford,flongle -Barcode3,CamC580Y (NB03),10000,Oxford,flongle -Barcode4,GB4 (NB04),10000,Oxford,flongle -Barcode5,HB3 (NB05),10000,Oxford,flongle \ No newline at end of file +barcode,sample_ID,sample_type,location,parasitemia,postclean_qubit +barcode01,3D7,pos,berlin,1000,87 +barcode02,HB3,pos,berlin,100,34 +barcode03,NTC,neg,berlin,0,0 +barcode04,DBS-A01,field,berlin,543,44 +barcode05,DBS-A02,field,berlin,7583,85 +barcode06,DBS-A03,field,berlin,349,32 \ No newline at end of file diff --git a/src/nomadic/util/_test_data/metadata/sample_info_dupbarcode.csv b/src/nomadic/util/_test_data/metadata/sample_info_dupbarcode.csv index fb381ed..2da2d0a 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_dupbarcode.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_dupbarcode.csv @@ -1,6 +1,7 @@ -barcode,sample_id,parasite_per_ul,location,platform -barcode01,3D7 (NB01),10000,Oxford,flongle -barcode02,Dd2 (NB02),10000,Oxford,flongle -barcode03,CamC580Y (NB03),10000,Oxford,flongle -barcode04,GB4 (NB04),10000,Oxford,flongle -barcode04,HB3 (NB05),10000,Oxford,flongle +barcode,sample_id,sample_type,location,parasitemia,postclean_qubit +barcode01,3D7,pos,berlin,1000,87 +barcode02,HB3,pos,berlin,100,34 +barcode03,NTC,neg,berlin,0,0 +barcode04,DBS-A01,field,berlin,543,44 +barcode05,DBS-A02,field,berlin,7583,85 +barcode05,DBS-A03,field,berlin,349,32 diff --git a/src/nomadic/util/_test_data/metadata/sample_info_eurosep.csv b/src/nomadic/util/_test_data/metadata/sample_info_eurosep.csv index f65f448..9967cc7 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_eurosep.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_eurosep.csv @@ -1,6 +1,7 @@ -barcode;sample_id;parasite_per_ul;location;platform;number -barcode01;3D7 (NB01);10000;Oxford;flongle;8,4 -barcode02;Dd2 (NB02);10000;Oxford;flongle;2,0 -barcode03;CamC580Y (NB03);10000;Oxford;flongle;4,3 -barcode04;GB4 (NB04);10000;Oxford;flongle;5,4 -barcode05;HB3 (NB05);10000;Oxford;flongle;6,1 +barcode;sample_id;sample_type;location;parasitemia;postclean_qubit;number +barcode01;3D7;pos;berlin;1000;87;8,4 +barcode02;HB3;pos;berlin;100;34;2,0 +barcode03;NTC;neg;berlin;0;0;4,3 +barcode04;DBS-A01;field;berlin;543;44;5,4 +barcode05;DBS-A02;field;berlin;7583;85;6,1 +barcode06;DBS-A03;field;berlin;349;32;7,2 diff --git a/src/nomadic/util/_test_data/metadata/sample_info_good.csv b/src/nomadic/util/_test_data/metadata/sample_info_good.csv index 758e1b3..574a558 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_good.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_good.csv @@ -1,6 +1,7 @@ -barcode,sample_id,parasite_per_ul,location,platform -barcode01,3D7 (NB01),10000,Oxford,flongle -barcode02,Dd2 (NB02),10000,Oxford,flongle -barcode03,CamC580Y (NB03),10000,Oxford,flongle -barcode04,GB4 (NB04),10000,Oxford,flongle -barcode05,HB3 (NB05),10000,Oxford,flongle \ No newline at end of file +barcode,sample_id,sample_type,location,parasitemia,postclean_qubit +barcode01,3D7,pos,berlin,1000,87 +barcode02,HB3,pos,berlin,100,34 +barcode03,NTC,neg,berlin,0,0 +barcode04,DBS-A01,field,berlin,543,44 +barcode05,DBS-A02,field,berlin,7583,85 +barcode06,DBS-A03,field,berlin,349,32 \ No newline at end of file diff --git a/src/nomadic/util/_test_data/metadata/sample_info_hybridsep-col.csv b/src/nomadic/util/_test_data/metadata/sample_info_hybridsep-col.csv index dcb4aa8..db7eb93 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_hybridsep-col.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_hybridsep-col.csv @@ -1,6 +1,7 @@ -barcode,sample_id,parasite_per_ul,location,platform,extras -barcode01,3D7 (NB01),10000,Oxford,flongle,a;1;3 -barcode02,Dd2 (NB02),10000,Oxford,flongle,b;2;4 -barcode03,CamC580Y (NB03),10000,Oxford,flongle,c;3;3 -barcode04,GB4 (NB04),10000,Oxford,flongle,d;2;2 -barcode05,HB3 (NB05),10000,Oxford,flongle,e;1;1 +barcode,sample_id,sample_type,location,parasitemia,postclean_qubit,extras +barcode01,3D7,pos,berlin,1000,87,a;1;3 +barcode02,HB3,pos,berlin,100,34,b;2;4 +barcode03,NTC,neg,berlin,0,0,c;3;3 +barcode04,DBS-A01,field,berlin,543,44,d;2;2 +barcode05,DBS-A02,field,berlin,7583,85,e;1;1 +barcode06,DBS-A03,field,berlin,349,32,f;2;2 \ No newline at end of file diff --git a/src/nomadic/util/_test_data/metadata/sample_info_nobarcode.csv b/src/nomadic/util/_test_data/metadata/sample_info_nobarcode.csv index 35acba5..835a7ba 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_nobarcode.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_nobarcode.csv @@ -1,6 +1,7 @@ -sample_id,parasite_per_ul,location,platform -3D7 (NB01),10000,Oxford,flongle -Dd2 (NB02),10000,Oxford,flongle -CamC580Y (NB03),10000,Oxford,flongle -GB4 (NB04),10000,Oxford,flongle -HB3 (NB05),10000,Oxford,flongle +sample_id,sample_type,location,parasitemia,postclean_qubit +3D7,pos,berlin,1000,87 +HB3,pos,berlin,100,34 +NTC,neg,berlin,0,0 +DBS-A01,field,berlin,543,44 +DBS-A02,field,berlin,7583,85 +DBS-A03,field,berlin,349,32 diff --git a/src/nomadic/util/_test_data/metadata/sample_info_onecolumn.csv b/src/nomadic/util/_test_data/metadata/sample_info_onecolumn.csv index ee54f69..797acbb 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_onecolumn.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_onecolumn.csv @@ -4,3 +4,4 @@ barcode02 barcode03 barcode04 barcode05 +barcode06 diff --git a/src/nomadic/util/_test_data/metadata/sample_info_semicolon.csv b/src/nomadic/util/_test_data/metadata/sample_info_semicolon.csv index ee61dc7..3388958 100644 --- a/src/nomadic/util/_test_data/metadata/sample_info_semicolon.csv +++ b/src/nomadic/util/_test_data/metadata/sample_info_semicolon.csv @@ -1,6 +1,7 @@ -barcode;sample_id;parasite_per_ul;location;platform -barcode01;3D7 (NB01);10000;Oxford;flongle -barcode02;Dd2 (NB02);10000;Oxford;flongle -barcode03;CamC580Y (NB03);10000;Oxford;flongle -barcode04;GB4 (NB04);10000;Oxford;flongle -barcode05;HB3 (NB05);10000;Oxford;flongle \ No newline at end of file +barcode;sample_id;sample_type;location;parasitemia;postclean_qubit +barcode01;3D7;pos;berlin;1000;87 +barcode02;HB3;pos;berlin;100;34 +barcode03;NTC;neg;berlin;0;0 +barcode04;DBS-A01;field;berlin;543;44 +barcode05;DBS-A02;field;berlin;7583;85 +barcode06;DBS-A03;field;berlin;349;32 \ No newline at end of file diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index 68c963c..267629f 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -5,6 +5,7 @@ from typing import NamedTuple from nomadic.util.dirs import produce_dir +from nomadic.util.exceptions import MetadataFormatError from nomadic.util.metadata import MetadataTableParser from nomadic.util.regions import RegionBEDParser @@ -151,8 +152,13 @@ def check_complete_experiment(expt_dir: str) -> None: raise FileNotFoundError(f"Experiment directory {expt_dir} does not exist.") # We can use this for now, but of course this is getting messy - _ = find_metadata(expt_dir) - _ = find_regions(expt_dir) + try: + _ = find_metadata(expt_dir) + _ = find_regions(expt_dir) + except MetadataFormatError as e: + raise MetadataFormatError( + f"Metadata or regions file issue in experiment directory {expt_dir}: {e}" + ) from e used_summary_files = None for file_format in [summary_files, legacy_summary_files]: diff --git a/src/nomadic/util/metadata.py b/src/nomadic/util/metadata.py index 69eabd0..f8a154e 100644 --- a/src/nomadic/util/metadata.py +++ b/src/nomadic/util/metadata.py @@ -161,7 +161,7 @@ class MetadataTableParser: """ - REQUIRED_COLUMNS = ["barcode", "sample_id"] + REQUIRED_COLUMNS = ["barcode", "sample_id", "sample_type"] UNIQUE_COLUMNS = ["barcode"] # If the required columns are not found, try these alternative names, case insensitive @@ -178,6 +178,14 @@ class MetadataTableParser: "sample id", "sample ids", ], + "sample_type": [ + "sampletype", + "sample-type", + "sample type", + "sampletypes", + "sample-types", + "sample types", + ], } def __init__(self, metadata_csv: str, include_unclassified: bool = True): @@ -194,16 +202,16 @@ def __init__(self, metadata_csv: str, include_unclassified: bool = True): self._correct_all_barcodes() self.barcodes = self.df["barcode"].tolist() - self.required_metadata = self.df[self.REQUIRED_COLUMNS].set_index("barcode") + self.sample_ids_df = self.df[["barcode", "sample_id"]].set_index("barcode") if include_unclassified: - self.required_metadata.loc["unclassified"] = ["unclassified"] + self.sample_ids_df.loc["unclassified"] = ["unclassified"] self.barcodes.append("unclassified") def get_sample_id(self, barcode: str) -> Optional[str]: if barcode == "unclassified": return barcode - metadata = self.required_metadata + metadata = self.sample_ids_df if barcode not in metadata.index: return None return metadata.loc[barcode].get("sample_id", None) diff --git a/src/nomadic/util/metadata_test.py b/src/nomadic/util/metadata_test.py index 70a21e7..66cb491 100644 --- a/src/nomadic/util/metadata_test.py +++ b/src/nomadic/util/metadata_test.py @@ -57,22 +57,23 @@ def test_check_barcode_warning_error(barcode, try_to_fix, expected): @pytest.mark.parametrize( "csv_path,csv_shape", [ - (test_files_folder + "metadata/sample_info_good.csv", (5, 5)), - (test_files_folder + "metadata/sample_info_semicolon.csv", (5, 5)), - (test_files_folder + "metadata/sample_info_eurosep.csv", (5, 6)), - (test_files_folder + "metadata/sample_info_hybridsep-col.csv", (5, 6)), + (test_files_folder + "metadata/sample_info_good.csv", (6, 6)), + (test_files_folder + "metadata/sample_info_semicolon.csv", (6, 6)), + (test_files_folder + "metadata/sample_info_eurosep.csv", (6, 7)), + (test_files_folder + "metadata/sample_info_hybridsep-col.csv", (6, 7)), ], ) def test_metadata_correct(csv_path, csv_shape): metadata = MetadataTableParser(csv_path) + print(metadata) assert metadata.df.shape == csv_shape @pytest.mark.parametrize( "csv_path,csv_shape", [ - (test_files_folder + "metadata/sample_info_badbarcode-format.csv", (5, 5)), - (test_files_folder + "metadata/sample_info_badbarcode-int.csv", (5, 5)), + (test_files_folder + "metadata/sample_info_badbarcode-format.csv", (6, 6)), + (test_files_folder + "metadata/sample_info_badbarcode-int.csv", (6, 6)), ], ) def test_metadata_warns(csv_path, csv_shape): @@ -90,7 +91,7 @@ def test_metadata_warns(csv_path, csv_shape): ), ( test_files_folder + "metadata/sample_info_dupbarcode.csv", - "Column barcode must contain only unique entires, but barcode04 is duplicated.", + "Column barcode must contain only unique entires, but barcode05 is duplicated.", ), ( test_files_folder + "metadata/sample_info_nobarcode.csv", @@ -98,7 +99,7 @@ def test_metadata_warns(csv_path, csv_shape): ), ( test_files_folder + "metadata/sample_info_badheader.csv", - "Found multiple delimiters (, ;) in header: barcode;sample_id,parasite_per_ul,location;platform;number.", + "Found multiple delimiters (, ;) in header: barcode;sample_id,sample_type,location;parasitemia;postclean_qubit.", ), ], ) @@ -119,9 +120,11 @@ def test_metadata_errors(csv_path, error_msg): ], ) def test_metadata_column_corrections(csv_path): - meta_table = MetadataTableParser(csv_path) + with pytest.warns(UserWarning): + meta_table = MetadataTableParser(csv_path) assert "barcode" in meta_table.df.columns assert "sample_id" in meta_table.df.columns + assert "sample_type" in meta_table.df.columns assert meta_table.df["barcode"].tolist() == [ "barcode01", @@ -129,11 +132,21 @@ def test_metadata_column_corrections(csv_path): "barcode03", "barcode04", "barcode05", + "barcode06", ] assert meta_table.df["sample_id"].tolist() == [ - "3D7 (NB01)", - "Dd2 (NB02)", - "CamC580Y (NB03)", - "GB4 (NB04)", - "HB3 (NB05)", + "3D7", + "HB3", + "NTC", + "DBS-A01", + "DBS-A02", + "DBS-A03", + ] + assert meta_table.df["sample_type"].tolist() == [ + "pos", + "pos", + "neg", + "field", + "field", + "field", ] From 12e5edfa6d598e5dce15dbd316498537e4ac0413 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Mon, 27 Oct 2025 16:34:19 +0100 Subject: [PATCH 17/67] Do not seperate mutations by alt alleles For now just don't group them by alt alleles. We might have a different set of alt alleles when we call the same mutation in different experiments, as we might have a triallelic side. As we in the end group by amino acid change, this leads to problems. We need to better discuss how to hanle csq calling correctly for multiple changes. --- src/nomadic/summarize/compute.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index 7e869e1..3da08e2 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -6,8 +6,8 @@ "gene", "chrom", "pos", - "ref", - "alt", + # "ref", + # "alt", "aa_change", "mut_type", "mutation", From 6793b0551e0e1b8d5214b51824f788d7ce25ac57 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Mon, 27 Oct 2025 16:39:26 +0100 Subject: [PATCH 18/67] Limit prevalence to samples in master metadata file --- src/nomadic/summarize/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 67e3383..c660ea6 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -94,7 +94,7 @@ def get_region_coverage_dataframe( left=metadata[["expt_name", "barcode", "sample_id", "sample_type"]], right=bed_df, on=["expt_name", "barcode"], - how="right", + how="inner", # ensure we only take samples that are in the master metadata ) # TODO: Do checks bed_dfs.append(bed_df) From 29b02222298b946224d5f9b6c437e4a64326a3e7 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Mon, 27 Oct 2025 16:44:51 +0100 Subject: [PATCH 19/67] Ensure we handle sample ids that are number better --- src/nomadic/summarize/main.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index c660ea6..7a832d7 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -123,14 +123,21 @@ def get_region_coverage_dataframe( return coverage_df -def calc_unknown_samples(inventory_metadata: pd.DataFrame, master_metadata): +def calc_unknown_samples( + inventory_metadata: pd.DataFrame, master_metadata: pd.DataFrame +): """ Returns the sample ids that are not in the masterdata file. """ field_samples = inventory_metadata.query("sample_type == 'field'") - unknown_samples = field_samples.loc[ - ~field_samples["sample_id"].isin(master_metadata["sample_id"]), "sample_id" - ].to_list() + unknown_samples = ( + field_samples.loc[ + ~field_samples["sample_id"].isin(master_metadata["sample_id"]), + "sample_id", + ] + .unique() + .tolist() + ) return unknown_samples @@ -533,6 +540,20 @@ def main( shared_columns = fixed_columns inventory_metadata = pd.concat([df[shared_columns] for df in dfs]) master_metadata = pd.read_csv(meta_data_path) + master_metadata = master_metadata.astype( + {"sample_id": "str"} + ) # ensure sample IDs are strings + inventory_metadata = inventory_metadata.astype( + {"sample_id": "str"} + ) # ensure sample IDs are strings + # strip whitespaces from sample IDs + inventory_metadata["sample_id"] = inventory_metadata["sample_id"].str.strip() + master_metadata["sample_id"] = master_metadata["sample_id"].str.strip() + + # In case sample IDs are numbers, we want to strip leading zeros (this was a problem in Zambia data) + inventory_metadata["sample_id"] = inventory_metadata["sample_id"].str.lstrip("0") + master_metadata["sample_id"] = master_metadata["sample_id"].str.lstrip("0") + # Do we want to have metadata in result files? # inventory_metadata = pd.merge( # left=inventory_metadata, right=master_metadata, on=["sample_id"], how="left" From 839b058a9cc843503c9b0b37353688e5c51f5bc4 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Mon, 27 Oct 2025 16:46:11 +0100 Subject: [PATCH 20/67] Store in inventory if samples are unknown --- src/nomadic/summarize/main.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 7a832d7..ac2cd7c 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -6,6 +6,7 @@ from pathlib import Path import pandas as pd +import numpy as np from nomadic.dashboard.main import ( find_regions, @@ -565,10 +566,17 @@ def main( ) # Delete unknown samples - inventory_metadata = inventory_metadata[ - ~inventory_metadata["sample_id"].isin(unknown_samples) - ] + inventory_metadata["status"] = np.where( + inventory_metadata["sample_id"].isin(unknown_samples), "unknown", "known" + ) + # set all controls + inventory_metadata["status"] = np.where( + inventory_metadata["sample_type"].isin(["pos", "neg"]), + "control", + inventory_metadata["status"], + ) inventory_metadata.to_csv(f"{output_dir}/inventory.csv", index=False) + inventory_metadata = inventory_metadata.query("status != 'unknown'") # Check regions are consistent check_regions_consistent(expt_dirs) @@ -582,6 +590,7 @@ def main( log.info(f" Negative controls: {throughput_df.loc['neg', 'All']}") log.info(f" Field samples (total): {throughput_df.loc['field', 'All']}") log.info(f" Field samples (unique): {throughput_df.loc['field_unique', 'All']}") + log.info(f" Unknown (excluded): {len(unknown_samples)}") throughput_df.to_csv(f"{output_dir}/summary.throughput.csv", index=True) # Now let's evaluate coverage From 24bcf8673980bfb48d83623e0c065d6e5df40a15 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Mon, 27 Oct 2025 17:00:10 +0100 Subject: [PATCH 21/67] Show legend in Sample statistic pie This helps to only look at the sequenced samples --- src/nomadic/summarize/dashboard/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index c67d18e..1079f5e 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -174,7 +174,7 @@ def _define_layout(self): MAR = 20 fig.update_layout( - showlegend=False, + showlegend=True, margin=dict(t=MAR, l=MAR, r=MAR, b=MAR), annotations=[ dict( From 3d9d56bbddd3d41f6aec0c136556889e093f4584 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 28 Oct 2025 13:00:29 +0100 Subject: [PATCH 22/67] Better text and labels for QC Summary --- setup.cfg | 3 +++ src/nomadic/summarize/dashboard/builders.py | 25 ++++++++++++++----- src/nomadic/summarize/dashboard/components.py | 16 ++++++++---- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/setup.cfg b/setup.cfg index 13b18e4..652786f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,9 @@ zip_safe = no nomadic.realtime.dashboard = assets/* translations/* +nomadic.summarize.dashboard = + assets/* + translations/* nomadic.start = data/** diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index c6ccbe3..75be879 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -1,9 +1,11 @@ +from importlib.resources import as_file, files import logging import threading from abc import ABC, abstractmethod from dash import Dash, html, dcc from i18n import t +import i18n # from importlib.resources import files, as_file from nomadic.summarize.dashboard.components import ( @@ -65,7 +67,7 @@ def run(self, in_thread: bool = False, **kwargs): """ - # setup_translations() + setup_translations() app = self._gen_app() self._gen_layout() @@ -127,7 +129,7 @@ def _add_samples(self, samples_csv: str, samples_amplicons_csv: str) -> None: quality_row = html.Div( className="samples-row", children=[ - html.H3("Samples Statistics", style=dict(marginTop="0px")), + html.H3("Sample Statistics", style=dict(marginTop="0px")), html.Div( className="samples-plots", children=[self.samples.get_layout(), self.amplicons.get_layout()], @@ -303,7 +305,18 @@ def _gen_layout(self): self._add_experiment_qc(self.coverage_csv) self._add_prevalence_row(self.analysis_csv, self.master_csv) self._add_prevalence_by_region_row(self.prevalence_region_csv) - # self._add_mapping_row(self.read_mapping_csv) - # self._add_region_coverage_row(self.region_coverage_csv, self.regions) - # self._add_depth_row(self.depth_profiles_csv, self.regions) - # self._add_footer() + + +def setup_translations(): + """ + Set up translations for the dashboard + + This function loads the translation files from the package resources + and appends them to the i18n load path. + + """ + with as_file(files("nomadic.summarize.dashboard").joinpath("translations")) as path: + i18n.load_path.append(str(path)) + i18n.set("filename_format", "{locale}.{format}") + i18n.load_everything() + i18n.set("locale", "en") diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 1079f5e..cd228b9 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -299,6 +299,13 @@ def callback(self, app: Dash) -> None: ) def _update(focus_stat: str): """Called whenver the input changes""" + legend = ( + "Field samples (%)" + if "per_" in focus_stat + else "Mean Coverage" + if "cov" in focus_stat + else "" + ) plot_data = [ go.Heatmap( x=self.plot_df[focus_stat].columns, @@ -308,9 +315,7 @@ def _update(focus_stat: str): # colorscale="Reds", xgap=1, ygap=1, - colorbar=dict( - title=focus_stat, outlinecolor="black", outlinewidth=1 - ), + colorbar=dict(title=legend, outlinecolor="black", outlinewidth=1), hoverongaps=False, # **STAT_KWARGS[STAT] ) @@ -325,7 +330,7 @@ def _update(focus_stat: str): hovermode="y unified", paper_bgcolor="white", # Sets the background color of the paper plot_bgcolor="white", - title=dict(text=focus_stat), + title=dict(text=t(focus_stat)), margin=dict(t=MAR, l=MAR, r=MAR, b=MAR), xaxis=dict( showline=True, linecolor="black", linewidth=2, dtick=1, mirror=True @@ -337,9 +342,10 @@ def _update(focus_stat: str): yaxis_showgrid=False, # height=n_mutations*SZ # TOOD: how to adjust dynamically ) + unit = "%" if "per_" in focus_stat else "x" if "cov" in focus_stat else "" fig.update_traces( text=self.plot_df[focus_stat], - texttemplate="%{text:.0f}", + texttemplate="%{text:.0f}" + unit, textfont_size=12, ) return fig From 3f39f5cd7066474ba0dd7f60fb84d64feeacd73e Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 28 Oct 2025 14:14:55 +0100 Subject: [PATCH 23/67] New colorscales for QC summary Green and red should be more intuitve what they mean --- src/nomadic/summarize/dashboard/components.py | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index cd228b9..5523c28 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -192,6 +192,39 @@ def callback(self, app: Dash) -> None: """ +COV_MAX = 10000 +COLORSCALES = { + "per_field_passing": [ + [0.00, "#FF0033"], + [0.50, "#FF9900"], + [0.70, "#FFEB33"], # set 70% as okay + [0.90, "#80FF7E"], # above 90% is good + [1.00, "#3A9A3E"], + ], + "per_field_contam": [ + [0.00, "#3A9A3E"], # low contamination is good + [0.20, "#FFEB33"], # set 30% as okay + [0.30, "#FF9900"], # contamination is bad + [1.00, "#FF0033"], + ], + "per_field_lowcov": [ + [0.00, "#3A9A3E"], # low lowcov is good + [0.10, "#80FF7E"], # above 90% is good + [0.30, "#FFEB33"], # set 30% as okay + [0.50, "#FF9900"], # lowcov is bad + [1.00, "#FF0033"], + ], + "mean_cov_field": [ + [0, "#FF0033"], # low coverage is bad + [25 / COV_MAX, "#FF9900"], + [50 / COV_MAX, "#FFEB33"], # set threshold as okay + [200 / COV_MAX, "#80FF7E"], # above 200 we can make good calls + [500 / COV_MAX, "#3A9A3E"], # above 500 is excellent also for low freq calls + [1.0, "#7585FE"], # coverage is uncapped + ], +} + + class AmpliconsBarplot(SummaryDashboardComponent): """ Make a bar chart that shows the Amplicons Statistics @@ -312,12 +345,13 @@ def _update(focus_stat: str): y=self.plot_df[focus_stat].index, z=self.plot_df[focus_stat], text=self.plot_df[focus_stat], - # colorscale="Reds", xgap=1, ygap=1, + zmin=0, + zmax=100 if "per_" in focus_stat else 10000, colorbar=dict(title=legend, outlinecolor="black", outlinewidth=1), hoverongaps=False, - # **STAT_KWARGS[STAT] + colorscale=COLORSCALES[focus_stat], ) ] MAR = 40 From 7e85cf5664e8ec231b5f25070c19e83100809185 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 28 Oct 2025 17:10:42 +0100 Subject: [PATCH 24/67] Ensure prevalence is ordered by aa positions --- src/nomadic/summarize/compute.py | 1 + src/nomadic/summarize/dashboard/components.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index 3da08e2..6f160d0 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -9,6 +9,7 @@ # "ref", # "alt", "aa_change", + "aa_pos", "mut_type", "mutation", ] diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 5523c28..6e86552 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -565,6 +565,15 @@ def _update(target_gene): data=df, ) + # Sort, so aa changes are in correct order + pos_order = ( + df.drop_duplicates("aa_change") + .set_index("aa_change")["aa_pos"] + .sort_values(ascending=True) + .index + ) + plot_df = plot_df.reindex(pos_order) + # Hover statment customdata = np.stack( [plot_df["n_mixed"], plot_df["n_mut"], plot_df["n_passed"]], axis=-1 From 5200e38a9d92ad722751cb0660e93a22c369c0f5 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 29 Oct 2025 14:10:01 +0100 Subject: [PATCH 25/67] Always report contaminated when over abs. thresh. It makes sense that we don't report contamination if we have low coverage, as this could actually just be caused by low coverage. But if we are over the abs threshold, we can be sure, it's contamination --- src/nomadic/summarize/main.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index ac2cd7c..12fe5ea 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -153,12 +153,12 @@ def calc_quality_control_columns( df["fail_lowcov"] = df["mean_cov"] < min_coverage # Check if coverage of negative control exceeds `max_contam` - df["fail_contam"] = (df["mean_cov_neg"] / (df["mean_cov"] + 0.01) >= max_contam) | ( - df["mean_cov_neg"] >= min_coverage - ) + df["fail_contam_rel"] = df["mean_cov_neg"] / (df["mean_cov"] + 0.01) >= max_contam + df["fail_contam_abs"] = df["mean_cov_neg"] >= min_coverage + df["fail_contam"] = ( - df["fail_contam"] & ~df["fail_lowcov"] - ) # If already failed low coverage, don't consider contamination. + (df["fail_contam_rel"] & ~df["fail_lowcov"]) | df["fail_contam_abs"] + ) # If already failed low coverage, don't consider contamination, unless it's absolute threshold is exceeded # Finally, define passing df["passing"] = ~df["fail_contam"] & ~df["fail_lowcov"] From 3b1367edf0907fcb0138717214624e02264a8cc3 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 29 Oct 2025 15:55:35 +0100 Subject: [PATCH 26/67] Allow to group by all columns in metadata file Exclude columns with to many entries and which are numeric. Maybe we want to filter more in the future, or have a way to provide a list --- src/nomadic/summarize/commands.py | 8 ++ .../dashboard/assets/summary-style.css | 55 ++++---- src/nomadic/summarize/dashboard/builders.py | 130 ++++++++++++++---- src/nomadic/summarize/dashboard/components.py | 22 +-- src/nomadic/summarize/main.py | 22 ++- 5 files changed, 159 insertions(+), 78 deletions(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 0ddbe1b..18cc9f0 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -33,6 +33,12 @@ show_default="/metadata/.csv", ) @click.option("-n", "--summary_name", type=str, help="Name of summary") +@click.option( + "--prevalence-by", + type=str, + help="Column to calculate prevalence by for output files.", + multiple=True, +) @click.option( "--dashboard/--no-dashboard", default=True, @@ -44,6 +50,7 @@ def summarize( workspace_path: str, metadata_csv: Path, dashboard: bool, + prevalence_by: tuple[str], ): """ Summarize a set of experiments to evaluate quality control and @@ -81,6 +88,7 @@ def summarize( summary_name=summary_name, meta_data_path=metadata_csv, show_dashboard=dashboard, + prevalence_by=list(prevalence_by), ) except MetadataFormatError as e: raise click.BadParameter( diff --git a/src/nomadic/summarize/dashboard/assets/summary-style.css b/src/nomadic/summarize/dashboard/assets/summary-style.css index 8724e9d..e38bee4 100644 --- a/src/nomadic/summarize/dashboard/assets/summary-style.css +++ b/src/nomadic/summarize/dashboard/assets/summary-style.css @@ -81,42 +81,18 @@ body { gap: 20px; } -#quality-heat { - flex: 1.0; -} - -/* Prevalence Section ------------------------------------------------------------------ */ - -.prevalence-radio-row { +.quality-dropdowns { display: flex; - gap: 1rem; -} - -.prevalence-radio-input { - display: none; /* hide the native radio */ -} - -.prevalence-radio-label { - padding: 0.2rem 0.6rem; - border: 1px solid #4442b8; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; - user-select: none; -} - -/* Highlight when selected */ -.prevalence-radio-label:has(input:checked) { - background-color: #4442b8; - color: white; - border-color: #4442b8; + flex-direction: row; + gap: 20px; + margin-bottom: 20px; } -/* Optional hover effect */ -.prevalence-radio-label:hover { - background-color: #e8f5e9; +#quality-heat { + flex: 1.0; } +/* Prevalence Section ------------------------------------------------------------------ */ .prevalence-row { padding: 20px; margin: 20px; @@ -127,6 +103,13 @@ body { box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } +.prevalence-dropdowns { + display: flex; + flex-direction: row; + gap: 20px; + margin-bottom: 20px; +} + .prevalence-plots { display: flex; flex-direction: row; @@ -137,7 +120,15 @@ body { flex: 1.0; } - +.prevalence-by-row { + padding: 20px; + margin: 20px; + border-color: var(--grey); + border-width: 2px; + border-style: solid; + border-radius: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} /* Footer ------------------------------------------------------------------ */ diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 75be879..afe647e 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -6,6 +6,7 @@ from i18n import t import i18n +import pandas as pd # from importlib.resources import files, as_file from nomadic.summarize.dashboard.components import ( @@ -167,7 +168,17 @@ def _add_experiment_qc(self, coverage_csv: str) -> None: className="quality-row", children=[ html.H3("Experiment QC Statistics", style=dict(marginTop="0px")), - dropdown, + html.Div( + className="quality-dropdowns", + children=[ + html.Div( + children=[ + html.Label("Select statistic:"), + dropdown, + ] + ), + ], + ), html.Div( className="quality-plots", children=[self.quality_control.get_layout()], @@ -184,26 +195,29 @@ def _add_prevalence_row(self, analysis_csv: str, master_csv: str) -> None: Add a panel that shows prevalence calls """ - radio = dcc.RadioItems( - id="prevalence-radio", + dropdown_genset = dcc.Dropdown( + id="prevalence-dropdown-gene-set", options=list(PrevalenceBarplot.GENE_SETS.keys()), value=list(PrevalenceBarplot.GENE_SETS.keys())[0], - inputClassName="prevalence-radio-input", - labelClassName="prevalence-radio-label", + style=dict(width="300px"), + clearable=False, ) - radio_by = dcc.RadioItems( - id="prevalence-radio-by", - options=["All", "region", "year", "region_year"], + + cols = cols_to_group_by(master_csv, analysis_csv, 10) + + dropdown_by = dcc.Dropdown( + id="prevalence-dropdown-by", + options=["All", *cols], value="All", - inputClassName="prevalence-radio-input", - labelClassName="prevalence-radio-label", + style=dict(width="300px"), + clearable=False, ) self.prevalence_bars = PrevalenceBarplot( self.summary_name, component_id="prevalence-bars", - radio_id="prevalence-radio", - radio_id_by="prevalence-radio-by", + radio_id="prevalence-dropdown-gene-set", + radio_id_by="prevalence-dropdown-by", analysis_csv=analysis_csv, master_csv=master_csv, ) @@ -213,8 +227,21 @@ def _add_prevalence_row(self, analysis_csv: str, master_csv: str) -> None: children=[ html.H3("Prevalence", style=dict(marginTop="0px")), html.Div( - className="prevalence-radio-row", - children=[radio, radio_by], + className="prevalence-dropdowns", + children=[ + html.Div( + children=[ + html.Label("Select gene set:"), + dropdown_genset, + ] + ), + html.Div( + children=[ + html.Label("Group by:"), + dropdown_by, + ] + ), + ], ), html.Div( className="prevalence-plots", @@ -227,29 +254,58 @@ def _add_prevalence_row(self, analysis_csv: str, master_csv: str) -> None: self.components.append(self.prevalence_bars) self.layout.append(prevalence_row) - def _add_prevalence_by_region_row(self, prevalence_region_csv: str) -> None: + def _add_prevalence_by_col_row(self, analysis_csv: str, master_csv: str) -> None: """ - Add a panel that shows prevalence calls by region + Add a panel that shows prevalence calls by cols """ - dropdown = dcc.Dropdown( + gen_dropdown = dcc.Dropdown( id="gene-dropdown", options=PrevalenceBarplot.GENE_SETS["Resistance"], value=PrevalenceBarplot.GENE_SETS["Resistance"][0], style=dict(width="300px"), + clearable=False, + ) + + cols = cols_to_group_by(master_csv, analysis_csv, 50) + + col_dropdown = dcc.Dropdown( + id="col-dropdown", + options=cols, + value=cols[0], + style=dict(width="300px"), + clearable=False, ) self.prevalence_heatmap = PrevalenceHeatmap( summary_name=self.summary_name, - prevalence_region_csv=prevalence_region_csv, + analysis_csv=analysis_csv, + master_csv=master_csv, component_id="prevalence-heatmap", gene_dropdown_id="gene-dropdown", + col_dropdown_id="col-dropdown", ) prevalence_row = html.Div( - className="prevalence-region-row", + className="prevalence-by-row", children=[ - html.H3("Prevalence by region", style=dict(marginTop="0px")), - dropdown, + html.H3("Prevalence by category", style=dict(marginTop="0px")), + html.Div( + className="prevalence-dropdowns", + children=[ + html.Div( + children=[ + html.Label("Select gene:"), + gen_dropdown, + ] + ), + html.Div( + children=[ + html.Label("Group by:"), + col_dropdown, + ] + ), + ], + ), html.Div( className="prevalence-region-plots", children=[self.prevalence_heatmap.get_layout()], @@ -279,7 +335,6 @@ def __init__( coverage_csv: str, analysis_csv: str, master_csv: str, - prevalence_region_csv: str, ): """ Initialise all of the dashboard components @@ -293,7 +348,6 @@ def __init__( self.coverage_csv = coverage_csv self.analysis_csv = analysis_csv self.master_csv = master_csv - self.prevalence_region_csv = prevalence_region_csv def _gen_layout(self): """ @@ -304,7 +358,7 @@ def _gen_layout(self): self._add_samples(self.samples_csv, self.samples_amplicons_csv) self._add_experiment_qc(self.coverage_csv) self._add_prevalence_row(self.analysis_csv, self.master_csv) - self._add_prevalence_by_region_row(self.prevalence_region_csv) + self._add_prevalence_by_col_row(self.analysis_csv, self.master_csv) def setup_translations(): @@ -320,3 +374,31 @@ def setup_translations(): i18n.set("filename_format", "{locale}.{format}") i18n.load_everything() i18n.set("locale", "en") + + +def cols_to_group_by(master_csv: str, analysis_csv, max_cat: int) -> list[str]: + """ + Get columns that can be used to group prevalence by + + """ + + master_df = pd.read_csv(master_csv) + analysis_df = pd.read_csv(analysis_csv) + df = pd.merge( + master_df, + analysis_df[["sample_id"]], + on="sample_id", + how="inner", + ) + cols = df.columns.tolist() + cols.remove("sample_id") + + for col in cols[:]: + if pd.api.types.is_numeric_dtype(df[col]): + cols.remove(col) + continue + n_unique = df[col].nunique() + if n_unique > max_cat: + cols.remove(col) + + return cols diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 6e86552..b634b0c 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -357,8 +357,6 @@ def _update(focus_stat: str): MAR = 40 fig = go.Figure(plot_data) fig.update_layout( - width=1200, - height=600, xaxis_title="Amplicons", yaxis_title="Experiments", hovermode="y unified", @@ -537,12 +535,16 @@ class PrevalenceHeatmap(SummaryDashboardComponent): def __init__( self, summary_name: str, - prevalence_region_csv: str, + analysis_csv: str, + master_csv: str, component_id: str, gene_dropdown_id: str, + col_dropdown_id: str, ): self.gene_dropdown_id = gene_dropdown_id - self.df = pd.read_csv(prevalence_region_csv) + self.col_dropdown_id = col_dropdown_id + self.analysis_df = pd.read_csv(analysis_csv) + self.master_df = pd.read_csv(master_csv) super().__init__(summary_name, component_id) def _define_layout(self): @@ -553,14 +555,18 @@ def callback(self, app: Dash) -> None: @app.callback( Output(self.component_id, "figure"), Input(self.gene_dropdown_id, "value"), + Input(self.col_dropdown_id, "value"), ) - def _update(target_gene): + def _update(target_gene, col_by): """Called every time an input changes""" - df = self.df.query("gene == @target_gene") + df = compute_variant_prevalence_per( + self.analysis_df.query("gene == @target_gene"), self.master_df, [col_by] + ) + plot_df = pd.pivot_table( index="aa_change", - columns="region", + columns=col_by, values=["prevalence", "n_mixed", "n_mut", "n_passed"], data=df, ) @@ -605,7 +611,7 @@ def _update(target_gene): MAR = 40 fig = go.Figure(plot_data) fig.update_layout( - xaxis_title="Regions", + xaxis_title=col_by, hovermode="y unified", paper_bgcolor="white", # Sets the background color of the paper plot_bgcolor="white", diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 12fe5ea..cecd52b 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -483,6 +483,7 @@ def main( summary_name: str, meta_data_path: Path, show_dashboard: bool = True, + prevalence_by: list[str], ) -> None: """ Define the main function for the summary analysis @@ -681,20 +682,13 @@ def main( prev_df = compute_variant_prevalence(analysis_df) prev_df.to_csv(f"{output_dir}/summary.variants.prevalence.csv", index=False) - prev_df_region = compute_variant_prevalence_per( - analysis_df, master_metadata, ["region"] - ) - prev_df_region.to_csv( - f"{output_dir}/summary.variants.prevalence-region.csv", index=False - ) - - prev_df_year = compute_variant_prevalence_per( - analysis_df, master_metadata, ["year"] - ) - prev_df_year.to_csv( - f"{output_dir}/summary.variants.prevalence-year.csv", index=False - ) - + for col in prevalence_by: + prev_by_col_df = compute_variant_prevalence_per( + analysis_df, master_metadata, [col] + ) + prev_by_col_df.to_csv( + f"{output_dir}/summary.variants.prevalence-{col}.csv", index=False + ) # -------------------------------------------------------------------------------- # Dashboard # From 7a85fd5ecac3ae266e7089223d2e99c4624e5a31 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 29 Oct 2025 15:56:43 +0100 Subject: [PATCH 27/67] Allow to start dashboard in debug mode via env --- src/nomadic/summarize/main.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index cecd52b..cac329f 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -703,15 +703,16 @@ def main( coverage_csv=f"{output_dir}/summary.experiments_qc.csv", analysis_csv=f"{output_dir}/summary.variants.analysis_set.csv", master_csv=str(meta_data_path), - prevalence_region_csv=f"{output_dir}/summary.variants.prevalence-region.csv", ) print("Done.") print("") print("Launching dashboard (press CNTRL+C to exit):") print("") - webbrowser.open("http://127.0.0.1:8050") - dashboard.run(debug=False) + debug = bool(os.getenv("NOMADIC_DEBUG")) + if not debug: + webbrowser.open("http://127.0.0.1:8050") + dashboard.run(debug=debug) # CHECKPOINT 2: # summary.quality_control.by_amplicon.csv From 37e6f26268e1259a121239225147e7682ea4cee7 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 29 Oct 2025 15:56:59 +0100 Subject: [PATCH 28/67] Don't check for depth files in summary They are big and we don't need them for the analysis --- src/nomadic/util/experiment.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index 267629f..e766207 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -167,6 +167,10 @@ def check_complete_experiment(expt_dir: str) -> None: used_summary_files = file_format for file in used_summary_files: + if "depth" in file: + # depth files are optional + continue + if not os.path.exists(f"{expt_dir}/{file}"): raise FileNotFoundError(f"Missing '{file}' file in {expt_dir}.") From a28b1a510667c7a6843ecaeabfd4e60f0dc29c25 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Thu, 30 Oct 2025 19:18:00 +0100 Subject: [PATCH 29/67] Add gene deletion detection --- src/nomadic/summarize/compute.py | 101 ++++++++++++++ src/nomadic/summarize/dashboard/builders.py | 54 +++++++ src/nomadic/summarize/dashboard/components.py | 132 ++++++++++++++++++ src/nomadic/summarize/main.py | 28 ++++ 4 files changed, 315 insertions(+) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index 6f160d0..d375dc2 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -118,3 +118,104 @@ def compute_variant_prevalence_per( prev_df["prevalence_highci"] = 100 * high return prev_df + + +def gene_deletions(coverage_df: pd.DataFrame, genes: list[str]) -> pd.DataFrame: + """ + Analyze gene deletions based on coverage data. + + Assumes `coverage_df` contains columns: + - 'sample_id' + - 'name' + - 'mean_cov' + + A gene is considered deleted in a sample if its mean coverage is below a threshold. + This is a very simple heuristic and might be improved in the future. + + Returns a DataFrame with deletion prevalence per gene. + """ + # How many of the other amplolicons must be covered to consider the sample for deletion analysis + AMPLICONS_QC_CUTOFF = 0.8 + # Minimum coverage to consider a gene deleted + DELETION_COVERAGE_THRESHOLD = 5 + + coverage_df = coverage_df[coverage_df["sample_type"] == "field"] + coverage_df["gene"] = coverage_df["name"].str.split("-").str[0] + + # QC: only analyze samples with sufficient control amplicon coverage + analysis_samples = ( + coverage_df[~coverage_df["gene"].isin(genes)] + .groupby(["expt_name", "barcode"]) + .agg( + n_ctrl_amplicons=pd.NamedAgg("name", "nunique"), + n_passing_ctrl_amplicons=pd.NamedAgg("passing", "sum"), + ) + ) + + analysis_set = coverage_df.merge( + analysis_samples, on=["expt_name", "barcode"], how="left" + ) + analysis_set = analysis_set[ + analysis_set["n_passing_ctrl_amplicons"] + >= AMPLICONS_QC_CUTOFF * analysis_set["n_ctrl_amplicons"] + ] + analysis_set["is_deleted"] = analysis_set["mean_cov"] < DELETION_COVERAGE_THRESHOLD + + # consider a gene deleted if all amplicons that belong to the gene are deleted + result = ( + analysis_set.groupby(["expt_name", "barcode", "sample_id", "gene"]) + .agg( + is_deleted=pd.NamedAgg("is_deleted", "all"), + ) + .reset_index() + ) + + # consider a gene deleted, if it is deleted for all replicates of a sample + result = ( + result.groupby(["sample_id", "gene"]) + .agg( + is_deleted=pd.NamedAgg("is_deleted", "all"), + n_deleted=pd.NamedAgg("is_deleted", "sum"), + n_replicates=pd.NamedAgg("barcode", "count"), + ) + .reset_index() + ) + + return result[result["gene"].isin(genes)] + + +def gene_deletion_prevalence_by( + gene_deletions_df: pd.DataFrame, master_df: pd.DataFrame, fields: list[str] +) -> pd.DataFrame: + """ + Compute the prevalence of gene deletions in `gene_deletions_df` + stratified by columns in `fields`. + """ + gene_deletions_df = gene_deletions_df.merge( + master_df[["sample_id", *fields]], on="sample_id", how="left" + ) + + prev_df = ( + gene_deletions_df.groupby(["gene", *fields]) + .agg( + n_samples=pd.NamedAgg("is_deleted", len), + n_passed=pd.NamedAgg("is_deleted", lambda x: sum(x.notnull())), + n_deleted=pd.NamedAgg("is_deleted", lambda x: sum(x)), + ) + .reset_index() + ) + + # Compute prevalence + prev_df["prevalence"] = 100 * prev_df["n_deleted"] / prev_df["n_passed"] + + # Compute prevalence 95% confidence intervals + low, high = proportion_confint( + prev_df["n_deleted"], + prev_df["n_passed"], + alpha=0.05, + method="beta", + ) + prev_df["prevalence_lowci"] = 100 * low + prev_df["prevalence_highci"] = 100 * high + + return prev_df diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index afe647e..177efad 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -11,6 +11,7 @@ # from importlib.resources import files, as_file from nomadic.summarize.dashboard.components import ( AmpliconsBarplot, + GeneDeletionsBarplot, PrevalenceHeatmap, SamplesPie, ThroughputSummary, @@ -317,6 +318,56 @@ def _add_prevalence_by_col_row(self, analysis_csv: str, master_csv: str) -> None self.components.append(self.prevalence_heatmap) self.layout.append(prevalence_row) + def _add_gene_deletion_row(self, gene_deletions_csv: str, master_csv: str) -> None: + """ + Add a panel that shows prevalence calls + + """ + + cols = cols_to_group_by(master_csv, gene_deletions_csv, 50) + + dropdown_by = dcc.Dropdown( + id="gene-deletions-dropdown-by", + options=["All", *cols], + value="All", + style=dict(width="300px"), + clearable=False, + ) + + self.prevalence_bars = GeneDeletionsBarplot( + self.summary_name, + component_id="gene-deletions-bars", + radio_id_by="gene-deletions-dropdown-by", + gene_deletions_csv=gene_deletions_csv, + master_csv=master_csv, + ) + + prevalence_row = html.Div( + className="gene-deltions-row", + children=[ + html.H3("Prevalence Gene Deletions", style=dict(marginTop="0px")), + html.Div( + className="prevalence-dropdowns", + children=[ + html.Div( + children=[ + html.Label("Group by:"), + dropdown_by, + ] + ), + ], + ), + html.Div( + className="gene-deletions-plots", + children=[self.prevalence_bars.get_layout()], + ), + ], + ) + + # Add components and layout + self.components.append(self.prevalence_bars) + self.layout.append(prevalence_row) + class BasicSummaryDashboard(SummaryDashboardBuilder): """ @@ -334,6 +385,7 @@ def __init__( samples_amplicons_csv: str, coverage_csv: str, analysis_csv: str, + gene_deletions_csv: str, master_csv: str, ): """ @@ -348,6 +400,7 @@ def __init__( self.coverage_csv = coverage_csv self.analysis_csv = analysis_csv self.master_csv = master_csv + self.gene_deletions_csv = gene_deletions_csv def _gen_layout(self): """ @@ -359,6 +412,7 @@ def _gen_layout(self): self._add_experiment_qc(self.coverage_csv) self._add_prevalence_row(self.analysis_csv, self.master_csv) self._add_prevalence_by_col_row(self.analysis_csv, self.master_csv) + self._add_gene_deletion_row(self.gene_deletions_csv, self.master_csv) def setup_translations(): diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index b634b0c..4309a5f 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -9,6 +9,7 @@ from nomadic.summarize.compute import ( compute_variant_prevalence, compute_variant_prevalence_per, + gene_deletion_prevalence_by, ) from i18n import t @@ -628,3 +629,134 @@ def _update(target_gene, col_by): # height=n_mutations*SZ # TOOD: how to adjust dynamically ) return fig + + +class GeneDeletionsBarplot(SummaryDashboardComponent): + def __init__( + self, + summary_name: str, + gene_deletions_csv: str, + master_csv: str, + component_id: str, + radio_id_by: str, + ) -> None: + """ + Initialisation loads the coverage data and prepares for plotting; + + """ + + self.gene_deletions_csv = gene_deletions_csv + self.gene_deletions_df = pd.read_csv(gene_deletions_csv) + + self.master__csv = master_csv + self.master_df = pd.read_csv(master_csv) + + self.radio_id_by = radio_id_by + super().__init__(summary_name, component_id) + + def _define_layout(self): + return dcc.Graph(id=self.component_id) + + def callback(self, app: Dash) -> None: + @app.callback( + Output(self.component_id, "figure"), + Input(self.radio_id_by, "value"), + ) + def _update(by: str): + """Called whenver the input changes""" + + if by == "All": + plot_df = gene_deletion_prevalence_by( + self.gene_deletions_df, self.master_df, [] + ) + else: + plot_df = gene_deletion_prevalence_by( + self.gene_deletions_df, self.master_df, by.split("_") + ) + if "_" in by: + # we need to create this column + plot_df[by] = ( + plot_df[by.split("_")].astype(str).agg("_".join, axis=1) + ) + data = [] + htemp = "%{y:0.1f}% (%{customdata[2]}/%{customdata[1]})" + + if by == "All": + # Prepare plotting data + customdata = np.stack( + [ + plot_df["n_samples"], + plot_df["n_passed"], + plot_df["n_deleted"], + ], + axis=-1, + ) + data.append( + go.Bar( + x=plot_df["gene"], + y=plot_df["prevalence"], + customdata=customdata, + hovertemplate=htemp, + name="Prevalence", + error_y=dict( + type="data", + array=plot_df["prevalence_highci"] - plot_df["prevalence"], + arrayminus=plot_df["prevalence"] + - plot_df["prevalence_lowci"], + ), + ) + ) + else: + for group in plot_df[by].unique(): + group_df = plot_df.query(f"{by} == @group") + # Prepare plotting data + customdata = np.stack( + [ + group_df["n_samples"], + group_df["n_passed"], + group_df["n_deleted"], + ], + axis=-1, + ) + data.append( + go.Bar( + x=group_df["gene"], + y=group_df["prevalence"], + customdata=customdata, + hovertemplate=htemp, + name=str(group), + error_y=dict( + type="data", + array=plot_df["prevalence_highci"] + - plot_df["prevalence"], + arrayminus=plot_df["prevalence"] + - plot_df["prevalence_lowci"], + ), + ) + ) + + # Plotting + fig = go.Figure(data) + fig.update_layout( + yaxis_title="Prevalence (%)", + xaxis=dict(showline=True, linewidth=1, linecolor="black", mirror=True), + yaxis=dict( + showline=True, + linewidth=1, + linecolor="black", + mirror=True, + showgrid=True, + gridcolor="lightgray", + gridwidth=0.5, + griddash="dot", + ), + legend=dict( + orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0 + ), + plot_bgcolor="rgba(0,0,0,0)", + hovermode="x unified", + ) + fig.update_yaxes(range=[0, 100]) + fig.update_traces(marker=dict(line=dict(color="black", width=1))) + + return fig diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index cac329f..f8cdbbc 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -15,6 +15,8 @@ compute_variant_prevalence, compute_variant_prevalence_per, filter_false_positives, + gene_deletion_prevalence_by, + gene_deletions, ) from nomadic.summarize.dashboard.builders import BasicSummaryDashboard from nomadic.util.dirs import produce_dir @@ -689,6 +691,31 @@ def main( prev_by_col_df.to_csv( f"{output_dir}/summary.variants.prevalence-{col}.csv", index=False ) + + # -------------------------------------------------------------------------------- + # Gene deletion analysis + # + # -------------------------------------------------------------------------------- + + log.info("Calculate gene deletions...") + gene_deletion_df = gene_deletions(coverage_df, ["hrp2", "hrp3"]) + gene_deletion_df.to_csv(f"{output_dir}/summary.gene_deletions.csv", index=False) + + prev_gen_deletions_df = gene_deletion_prevalence_by( + gene_deletion_df, master_metadata, [] + ) + prev_gen_deletions_df.to_csv( + f"{output_dir}/summary.gene-deletions.prevalence.csv", index=False + ) + + for col in prevalence_by: + prev_gen_deletion_by_col_df = gene_deletion_prevalence_by( + gene_deletion_df, master_metadata, [col] + ) + prev_gen_deletion_by_col_df.to_csv( + f"{output_dir}/summary.gene-deletions.prevalence-{col}.csv", index=False + ) + # -------------------------------------------------------------------------------- # Dashboard # @@ -702,6 +729,7 @@ def main( samples_amplicons_csv=f"{output_dir}/summary.samples_amplicons_qc.csv", coverage_csv=f"{output_dir}/summary.experiments_qc.csv", analysis_csv=f"{output_dir}/summary.variants.analysis_set.csv", + gene_deletions_csv=f"{output_dir}/summary.gene_deletions.csv", master_csv=str(meta_data_path), ) print("Done.") From 601e32516b02e0730b068bd609965d0bc954a1fe Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 31 Oct 2025 11:32:29 +0100 Subject: [PATCH 30/67] First version on map in summary --- .../dashboard/assets/summary-style.css | 7 + src/nomadic/summarize/dashboard/builders.py | 89 +++++++++++- src/nomadic/summarize/dashboard/components.py | 133 ++++++++++++++++++ src/nomadic/summarize/main.py | 1 + 4 files changed, 229 insertions(+), 1 deletion(-) diff --git a/src/nomadic/summarize/dashboard/assets/summary-style.css b/src/nomadic/summarize/dashboard/assets/summary-style.css index e38bee4..17a1d40 100644 --- a/src/nomadic/summarize/dashboard/assets/summary-style.css +++ b/src/nomadic/summarize/dashboard/assets/summary-style.css @@ -130,6 +130,13 @@ body { box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } +.map-dropdowns { + display: flex; + flex-direction: row; + gap: 20px; + margin-bottom: 20px; +} + /* Footer ------------------------------------------------------------------ */ .footer { diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 177efad..2f9c028 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -1,3 +1,4 @@ +import glob from importlib.resources import as_file, files import logging import threading @@ -17,6 +18,7 @@ ThroughputSummary, QualityControl, PrevalenceBarplot, + MapComponent, ) @@ -368,6 +370,85 @@ def _add_gene_deletion_row(self, gene_deletions_csv: str, master_csv: str) -> No self.components.append(self.prevalence_bars) self.layout.append(prevalence_row) + def _add_map_row( + self, analysis_csv: str, master_csv: str, geojsons: list[str] + ) -> None: + """ + Add a panel that shows a choropleth map of drug resistance marker prevalence + """ + # Get unique mutations from the analysis CSV for resistance genes only + analysis_df = pd.read_csv(analysis_csv) + resistance_genes = PrevalenceBarplot.GENE_SETS["Resistance"] + resistance_df = analysis_df[analysis_df["gene"].isin(resistance_genes)] + resistance_df["gene_mutation"] = ( + resistance_df["gene"] + "-" + resistance_df["aa_change"] + ) + gene_mutations = sorted(resistance_df["gene_mutation"].unique()) + + # Create the dropdowns + mutation_dropdown = dcc.Dropdown( + id="map-mutation-dropdown", + options=gene_mutations, + value=gene_mutations[0] if gene_mutations else None, + style=dict(width="300px"), + clearable=False, + ) + + regions = { + path.split("/")[-1].split(".")[0].split("-")[1]: path for path in geojsons + } + + region_dropdown = dcc.Dropdown( + id="map-region-dropdown", + options=list(regions.keys()), + value="district", + style=dict(width="300px"), + clearable=False, + ) + + # Create the map component with the prepared dropdowns + self.prevalence_map = MapComponent( + summary_name=self.summary_name, + analysis_csv=analysis_csv, + master_csv=master_csv, + component_id="prevalence-map", + mutation_dropdown_id="map-mutation-dropdown", + region_dropdown_id="map-region-dropdown", + geojsons=regions, + ) + + map_row = html.Div( + className="map-row", + children=[ + html.H3("Geographic Distribution", style=dict(marginTop="0px")), + html.Div( + className="map-dropdowns", + children=[ + html.Div( + children=[ + html.Label("Select mutation:"), + mutation_dropdown, + ] + ), + html.Div( + children=[ + html.Label("Group by:"), + region_dropdown, + ] + ), + ], + ), + html.Div( + className="map-plot", + children=[self.prevalence_map.get_layout()], + ), + ], + ) + + # Add components and layout + self.components.append(self.prevalence_map) + self.layout.append(map_row) + class BasicSummaryDashboard(SummaryDashboardBuilder): """ @@ -387,10 +468,10 @@ def __init__( analysis_csv: str, gene_deletions_csv: str, master_csv: str, + geojson_glob: str, ): """ Initialise all of the dashboard components - """ super().__init__(summary_name, self.CSS_STYLE) @@ -401,6 +482,7 @@ def __init__( self.analysis_csv = analysis_csv self.master_csv = master_csv self.gene_deletions_csv = gene_deletions_csv + self.geojson_glob = geojson_glob def _gen_layout(self): """ @@ -414,6 +496,11 @@ def _gen_layout(self): self._add_prevalence_by_col_row(self.analysis_csv, self.master_csv) self._add_gene_deletion_row(self.gene_deletions_csv, self.master_csv) + if glob.glob(self.geojson_glob): + self._add_map_row( + self.analysis_csv, self.master_csv, glob.glob(self.geojson_glob) + ) + def setup_translations(): """ diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 4309a5f..e988de9 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +import json import numpy as np import pandas as pd @@ -760,3 +761,135 @@ def _update(by: str): fig.update_traces(marker=dict(line=dict(color="black", width=1))) return fig + + +class MapComponent(SummaryDashboardComponent): + """ + Component for displaying a choropleth map of drug resistance marker prevalence + """ + + def __init__( + self, + summary_name: str, + analysis_csv: str, + master_csv: str, + component_id: str, + mutation_dropdown_id: str, + region_dropdown_id: str, + geojsons: dict[str, str], + ): + self.mutation_dropdown_id = mutation_dropdown_id + self.region_dropdown_id = region_dropdown_id + self.analysis_df = pd.read_csv(analysis_csv) + self.master_df = pd.read_csv(master_csv) + + # Load GeoJSON data + self.geojson_data = {} + for region, path in geojsons.items(): + with open(path) as f: + self.geojson_data[region] = json.load(f) + super().__init__(summary_name, component_id) + + def _define_layout(self): + """Layout is graph""" + return dcc.Graph(id=self.component_id) + + def callback(self, app: Dash) -> None: + @app.callback( + Output(self.component_id, "figure"), + Input(self.mutation_dropdown_id, "value"), + Input(self.region_dropdown_id, "value"), + ) + def _update(target_mutation, region_by): + """Called every time an input changes""" + + def normalize_location(loc): + """Normalize location names for consistent matching""" + return loc.lower().replace("-", "").replace(" ", "") + + # Split the gene-mutation value and calculate prevalence by region + gene, aa_change = target_mutation.split("-") + df = compute_variant_prevalence_per( + self.analysis_df.query("gene == @gene and aa_change == @aa_change"), + self.master_df, + [region_by], + ) + + # Normalize location names in the data + df[f"{region_by}_normalized"] = df[region_by].apply(normalize_location) + + # Get the appropriate GeoJSON data based on the region selection + if region_by not in self.geojson_data: + raise ValueError( + f"No GeoJSON data available for region type: {region_by}" + ) + + # Create a mapping from normalized names to original GeoJSON names + geojson_name_map = { + normalize_location(feat["properties"]["shapeName"]): feat["properties"][ + "shapeName" + ] + for feat in self.geojson_data[region_by]["features"] + } + + # Debug: Print location names from both sources + geojson_locations = set(geojson_name_map.keys()) + data_locations = set(df[f"{region_by}_normalized"].unique()) + print(f"Normalized GeoJSON locations: {geojson_locations}") + print(f"Normalized data locations: {data_locations}") + print( + f"Locations in data but not in GeoJSON: {data_locations - geojson_locations}" + ) + # print( + # f"Locations in GeoJSON but not in data: {geojson_locations - data_locations}" + # ) + + # Map the normalized names back to GeoJSON names for display + df[f"{region_by}_display"] = df[f"{region_by}_normalized"].map( + {k: v for k, v in geojson_name_map.items()} + ) + + # Create choropleth map + fig = go.Figure( + go.Choroplethmapbox( + geojson=self.geojson_data[region_by], + locations=df[ + f"{region_by}_display" + ], # Use the mapped display names + z=df["prevalence"], + colorscale="Spectral_r", + zmin=0, + zmax=100, + marker_opacity=0.8, + marker_line_width=1.0, + featureidkey="properties.shapeName", # Specify which GeoJSON property matches the location names + customdata=np.stack( + [ + df["n_samples"], + df["n_passed"], + df["n_mut"] + df["n_mixed"], + ], + axis=-1, + ), + hovertemplate=( + "%{location}
" + + "Prevalence: %{z:.1f}%
" + + "Samples: %{customdata[1]}
" + + "Mutations: %{customdata[2]}
" + + "" + ), + ) + ) + + fig.update_layout( + mapbox_style="carto-positron", + # TODO, allow to customize center and zoom + mapbox=dict( + center=dict(lat=-13.133897, lon=27.849332), # Center of Zambia + zoom=5, + ), + margin={"r": 0, "t": 0, "l": 0, "b": 0}, + height=600, + ) + + return fig diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index f8cdbbc..c748baf 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -731,6 +731,7 @@ def main( analysis_csv=f"{output_dir}/summary.variants.analysis_set.csv", gene_deletions_csv=f"{output_dir}/summary.gene_deletions.csv", master_csv=str(meta_data_path), + geojson_glob=f"metadata/{summary_name}-*.geojson", ) print("Done.") From 2891a7eba0acf3d4ec7b8d2394fc9e87d5e019bf Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 31 Oct 2025 17:55:25 +0100 Subject: [PATCH 31/67] Sort drug resistance markers by prevalence --- src/nomadic/summarize/compute.py | 2 +- src/nomadic/summarize/dashboard/builders.py | 35 ++++++++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index d375dc2..8789c04 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -29,7 +29,7 @@ def filter_false_positives(variants_df: pd.DataFrame): return df -def compute_variant_prevalence(variants_df: str) -> pd.DataFrame: +def compute_variant_prevalence(variants_df: pd.DataFrame) -> pd.DataFrame: """ Compute the prevalence of each mutation in `variants_df` diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 2f9c028..ac784ea 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -9,6 +9,8 @@ import i18n import pandas as pd +from nomadic.summarize.compute import compute_variant_prevalence + # from importlib.resources import files, as_file from nomadic.summarize.dashboard.components import ( AmpliconsBarplot, @@ -376,20 +378,37 @@ def _add_map_row( """ Add a panel that shows a choropleth map of drug resistance marker prevalence """ - # Get unique mutations from the analysis CSV for resistance genes only + # Get mutations and their prevalence for resistance genes analysis_df = pd.read_csv(analysis_csv) resistance_genes = PrevalenceBarplot.GENE_SETS["Resistance"] resistance_df = analysis_df[analysis_df["gene"].isin(resistance_genes)] - resistance_df["gene_mutation"] = ( - resistance_df["gene"] + "-" + resistance_df["aa_change"] - ) - gene_mutations = sorted(resistance_df["gene_mutation"].unique()) + prevalence_df = compute_variant_prevalence(resistance_df) + + # Create a dictionary with mutation info and prevalence + mutations_info = {} + for _, row in prevalence_df.iterrows(): + mutation_id = f"{row['gene']}-{row['aa_change']}" + mutations_info[mutation_id] = { + "prevalence": row["prevalence"], + "label": f"{mutation_id} ({row['prevalence']:.1f}%)", + "value": mutation_id, + } + + # Sort by prevalence and create dropdown options + gene_mutations = [ + info + for info in sorted( + mutations_info.values(), key=lambda x: x["prevalence"], reverse=True + ) + ] - # Create the dropdowns + # Create the dropdown with sorted mutations mutation_dropdown = dcc.Dropdown( id="map-mutation-dropdown", - options=gene_mutations, - value=gene_mutations[0] if gene_mutations else None, + options=[ + {"label": m["label"], "value": m["value"]} for m in gene_mutations + ], + value=gene_mutations[0]["value"] if gene_mutations else None, style=dict(width="300px"), clearable=False, ) From 9a9a7db9aea0334de48588353ac18836dc8676e2 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 31 Oct 2025 17:56:31 +0100 Subject: [PATCH 32/67] Use read mapping file instead of fastq file Fastq files are optional so it is better to check a file that is mandatory --- src/nomadic/util/experiment.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index e766207..3ec8866 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -123,7 +123,7 @@ def _setup_metadata_dir( def get_summary_files(exp_path: Path) -> SummaryFiles: if not exp_path.exists(): raise FileNotFoundError(f"Experiment path does not exist: {exp_path}") - if (exp_path / legacy_summary_files.fastqs_processed).exists(): + if (exp_path / legacy_summary_files.read_mapping).exists(): # Use legacy summary files if the old format exists return SummaryFiles( fastqs_processed=str(exp_path / legacy_summary_files.fastqs_processed), @@ -162,7 +162,7 @@ def check_complete_experiment(expt_dir: str) -> None: used_summary_files = None for file_format in [summary_files, legacy_summary_files]: - if not os.path.exists(f"{expt_dir}/{file_format.fastqs_processed}"): + if not os.path.exists(f"{expt_dir}/{file_format.read_mapping}"): continue used_summary_files = file_format @@ -170,6 +170,9 @@ def check_complete_experiment(expt_dir: str) -> None: if "depth" in file: # depth files are optional continue + if "fastq" in file: + # fastq files are optional + continue if not os.path.exists(f"{expt_dir}/{file}"): raise FileNotFoundError(f"Missing '{file}' file in {expt_dir}.") From 95c24121af77deabf54a0e5ce21a7c1237bc4136 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 31 Oct 2025 17:58:47 +0100 Subject: [PATCH 33/67] Do not require vcf folder at the moment Burkina Faso does not have this folder. And as we don't use this folder at the moment, let's not check it. --- src/nomadic/util/experiment.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index 3ec8866..56c61b5 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -180,9 +180,9 @@ def check_complete_experiment(expt_dir: str) -> None: if not used_summary_files: raise FileNotFoundError(f"Could not find any summary files in {expt_dir}.") - # TODO: for now, using this for VCF - if not os.path.exists(f"{expt_dir}/vcfs"): - raise FileNotFoundError(f"Could not find VCF directory in {expt_dir}.") + # TODO: at the moment we don't require VCFs to be present as we don't use them at the moment + # if not os.path.exists(f"{expt_dir}/vcfs"): + # raise FileNotFoundError(f"Could not find VCF directory in {expt_dir}.") def find_metadata(input_dir: str) -> MetadataTableParser: From a9ac5492a67f5ced23e905a4c76ecb3ad36a2200 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 4 Nov 2025 15:47:58 +0100 Subject: [PATCH 34/67] Fix error message of wrong exp meta data file --- src/nomadic/summarize/commands.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 18cc9f0..221ffa4 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -92,6 +92,5 @@ def summarize( ) except MetadataFormatError as e: raise click.BadParameter( - param_hint="-m/--metadata_csv", message=f"Metadata format error: {e}", ) from e From b14c555a39d1d4a2e46b3293950abe7b95c7c1d4 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 4 Nov 2025 15:48:29 +0100 Subject: [PATCH 35/67] For gene deletion analysis, exclude contaminated samples --- src/nomadic/summarize/compute.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index 8789c04..d38298a 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -159,6 +159,11 @@ def gene_deletions(coverage_df: pd.DataFrame, genes: list[str]) -> pd.DataFrame: analysis_set["n_passing_ctrl_amplicons"] >= AMPLICONS_QC_CUTOFF * analysis_set["n_ctrl_amplicons"] ] + + # QC: don't analyze amplicons that did not pass negative control + analysis_set = analysis_set[~analysis_set["fail_contam_abs"]] + + # Determine deletions analysis_set["is_deleted"] = analysis_set["mean_cov"] < DELETION_COVERAGE_THRESHOLD # consider a gene deleted if all amplicons that belong to the gene are deleted From 995902e1285918aaa97a355521f0bce46390319b Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 4 Nov 2025 15:49:52 +0100 Subject: [PATCH 36/67] Add site markers to map and settings file --- src/nomadic/summarize/commands.py | 12 + src/nomadic/summarize/dashboard/builders.py | 43 +++- src/nomadic/summarize/dashboard/components.py | 239 ++++++++++++------ src/nomadic/summarize/main.py | 19 +- src/nomadic/util/workspace.py | 6 + 5 files changed, 233 insertions(+), 86 deletions(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 221ffa4..5b91e06 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -44,6 +44,13 @@ default=True, help="Whether to start the web dashboard to monitor the run.", ) +@click.option( + "-s", + "--settings-file", + type=click.Path(exists=True, dir_okay=False, file_okay=True, path_type=Path), + show_default="/metadata/.yaml", + help="Path to the summary settings YAML file.", +) def summarize( experiment_dirs: tuple[str], summary_name: str, @@ -51,6 +58,7 @@ def summarize( metadata_csv: Path, dashboard: bool, prevalence_by: tuple[str], + settings_file: Path, ): """ Summarize a set of experiments to evaluate quality control and @@ -71,6 +79,9 @@ def summarize( if metadata_csv is None: metadata_csv = Path(workspace.get_master_metadata_csv(summary_name)) + if settings_file is None: + settings_file = Path(workspace.get_summary_settings_file(summary_name)) + if not metadata_csv.exists(): raise click.BadParameter( param_hint="-m/--metadata_csv", @@ -87,6 +98,7 @@ def summarize( expt_dirs=experiment_dirs, summary_name=summary_name, meta_data_path=metadata_csv, + settings_file_path=settings_file, show_dashboard=dashboard, prevalence_by=list(prevalence_by), ) diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index ac784ea..13fa66a 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -1,6 +1,7 @@ import glob from importlib.resources import as_file, files import logging +import os import threading from abc import ABC, abstractmethod from dash import Dash, html, dcc @@ -22,6 +23,7 @@ PrevalenceBarplot, MapComponent, ) +from nomadic.util.summary import Settings, get_map_settings class SummaryDashboardBuilder(ABC): @@ -373,10 +375,27 @@ def _add_gene_deletion_row(self, gene_deletions_csv: str, master_csv: str) -> No self.layout.append(prevalence_row) def _add_map_row( - self, analysis_csv: str, master_csv: str, geojsons: list[str] + self, + analysis_csv: str, + master_csv: str, + geojsons: list[str], + location_coords_csv: str, + map_center: tuple[float, float] | None, + map_zoom_level: int | None, ) -> None: """ Add a panel that shows a choropleth map of drug resistance marker prevalence + + Parameters + ---------- + analysis_csv : str + Path to the analysis CSV file + master_csv : str + Path to the master CSV file + geojsons : list[str] + List of paths to GeoJSON files for different region types + location_coords_csv : str | None, optional + Path to a CSV file containing location to coordinate mappings """ # Get mutations and their prevalence for resistance genes analysis_df = pd.read_csv(analysis_csv) @@ -434,6 +453,9 @@ def _add_map_row( mutation_dropdown_id="map-mutation-dropdown", region_dropdown_id="map-region-dropdown", geojsons=regions, + location_coords_csv=location_coords_csv, + map_center=map_center, + map_zoom_level=map_zoom_level, ) map_row = html.Div( @@ -488,9 +510,17 @@ def __init__( gene_deletions_csv: str, master_csv: str, geojson_glob: str, + settings: Settings, + location_coords_csv: str, ): """ Initialise all of the dashboard components + + Parameters + ---------- + location_coords_csv : str | None, optional + Path to a CSV file containing location to coordinate mappings. + The file should have columns: location,latitude,longitude """ super().__init__(summary_name, self.CSS_STYLE) @@ -502,6 +532,8 @@ def __init__( self.master_csv = master_csv self.gene_deletions_csv = gene_deletions_csv self.geojson_glob = geojson_glob + self.location_coords_csv = location_coords_csv + self.map_center, self.map_zoom_level = get_map_settings(settings) def _gen_layout(self): """ @@ -515,9 +547,14 @@ def _gen_layout(self): self._add_prevalence_by_col_row(self.analysis_csv, self.master_csv) self._add_gene_deletion_row(self.gene_deletions_csv, self.master_csv) - if glob.glob(self.geojson_glob): + if glob.glob(self.geojson_glob) or os.path.exists(self.location_coords_csv): self._add_map_row( - self.analysis_csv, self.master_csv, glob.glob(self.geojson_glob) + self.analysis_csv, + self.master_csv, + glob.glob(self.geojson_glob), + self.location_coords_csv, + self.map_center, + self.map_zoom_level, ) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index e988de9..e2be07c 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod import json +import os +from typing import Optional import numpy as np import pandas as pd @@ -435,13 +437,13 @@ def _update(gene_set: str, by: str): plot_df = compute_variant_prevalence(analysis_df) else: plot_df = compute_variant_prevalence_per( - analysis_df, self.master_df, by.split("_") + analysis_df, self.master_df, [by] ) - if "_" in by: - # we need to create this column - plot_df[by] = ( - plot_df[by.split("_")].astype(str).agg("_".join, axis=1) - ) + # if "_" in by: + # # we need to create this column + # plot_df[by] = ( + # plot_df[by.split("_")].astype(str).agg("_".join, axis=1) + # ) plot_df.sort_values(["gene", "chrom", "pos"], inplace=True) data = [] @@ -672,13 +674,13 @@ def _update(by: str): ) else: plot_df = gene_deletion_prevalence_by( - self.gene_deletions_df, self.master_df, by.split("_") + self.gene_deletions_df, self.master_df, [by] ) - if "_" in by: - # we need to create this column - plot_df[by] = ( - plot_df[by.split("_")].astype(str).agg("_".join, axis=1) - ) + # if "_" in by: + # # we need to create this column + # plot_df[by] = ( + # plot_df[by.split("_")].astype(str).agg("_".join, axis=1) + # ) data = [] htemp = "%{y:0.1f}% (%{customdata[2]}/%{customdata[1]})" @@ -765,7 +767,7 @@ def _update(by: str): class MapComponent(SummaryDashboardComponent): """ - Component for displaying a choropleth map of drug resistance marker prevalence + Component for displaying a choropleth map of drug resistance marker prevalence with sample site markers """ def __init__( @@ -777,11 +779,21 @@ def __init__( mutation_dropdown_id: str, region_dropdown_id: str, geojsons: dict[str, str], + location_coords_csv: str, + map_zoom_level: Optional[int] = None, + map_center: Optional[tuple[float, float]] = None, ): self.mutation_dropdown_id = mutation_dropdown_id self.region_dropdown_id = region_dropdown_id self.analysis_df = pd.read_csv(analysis_csv) self.master_df = pd.read_csv(master_csv) + self.map_zoom_level = map_zoom_level + self.map_center = map_center + + # Load location coordinates if provided + self.location_coords = None + if os.path.exists(location_coords_csv): + self.location_coords = pd.read_csv(location_coords_csv) # Load GeoJSON data self.geojson_data = {} @@ -800,93 +812,158 @@ def callback(self, app: Dash) -> None: Input(self.mutation_dropdown_id, "value"), Input(self.region_dropdown_id, "value"), ) - def _update(target_mutation, region_by): + def _update(target_mutation, region_by: Optional[str]): """Called every time an input changes""" + fig = go.Figure() + def normalize_location(loc): """Normalize location names for consistent matching""" return loc.lower().replace("-", "").replace(" ", "") # Split the gene-mutation value and calculate prevalence by region gene, aa_change = target_mutation.split("-") - df = compute_variant_prevalence_per( - self.analysis_df.query("gene == @gene and aa_change == @aa_change"), - self.master_df, - [region_by], - ) - # Normalize location names in the data - df[f"{region_by}_normalized"] = df[region_by].apply(normalize_location) + if region_by is not None: + df = compute_variant_prevalence_per( + self.analysis_df.query("gene == @gene and aa_change == @aa_change"), + self.master_df, + [region_by], + ) + + # Normalize location names in the data + df[f"{region_by}_normalized"] = df[region_by].apply(normalize_location) + + # Get the appropriate GeoJSON data based on the region selection + if region_by not in self.geojson_data: + raise ValueError( + f"No GeoJSON data available for region type: {region_by}" + ) - # Get the appropriate GeoJSON data based on the region selection - if region_by not in self.geojson_data: - raise ValueError( - f"No GeoJSON data available for region type: {region_by}" + # Create a mapping from normalized names to original GeoJSON names + geojson_name_map = { + normalize_location(feat["properties"]["shapeName"]): feat[ + "properties" + ]["shapeName"] + for feat in self.geojson_data[region_by]["features"] + } + + # Map the normalized names back to GeoJSON names for display + df[f"{region_by}_display"] = df[f"{region_by}_normalized"].map( + {k: v for k, v in geojson_name_map.items()} ) - # Create a mapping from normalized names to original GeoJSON names - geojson_name_map = { - normalize_location(feat["properties"]["shapeName"]): feat["properties"][ - "shapeName" - ] - for feat in self.geojson_data[region_by]["features"] - } - - # Debug: Print location names from both sources - geojson_locations = set(geojson_name_map.keys()) - data_locations = set(df[f"{region_by}_normalized"].unique()) - print(f"Normalized GeoJSON locations: {geojson_locations}") - print(f"Normalized data locations: {data_locations}") - print( - f"Locations in data but not in GeoJSON: {data_locations - geojson_locations}" - ) - # print( - # f"Locations in GeoJSON but not in data: {geojson_locations - data_locations}" - # ) + # Add choropleth layer + fig.add_trace( + go.Choroplethmapbox( + geojson=self.geojson_data[region_by], + locations=df[f"{region_by}_display"], + z=df["prevalence"], + colorscale="Spectral_r", + zmin=0, + zmax=100, + marker_opacity=0.8, + marker_line_width=1.0, + featureidkey="properties.shapeName", + customdata=np.stack( + [ + df["n_samples"], + df["n_passed"], + df["n_mut"] + df["n_mixed"], + ], + axis=-1, + ), + hovertemplate=( + "%{location}
" + + "Prevalence: %{z:.1f}%
" + + "Samples: %{customdata[1]}
" + + "Mutations: %{customdata[2]}
" + + "" + ), + ) + ) - # Map the normalized names back to GeoJSON names for display - df[f"{region_by}_display"] = df[f"{region_by}_normalized"].map( - {k: v for k, v in geojson_name_map.items()} - ) + # Add sample site markers if we have location coordinates + if self.location_coords is not None: + # Case-insensitive column matching for the location join + master_location_col = "location" + coords_location_col = "location" + + # Group by location to get sample counts and average prevalence + site_data = compute_variant_prevalence_per( + self.analysis_df.query("gene == @gene and aa_change == @aa_change"), + self.master_df, + [master_location_col], + ) - # Create choropleth map - fig = go.Figure( - go.Choroplethmapbox( - geojson=self.geojson_data[region_by], - locations=df[ - f"{region_by}_display" - ], # Use the mapped display names - z=df["prevalence"], - colorscale="Spectral_r", - zmin=0, - zmax=100, - marker_opacity=0.8, - marker_line_width=1.0, - featureidkey="properties.shapeName", # Specify which GeoJSON property matches the location names - customdata=np.stack( - [ - df["n_samples"], - df["n_passed"], - df["n_mut"] + df["n_mixed"], - ], - axis=-1, - ), - hovertemplate=( - "%{location}
" - + "Prevalence: %{z:.1f}%
" - + "Samples: %{customdata[1]}
" - + "Mutations: %{customdata[2]}
" - + "" - ), + # Create copies with lowercase location names for case-insensitive join + site_data["location_normalized"] = site_data[ + master_location_col + ].str.lower() + + coords_df = self.location_coords.copy() + coords_df["location_normalized"] = coords_df[ + coords_location_col + ].str.lower() + + coords_df.rename( + columns={coords_location_col: "location_display"}, inplace=True + ) + + # Merge using lowercase columns + merged_df = pd.merge( + site_data, + coords_df, + on="location_normalized", + how="inner", + ) + + # --- Scale bubbles by area --- + max_size = 40 # max bubble diameter (px) + min_size = 5 # min bubble diameter (px) + + # Scale radius ~ sqrt(sample_size) + merged_df["scaled_size"] = ( + np.sqrt(merged_df["n_passed"] / merged_df["n_passed"].max()) + * max_size + ) + merged_df["scaled_size"] = merged_df["scaled_size"].clip(lower=min_size) + # Add scatter markers for sample sites + fig.add_trace( + go.Scattermapbox( + lat=merged_df["lat"], + lon=merged_df["lng"], + mode="markers", + marker=dict( + size=merged_df["scaled_size"], + color=merged_df["prevalence"], # Color by prevalence + colorscale="Spectral_r", + cmin=0, + cmax=100, + showscale=False, # Don't show a second colorbar + ), + text=merged_df.apply( + lambda row: f"{row['location_display']}
" + f"Site Samples: {row['n_passed']}
" + f"Site Prevalence: {row['prevalence']:.1f}%
" + f"Site Mutations: {row['n_mut'] + row['n_mixed']}", + axis=1, + ), + hoverinfo="text", + ) ) - ) fig.update_layout( mapbox_style="carto-positron", - # TODO, allow to customize center and zoom mapbox=dict( - center=dict(lat=-13.133897, lon=27.849332), # Center of Zambia - zoom=5, + # center=dict(lat=-13.133897, lon=27.849332), # Center of Zambia + # center=dict(lat=12.3787, lon=-1.5328), # Center of Burkina Faso + center=dict(lat=self.map_center[0], lon=self.map_center[1]) + if self.map_center is not None + else None, + zoom=self.map_zoom_level + if self.map_zoom_level is not None + else None, ), margin={"r": 0, "t": 0, "l": 0, "b": 0}, height=600, diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index c748baf..28d4292 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -28,6 +28,7 @@ ) from nomadic.util.logging_config import LoggingFascade from nomadic.util.metadata import ExtendedMetadataTableParser +from nomadic.util.summary import Settings, get_master_columns_mapping, load_settings # -------------------------------------------------------------------------------- @@ -484,6 +485,7 @@ def main( expt_dirs: tuple[str], summary_name: str, meta_data_path: Path, + settings_file_path: Path, show_dashboard: bool = True, prevalence_by: list[str], ) -> None: @@ -509,11 +511,18 @@ def main( log.info("Input parameters:") log.info(f" Summary Name: {summary_name}") log.info(f" Master metadata: {meta_data_path}") + log.info(f" Setting file: {settings_file_path}") log.info(f" Found {len(expt_dirs)} experiment directories.") for expt_dir in expt_dirs: check_complete_experiment(expt_dir) log.info(" All experiments are complete.") + settings: Settings = Settings() + + if settings_file_path.exists(): + settings = load_settings(settings_file_path) + log.info(f" Loaded summary settings from {settings_file_path}.") + # CHECK METADATA IS VALID # TODO: # - Should I already interrogate geospatial information? @@ -543,7 +552,9 @@ def main( # shared_columns = fixed_columns + list(shared_columns) shared_columns = fixed_columns inventory_metadata = pd.concat([df[shared_columns] for df in dfs]) - master_metadata = pd.read_csv(meta_data_path) + master_metadata = pd.read_csv(meta_data_path).rename( + columns=get_master_columns_mapping(settings) + ) master_metadata = master_metadata.astype( {"sample_id": "str"} ) # ensure sample IDs are strings @@ -716,6 +727,8 @@ def main( f"{output_dir}/summary.gene-deletions.prevalence-{col}.csv", index=False ) + master_metadata.to_csv(f"{output_dir}/{summary_name}.metadata.csv", index=False) + # -------------------------------------------------------------------------------- # Dashboard # @@ -730,8 +743,10 @@ def main( coverage_csv=f"{output_dir}/summary.experiments_qc.csv", analysis_csv=f"{output_dir}/summary.variants.analysis_set.csv", gene_deletions_csv=f"{output_dir}/summary.gene_deletions.csv", - master_csv=str(meta_data_path), + master_csv=f"{output_dir}/{summary_name}.metadata.csv", geojson_glob=f"metadata/{summary_name}-*.geojson", + location_coords_csv=f"metadata/{summary_name}.coords.csv", + settings=settings, ) print("Done.") diff --git a/src/nomadic/util/workspace.py b/src/nomadic/util/workspace.py index 2888729..3b3d806 100644 --- a/src/nomadic/util/workspace.py +++ b/src/nomadic/util/workspace.py @@ -97,6 +97,12 @@ def get_master_metadata_csv(self, summary_name: str): """ return os.path.join(self.get_metadata_dir(), f"{summary_name}.csv") + def get_summary_settings_file(self, summary_name: str): + """ + Get the path to the master metadata CSV file for summaries. + """ + return os.path.join(self.get_metadata_dir(), f"{summary_name}.yaml") + def get_bed_file(self, panel_name: str): """ Get the path to the BED file for a given panel name. From d89efd579dcdfcc431ddda5b46fd4eed7c29b851 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 4 Nov 2025 18:20:37 +0100 Subject: [PATCH 37/67] Add missing files --- .../summarize/dashboard/translations/en.yml | 9 +++++ src/nomadic/util/summary.py | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/nomadic/summarize/dashboard/translations/en.yml create mode 100644 src/nomadic/util/summary.py diff --git a/src/nomadic/summarize/dashboard/translations/en.yml b/src/nomadic/summarize/dashboard/translations/en.yml new file mode 100644 index 0000000..98ab741 --- /dev/null +++ b/src/nomadic/summarize/dashboard/translations/en.yml @@ -0,0 +1,9 @@ +en: + mean_cov_field: "Mean Coverage field samples" + per_field_passing: "Field samples passing QC (%%)" + per_field_contam: "Field samples with contamination (%%)" + per_field_lowcov: "Field samples with low coverage (%%)" + mean_cov_field_tooltip: "The average mean coverage per amplicon across all field samples." + per_field_passing_tooltip: "The percentage of field samples that passed quality control (QC) for this amplicon. We check for minimum coverage and no contamination" + per_field_contam_tooltip: "The percentage of field samples that showed contamination for this amplicon. This is defined as having more than 50x mean coverage of negative controls for this amplicon, or the ratio of mean coverage of negative controls to mean coverage is more than 10%%" + per_field_lowcov_tooltip: "The percentage of field samples that had low coverage for this amplicon. This is defined as having mean coverage less than 50x." diff --git a/src/nomadic/util/summary.py b/src/nomadic/util/summary.py new file mode 100644 index 0000000..60ef25c --- /dev/null +++ b/src/nomadic/util/summary.py @@ -0,0 +1,39 @@ +from pathlib import Path +from typing import Optional + +import yaml +from pydantic import BaseModel + + +class ColumnSettings(BaseModel): + use_as: str + + +class MapSettings(BaseModel): + center: tuple[float, float] + zoom_level: int + + +class Settings(BaseModel): + master_columns: Optional[dict[str, ColumnSettings]] = None + map: Optional[MapSettings] = None + + +def load_settings(settings_file: Path) -> Settings: + """Load settings from a file.""" + data = yaml.safe_load(open(settings_file, "r")) + return Settings(**data) + + +def get_master_columns_mapping(settings: Settings) -> dict[str, str]: + if settings.master_columns is None: + return {} + return {col: column.use_as for col, column in settings.master_columns.items()} + + +def get_map_settings( + settings: Settings, +) -> tuple[Optional[tuple[float, float]], Optional[int]]: + if settings.map is None: + return None, None + return (settings.map.center, settings.map.zoom_level) From 77a06916cbfe1d4274105932ec0b576ecb92b770 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 5 Nov 2025 17:29:59 +0100 Subject: [PATCH 38/67] Add show neg control coverage in summary --- src/nomadic/summarize/dashboard/builders.py | 4 +- src/nomadic/summarize/dashboard/components.py | 102 ++++++++++++------ .../summarize/dashboard/translations/en.yml | 4 +- 3 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 13fa66a..336b28d 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -160,7 +160,7 @@ def _add_experiment_qc(self, coverage_csv: str) -> None: {"label": t(option), "value": option, "title": t(f"{option}_tooltip")} for option in QualityControl.STATISTICS ], - value=QualityControl.STATISTICS[1], + value=QualityControl.STATISTICS[2], style=dict(width="300px"), ) @@ -351,7 +351,7 @@ def _add_gene_deletion_row(self, gene_deletions_csv: str, master_csv: str) -> No prevalence_row = html.Div( className="gene-deltions-row", children=[ - html.H3("Prevalence Gene Deletions", style=dict(marginTop="0px")), + html.H3("Potential Gene Deletions", style=dict(marginTop="0px")), html.Div( className="prevalence-dropdowns", children=[ diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index e2be07c..a83ce1f 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -196,39 +196,6 @@ def callback(self, app: Dash) -> None: """ -COV_MAX = 10000 -COLORSCALES = { - "per_field_passing": [ - [0.00, "#FF0033"], - [0.50, "#FF9900"], - [0.70, "#FFEB33"], # set 70% as okay - [0.90, "#80FF7E"], # above 90% is good - [1.00, "#3A9A3E"], - ], - "per_field_contam": [ - [0.00, "#3A9A3E"], # low contamination is good - [0.20, "#FFEB33"], # set 30% as okay - [0.30, "#FF9900"], # contamination is bad - [1.00, "#FF0033"], - ], - "per_field_lowcov": [ - [0.00, "#3A9A3E"], # low lowcov is good - [0.10, "#80FF7E"], # above 90% is good - [0.30, "#FFEB33"], # set 30% as okay - [0.50, "#FF9900"], # lowcov is bad - [1.00, "#FF0033"], - ], - "mean_cov_field": [ - [0, "#FF0033"], # low coverage is bad - [25 / COV_MAX, "#FF9900"], - [50 / COV_MAX, "#FFEB33"], # set threshold as okay - [200 / COV_MAX, "#80FF7E"], # above 200 we can make good calls - [500 / COV_MAX, "#3A9A3E"], # above 500 is excellent also for low freq calls - [1.0, "#7585FE"], # coverage is uncapped - ], -} - - class AmpliconsBarplot(SummaryDashboardComponent): """ Make a bar chart that shows the Amplicons Statistics @@ -297,9 +264,52 @@ def callback(self, app: Dash) -> None: pass +COV_MAX = 10000 +COV_NEG_MAX = 50 +COLORSCALES = { + "per_field_passing": [ + [0.00, "#FF0033"], + [0.50, "#FF9900"], + [0.70, "#FFEB33"], # set 70% as okay + [0.90, "#80FF7E"], # above 90% is good + [1.00, "#3A9A3E"], + ], + "per_field_contam": [ + [0.00, "#3A9A3E"], # low contamination is good + [0.20, "#FFEB33"], # set 30% as okay + [0.30, "#FF9900"], # contamination is bad + [1.00, "#FF0033"], + ], + "per_field_lowcov": [ + [0.00, "#3A9A3E"], # low lowcov is good + [0.10, "#80FF7E"], # above 90% is good + [0.30, "#FFEB33"], # set 30% as okay + [0.50, "#FF9900"], # lowcov is bad + [1.00, "#FF0033"], + ], + "mean_cov_field": [ + [0, "#FF0033"], # low coverage is bad + [25 / COV_MAX, "#FF9900"], + [50 / COV_MAX, "#FFEB33"], # set threshold as okay + [200 / COV_MAX, "#80FF7E"], # above 200 we can make good calls + [500 / COV_MAX, "#3A9A3E"], # above 500 is excellent also for low freq calls + [1.0, "#7585FE"], # coverage is uncapped + ], + "mean_cov_neg": [ + [0, "#3A9A3E"], # low neg cov is good + [2 / COV_NEG_MAX, "#80FF7E"], + [5 / COV_NEG_MAX, "#FFEB33"], + [10 / COV_NEG_MAX, "#FF9900"], + [50 / COV_NEG_MAX, "#FF0033"], # from 50 cov we fail neg control + [1.0, "#FF0033"], # coverage is uncapped + ], +} + + class QualityControl(SummaryDashboardComponent): STATISTICS = [ "mean_cov_field", + "mean_cov_neg", "per_field_passing", "per_field_contam", "per_field_lowcov", @@ -318,7 +328,7 @@ def __init__( self.plot_df = pd.pivot_table( index="expt_name", columns="name", - values=self.STATISTICS, + values=[*self.STATISTICS, "n_field"], dropna=False, observed=False, data=self.coverage_df, @@ -343,6 +353,14 @@ def _update(focus_stat: str): if "cov" in focus_stat else "" ) + # Create customdata array for hover + customdata = np.stack( + [ + self.plot_df["n_field"], + ], + axis=-1, + ) + plot_data = [ go.Heatmap( x=self.plot_df[focus_stat].columns, @@ -352,10 +370,24 @@ def _update(focus_stat: str): xgap=1, ygap=1, zmin=0, - zmax=100 if "per_" in focus_stat else 10000, + zmax=100 + if "per_" in focus_stat + else COV_MAX + if "mean_cov_field" in focus_stat + else COV_NEG_MAX, colorbar=dict(title=legend, outlinecolor="black", outlinewidth=1), hoverongaps=False, colorscale=COLORSCALES[focus_stat], + customdata=customdata, + hovertemplate=( + ( + "%{z:.1f}%
" + if "per_" in focus_stat + else "%{z:.1f}x
" + ) + + "Amplicon: %{x}
" + + "Number of samples: %{customdata[0]}
" + ), ) ] MAR = 40 diff --git a/src/nomadic/summarize/dashboard/translations/en.yml b/src/nomadic/summarize/dashboard/translations/en.yml index 98ab741..cc112ab 100644 --- a/src/nomadic/summarize/dashboard/translations/en.yml +++ b/src/nomadic/summarize/dashboard/translations/en.yml @@ -1,9 +1,11 @@ en: - mean_cov_field: "Mean Coverage field samples" + mean_cov_field: "Mean coverage field samples" + mean_cov_neg: "Mean coverage negative controls" per_field_passing: "Field samples passing QC (%%)" per_field_contam: "Field samples with contamination (%%)" per_field_lowcov: "Field samples with low coverage (%%)" mean_cov_field_tooltip: "The average mean coverage per amplicon across all field samples." + mean_cov_neg_tooltip: "The average mean coverage per amplicon across all negative control samples." per_field_passing_tooltip: "The percentage of field samples that passed quality control (QC) for this amplicon. We check for minimum coverage and no contamination" per_field_contam_tooltip: "The percentage of field samples that showed contamination for this amplicon. This is defined as having more than 50x mean coverage of negative controls for this amplicon, or the ratio of mean coverage of negative controls to mean coverage is more than 10%%" per_field_lowcov_tooltip: "The percentage of field samples that had low coverage for this amplicon. This is defined as having mean coverage less than 50x." From 705a6c9f71d83d392e68d6ea13d8921c9e8834a5 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 12 Nov 2025 23:16:31 +0100 Subject: [PATCH 39/67] Add option to create summary without master file --- src/nomadic/summarize/commands.py | 12 +++++++-- src/nomadic/summarize/dashboard/builders.py | 4 +++ src/nomadic/summarize/main.py | 29 ++++++++++++++------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 5b91e06..2d27650 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -51,6 +51,12 @@ show_default="/metadata/.yaml", help="Path to the summary settings YAML file.", ) +@click.option( + "--no-master-metadata", + is_flag=True, + default=False, + help="If set, no master metadata CSV needs to be provided. This is not recommended, as it's better to be explicit about the samples to be included, but it can be used to quickly get an overview of the data in the workspace.", +) def summarize( experiment_dirs: tuple[str], summary_name: str, @@ -59,6 +65,7 @@ def summarize( dashboard: bool, prevalence_by: tuple[str], settings_file: Path, + no_master_metadata: bool, ): """ Summarize a set of experiments to evaluate quality control and @@ -76,13 +83,13 @@ def summarize( if summary_name is None: summary_name = workspace.get_name() - if metadata_csv is None: + if metadata_csv is None and not no_master_metadata: metadata_csv = Path(workspace.get_master_metadata_csv(summary_name)) if settings_file is None: settings_file = Path(workspace.get_summary_settings_file(summary_name)) - if not metadata_csv.exists(): + if not no_master_metadata and not metadata_csv.exists(): raise click.BadParameter( param_hint="-m/--metadata_csv", message=f"Master metadata file '{metadata_csv}' does not exist.", @@ -101,6 +108,7 @@ def summarize( settings_file_path=settings_file, show_dashboard=dashboard, prevalence_by=list(prevalence_by), + no_master_metadata=no_master_metadata, ) except MetadataFormatError as e: raise click.BadParameter( diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 336b28d..9bc6bc5 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -276,6 +276,10 @@ def _add_prevalence_by_col_row(self, analysis_csv: str, master_csv: str) -> None cols = cols_to_group_by(master_csv, analysis_csv, 50) + if not cols: + # Nothing to show + return + col_dropdown = dcc.Dropdown( id="col-dropdown", options=cols, diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 28d4292..2a44090 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -1,5 +1,5 @@ import os -from typing import Iterable +from typing import Iterable, Optional from warnings import warn import webbrowser from enum import StrEnum, auto @@ -484,10 +484,11 @@ def main( *, expt_dirs: tuple[str], summary_name: str, - meta_data_path: Path, + meta_data_path: Optional[Path], settings_file_path: Path, show_dashboard: bool = True, prevalence_by: list[str], + no_master_metadata: bool = False, ) -> None: """ Define the main function for the summary analysis @@ -502,6 +503,9 @@ def main( in the shared metadata; for example parasitemia """ + + assert (meta_data_path is not None) or no_master_metadata + output_dir = produce_dir( "summaries", summary_name ) # TODO allow to change output dir @@ -510,7 +514,10 @@ def main( log = LoggingFascade(logger_name="nomadic") log.info("Input parameters:") log.info(f" Summary Name: {summary_name}") - log.info(f" Master metadata: {meta_data_path}") + if not no_master_metadata: + log.info(f" Master metadata: {meta_data_path}") + else: + log.info(" No master metadata will be used.") log.info(f" Setting file: {settings_file_path}") log.info(f" Found {len(expt_dirs)} experiment directories.") for expt_dir in expt_dirs: @@ -549,12 +556,16 @@ def main( fixed_columns = ["expt_name", "barcode", "sample_id", "sample_type"] shared_columns.difference_update(fixed_columns) # for now we use the master metadata file - # shared_columns = fixed_columns + list(shared_columns) - shared_columns = fixed_columns - inventory_metadata = pd.concat([df[shared_columns] for df in dfs]) - master_metadata = pd.read_csv(meta_data_path).rename( - columns=get_master_columns_mapping(settings) - ) + inventory_metadata = pd.concat([df[fixed_columns] for df in dfs]) + if meta_data_path is not None and not no_master_metadata: + master_metadata = pd.read_csv(meta_data_path).rename( + columns=get_master_columns_mapping(settings) + ) + else: + # create metadata from experiment meta data files + shared_columns = ["sample_id"] + list(shared_columns) + master_metadata = pd.concat([df[shared_columns] for df in dfs]) + master_metadata = master_metadata.astype( {"sample_id": "str"} ) # ensure sample IDs are strings From 10a0d891259f8b65dd384894dc923e360498fcdd Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 12 Nov 2025 23:16:57 +0100 Subject: [PATCH 40/67] Improve messages for summarize --- src/nomadic/summarize/commands.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 2d27650..40cb450 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -32,7 +32,13 @@ help="Path to the master metadata CSV file.", show_default="/metadata/.csv", ) -@click.option("-n", "--summary_name", type=str, help="Name of summary") +@click.option( + "-n", + "--summary_name", + type=str, + help="Name of summary", + show_default="name of the workspace.", +) @click.option( "--prevalence-by", type=str, @@ -42,7 +48,7 @@ @click.option( "--dashboard/--no-dashboard", default=True, - help="Whether to start the web dashboard to monitor the run.", + help="Whether to start the web dashboard to look at the summary.", ) @click.option( "-s", From e6f522a5ef252c6557a44d89e8c7a5e7b3568e29 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 12 Nov 2025 23:17:14 +0100 Subject: [PATCH 41/67] Fix bug in error bars of summary --- src/nomadic/summarize/dashboard/components.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index a83ce1f..ca7444f 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -527,10 +527,10 @@ def _update(gene_set: str, by: str): name=str(group), error_y=dict( type="data", - array=plot_df["prevalence_highci"] - - plot_df["prevalence"], - arrayminus=plot_df["prevalence"] - - plot_df["prevalence_lowci"], + array=group_df["prevalence_highci"] + - group_df["prevalence"], + arrayminus=group_df["prevalence"] + - group_df["prevalence_lowci"], ), ) ) From 4eaa20495384459eb999c6946638122b908db89b Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 12 Nov 2025 23:20:34 +0100 Subject: [PATCH 42/67] Remove problematic mutations again --- src/nomadic/summarize/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 2a44090..2338d23 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -695,7 +695,7 @@ def main( variant_df.query("status == 'pass'") .query("mut_type == 'missense'") .query("gene not in @remove_genes") - # .query("mutation not in @remove_mutations") + .query("mutation not in @remove_mutations") ) # Filter out false positives From cdc5e30328983e4ea9638a3475a7c1fdecc60563 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 12 Nov 2025 23:41:38 +0100 Subject: [PATCH 43/67] Add warnings to help with finding the right region names --- src/nomadic/summarize/dashboard/components.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index ca7444f..240e478 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -2,6 +2,7 @@ import json import os from typing import Optional +import warnings import numpy as np import pandas as pd @@ -880,6 +881,15 @@ def normalize_location(loc): for feat in self.geojson_data[region_by]["features"] } + geojson_regions = set(geojson_name_map.keys()) + metadata_regions = set(df[f"{region_by}_normalized"]) + + not_in_geojson = metadata_regions - geojson_regions + if not_in_geojson: + warnings.warn( + f"Some regions in metadata could not be mapped to a region in the geojson: {not_in_geojson}.\nDo you mean any of: {geojson_regions}" + ) + # Map the normalized names back to GeoJSON names for display df[f"{region_by}_display"] = df[f"{region_by}_normalized"].map( {k: v for k, v in geojson_name_map.items()} From b56081c9a324b9739b1b18aa35e8f1c7b69abc0d Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Wed, 12 Nov 2025 23:45:34 +0100 Subject: [PATCH 44/67] Add init file to summarize module --- src/nomadic/summarize/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/nomadic/summarize/__init__.py diff --git a/src/nomadic/summarize/__init__.py b/src/nomadic/summarize/__init__.py new file mode 100644 index 0000000..e69de29 From c82f8b796299b6e364bb79bca4e1896624eab6a1 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Thu, 4 Dec 2025 12:48:13 +0100 Subject: [PATCH 45/67] Apply wording change for summary from code review Co-authored-by: danieljbridges <51692943+danieljbridges@users.noreply.github.com> --- src/nomadic/summarize/dashboard/translations/en.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/nomadic/summarize/dashboard/translations/en.yml b/src/nomadic/summarize/dashboard/translations/en.yml index cc112ab..af76cd0 100644 --- a/src/nomadic/summarize/dashboard/translations/en.yml +++ b/src/nomadic/summarize/dashboard/translations/en.yml @@ -6,6 +6,6 @@ en: per_field_lowcov: "Field samples with low coverage (%%)" mean_cov_field_tooltip: "The average mean coverage per amplicon across all field samples." mean_cov_neg_tooltip: "The average mean coverage per amplicon across all negative control samples." - per_field_passing_tooltip: "The percentage of field samples that passed quality control (QC) for this amplicon. We check for minimum coverage and no contamination" - per_field_contam_tooltip: "The percentage of field samples that showed contamination for this amplicon. This is defined as having more than 50x mean coverage of negative controls for this amplicon, or the ratio of mean coverage of negative controls to mean coverage is more than 10%%" + per_field_passing_tooltip: "The percentage of field samples that passed quality control (QC) for this amplicon i.e. has minimum coverage and no (or minimal) contamination" + per_field_contam_tooltip: "The percentage of field samples with contamination for this amplicon. Contamination is where negative controls have 50x mean coverage, or the mean coverage of negative controls is more than 10%% of the sample coverage" per_field_lowcov_tooltip: "The percentage of field samples that had low coverage for this amplicon. This is defined as having mean coverage less than 50x." From 4acbf6d608700022fd276c005b5961fa99c9f637 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Thu, 4 Dec 2025 13:02:21 +0100 Subject: [PATCH 46/67] Clean up some things in summarize --- src/nomadic/summarize/compute.py | 22 ++++++++++--------- src/nomadic/summarize/dashboard/builders.py | 22 +++++-------------- src/nomadic/summarize/dashboard/components.py | 12 ---------- src/nomadic/summarize/main.py | 4 ++-- 4 files changed, 20 insertions(+), 40 deletions(-) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index d38298a..6dce90e 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -7,6 +7,7 @@ "chrom", "pos", # "ref", + # The alt might differ for multiallelic sites # "alt", "aa_change", "aa_pos", @@ -16,6 +17,14 @@ def filter_false_positives(variants_df: pd.DataFrame): + """Filter out likely false positive variant calls. + + Removes variants where only one mutation is called and the + maximum WSAF is below 15%. + """ + N_MUT = 1 + WSAF_CUTOFF = 0.15 + mut = variants_df[variants_df["gt_int"].isin([1, 2])] df = variants_df.merge( mut.groupby(variants_group_columns).agg( @@ -23,7 +32,7 @@ def filter_false_positives(variants_df: pd.DataFrame): ), on=variants_group_columns, ) - df = df[~(df["n_mut"].le(1) & df["wsaf_max"].lt(0.15))].drop( + df = df[~(df["n_mut"].le(N_MUT) & df["wsaf_max"].lt(WSAF_CUTOFF))].drop( columns=["n_mut", "wsaf_max"] ) return df @@ -32,10 +41,6 @@ def filter_false_positives(variants_df: pd.DataFrame): def compute_variant_prevalence(variants_df: pd.DataFrame) -> pd.DataFrame: """ Compute the prevalence of each mutation in `variants_df` - - Assumes columns several columns exist; compute across all samples - in data; - """ prev_df = ( @@ -77,11 +82,8 @@ def compute_variant_prevalence_per( variants_df, master_df, fields: list[str] ) -> pd.DataFrame: """ - Compute the prevalence of each mutation in `variants_df` - - Assumes columns several columns exist; compute across all samples - in data; - + Compute the prevalence of each mutation in `variants_df` and groups by `fields` + from `master_df`. """ variants_df = variants_df.merge( master_df[["sample_id", *fields]], on="sample_id", how="left" diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 9bc6bc5..c49f260 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -12,7 +12,6 @@ from nomadic.summarize.compute import compute_variant_prevalence -# from importlib.resources import files, as_file from nomadic.summarize.dashboard.components import ( AmpliconsBarplot, GeneDeletionsBarplot, @@ -61,14 +60,6 @@ def _gen_app(self): return app - # def _gen_timer(self, name, speed): - # """ - # Generate the timer which is a feature of all real-time dashboards - - # """ - - # return dcc.Interval(id=name, interval=speed, n_intervals=0) - def run(self, in_thread: bool = False, **kwargs): """ Run the dashboard @@ -100,8 +91,7 @@ def run(self, in_thread: bool = False, **kwargs): def _add_throughput_banner(self, throughput_csv: str) -> None: """ - Add a banner which shows the logo and summarise the number of FASTQ files - processed + Add a banner which shows the logo and summarise the number of samples processed. """ @@ -121,7 +111,7 @@ def _add_throughput_banner(self, throughput_csv: str) -> None: def _add_samples(self, samples_csv: str, samples_amplicons_csv: str) -> None: """ - Add a panel that shows progress of samples + Add a panel that shows progress of samples sequenced """ self.samples = SamplesPie( @@ -210,7 +200,7 @@ def _add_prevalence_row(self, analysis_csv: str, master_csv: str) -> None: clearable=False, ) - cols = cols_to_group_by(master_csv, analysis_csv, 10) + cols = cols_to_group_by(master_csv, analysis_csv, max_cat=10) dropdown_by = dcc.Dropdown( id="prevalence-dropdown-by", @@ -274,7 +264,7 @@ def _add_prevalence_by_col_row(self, analysis_csv: str, master_csv: str) -> None clearable=False, ) - cols = cols_to_group_by(master_csv, analysis_csv, 50) + cols = cols_to_group_by(master_csv, analysis_csv, max_cat=50) if not cols: # Nothing to show @@ -334,7 +324,7 @@ def _add_gene_deletion_row(self, gene_deletions_csv: str, master_csv: str) -> No """ - cols = cols_to_group_by(master_csv, gene_deletions_csv, 50) + cols = cols_to_group_by(master_csv, gene_deletions_csv, max_cat=50) dropdown_by = dcc.Dropdown( id="gene-deletions-dropdown-by", @@ -577,7 +567,7 @@ def setup_translations(): i18n.set("locale", "en") -def cols_to_group_by(master_csv: str, analysis_csv, max_cat: int) -> list[str]: +def cols_to_group_by(master_csv: str, analysis_csv, *, max_cat: int) -> list[str]: """ Get columns that can be used to group prevalence by diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 240e478..7c07d9f 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -472,11 +472,6 @@ def _update(gene_set: str, by: str): plot_df = compute_variant_prevalence_per( analysis_df, self.master_df, [by] ) - # if "_" in by: - # # we need to create this column - # plot_df[by] = ( - # plot_df[by.split("_")].astype(str).agg("_".join, axis=1) - # ) plot_df.sort_values(["gene", "chrom", "pos"], inplace=True) data = [] @@ -709,11 +704,6 @@ def _update(by: str): plot_df = gene_deletion_prevalence_by( self.gene_deletions_df, self.master_df, [by] ) - # if "_" in by: - # # we need to create this column - # plot_df[by] = ( - # plot_df[by.split("_")].astype(str).agg("_".join, axis=1) - # ) data = [] htemp = "%{y:0.1f}% (%{customdata[2]}/%{customdata[1]})" @@ -998,8 +988,6 @@ def normalize_location(loc): fig.update_layout( mapbox_style="carto-positron", mapbox=dict( - # center=dict(lat=-13.133897, lon=27.849332), # Center of Zambia - # center=dict(lat=12.3787, lon=-1.5328), # Center of Burkina Faso center=dict(lat=self.map_center[0], lon=self.map_center[1]) if self.map_center is not None else None, diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 2338d23..225511c 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -613,9 +613,9 @@ def main( throughput_df = compute_throughput(inventory_metadata) log.info(f" Positive controls: {throughput_df.loc['pos', 'All']}") log.info(f" Negative controls: {throughput_df.loc['neg', 'All']}") - log.info(f" Field samples (total): {throughput_df.loc['field', 'All']}") + log.info(f" Fields samples sequenced (total): {throughput_df.loc['field', 'All']}") log.info(f" Field samples (unique): {throughput_df.loc['field_unique', 'All']}") - log.info(f" Unknown (excluded): {len(unknown_samples)}") + log.info(f" Unknown samples (excluded): {len(unknown_samples)}") throughput_df.to_csv(f"{output_dir}/summary.throughput.csv", index=True) # Now let's evaluate coverage From 506b5fd769d9e9fcf46995216b6df62caad42172 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 5 Dec 2025 11:32:54 +0100 Subject: [PATCH 47/67] Add pydantic to dependencies --- build/conda/meta.yaml | 1 + environments/run.yml | 3 ++- setup.cfg | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/build/conda/meta.yaml b/build/conda/meta.yaml index 40c35d3..defca77 100644 --- a/build/conda/meta.yaml +++ b/build/conda/meta.yaml @@ -35,6 +35,7 @@ requirements: - pysam - pyyaml - i18nice + - pydantic # tools - minimap2 - samtools >=1.20 diff --git a/environments/run.yml b/environments/run.yml index 951751d..1a49e17 100644 --- a/environments/run.yml +++ b/environments/run.yml @@ -3,7 +3,7 @@ channels: - conda-forge - bioconda dependencies: - - python >= 3.10 + - python >= 3.10 - minimap2 - samtools >=1.20 - bcftools >=1.20 @@ -19,5 +19,6 @@ dependencies: - platformdirs - pyyaml - i18nice + - pydantic - pip - rsync diff --git a/setup.cfg b/setup.cfg index 652786f..8b52b71 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,7 @@ install_requires = pyyaml seaborn i18nice + pydantic python_requires = >=3.10 zip_safe = no From 3ec23f215bdb86df9b597ffb9eef56ec895c7c9a Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 5 Dec 2025 11:58:12 +0100 Subject: [PATCH 48/67] Rename missing to not_sequenced --- src/nomadic/summarize/compute.py | 91 ++++++++++++++++++- src/nomadic/summarize/dashboard/components.py | 23 +++-- .../summarize/dashboard/translations/en.yml | 3 + src/nomadic/summarize/main.py | 83 +---------------- 4 files changed, 108 insertions(+), 92 deletions(-) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index 6dce90e..c508c98 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -1,7 +1,96 @@ -import pandas as pd +import enum +import pandas as pd from statsmodels.stats.proportion import proportion_confint + +class Status(enum.Enum): + PASSING = "passing" + FAILING = "failing" + NOT_SEQUENCED = "not_sequenced" + + +def calc_samples_summary( + master_metadata_df: pd.DataFrame, replicates_qc_df: pd.DataFrame +) -> pd.DataFrame: + """ + Calculates a summary of which samples have how many replicates that are passing or failing, + and if it has at least one passing replicate, it is concidered as passing. + + This can be used to get a list of to be resequenced samples. + """ + samples_summary_df = ( + replicates_qc_df.groupby(["sample_id"]) + .agg( + n_replicates=pd.NamedAgg("barcode", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + ) + .reset_index() + ) + samples_summary_df = ( + samples_summary_df.merge( + master_metadata_df[["sample_id"]], how="right", on="sample_id" + ) + .fillna({"n_replicates": 0, "n_passing": 0}) + .astype({"n_replicates": int, "n_passing": int}) + ) + samples_summary_df["status"] = samples_summary_df.apply( + lambda row: Status.PASSING.value + if row["n_passing"] > 0 + else Status.FAILING.value + if row["n_replicates"] > 0 + else Status.NOT_SEQUENCED.value, + axis=1, + ) + samples_summary_df.sort_values( + by=["n_passing", "n_replicates", "sample_id"], + inplace=True, + ascending=[False, False, True], + ) + + return samples_summary_df + + +def calc_amplicons_summary(master_metadata, replicates_amplicon_qc_df): + """ + Calculates a summary of which samples have how many replicates per amplicon that are passing or failing, + and if it has at least one passing replicate over that amplicon, it is concidered as passing. + + This can be used to understand if there are certain amplicons of samples that have no coverage yet + and make decisions on resampling, that are more fine granular than per sample. + """ + samples_by_amplicons_summary_df = ( + replicates_amplicon_qc_df.groupby(["sample_id", "name"]) + .agg( + n_replicates=pd.NamedAgg("barcode", "count"), + n_passing=pd.NamedAgg("passing", "sum"), + ) + .reset_index() + ) + samples_by_amplicons_summary_df = ( + samples_by_amplicons_summary_df.merge( + master_metadata[["sample_id"]], how="right", on="sample_id" + ) + .fillna({"n_replicates": 0, "n_passing": 0}) + .astype({"n_replicates": int, "n_passing": int}) + ) + samples_by_amplicons_summary_df["status"] = samples_by_amplicons_summary_df.apply( + lambda row: Status.PASSING.value + if row["n_passing"] > 0 + else Status.FAILING.value + if row["n_replicates"] > 0 + else Status.NOT_SEQUENCED.value, + axis=1, + ) + samples_by_amplicons_summary_df.sort_values( + by=["n_passing", "n_replicates", "sample_id"], + inplace=True, + ascending=[False, False, True], + ) + + return samples_by_amplicons_summary_df + + variants_group_columns = [ "gene", "chrom", diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 7c07d9f..273d99f 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -11,6 +11,7 @@ from dash.dependencies import Input, Output from nomadic.summarize.compute import ( + Status, compute_variant_prevalence, compute_variant_prevalence_per, gene_deletion_prevalence_by, @@ -134,9 +135,9 @@ def callback(self, app: Dash) -> None: SAMPLE_COLORS = { - "missing": "#636EFA", - "failing": "#EF553B", - "passing": "#00CC96", + Status.NOT_SEQUENCED.value: "#636EFA", + Status.FAILING.value: "#EF553B", + Status.PASSING.value: "#00CC96", } @@ -168,7 +169,7 @@ def _define_layout(self): data=[ go.Pie( values=self.df.values, - labels=self.df.index, + labels=[t(label) for label in self.df.index], sort=False, hole=0.3, textinfo="label+percent+value", @@ -212,19 +213,21 @@ def __init__( df = pd.read_csv(samples_amplicons_csv) # Store inputs plot_df = pd.crosstab(df["name"], df["status"]) - n_missing = (df["status"] == "missing").sum() - missing = [n_missing] * len(plot_df.index) + n_not_sequenced = (df["status"] == Status.NOT_SEQUENCED.value).sum() + not_sequenced = [n_not_sequenced] * len(plot_df.index) # Generate figure fig = go.Figure( data=[ go.Bar( x=plot_df.index, - y=missing if column == "missing" else plot_df[column], + y=not_sequenced + if status == Status.NOT_SEQUENCED + else plot_df[status.value], texttemplate="%{y}", - name=column, - marker=dict(color=SAMPLE_COLORS[column]), + name=t(status.value), + marker=dict(color=SAMPLE_COLORS[status.value]), ) - for column in ["passing", "failing", "missing"] + for status in Status ] ) diff --git a/src/nomadic/summarize/dashboard/translations/en.yml b/src/nomadic/summarize/dashboard/translations/en.yml index af76cd0..f630c41 100644 --- a/src/nomadic/summarize/dashboard/translations/en.yml +++ b/src/nomadic/summarize/dashboard/translations/en.yml @@ -1,4 +1,7 @@ en: + passing: "Passing QC" + failing: "Failing QC" + not_sequenced: "Not sequenced" mean_cov_field: "Mean coverage field samples" mean_cov_neg: "Mean coverage negative controls" per_field_passing: "Field samples passing QC (%%)" diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 225511c..10d6f51 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -12,6 +12,8 @@ find_regions, ) from nomadic.summarize.compute import ( + calc_amplicons_summary, + calc_samples_summary, compute_variant_prevalence, compute_variant_prevalence_per, filter_false_positives, @@ -363,87 +365,6 @@ def load_and_concat_variants(expt_dirs: list[str]) -> pd.DataFrame: return pd.concat(full_variant_dfs) -def calc_samples_summary( - master_metadata_df: pd.DataFrame, replicates_qc_df: pd.DataFrame -) -> pd.DataFrame: - """ - Calculates a summary of which samples have how many replicates that are passing or failing, - and if it has at least one passing replicate, it is concidered as passing. - - This can be used to get a list of to be resequenced samples. - """ - samples_summary_df = ( - replicates_qc_df.groupby(["sample_id"]) - .agg( - n_replicates=pd.NamedAgg("barcode", "count"), - n_passing=pd.NamedAgg("passing", "sum"), - ) - .reset_index() - ) - samples_summary_df = ( - samples_summary_df.merge( - master_metadata_df[["sample_id"]], how="right", on="sample_id" - ) - .fillna({"n_replicates": 0, "n_passing": 0}) - .astype({"n_replicates": int, "n_passing": int}) - ) - samples_summary_df["status"] = samples_summary_df.apply( - lambda row: "passing" - if row["n_passing"] > 0 - else "failing" - if row["n_replicates"] > 0 - else "missing", - axis=1, - ) - samples_summary_df.sort_values( - by=["n_passing", "n_replicates", "sample_id"], - inplace=True, - ascending=[False, False, True], - ) - - return samples_summary_df - - -def calc_amplicons_summary(master_metadata, replicates_amplicon_qc_df): - """ - Calculates a summary of which samples have how many replicates per amplicon that are passing or failing, - and if it has at least one passing replicate over that amplicon, it is concidered as passing. - - This can be used to understand if there are certain amplicons of samples that have no coverage yet - and make decisions on resampling, that are more fine granular than per sample. - """ - samples_by_amplicons_summary_df = ( - replicates_amplicon_qc_df.groupby(["sample_id", "name"]) - .agg( - n_replicates=pd.NamedAgg("barcode", "count"), - n_passing=pd.NamedAgg("passing", "sum"), - ) - .reset_index() - ) - samples_by_amplicons_summary_df = ( - samples_by_amplicons_summary_df.merge( - master_metadata[["sample_id"]], how="right", on="sample_id" - ) - .fillna({"n_replicates": 0, "n_passing": 0}) - .astype({"n_replicates": int, "n_passing": int}) - ) - samples_by_amplicons_summary_df["status"] = samples_by_amplicons_summary_df.apply( - lambda row: "passing" - if row["n_passing"] > 0 - else "failing" - if row["n_replicates"] > 0 - else "missing", - axis=1, - ) - samples_by_amplicons_summary_df.sort_values( - by=["n_passing", "n_replicates", "sample_id"], - inplace=True, - ascending=[False, False, True], - ) - - return samples_by_amplicons_summary_df - - def replicates_qc( coverage_df: pd.DataFrame, REPLICATE_PASSING_THRESHOLD: float ) -> pd.DataFrame: From dedf24c36f4b85d0a0eeb8e9ca49ffd73fe4faac Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 5 Dec 2025 12:14:37 +0100 Subject: [PATCH 49/67] Use regex for matching of alt column names --- src/nomadic/util/metadata.py | 34 ++++++++-------------------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/src/nomadic/util/metadata.py b/src/nomadic/util/metadata.py index f8a154e..03c142a 100644 --- a/src/nomadic/util/metadata.py +++ b/src/nomadic/util/metadata.py @@ -166,26 +166,9 @@ class MetadataTableParser: # If the required columns are not found, try these alternative names, case insensitive ALTERNATIVE_NAMES = { - "barcode": ["barcodes"], - "sample_id": [ - "sample", - "sampleid", - "sample-id", - "sample_id", - "sampleids", - "sample-ids", - "sample_ids", - "sample id", - "sample ids", - ], - "sample_type": [ - "sampletype", - "sample-type", - "sample type", - "sampletypes", - "sample-types", - "sample types", - ], + "barcode": r"barcode[s]?", + "sample_id": r"sample[s]?[\-\_\s]?(id[s]?)?", + "sample_type": r"sample[s]?[\-\_\s]?(type[s]?)?", } def __init__(self, metadata_csv: str, include_unclassified: bool = True): @@ -226,13 +209,12 @@ def _correct_columns(self): for required_column in self.REQUIRED_COLUMNS: if required_column not in self.df.columns: - for alt in [ - required_column, - *self.ALTERNATIVE_NAMES.get(required_column, []), - ]: - if alt in normalized_column_names: + for normalized_column in normalized_column_names: + if re.fullmatch( + self.ALTERNATIVE_NAMES[required_column], normalized_column + ): column_name = self.df.columns[ - normalized_column_names.index(alt) + normalized_column_names.index(normalized_column) ] warnings.warn( f"Using column '{column_name}' as '{required_column}' in metadata CSV." From b389612fa1a82fd49305c5dbdd709e29408b80ae Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 5 Dec 2025 12:20:10 +0100 Subject: [PATCH 50/67] Also auto open browser after a delay for summary This is because the dashboard needs some time to start --- src/nomadic/summarize/dashboard/builders.py | 8 +++++++- src/nomadic/summarize/main.py | 5 +---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index c49f260..52bb354 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -4,6 +4,7 @@ import os import threading from abc import ABC, abstractmethod +import webbrowser from dash import Dash, html, dcc from i18n import t @@ -60,7 +61,7 @@ def _gen_app(self): return app - def run(self, in_thread: bool = False, **kwargs): + def run(self, in_thread: bool = False, auto_open: bool = True, **kwargs): """ Run the dashboard @@ -75,6 +76,11 @@ def run(self, in_thread: bool = False, **kwargs): app.layout = html.Div(id="overall", children=self.layout) + if auto_open: + threading.Timer( + 1, webbrowser.open, kwargs=dict(url="http://127.0.0.1:8050") + ).start() + if in_thread: dashboard_thread = threading.Thread( target=lambda: app.run(**kwargs), name="dashboard", daemon=True diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 10d6f51..df6982d 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -1,7 +1,6 @@ import os from typing import Iterable, Optional from warnings import warn -import webbrowser from enum import StrEnum, auto from pathlib import Path @@ -686,9 +685,7 @@ def main( print("Launching dashboard (press CNTRL+C to exit):") print("") debug = bool(os.getenv("NOMADIC_DEBUG")) - if not debug: - webbrowser.open("http://127.0.0.1:8050") - dashboard.run(debug=debug) + dashboard.run(debug=debug, auto_open=not debug) # CHECKPOINT 2: # summary.quality_control.by_amplicon.csv From 2b9d189a1742c1ba5372e650ffe3095d8d9851f2 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 5 Dec 2025 13:41:37 +0100 Subject: [PATCH 51/67] Ensure dtype of throughput table is int So we don't show 123.0 instead of 123 --- src/nomadic/summarize/dashboard/components.py | 13 ++++++++++++- src/nomadic/summarize/main.py | 7 +++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 273d99f..86c4944 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -92,7 +92,18 @@ class ThroughputSummary(SummaryDashboardComponent): def __init__(self, summary_name: str, throughput_csv: str, component_id: str): self.throughput_csv = throughput_csv - self.throughput_df = pd.read_csv(throughput_csv, index_col="sample_type") + + # Read header only + df_header = pd.read_csv(throughput_csv, nrows=0) + dtypes: dict[str, type[str] | type[int]] = { + col: int for col in df_header.columns + } + + dtypes["sample_type"] = str + + self.throughput_df = pd.read_csv( + throughput_csv, index_col="sample_type", dtype=dtypes + ) super().__init__(summary_name, component_id) def _define_layout(self): diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index df6982d..5ffa6e6 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -63,15 +63,18 @@ def compute_throughput(metadata: pd.DataFrame, add_unique: bool = True) -> pd.Da """ throughput_df = pd.crosstab( - metadata["sample_type"], metadata["expt_name"], margins="All" + metadata["sample_type"], metadata["expt_name"], margins=True ) if add_unique: um = metadata.drop_duplicates("sample_id") throughput_df.loc["field_unique"] = pd.crosstab( - um["sample_type"], um["expt_name"], margins="All" + um["sample_type"], um["expt_name"], margins=True ).loc["field"] + throughput_df.fillna(0, inplace=True) + throughput_df = throughput_df.astype(int) + return throughput_df From 99fd811d68b97b4ed7b5212f861afa81891037a3 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 5 Dec 2025 13:44:05 +0100 Subject: [PATCH 52/67] Rename meta_data to metadata --- src/nomadic/summarize/commands.py | 2 +- src/nomadic/summarize/main.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 40cb450..703fdf7 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -110,7 +110,7 @@ def summarize( main( expt_dirs=experiment_dirs, summary_name=summary_name, - meta_data_path=metadata_csv, + metadata_path=metadata_csv, settings_file_path=settings_file, show_dashboard=dashboard, prevalence_by=list(prevalence_by), diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 5ffa6e6..1990d2b 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -407,7 +407,7 @@ def main( *, expt_dirs: tuple[str], summary_name: str, - meta_data_path: Optional[Path], + metadata_path: Optional[Path], settings_file_path: Path, show_dashboard: bool = True, prevalence_by: list[str], @@ -427,7 +427,7 @@ def main( """ - assert (meta_data_path is not None) or no_master_metadata + assert (metadata_path is not None) or no_master_metadata output_dir = produce_dir( "summaries", summary_name @@ -438,7 +438,7 @@ def main( log.info("Input parameters:") log.info(f" Summary Name: {summary_name}") if not no_master_metadata: - log.info(f" Master metadata: {meta_data_path}") + log.info(f" Master metadata: {metadata_path}") else: log.info(" No master metadata will be used.") log.info(f" Setting file: {settings_file_path}") @@ -480,8 +480,8 @@ def main( shared_columns.difference_update(fixed_columns) # for now we use the master metadata file inventory_metadata = pd.concat([df[fixed_columns] for df in dfs]) - if meta_data_path is not None and not no_master_metadata: - master_metadata = pd.read_csv(meta_data_path).rename( + if metadata_path is not None and not no_master_metadata: + master_metadata = pd.read_csv(metadata_path).rename( columns=get_master_columns_mapping(settings) ) else: From 61780463603c41d7dff56ddd2978885d166c8b05 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 5 Dec 2025 13:45:01 +0100 Subject: [PATCH 53/67] Remove summary command structure comment --- src/nomadic/summarize/main.py | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 1990d2b..11c9041 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -689,38 +689,3 @@ def main( print("") debug = bool(os.getenv("NOMADIC_DEBUG")) dashboard.run(debug=debug, auto_open=not debug) - - # CHECKPOINT 2: - # summary.quality_control.by_amplicon.csv - # summary.quality_control.by_experiment.csv - # -> the .by_amplicon.csv we use... - # -> Some visualisations and statistics on these tables - - # PART 3: Mutation prevalence - # -> In future versions, will change - - # 3a. get the data and filter to passing - # Load the variants for each experiment - - # Get the unique mutations - - # Use this to make sure every sample has all mutations - - # Merge with the sumary.quality_control.by_amplicon.csv table! - # REDUCE to the set of amplicons we use for analysis - # -> Limit to passing - # -> Limit to missense mutations - - # 3b. Nice analysis to compute the prevalence by site - # Idea would be to do country-wide prevalence for each bar; - # but then partition the bar by the SITE - # Then if I pick a site, just show the prevalence there. - - # CHECKPOINT 3: - # summary.variants_prevalence.by_site.csv - # - - # PART 4: Mapping - # -> Simple: input the summary.variants_prevalence.by_site.csv - # -> Load hte site data, if we have it; - # plot From 1dce30244e1a6321822a6bee5a89349812a22dc3 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 5 Dec 2025 14:09:15 +0100 Subject: [PATCH 54/67] Expose min coverage and max contamination values --- src/nomadic/summarize/commands.py | 18 ++++++++++++++++++ .../summarize/dashboard/translations/en.yml | 4 ++-- src/nomadic/summarize/main.py | 14 +++++++++----- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index 703fdf7..b232dd1 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -63,6 +63,20 @@ default=False, help="If set, no master metadata CSV needs to be provided. This is not recommended, as it's better to be explicit about the samples to be included, but it can be used to quickly get an overview of the data in the workspace.", ) +@click.option( + "--qc-min-coverage", + type=int, + default=50, + show_default=True, + help="Minimum coverage threshold for quality control. Amplicons with less than this coverage will be marked as low coverage.", +) +@click.option( + "--qc-max-contam", + type=float, + default=0.1, + show_default=True, + help="Maximum contamination fraction for quality control. Samples with contamination above this fraction will be marked as contaminated. Contamination is defined as the mean coverage of negative controls being more than this fraction of the sample coverage.", +) def summarize( experiment_dirs: tuple[str], summary_name: str, @@ -72,6 +86,8 @@ def summarize( prevalence_by: tuple[str], settings_file: Path, no_master_metadata: bool, + qc_min_coverage: int, + qc_max_contam: float, ): """ Summarize a set of experiments to evaluate quality control and @@ -115,6 +131,8 @@ def summarize( show_dashboard=dashboard, prevalence_by=list(prevalence_by), no_master_metadata=no_master_metadata, + qc_min_coverage=qc_min_coverage, + qc_max_contam=qc_max_contam, ) except MetadataFormatError as e: raise click.BadParameter( diff --git a/src/nomadic/summarize/dashboard/translations/en.yml b/src/nomadic/summarize/dashboard/translations/en.yml index f630c41..1b368fc 100644 --- a/src/nomadic/summarize/dashboard/translations/en.yml +++ b/src/nomadic/summarize/dashboard/translations/en.yml @@ -10,5 +10,5 @@ en: mean_cov_field_tooltip: "The average mean coverage per amplicon across all field samples." mean_cov_neg_tooltip: "The average mean coverage per amplicon across all negative control samples." per_field_passing_tooltip: "The percentage of field samples that passed quality control (QC) for this amplicon i.e. has minimum coverage and no (or minimal) contamination" - per_field_contam_tooltip: "The percentage of field samples with contamination for this amplicon. Contamination is where negative controls have 50x mean coverage, or the mean coverage of negative controls is more than 10%% of the sample coverage" - per_field_lowcov_tooltip: "The percentage of field samples that had low coverage for this amplicon. This is defined as having mean coverage less than 50x." + per_field_contam_tooltip: "The percentage of field samples with contamination for this amplicon. Contamination is where negative controls have 50x(default) mean coverage, or the mean coverage of negative controls is more than 10%%(default) of the sample coverage" + per_field_lowcov_tooltip: "The percentage of field samples that had low coverage for this amplicon. This is defined as having mean coverage less than 50x(default)." diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 11c9041..fa60cf6 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -412,6 +412,8 @@ def main( show_dashboard: bool = True, prevalence_by: list[str], no_master_metadata: bool = False, + qc_min_coverage: int, + qc_max_contam: float, ) -> None: """ Define the main function for the summary analysis @@ -543,10 +545,8 @@ def main( # Now let's evaluate coverage coverage_df = get_region_coverage_dataframe(expt_dirs, inventory_metadata) - MIN_COV = 50 - MAX_CONTAM = 0.1 calc_quality_control_columns( - coverage_df, min_coverage=MIN_COV, max_contam=MAX_CONTAM + coverage_df, min_coverage=qc_min_coverage, max_contam=qc_max_contam ) log.info("Amplicon-Sample QC Statistics:") @@ -555,8 +555,12 @@ def main( n_lowcov = field_coverage_df["fail_lowcov"].sum() n_contam = field_coverage_df["fail_contam"].sum() n_pass = field_coverage_df["passing"].sum() - log.info(f" Coverage below <{MIN_COV}x: {n_lowcov} ({100 * n_lowcov / n:.2f}%)") - log.info(f" Contamination >{MAX_CONTAM}: {n_contam} ({100 * n_contam / n:.2f}%)") + log.info( + f" Coverage below <{qc_min_coverage}x: {n_lowcov} ({100 * n_lowcov / n:.2f}%)" + ) + log.info( + f" Contamination >{qc_max_contam}: {n_contam} ({100 * n_contam / n:.2f}%)" + ) log.info(f" Passing QC: {n_pass} ({100 * n_pass / n:.2f}%)") add_quality_control_status_column(coverage_df) log.info(str(coverage_df["status"].value_counts())) From d9d70d8732cd0f17ae2f11e8099da704d2904cc9 Mon Sep 17 00:00:00 2001 From: JasonAHendry Date: Sun, 7 Dec 2025 13:26:45 +0100 Subject: [PATCH 55/67] Expose false-positive filter and consolidate prev calc Make the parameters used to define likely false-positive mutations function arguments; consolidate two versions of prevalence calculation functions. --- src/nomadic/summarize/compute.py | 104 +++++++----------- src/nomadic/summarize/dashboard/components.py | 11 +- src/nomadic/summarize/main.py | 5 +- 3 files changed, 42 insertions(+), 78 deletions(-) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index c508c98..83c2892 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -4,6 +4,21 @@ from statsmodels.stats.proportion import proportion_confint +# These columns are used to define unique variants +VARIANTS_GROUP_COLUMNS = [ + "gene", + "chrom", + "pos", + # "ref", + # The alt might differ for multiallelic sites + # "alt", + "aa_change", + "aa_pos", + "mut_type", + "mutation", +] + + class Status(enum.Enum): PASSING = "passing" FAILING = "failing" @@ -91,95 +106,50 @@ def calc_amplicons_summary(master_metadata, replicates_amplicon_qc_df): return samples_by_amplicons_summary_df -variants_group_columns = [ - "gene", - "chrom", - "pos", - # "ref", - # The alt might differ for multiallelic sites - # "alt", - "aa_change", - "aa_pos", - "mut_type", - "mutation", -] +def filter_false_positives( + variants_df: pd.DataFrame, min_obs: int = 1, min_wsaf: float = 0.15 +): + """Filter out likely false positive variant calls. + Remove variants that have only been observed `min_obs` times across all samples in + the analysis set, and with a WSAF below `min_wsaf` -def filter_false_positives(variants_df: pd.DataFrame): - """Filter out likely false positive variant calls. + This removes likely false-positive mutations, which are usually rare and at low + WSAF. - Removes variants where only one mutation is called and the - maximum WSAF is below 15%. """ - N_MUT = 1 - WSAF_CUTOFF = 0.15 mut = variants_df[variants_df["gt_int"].isin([1, 2])] df = variants_df.merge( - mut.groupby(variants_group_columns).agg( + mut.groupby(VARIANTS_GROUP_COLUMNS).agg( n_mut=pd.NamedAgg("gt_int", len), wsaf_max=pd.NamedAgg("wsaf", "max") ), - on=variants_group_columns, + on=VARIANTS_GROUP_COLUMNS, ) - df = df[~(df["n_mut"].le(N_MUT) & df["wsaf_max"].lt(WSAF_CUTOFF))].drop( + df = df[~(df["n_mut"].le(min_obs) & df["wsaf_max"].lt(min_wsaf))].drop( columns=["n_mut", "wsaf_max"] ) return df -def compute_variant_prevalence(variants_df: pd.DataFrame) -> pd.DataFrame: +def compute_variant_prevalence( + variants_df: pd.DataFrame, + master_df: pd.DataFrame = None, + additional_groups: list[str] = [], +) -> pd.DataFrame: """ Compute the prevalence of each mutation in `variants_df` """ - prev_df = ( - variants_df.groupby( - variants_group_columns, + if master_df is not None and additional_groups: + variants_df = variants_df.merge( + master_df[["sample_id", *additional_groups]], on="sample_id", how="left" ) - .agg( - n_samples=pd.NamedAgg("gt_int", len), - n_passed=pd.NamedAgg("gt_int", lambda x: sum(x != -1)), - n_wt=pd.NamedAgg("gt_int", lambda x: sum(x == 0)), - n_mixed=pd.NamedAgg("gt_int", lambda x: sum(x == 1)), - n_mut=pd.NamedAgg("gt_int", lambda x: sum(x == 2)), - ) - .reset_index() - ) - - # Compute frequencies - prev_df["per_wt"] = 100 * prev_df["n_wt"] / prev_df["n_passed"] - prev_df["per_mixed"] = 100 * prev_df["n_mixed"] / prev_df["n_passed"] - prev_df["per_mut"] = 100 * prev_df["n_mut"] / prev_df["n_passed"] - - # Compute prevalence - prev_df["prevalence"] = prev_df["per_mixed"] + prev_df["per_mut"] - - # Compute prevalence 95% confidence intervals - low, high = proportion_confint( - prev_df["n_mut"] + prev_df["n_mixed"], - prev_df["n_passed"], - alpha=0.05, - method="beta", - ) - prev_df["prevalence_lowci"] = 100 * low - prev_df["prevalence_highci"] = 100 * high - - return prev_df - - -def compute_variant_prevalence_per( - variants_df, master_df, fields: list[str] -) -> pd.DataFrame: - """ - Compute the prevalence of each mutation in `variants_df` and groups by `fields` - from `master_df`. - """ - variants_df = variants_df.merge( - master_df[["sample_id", *fields]], on="sample_id", how="left" - ) prev_df = ( - variants_df.groupby([*variants_group_columns, *fields]) + variants_df.groupby( + VARIANTS_GROUP_COLUMNS + additional_groups, + ) .agg( n_samples=pd.NamedAgg("gt_int", len), n_passed=pd.NamedAgg("gt_int", lambda x: sum(x != -1)), diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 86c4944..7f229cc 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -13,7 +13,6 @@ from nomadic.summarize.compute import ( Status, compute_variant_prevalence, - compute_variant_prevalence_per, gene_deletion_prevalence_by, ) from i18n import t @@ -483,9 +482,7 @@ def _update(gene_set: str, by: str): if by == "All": plot_df = compute_variant_prevalence(analysis_df) else: - plot_df = compute_variant_prevalence_per( - analysis_df, self.master_df, [by] - ) + plot_df = compute_variant_prevalence(analysis_df, self.master_df, [by]) plot_df.sort_values(["gene", "chrom", "pos"], inplace=True) data = [] @@ -606,7 +603,7 @@ def callback(self, app: Dash) -> None: def _update(target_gene, col_by): """Called every time an input changes""" - df = compute_variant_prevalence_per( + df = compute_variant_prevalence( self.analysis_df.query("gene == @target_gene"), self.master_df, [col_by] ) @@ -862,7 +859,7 @@ def normalize_location(loc): gene, aa_change = target_mutation.split("-") if region_by is not None: - df = compute_variant_prevalence_per( + df = compute_variant_prevalence( self.analysis_df.query("gene == @gene and aa_change == @aa_change"), self.master_df, [region_by], @@ -936,7 +933,7 @@ def normalize_location(loc): coords_location_col = "location" # Group by location to get sample counts and average prevalence - site_data = compute_variant_prevalence_per( + site_data = compute_variant_prevalence( self.analysis_df.query("gene == @gene and aa_change == @aa_change"), self.master_df, [master_location_col], diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index fa60cf6..7dfd293 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -14,7 +14,6 @@ calc_amplicons_summary, calc_samples_summary, compute_variant_prevalence, - compute_variant_prevalence_per, filter_false_positives, gene_deletion_prevalence_by, gene_deletions, @@ -634,9 +633,7 @@ def main( prev_df.to_csv(f"{output_dir}/summary.variants.prevalence.csv", index=False) for col in prevalence_by: - prev_by_col_df = compute_variant_prevalence_per( - analysis_df, master_metadata, [col] - ) + prev_by_col_df = compute_variant_prevalence(analysis_df, master_metadata, [col]) prev_by_col_df.to_csv( f"{output_dir}/summary.variants.prevalence-{col}.csv", index=False ) From f13d90bd27b50883c4f79d994fc715bcdb8bc3dc Mon Sep 17 00:00:00 2001 From: JasonAHendry Date: Mon, 8 Dec 2025 09:58:43 +0100 Subject: [PATCH 56/67] Simplify experiment coapletion checks This commit does three things 1. Define a new class, called ExperimentOutputChecker, that holds information about experiment outputs for nomadic summarize. 2. Consolidate some functions that were duplicate (e.g. find_metadata and get_metadata_csv 3. Reorganise the util/experiment.py module --- src/nomadic/summarize/main.py | 105 ++++++++-------- src/nomadic/util/exceptions.py | 2 +- src/nomadic/util/experiment.py | 221 ++++++++++++++++++--------------- 3 files changed, 181 insertions(+), 147 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 7dfd293..ae6c161 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -2,14 +2,12 @@ from typing import Iterable, Optional from warnings import warn from enum import StrEnum, auto +from collections import Counter from pathlib import Path import pandas as pd import numpy as np -from nomadic.dashboard.main import ( - find_regions, -) from nomadic.summarize.compute import ( calc_amplicons_summary, calc_samples_summary, @@ -20,22 +18,21 @@ ) from nomadic.summarize.dashboard.builders import BasicSummaryDashboard from nomadic.util.dirs import produce_dir -from nomadic.util.exceptions import MetadataFormatError from nomadic.util.experiment import ( - check_complete_experiment, - get_metadata_csv, get_summary_files, + ExperimentOutputChecker, ) from nomadic.util.logging_config import LoggingFascade -from nomadic.util.metadata import ExtendedMetadataTableParser from nomadic.util.summary import Settings, get_master_columns_mapping, load_settings # -------------------------------------------------------------------------------- -# Check complete experiment +# Check for completion and consistency across experiments # # -------------------------------------------------------------------------------- -def check_regions_consistent(expt_dirs: tuple[str]) -> None: + + +def check_regions_consistent(expts: ExperimentOutputChecker) -> None: """ Check that the regions are consistent across all experiment directories @@ -43,8 +40,7 @@ def check_regions_consistent(expt_dirs: tuple[str]) -> None: - Might make sense to *extract* the region that was used and save it; """ - region_sets = [find_regions(expt_dir) for expt_dir in expt_dirs] - + region_sets = [expt.regions for expt in expts] base = region_sets[0] for r in region_sets: if not (r.df == base.df).all().all(): @@ -53,6 +49,38 @@ def check_regions_consistent(expt_dirs: tuple[str]) -> None: ) +def check_calling_consistent(expts: ExperimentOutputChecker) -> None: + """ + Check that the same variant caller was used + """ + caller_counts = Counter([expt.caller for expt in expts]) + if len(caller_counts) > 1: + raise ValueError( + "Found more than one variant caller used across experiments: " + + f"{', '.join([f'{v} experiment(s) used {c}' for c, v in caller_counts.items()])}." + ) + return caller_counts.most_common()[0][0] + + +def get_shared_metadata_columns( + metadata_dfs: list[pd.DataFrame], + fixed_columns: list[str] = ["expt_name", "barcode", "sample_id", "sample_type"], +) -> list[str]: + """Get metadata columns that are shared acrossa all experiments""" + + shared_columns = set(metadata_dfs[0].columns) + for df in metadata_dfs[1:]: + shared_columns.intersection_update(df.columns) + shared_columns.difference_update(fixed_columns) # why am I doing this? + return shared_columns + + +# -------------------------------------------------------------------------------- +# Throughput +# +# -------------------------------------------------------------------------------- + + def compute_throughput(metadata: pd.DataFrame, add_unique: bool = True) -> pd.DataFrame: """ Compute a simple throughput crosstable @@ -417,14 +445,6 @@ def main( """ Define the main function for the summary analysis - TODO: - - Ideas for location? - - Either force a specific column name; e.g. site - - Or allow for the user to indicate the name - - Easiest is to require either lat/lon; or a file mapping to lat/lon. - - It is nice to allow arbitrary grouping by columns that are valid for prevalence plot - - The best is probably to enable certain panels / analyses IF certain columns are present - in the shared metadata; for example parasitemia """ @@ -444,43 +464,34 @@ def main( log.info(" No master metadata will be used.") log.info(f" Setting file: {settings_file_path}") log.info(f" Found {len(expt_dirs)} experiment directories.") - for expt_dir in expt_dirs: - check_complete_experiment(expt_dir) + + # Check experiments are complete + expts = [ExperimentOutputChecker(expt_dir) for expt_dir in expt_dirs] log.info(" All experiments are complete.") - settings: Settings = Settings() + # Check experiments are consistent + check_regions_consistent(expts) + log.info(" All experiments use the same regions.") + caller = check_calling_consistent(expts) + log.info(f" All experiments use same variant caller: {caller}") + settings: Settings = Settings() if settings_file_path.exists(): settings = load_settings(settings_file_path) log.info(f" Loaded summary settings from {settings_file_path}.") # CHECK METADATA IS VALID - # TODO: - # - Should I already interrogate geospatial information? - # - Where should I compute throughput information? - dfs = [] - for expt_dir in expt_dirs: - metadata_csv = get_metadata_csv(expt_dir) - try: - parser = ExtendedMetadataTableParser(metadata_csv) - parser.df.insert(0, "expt_name", os.path.basename(expt_dir)) - except MetadataFormatError as e: - raise MetadataFormatError( - f"Metadata format issue in experiment directory {expt_dir}: {e}" - ) from e - if not dfs: - shared_columns = set(parser.df.columns) - shared_columns.intersection_update(parser.df.columns) - dfs.append(parser.df) - # Should I not take all common columns? + FIXED_COLUMNS = ["expt_name", "barcode", "sample_id", "sample_type"] + shared_columns = get_shared_metadata_columns( + [expt.metadata for expt in expts], fixed_columns=FIXED_COLUMNS + ) log.info(" All metadata tables pass completion checks.") log.info( - f" Found {len(shared_columns)} shared columns across all metadata files: {', '.join(shared_columns)}" + f" Found {len(shared_columns)} non-essential shared columns across all metadata files: {', '.join(shared_columns)}" ) - fixed_columns = ["expt_name", "barcode", "sample_id", "sample_type"] - shared_columns.difference_update(fixed_columns) + # for now we use the master metadata file - inventory_metadata = pd.concat([df[fixed_columns] for df in dfs]) + inventory_metadata = pd.concat([expt.metadata[FIXED_COLUMNS] for expt in expts]) if metadata_path is not None and not no_master_metadata: master_metadata = pd.read_csv(metadata_path).rename( columns=get_master_columns_mapping(settings) @@ -488,7 +499,7 @@ def main( else: # create metadata from experiment meta data files shared_columns = ["sample_id"] + list(shared_columns) - master_metadata = pd.concat([df[shared_columns] for df in dfs]) + master_metadata = pd.concat([expt.metadata[shared_columns] for expt in expts]) master_metadata = master_metadata.astype( {"sample_id": "str"} @@ -527,10 +538,6 @@ def main( inventory_metadata.to_csv(f"{output_dir}/inventory.csv", index=False) inventory_metadata = inventory_metadata.query("status != 'unknown'") - # Check regions are consistent - check_regions_consistent(expt_dirs) - log.info(" All experiments use the same regions.") - # Throughput data # TODO: Need to make a real decision about how to handle duplicated sample IDs log.info("Overall sequencing throughput:") diff --git a/src/nomadic/util/exceptions.py b/src/nomadic/util/exceptions.py index 632b242..15b64a7 100644 --- a/src/nomadic/util/exceptions.py +++ b/src/nomadic/util/exceptions.py @@ -1,5 +1,5 @@ class MetadataFormatError(Exception): - """Error in format / contents of a metadata file""" + """Error in format or contents of a metadata file""" pass diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index 56c61b5..bad8d36 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -1,19 +1,23 @@ import glob import os +import json import shutil from pathlib import Path from typing import NamedTuple from nomadic.util.dirs import produce_dir -from nomadic.util.exceptions import MetadataFormatError -from nomadic.util.metadata import MetadataTableParser +from nomadic.util.metadata import MetadataTableParser, ExtendedMetadataTableParser from nomadic.util.regions import RegionBEDParser +# -------------------------------------------------------------------------------- +# Handle summary file names: legacy and current +# +# -------------------------------------------------------------------------------- + + class SummaryFiles(NamedTuple): - """ - Named tuple to hold paths to summary files. - """ + """Define summary file names / paths""" fastqs_processed: str read_mapping: str @@ -23,8 +27,8 @@ class SummaryFiles(NamedTuple): # Currently used summary file names -default_config_path = "config/defaults.json" -summary_files = SummaryFiles( +DEFAULT_CONFIG_PATH = "config/defaults.json" +SUMMARY_NAMES = SummaryFiles( fastqs_processed="summary.fastqs_processed.csv", read_mapping="summary.read_mapping.csv", region_coverage="summary.region_coverage.csv", @@ -33,7 +37,7 @@ class SummaryFiles(NamedTuple): ) # Legacy summary file names for backward compatibility -legacy_summary_files = SummaryFiles( +SUMMARY_NAMES_LEGACY = SummaryFiles( fastqs_processed="summary.fastq.csv", read_mapping="summary.bam_flagstats.csv", region_coverage="summary.bedcov.csv", @@ -42,6 +46,30 @@ class SummaryFiles(NamedTuple): ) +def get_summary_files(expt_dir: Path) -> SummaryFiles: + """ + Determine whether the summary files are use the legacy or current names, + and return a SummaryFiles object with the appropriate file names + + """ + + if not expt_dir.exists(): + raise FileNotFoundError(f"Experiment path does not exist: {expt_dir}") + + if (expt_dir / SUMMARY_NAMES_LEGACY.read_mapping).exists(): + # Detect legacy format using *one* of the differentiating file names + format_used = SUMMARY_NAMES_LEGACY + else: + format_used = SUMMARY_NAMES + return SummaryFiles(*[str(expt_dir / field) for field in format_used]) + + +# -------------------------------------------------------------------------------- +# Define experiment directories +# +# -------------------------------------------------------------------------------- + + class ExperimentDirectories: """ Put all the information about experimental @@ -120,117 +148,116 @@ def _setup_metadata_dir( shutil.copy(regions.path, self.regions_bed) -def get_summary_files(exp_path: Path) -> SummaryFiles: - if not exp_path.exists(): - raise FileNotFoundError(f"Experiment path does not exist: {exp_path}") - if (exp_path / legacy_summary_files.read_mapping).exists(): - # Use legacy summary files if the old format exists - return SummaryFiles( - fastqs_processed=str(exp_path / legacy_summary_files.fastqs_processed), - read_mapping=str(exp_path / legacy_summary_files.read_mapping), - region_coverage=str(exp_path / legacy_summary_files.region_coverage), - depth_profiles=str(exp_path / legacy_summary_files.depth_profiles), - variants=str(exp_path / legacy_summary_files.variants), - ) - else: - return SummaryFiles( - fastqs_processed=str(exp_path / summary_files.fastqs_processed), - read_mapping=str(exp_path / summary_files.read_mapping), - region_coverage=str(exp_path / summary_files.region_coverage), - depth_profiles=str(exp_path / summary_files.depth_profiles), - variants=str(exp_path / summary_files.variants), - ) +# -------------------------------------------------------------------------------- +# Checks on experiment outputs +# +# -------------------------------------------------------------------------------- -def check_complete_experiment(expt_dir: str) -> None: - """ - Check if an experiment is complete; in reality, it would be nice, at this point, to load an object - that represents all the files I'd want to work with, e.g. the experiment directories class +def find_metadata( + expt_dir: str, Parser: MetadataTableParser = MetadataTableParser +) -> MetadataTableParser: """ + Given an experiment directory, search for the metadata CSV file in thee + expected location and load it - if not os.path.isdir(expt_dir): - raise FileNotFoundError(f"Experiment directory {expt_dir} does not exist.") - - # We can use this for now, but of course this is getting messy - try: - _ = find_metadata(expt_dir) - _ = find_regions(expt_dir) - except MetadataFormatError as e: - raise MetadataFormatError( - f"Metadata or regions file issue in experiment directory {expt_dir}: {e}" - ) from e - - used_summary_files = None - for file_format in [summary_files, legacy_summary_files]: - if not os.path.exists(f"{expt_dir}/{file_format.read_mapping}"): - continue - - used_summary_files = file_format - for file in used_summary_files: - if "depth" in file: - # depth files are optional - continue - if "fastq" in file: - # fastq files are optional - continue + """ - if not os.path.exists(f"{expt_dir}/{file}"): - raise FileNotFoundError(f"Missing '{file}' file in {expt_dir}.") + # In most cases, should match experiment name + csv = f"{expt_dir}/metadata/{os.path.basename(expt_dir)}.csv" + if os.path.exists(csv): + return Parser(csv) - if not used_summary_files: - raise FileNotFoundError(f"Could not find any summary files in {expt_dir}.") + csv = glob.glob(f"{expt_dir}/metadata/*.csv") + if len(csv) == 1: + return Parser(csv[0]) - # TODO: at the moment we don't require VCFs to be present as we don't use them at the moment - # if not os.path.exists(f"{expt_dir}/vcfs"): - # raise FileNotFoundError(f"Could not find VCF directory in {expt_dir}.") + raise ValueError( + f"Found {len(csv)} *.csv files in '{expt_dir}/metadata', cannot determine which is metadata." + ) -def find_metadata(input_dir: str) -> MetadataTableParser: +def find_regions(expt_dir: str) -> RegionBEDParser: """ Given an experiment directory, search for the metadata CSV file in thee expected location - TODO this function is probably not needed anymore, could combine it with get_metadata_csv """ - csv = get_metadata_csv(expt_dir=input_dir) - return MetadataTableParser(csv) + bed = [ + f + for f in glob.glob(f"{expt_dir}/metadata/*.bed") + if f.endswith(".bed") and not f.endswith(".lowcomplexity_mask.bed") + ] + if len(bed) == 1: + return RegionBEDParser(bed[0]) -def get_metadata_csv(expt_dir: str) -> str: - """ - Get the metadata CSV file - """ - # In most cases, should match experiment name - metadata_csv = f"{expt_dir}/metadata/{os.path.basename(expt_dir)}.csv" - if os.path.exists(metadata_csv): - return metadata_csv - metadata_csv = glob.glob(f"{expt_dir}/metadata/*.csv") - if len(metadata_csv) == 1: - return metadata_csv[0] - raise ValueError( - f"Found {len(metadata_csv)} *.csv files in '{expt_dir}/metadata', cannot determine which is metadata." + raise FileNotFoundError( + f"Expected one region BED file (*.bed) at '{expt_dir}/metadata', but found {len(bed)}." ) -def find_regions(input_dir: str) -> RegionBEDParser: - """ - Given an experiment directory, search for the metadata CSV file in thee - expected location +class ExperimentOutputChecker: + """Check that all the relevant output files of an experiment present""" - TODO: Bad duplication from above, can write inner function - """ + def __init__(self, expt_dir: str): + # Confirm experiment directory exists + if not os.path.isdir(expt_dir): + raise FileNotFoundError(f"Experiment directory {expt_dir} does not exist.") + self.expt_dir = expt_dir + + # Load metadata + parser = find_metadata(expt_dir, Parser=ExtendedMetadataTableParser) + self.metadata = parser.df + self.metadata.insert(0, "expt_name", os.path.basename(self.expt_dir)) + + # Load regions + self.regions = find_regions(expt_dir) - metadata_dir = os.path.join(input_dir, "metadata") - beds = [ - f"{metadata_dir}/{file}" - for file in os.listdir(metadata_dir) - if file.endswith(".bed") and not file.endswith(".lowcomplexity_mask.bed") - ] # TODO: what about no-suffix files? + # Other file checks + self._check_summary_files() + self._check_vcf_files() + self._load_settings() + self._set_variant_caller() + + def _check_summary_files(self): + # Define the summary file paths + self.summary_files = get_summary_files(Path(self.expt_dir)) + + # Check if they are the legacy files, pretty ugly + self.summary_files_legacy = self.summary_files.read_mapping.endswith( + "bam_flagstats.csv" + ) - if len(beds) != 1: # Could alternatively load and LOOK - raise FileNotFoundError( - f"Expected one region BED file (*.bed) at {metadata_dir}, but found {len(beds)}." + # Check they exist + for file in self.summary_files: + if "depth" in file: + # depth files are optional, TODO: not so robust + continue + if "fastq" in file: + # fastq files are optional + continue + if not os.path.exists(file): + raise FileNotFoundError(f"Missing '{file}' file in {self.expt_dir}.") + + def _check_vcf_files(self): + vcf_dir = f"{self.expt_dir}/vcfs" + self.vcf_dir_exists = os.path.exists(vcf_dir) + self.vcf_complete_exists = os.path.exists(f"{vcf_dir}/summary.variants.vcf.gz") + self.vcf_filtered_exists = os.path.exists( + f"{vcf_dir}/summary.variants.filtered.annotated.vcf.gz" ) - return RegionBEDParser(beds[0]) + def _load_settings(self): + settings_path = f"{self.expt_dir}/metadata/settings.json" + if not os.path.exists(settings_path): + self.settings = None + else: + self.settings = json.load(open(settings_path, "r")) + + def _set_variant_caller(self): + if self.settings is None: + self.caller = "bcftools" + else: + self.caller = self.settings["caller"] From 2cacbd9a0d4a6d0c92f770d284453bb1966f1ecd Mon Sep 17 00:00:00 2001 From: JasonAHendry Date: Mon, 8 Dec 2025 11:59:25 +0100 Subject: [PATCH 57/67] Remove ExperimentResultsChecker class Replace with a function and dataclass --- src/nomadic/summarize/main.py | 25 +++--- src/nomadic/util/experiment.py | 135 +++++++++++++++++---------------- 2 files changed, 85 insertions(+), 75 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index ae6c161..023bd08 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -18,9 +18,11 @@ ) from nomadic.summarize.dashboard.builders import BasicSummaryDashboard from nomadic.util.dirs import produce_dir +from nomadic.util.regions import RegionBEDParser from nomadic.util.experiment import ( get_summary_files, - ExperimentOutputChecker, + check_experiment_outputs, + ExperimentOutputs, ) from nomadic.util.logging_config import LoggingFascade from nomadic.util.summary import Settings, get_master_columns_mapping, load_settings @@ -32,7 +34,7 @@ # -------------------------------------------------------------------------------- -def check_regions_consistent(expts: ExperimentOutputChecker) -> None: +def check_regions_consistent(expt_regions: list[RegionBEDParser]) -> None: """ Check that the regions are consistent across all experiment directories @@ -40,20 +42,20 @@ def check_regions_consistent(expts: ExperimentOutputChecker) -> None: - Might make sense to *extract* the region that was used and save it; """ - region_sets = [expt.regions for expt in expts] - base = region_sets[0] - for r in region_sets: + base = expt_regions[0] + for r in expt_regions: if not (r.df == base.df).all().all(): raise ValueError( "Different regions used across experiments, this is not supported. Check region BED files are the same." ) -def check_calling_consistent(expts: ExperimentOutputChecker) -> None: +def check_calling_consistent(expt_callers: list[str]) -> None: """ - Check that the same variant caller was used + Check that the same variant caller was used across all experiments, + where `expt_callers` is a list of used variant callers """ - caller_counts = Counter([expt.caller for expt in expts]) + caller_counts = Counter([caller for caller in expt_callers]) if len(caller_counts) > 1: raise ValueError( "Found more than one variant caller used across experiments: " @@ -466,13 +468,14 @@ def main( log.info(f" Found {len(expt_dirs)} experiment directories.") # Check experiments are complete - expts = [ExperimentOutputChecker(expt_dir) for expt_dir in expt_dirs] + expts = [check_experiment_outputs(expt_dir) for expt_dir in expt_dirs] + print(expts) log.info(" All experiments are complete.") # Check experiments are consistent - check_regions_consistent(expts) + check_regions_consistent([expt.regions for expt in expts]) log.info(" All experiments use the same regions.") - caller = check_calling_consistent(expts) + caller = check_calling_consistent([expt.caller for expt in expts]) log.info(f" All experiments use same variant caller: {caller}") settings: Settings = Settings() diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index bad8d36..c921f1d 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -2,8 +2,10 @@ import os import json import shutil +import pandas as pd from pathlib import Path -from typing import NamedTuple +from typing import NamedTuple, Any +from dataclasses import dataclass from nomadic.util.dirs import produce_dir from nomadic.util.metadata import MetadataTableParser, ExtendedMetadataTableParser @@ -154,6 +156,22 @@ def _setup_metadata_dir( # -------------------------------------------------------------------------------- +@dataclass +class ExperimentOutputs: + """Store information about outputs in `expt_dir`""" + + expt_dir: str # TODO: change to Path + metadata: pd.DataFrame + regions: RegionBEDParser + summary_files: SummaryFiles + settings: dict[str, Any] + + # Variant calling outputs + caller: str + has_complete_vcf: bool + has_filtered_vcf: bool + + def find_metadata( expt_dir: str, Parser: MetadataTableParser = MetadataTableParser ) -> MetadataTableParser: @@ -198,66 +216,55 @@ def find_regions(expt_dir: str) -> RegionBEDParser: ) -class ExperimentOutputChecker: - """Check that all the relevant output files of an experiment present""" - - def __init__(self, expt_dir: str): - # Confirm experiment directory exists - if not os.path.isdir(expt_dir): - raise FileNotFoundError(f"Experiment directory {expt_dir} does not exist.") - self.expt_dir = expt_dir - - # Load metadata - parser = find_metadata(expt_dir, Parser=ExtendedMetadataTableParser) - self.metadata = parser.df - self.metadata.insert(0, "expt_name", os.path.basename(self.expt_dir)) - - # Load regions - self.regions = find_regions(expt_dir) - - # Other file checks - self._check_summary_files() - self._check_vcf_files() - self._load_settings() - self._set_variant_caller() - - def _check_summary_files(self): - # Define the summary file paths - self.summary_files = get_summary_files(Path(self.expt_dir)) - - # Check if they are the legacy files, pretty ugly - self.summary_files_legacy = self.summary_files.read_mapping.endswith( - "bam_flagstats.csv" - ) - - # Check they exist - for file in self.summary_files: - if "depth" in file: - # depth files are optional, TODO: not so robust - continue - if "fastq" in file: - # fastq files are optional - continue - if not os.path.exists(file): - raise FileNotFoundError(f"Missing '{file}' file in {self.expt_dir}.") - - def _check_vcf_files(self): - vcf_dir = f"{self.expt_dir}/vcfs" - self.vcf_dir_exists = os.path.exists(vcf_dir) - self.vcf_complete_exists = os.path.exists(f"{vcf_dir}/summary.variants.vcf.gz") - self.vcf_filtered_exists = os.path.exists( - f"{vcf_dir}/summary.variants.filtered.annotated.vcf.gz" - ) - - def _load_settings(self): - settings_path = f"{self.expt_dir}/metadata/settings.json" - if not os.path.exists(settings_path): - self.settings = None - else: - self.settings = json.load(open(settings_path, "r")) - - def _set_variant_caller(self): - if self.settings is None: - self.caller = "bcftools" - else: - self.caller = self.settings["caller"] +def check_experiment_outputs(expt_dir: str) -> ExperimentOutputs: + """For a given `expt_dir` check what experiment outputs exist + + Will raise exceptions if data required for summarising is missing. + + """ + + # Existence of directory + if not os.path.isdir(expt_dir): + raise FileNotFoundError(f"Experiment directory {expt_dir} does not exist.") + + # Existence of metadata + parser = find_metadata(expt_dir, Parser=ExtendedMetadataTableParser) + metadata = parser.df + metadata.insert(0, "expt_name", os.path.basename(expt_dir)) + + # Existence of regions + regions = find_regions(expt_dir) + + # Existence of summary Files + summary_files = get_summary_files(Path(expt_dir)) + for file in summary_files: + if "depth" in file: + # depth files are optional, TODO: not so robust + continue + if "fastq" in file: + # fastq files are optional + continue + if not os.path.exists(file): + raise FileNotFoundError(f"Missing '{file}' file in {expt_dir}.") + + # Existence of settings / caller + settings_path = f"{expt_dir}/metadata/settings.json" + if not os.path.exists(settings_path): + settings = None + caller = "bcftools" # if no settings, was using bcftools + else: + settings = json.load(open(settings_path, "r")) + caller = settings["caller"] + + return ExperimentOutputs( + expt_dir=expt_dir, + metadata=metadata, + regions=regions, + summary_files=summary_files, + settings=settings, + caller=caller, + has_complete_vcf=os.path.exists(f"{expt_dir}/vcfs/summary.variants.vcf.gz"), + has_filtered_vcf=os.path.exists( + f"{expt_dir}/vcfs/summary.variants.filtered.annotated.vcf.gz" + ), + ) From ebe4be2d31b0146cc7069971c6ab2026b289ad0a Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 9 Dec 2025 12:06:28 +0100 Subject: [PATCH 58/67] A few typing fixes --- src/nomadic/summarize/compute.py | 15 ++++++++++++--- src/nomadic/summarize/main.py | 10 +++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index 83c2892..c92cba3 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -1,4 +1,5 @@ import enum +from typing import Optional import pandas as pd from statsmodels.stats.proportion import proportion_confint @@ -134,14 +135,22 @@ def filter_false_positives( def compute_variant_prevalence( variants_df: pd.DataFrame, - master_df: pd.DataFrame = None, - additional_groups: list[str] = [], + master_df: Optional[pd.DataFrame] = None, + additional_groups: Optional[list[str]] = None, ) -> pd.DataFrame: """ Compute the prevalence of each mutation in `variants_df` """ + if additional_groups is None: + additional_groups = [] - if master_df is not None and additional_groups: + if additional_groups: + assert master_df is not None, ( + "master_df must be provided if additional_groups are used" + ) + assert all(group in master_df.columns for group in additional_groups), ( + "all additional_groups must be columns in master_df" + ) variants_df = variants_df.merge( master_df[["sample_id", *additional_groups]], on="sample_id", how="left" ) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 023bd08..3ac1883 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -22,7 +22,6 @@ from nomadic.util.experiment import ( get_summary_files, check_experiment_outputs, - ExperimentOutputs, ) from nomadic.util.logging_config import LoggingFascade from nomadic.util.summary import Settings, get_master_columns_mapping, load_settings @@ -42,6 +41,9 @@ def check_regions_consistent(expt_regions: list[RegionBEDParser]) -> None: - Might make sense to *extract* the region that was used and save it; """ + if len(expt_regions) == 0: + # Nothing to check + return base = expt_regions[0] for r in expt_regions: if not (r.df == base.df).all().all(): @@ -50,11 +52,13 @@ def check_regions_consistent(expt_regions: list[RegionBEDParser]) -> None: ) -def check_calling_consistent(expt_callers: list[str]) -> None: +def check_calling_consistent(expt_callers: list[str]) -> Optional[str]: """ Check that the same variant caller was used across all experiments, where `expt_callers` is a list of used variant callers """ + if len(expt_callers) == 0: + return None caller_counts = Counter([caller for caller in expt_callers]) if len(caller_counts) > 1: raise ValueError( @@ -74,7 +78,7 @@ def get_shared_metadata_columns( for df in metadata_dfs[1:]: shared_columns.intersection_update(df.columns) shared_columns.difference_update(fixed_columns) # why am I doing this? - return shared_columns + return list(shared_columns) # -------------------------------------------------------------------------------- From 6a2a22509a216579d2dcfad5cf4025f813690134 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 9 Dec 2025 13:08:09 +0100 Subject: [PATCH 59/67] Remove fixing of leading zeros We don't want to do this kind of fixing in nomadic, it should be done before. --- src/nomadic/summarize/main.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 3ac1883..0f86b7e 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -518,14 +518,6 @@ def main( inventory_metadata["sample_id"] = inventory_metadata["sample_id"].str.strip() master_metadata["sample_id"] = master_metadata["sample_id"].str.strip() - # In case sample IDs are numbers, we want to strip leading zeros (this was a problem in Zambia data) - inventory_metadata["sample_id"] = inventory_metadata["sample_id"].str.lstrip("0") - master_metadata["sample_id"] = master_metadata["sample_id"].str.lstrip("0") - - # Do we want to have metadata in result files? - # inventory_metadata = pd.merge( - # left=inventory_metadata, right=master_metadata, on=["sample_id"], how="left" - # ) unknown_samples = calc_unknown_samples(inventory_metadata, master_metadata) if unknown_samples: warn( @@ -546,7 +538,6 @@ def main( inventory_metadata = inventory_metadata.query("status != 'unknown'") # Throughput data - # TODO: Need to make a real decision about how to handle duplicated sample IDs log.info("Overall sequencing throughput:") throughput_df = compute_throughput(inventory_metadata) log.info(f" Positive controls: {throughput_df.loc['pos', 'All']}") From d69d5e24b3d005d25da9b7f2df46bd0a3454450f Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 9 Dec 2025 14:36:32 +0100 Subject: [PATCH 60/67] Allow to only view with nomadic summarize command We try it first like this, but imo it would be better to actually separate viewing the summary into a different command or combine it with dashboard. It is a bit awkward to combine an output command (produce the files of summary) with an input command (showing files from a folder). --- src/nomadic/summarize/commands.py | 31 +++++++++ src/nomadic/summarize/compute.py | 2 +- src/nomadic/summarize/dashboard/builders.py | 2 +- src/nomadic/summarize/main.py | 77 +++++++++++++++------ src/nomadic/util/workspace.py | 6 ++ 5 files changed, 93 insertions(+), 25 deletions(-) diff --git a/src/nomadic/summarize/commands.py b/src/nomadic/summarize/commands.py index b232dd1..ef728ea 100644 --- a/src/nomadic/summarize/commands.py +++ b/src/nomadic/summarize/commands.py @@ -50,6 +50,13 @@ default=True, help="Whether to start the web dashboard to look at the summary.", ) +@click.option( + "--only-dashboard", + type=bool, + is_flag=True, + default=False, + help="If set, only open the summary dashboard in the web browser. Can not be combined with --no-dashboard.", +) @click.option( "-s", "--settings-file", @@ -77,12 +84,20 @@ show_default=True, help="Maximum contamination fraction for quality control. Samples with contamination above this fraction will be marked as contaminated. Contamination is defined as the mean coverage of negative controls being more than this fraction of the sample coverage.", ) +@click.option( + "--output-dir", + "-o", + type=click.Path(dir_okay=True, file_okay=False, path_type=Path), + help="Path to the output directory where the summary will be stored. By default, this is /summaries/.", +) def summarize( experiment_dirs: tuple[str], summary_name: str, workspace_path: str, + output_dir: Path, metadata_csv: Path, dashboard: bool, + only_dashboard: bool, prevalence_by: tuple[str], settings_file: Path, no_master_metadata: bool, @@ -105,6 +120,17 @@ def summarize( if summary_name is None: summary_name = workspace.get_name() + if only_dashboard and not dashboard: + raise click.BadParameter( + param_hint="--dashboard-only", + message="--dashboard-only can not be used together with --no-dashboard.", + ) + + if only_dashboard: + from .main import view + + return view(Path(workspace.get_summary_dir(summary_name)), summary_name) + if metadata_csv is None and not no_master_metadata: metadata_csv = Path(workspace.get_master_metadata_csv(summary_name)) @@ -120,10 +146,15 @@ def summarize( if len(experiment_dirs) == 0: experiment_dirs = workspace.get_experiment_dirs() + if output_dir is None: + output_dir = Path(workspace.get_summary_dir(summary_name)) + from .main import main try: main( + workspace=workspace, + output_dir=output_dir, expt_dirs=experiment_dirs, summary_name=summary_name, metadata_path=metadata_csv, diff --git a/src/nomadic/summarize/compute.py b/src/nomadic/summarize/compute.py index c92cba3..c3bd424 100644 --- a/src/nomadic/summarize/compute.py +++ b/src/nomadic/summarize/compute.py @@ -209,7 +209,7 @@ def gene_deletions(coverage_df: pd.DataFrame, genes: list[str]) -> pd.DataFrame: # Minimum coverage to consider a gene deleted DELETION_COVERAGE_THRESHOLD = 5 - coverage_df = coverage_df[coverage_df["sample_type"] == "field"] + coverage_df = coverage_df[coverage_df["sample_type"] == "field"].copy() coverage_df["gene"] = coverage_df["name"].str.split("-").str[0] # QC: only analyze samples with sufficient control amplicon coverage diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 52bb354..5d8fea3 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -433,7 +433,7 @@ def _add_map_row( ) regions = { - path.split("/")[-1].split(".")[0].split("-")[1]: path for path in geojsons + path.split("/")[-1].split(".")[0].split("-")[-1]: path for path in geojsons } region_dropdown = dcc.Dropdown( diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 0f86b7e..89a5a5f 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -1,4 +1,6 @@ +import glob import os +import shutil from typing import Iterable, Optional from warnings import warn from enum import StrEnum, auto @@ -17,6 +19,7 @@ gene_deletions, ) from nomadic.summarize.dashboard.builders import BasicSummaryDashboard +from nomadic.util.workspace import Workspace from nomadic.util.dirs import produce_dir from nomadic.util.regions import RegionBEDParser from nomadic.util.experiment import ( @@ -438,6 +441,8 @@ def replicates_amplicon_qc(coverage_df): def main( *, + workspace: Workspace, + output_dir: Path, expt_dirs: tuple[str], summary_name: str, metadata_path: Optional[Path], @@ -456,9 +461,7 @@ def main( assert (metadata_path is not None) or no_master_metadata - output_dir = produce_dir( - "summaries", summary_name - ) # TODO allow to change output dir + produce_dir(str(output_dir)) # PARSE EXPERIMENT DIRECTORIES log = LoggingFascade(logger_name="nomadic") @@ -667,7 +670,23 @@ def main( f"{output_dir}/summary.gene-deletions.prevalence-{col}.csv", index=False ) - master_metadata.to_csv(f"{output_dir}/{summary_name}.metadata.csv", index=False) + master_metadata.to_csv(f"{output_dir}/metadata.csv", index=False) + + # Copy relevant files + for file in glob.glob(f"{workspace.get_metadata_dir()}/{summary_name}*.geojson"): + shutil.copy(file, f"{output_dir}/{file.split('-')[-1]}") + coords_file = f"{workspace.get_metadata_dir()}/{summary_name}.coords.csv" + if os.path.isfile(coords_file): + shutil.copy( + coords_file, + os.path.join(output_dir, "coords.csv"), + ) + summary_settings_file = workspace.get_summary_settings_file(summary_name) + if os.path.isfile(summary_settings_file): + shutil.copy( + summary_settings_file, + os.path.join(output_dir, "settings.yaml"), + ) # -------------------------------------------------------------------------------- # Dashboard @@ -675,23 +694,35 @@ def main( # -------------------------------------------------------------------------------- if show_dashboard: - dashboard = BasicSummaryDashboard( - summary_name, - throughput_csv=f"{output_dir}/summary.throughput.csv", - samples_csv=f"{output_dir}/summary.samples_qc.csv", - samples_amplicons_csv=f"{output_dir}/summary.samples_amplicons_qc.csv", - coverage_csv=f"{output_dir}/summary.experiments_qc.csv", - analysis_csv=f"{output_dir}/summary.variants.analysis_set.csv", - gene_deletions_csv=f"{output_dir}/summary.gene_deletions.csv", - master_csv=f"{output_dir}/{summary_name}.metadata.csv", - geojson_glob=f"metadata/{summary_name}-*.geojson", - location_coords_csv=f"metadata/{summary_name}.coords.csv", - settings=settings, - ) - print("Done.") + view(output_dir, summary_name) + + +def view(input_dir: Path, summary_name: str) -> None: + """ + View the summary dashboard for a given summary + """ + settings: Settings = Settings() + settings_file = Path(f"{input_dir}/settings.yaml") + if settings_file.exists(): + settings = load_settings(settings_file) + + dashboard = BasicSummaryDashboard( + summary_name, + throughput_csv=f"{input_dir}/summary.throughput.csv", + samples_csv=f"{input_dir}/summary.samples_qc.csv", + samples_amplicons_csv=f"{input_dir}/summary.samples_amplicons_qc.csv", + coverage_csv=f"{input_dir}/summary.experiments_qc.csv", + analysis_csv=f"{input_dir}/summary.variants.analysis_set.csv", + gene_deletions_csv=f"{input_dir}/summary.gene_deletions.csv", + master_csv=f"{input_dir}/metadata.csv", + geojson_glob=f"{input_dir}/*.geojson", + location_coords_csv=f"{input_dir}/coords.csv", + settings=settings, + ) + print("Done.") - print("") - print("Launching dashboard (press CNTRL+C to exit):") - print("") - debug = bool(os.getenv("NOMADIC_DEBUG")) - dashboard.run(debug=debug, auto_open=not debug) + print("") + print("Launching dashboard (press CNTRL+C to exit):") + print("") + debug = bool(os.getenv("NOMADIC_DEBUG")) + dashboard.run(debug=debug, auto_open=not debug) diff --git a/src/nomadic/util/workspace.py b/src/nomadic/util/workspace.py index 3b3d806..77ee151 100644 --- a/src/nomadic/util/workspace.py +++ b/src/nomadic/util/workspace.py @@ -73,6 +73,12 @@ def get_output_dir(self, experiment_name: str): """ return os.path.join(self.get_results_dir(), experiment_name) + def get_summary_dir(self, summary_name: str): + """ + Get the summary directory of the workspace. + """ + return os.path.join(self.path, "summaries", summary_name) + def get_beds_dir(self): """ Get the beds directory of the workspace. From 9e7dc4996af32ac04cce1b38cf990ba38224bb4f Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 9 Dec 2025 17:25:21 +0100 Subject: [PATCH 61/67] Make summary general Put all mvp specific setting into an object and make the dashboard work for any panel with amplicons. --- src/nomadic/summarize/dashboard/builders.py | 121 ++++++++++++++---- src/nomadic/summarize/dashboard/components.py | 28 ++-- src/nomadic/summarize/main.py | 62 +++++++-- src/nomadic/util/panel.py | 57 +++++++++ src/nomadic/util/regions.py | 2 + 5 files changed, 217 insertions(+), 53 deletions(-) create mode 100644 src/nomadic/util/panel.py diff --git a/src/nomadic/summarize/dashboard/builders.py b/src/nomadic/summarize/dashboard/builders.py index 5d8fea3..7f42b77 100644 --- a/src/nomadic/summarize/dashboard/builders.py +++ b/src/nomadic/summarize/dashboard/builders.py @@ -5,7 +5,7 @@ import threading from abc import ABC, abstractmethod import webbrowser -from dash import Dash, html, dcc +from dash import Dash, Input, Output, callback, html, dcc from i18n import t import i18n @@ -193,18 +193,33 @@ def _add_experiment_qc(self, coverage_csv: str) -> None: self.components.append(self.quality_control) self.layout.append(quality_row) - def _add_prevalence_row(self, analysis_csv: str, master_csv: str) -> None: + def _add_prevalence_row( + self, + analysis_csv: str, + master_csv: str, + amplicon_names: list[str], + amplicon_sets: dict[str, list[str]], + ) -> None: """ Add a panel that shows prevalence calls """ - dropdown_genset = dcc.Dropdown( - id="prevalence-dropdown-gene-set", - options=list(PrevalenceBarplot.GENE_SETS.keys()), - value=list(PrevalenceBarplot.GENE_SETS.keys())[0], - style=dict(width="300px"), - clearable=False, - ) + if amplicon_sets: + dropdown_amplicon_set = dcc.Dropdown( + id="prevalence-dropdown-amplicon-set", + options=list(amplicon_sets.keys()), + value=list(amplicon_sets.keys())[0], + style=dict(width="300px"), + clearable=False, + ) + else: + dropdown_amplicon_set = dcc.Dropdown( + id="prevalence-dropdown-amplicon-set", + options=["All"], + value="All", + style=dict(width="300px"), + clearable=False, + ) cols = cols_to_group_by(master_csv, analysis_csv, max_cat=10) @@ -219,11 +234,30 @@ def _add_prevalence_row(self, analysis_csv: str, master_csv: str) -> None: self.prevalence_bars = PrevalenceBarplot( self.summary_name, component_id="prevalence-bars", - radio_id="prevalence-dropdown-gene-set", + radio_id="amplicons-dropdown", radio_id_by="prevalence-dropdown-by", analysis_csv=analysis_csv, master_csv=master_csv, + amplicon_sets=amplicon_sets, + ) + + @callback( + Output("amplicons-dropdown", "options"), + Input("prevalence-dropdown-amplicon-set", "value"), + ) + def update_amplicons_options(amplicon_set): + if amplicon_set == "All": + return amplicon_names + return amplicon_sets[amplicon_set] + + @callback( + Output("amplicons-dropdown", "value"), + Input("prevalence-dropdown-amplicon-set", "value"), ) + def update_amplicons_value(amplicon_set): + if amplicon_set == "All": + return amplicon_names + return amplicon_sets[amplicon_set] prevalence_row = html.Div( className="prevalence-row", @@ -234,8 +268,18 @@ def _add_prevalence_row(self, analysis_csv: str, master_csv: str) -> None: children=[ html.Div( children=[ - html.Label("Select gene set:"), - dropdown_genset, + html.Label("Select amplicon set:"), + dropdown_amplicon_set, + ] + ), + html.Div( + children=[ + html.Label("Select amplicons:"), + dcc.Dropdown( + id="amplicons-dropdown", + multi=True, + style=dict(width="300px"), + ), ] ), html.Div( @@ -257,15 +301,25 @@ def _add_prevalence_row(self, analysis_csv: str, master_csv: str) -> None: self.components.append(self.prevalence_bars) self.layout.append(prevalence_row) - def _add_prevalence_by_col_row(self, analysis_csv: str, master_csv: str) -> None: + def _add_prevalence_by_col_row( + self, + analysis_csv: str, + master_csv: str, + amplicon_names: list[str], + amplicon_sets: dict[str, list[str]], + ) -> None: """ Add a panel that shows prevalence calls by cols """ - gen_dropdown = dcc.Dropdown( - id="gene-dropdown", - options=PrevalenceBarplot.GENE_SETS["Resistance"], - value=PrevalenceBarplot.GENE_SETS["Resistance"][0], + + if "Resistance" in amplicon_sets: + amplicon_names = amplicon_sets["Resistance"] + + amplicon_dropdown = dcc.Dropdown( + id="amplicon-dropdown", + options=amplicon_names, + value=amplicon_names[0], style=dict(width="300px"), clearable=False, ) @@ -289,7 +343,7 @@ def _add_prevalence_by_col_row(self, analysis_csv: str, master_csv: str) -> None analysis_csv=analysis_csv, master_csv=master_csv, component_id="prevalence-heatmap", - gene_dropdown_id="gene-dropdown", + amplicon_dropdown_id="amplicon-dropdown", col_dropdown_id="col-dropdown", ) prevalence_row = html.Div( @@ -301,8 +355,8 @@ def _add_prevalence_by_col_row(self, analysis_csv: str, master_csv: str) -> None children=[ html.Div( children=[ - html.Label("Select gene:"), - gen_dropdown, + html.Label("Select amplicon:"), + amplicon_dropdown, ] ), html.Div( @@ -382,6 +436,7 @@ def _add_map_row( location_coords_csv: str, map_center: tuple[float, float] | None, map_zoom_level: int | None, + amplicon_sets: dict[str, list[str]], ) -> None: """ Add a panel that shows a choropleth map of drug resistance marker prevalence @@ -399,8 +454,12 @@ def _add_map_row( """ # Get mutations and their prevalence for resistance genes analysis_df = pd.read_csv(analysis_csv) - resistance_genes = PrevalenceBarplot.GENE_SETS["Resistance"] - resistance_df = analysis_df[analysis_df["gene"].isin(resistance_genes)] + if "Resistance" in amplicon_sets: + resistance_df = analysis_df[ + analysis_df["amplicon"].isin(amplicon_sets["Resistance"]) + ] + else: + resistance_df = analysis_df prevalence_df = compute_variant_prevalence(resistance_df) # Create a dictionary with mutation info and prevalence @@ -509,6 +568,9 @@ def __init__( analysis_csv: str, gene_deletions_csv: str, master_csv: str, + amplicons: list[str], + amplicon_sets: dict[str, list[str]], + deletion_genes: list[str], geojson_glob: str, settings: Settings, location_coords_csv: str, @@ -534,6 +596,9 @@ def __init__( self.geojson_glob = geojson_glob self.location_coords_csv = location_coords_csv self.map_center, self.map_zoom_level = get_map_settings(settings) + self.amplicon_names = amplicons + self.amplicon_sets = amplicon_sets + self.deletion_genes = deletion_genes def _gen_layout(self): """ @@ -543,9 +608,14 @@ def _gen_layout(self): self._add_throughput_banner(self.throughput_csv) self._add_samples(self.samples_csv, self.samples_amplicons_csv) self._add_experiment_qc(self.coverage_csv) - self._add_prevalence_row(self.analysis_csv, self.master_csv) - self._add_prevalence_by_col_row(self.analysis_csv, self.master_csv) - self._add_gene_deletion_row(self.gene_deletions_csv, self.master_csv) + self._add_prevalence_row( + self.analysis_csv, self.master_csv, self.amplicon_names, self.amplicon_sets + ) + self._add_prevalence_by_col_row( + self.analysis_csv, self.master_csv, self.amplicon_names, self.amplicon_sets + ) + if self.deletion_genes: + self._add_gene_deletion_row(self.gene_deletions_csv, self.master_csv) if glob.glob(self.geojson_glob) or os.path.exists(self.location_coords_csv): self._add_map_row( @@ -555,6 +625,7 @@ def _gen_layout(self): self.location_coords_csv, self.map_center, self.map_zoom_level, + self.amplicon_sets, ) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 7f229cc..4dde9ed 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -434,11 +434,6 @@ def _update(focus_stat: str): class PrevalenceBarplot(SummaryDashboardComponent): - GENE_SETS = { - "Resistance": ["crt", "dhps", "dhfr", "kelch13", "mdr1"], - "Diversity": ["ama1", "csp"], - } - def __init__( self, summary_name: str, @@ -447,6 +442,7 @@ def __init__( component_id: str, radio_id: str, radio_id_by: str, + amplicon_sets: dict[str, list[str]], ) -> None: """ Initialisation loads the coverage data and prepares for plotting; @@ -461,6 +457,8 @@ def __init__( self.radio_id = radio_id self.radio_id_by = radio_id_by + + self.amplicon_sets = amplicon_sets super().__init__(summary_name, component_id) def _define_layout(self): @@ -472,13 +470,11 @@ def callback(self, app: Dash) -> None: Input(self.radio_id, "value"), Input(self.radio_id_by, "value"), ) - def _update(gene_set: str, by: str): + def _update(amplicons: list[str], by: str): """Called whenver the input changes""" - genes = self.GENE_SETS[gene_set] # noqa: F841 later used in query + analysis_df = self.analysis_df.query("amplicon in @amplicons") - # Limit to key genes - analysis_df = self.analysis_df.query("gene in @genes") if by == "All": plot_df = compute_variant_prevalence(analysis_df) else: @@ -581,10 +577,10 @@ def __init__( analysis_csv: str, master_csv: str, component_id: str, - gene_dropdown_id: str, + amplicon_dropdown_id: str, col_dropdown_id: str, ): - self.gene_dropdown_id = gene_dropdown_id + self.amplicon_dropdown_id = amplicon_dropdown_id self.col_dropdown_id = col_dropdown_id self.analysis_df = pd.read_csv(analysis_csv) self.master_df = pd.read_csv(master_csv) @@ -597,14 +593,16 @@ def _define_layout(self): def callback(self, app: Dash) -> None: @app.callback( Output(self.component_id, "figure"), - Input(self.gene_dropdown_id, "value"), + Input(self.amplicon_dropdown_id, "value"), Input(self.col_dropdown_id, "value"), ) - def _update(target_gene, col_by): + def _update(target_amplicon, col_by): """Called every time an input changes""" df = compute_variant_prevalence( - self.analysis_df.query("gene == @target_gene"), self.master_df, [col_by] + self.analysis_df.query("amplicon == @target_amplicon"), + self.master_df, + [col_by], ) plot_df = pd.pivot_table( @@ -658,7 +656,7 @@ def _update(target_gene, col_by): hovermode="y unified", paper_bgcolor="white", # Sets the background color of the paper plot_bgcolor="white", - title=dict(text=target_gene), + title=dict(text=target_amplicon), margin=dict(t=MAR, l=MAR, r=MAR, b=MAR), xaxis=dict( showline=True, linecolor="black", linewidth=2, dtick=1, mirror=True diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 89a5a5f..e062aa5 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -19,6 +19,7 @@ gene_deletions, ) from nomadic.summarize.dashboard.builders import BasicSummaryDashboard +from nomadic.util.panel import get_panel_settings from nomadic.util.workspace import Workspace from nomadic.util.dirs import produce_dir from nomadic.util.regions import RegionBEDParser @@ -482,9 +483,17 @@ def main( # Check experiments are consistent check_regions_consistent([expt.regions for expt in expts]) log.info(" All experiments use the same regions.") + if expts: + panel_name = expts[0].regions.name + else: + panel_name = "Unknown" + log.info(f" Panel used: {panel_name}") caller = check_calling_consistent([expt.caller for expt in expts]) log.info(f" All experiments use same variant caller: {caller}") + panel_settings = get_panel_settings(panel_name) + log.info(f" Loaded panel settings for panel '{panel_settings.name}'.") + settings: Settings = Settings() if settings_file_path.exists(): settings = load_settings(settings_file_path) @@ -623,12 +632,12 @@ def main( ) log.info("Filtering to analysis set...") - remove_genes = ["hrp2", "hrp3"] # noqa: F841 later used in query - remove_mutations = ["crt-N75K"] # noqa: F841 later used in query + remove_amplicons = panel_settings.excluded_amplicons # noqa: F841 later used in query + remove_mutations = panel_settings.filtered_mutations # noqa: F841 later used in query analysis_df = ( variant_df.query("status == 'pass'") .query("mut_type == 'missense'") - .query("gene not in @remove_genes") + .query("amplicon not in @remove_amplicons") .query("mutation not in @remove_mutations") ) @@ -651,16 +660,17 @@ def main( # # -------------------------------------------------------------------------------- - log.info("Calculate gene deletions...") - gene_deletion_df = gene_deletions(coverage_df, ["hrp2", "hrp3"]) - gene_deletion_df.to_csv(f"{output_dir}/summary.gene_deletions.csv", index=False) + if panel_settings.deletion_genes: + log.info("Calculate gene deletions...") + gene_deletion_df = gene_deletions(coverage_df, panel_settings.deletion_genes) + gene_deletion_df.to_csv(f"{output_dir}/summary.gene_deletions.csv", index=False) - prev_gen_deletions_df = gene_deletion_prevalence_by( - gene_deletion_df, master_metadata, [] - ) - prev_gen_deletions_df.to_csv( - f"{output_dir}/summary.gene-deletions.prevalence.csv", index=False - ) + prev_gen_deletions_df = gene_deletion_prevalence_by( + gene_deletion_df, master_metadata, [] + ) + prev_gen_deletions_df.to_csv( + f"{output_dir}/summary.gene-deletions.prevalence.csv", index=False + ) for col in prevalence_by: prev_gen_deletion_by_col_df = gene_deletion_prevalence_by( @@ -672,7 +682,14 @@ def main( master_metadata.to_csv(f"{output_dir}/metadata.csv", index=False) + log.info("Copy relevant files to summary output directory...") + # Copy relevant files + if expts: + shutil.copy( + expts[0].regions.path, + os.path.join(output_dir, os.path.basename(expts[0].regions.path)), + ) for file in glob.glob(f"{workspace.get_metadata_dir()}/{summary_name}*.geojson"): shutil.copy(file, f"{output_dir}/{file.split('-')[-1]}") coords_file = f"{workspace.get_metadata_dir()}/{summary_name}.coords.csv" @@ -688,6 +705,8 @@ def main( os.path.join(output_dir, "settings.yaml"), ) + log.info("Summary analysis complete.") + # -------------------------------------------------------------------------------- # Dashboard # @@ -704,8 +723,23 @@ def view(input_dir: Path, summary_name: str) -> None: settings: Settings = Settings() settings_file = Path(f"{input_dir}/settings.yaml") if settings_file.exists(): + print(f"Loading settings from {settings_file}...") settings = load_settings(settings_file) + bed_files = glob.glob(f"{input_dir}/*.bed") + if bed_files: + panel_name = os.path.basename(bed_files[0]).split(".")[0] + print(f"Use panel name from regions BED file: {panel_name}") + amplicons = RegionBEDParser(bed_files[0]).names + else: + raise ValueError("No regions BED file found in summary directory.") + + panel_settings = get_panel_settings(panel_name) + amplicon_sets = panel_settings.amplicon_sets + deletion_genes = panel_settings.deletion_genes + + print("Load data...") + dashboard = BasicSummaryDashboard( summary_name, throughput_csv=f"{input_dir}/summary.throughput.csv", @@ -718,8 +752,10 @@ def view(input_dir: Path, summary_name: str) -> None: geojson_glob=f"{input_dir}/*.geojson", location_coords_csv=f"{input_dir}/coords.csv", settings=settings, + amplicons=amplicons, + amplicon_sets=amplicon_sets, + deletion_genes=deletion_genes, ) - print("Done.") print("") print("Launching dashboard (press CNTRL+C to exit):") diff --git a/src/nomadic/util/panel.py b/src/nomadic/util/panel.py new file mode 100644 index 0000000..b4a277a --- /dev/null +++ b/src/nomadic/util/panel.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass + + +@dataclass +class PanelSettings: + """Settings for a panel in the summary.""" + + name: str + # List of amplicons to exclude from analysis set + excluded_amplicons: list[str] + # List of mutations to exclude from analysis set + filtered_mutations: list[str] + # Amplicon sets + amplicon_sets: dict[str, list[str]] + # List of genes for which to compute deletion prevalence + deletion_genes: list[str] + + +MVP_PANEL_SETTINGS = PanelSettings( + name="nomadsMVP", + excluded_amplicons=[ + "hrp2-p14-306", + "hrp3-p14-276", + ], + filtered_mutations=["crt-N75K"], + amplicon_sets={ + "Resistance": [ + "crt-p14-125", + "dhps-p317-707", + "dhfr-p1-410", + "kelch13-p383-727", + "mdr1-p968-1278", + "mdr1-p46-245", + ], + "Diversity": ["ama1-p74-384", "csp-p19-398"], + }, + # Don't show deletion genes at the moment, because the code is not robust + # deletion_genes=["hrp2", "hrp3"], + deletion_genes=[], +) + + +UNKNOWN_PANEL_SETTINGS = PanelSettings( + name="Unknown", + excluded_amplicons=[], + filtered_mutations=[], + amplicon_sets={}, + deletion_genes=[], +) + + +def get_panel_settings(panel_name: str) -> PanelSettings: + """Get panel settings by panel name.""" + if panel_name == "nomadsMVP": + return MVP_PANEL_SETTINGS + else: + return UNKNOWN_PANEL_SETTINGS diff --git a/src/nomadic/util/regions.py b/src/nomadic/util/regions.py index 12659a0..f730a01 100644 --- a/src/nomadic/util/regions.py +++ b/src/nomadic/util/regions.py @@ -1,3 +1,4 @@ +import os import seaborn as sns from matplotlib.colors import rgb2hex @@ -19,6 +20,7 @@ def __init__(self, bed_path: str): """Load the BED file, assign colors to regions""" self.path = bed_path + self.name = os.path.basename(bed_path).split(".")[0] self.df = load_bed_as_dataframe(bed_path) self.n_regions = self.df.shape[0] From 7b58bfdf5e8778a998ca684757df1d2eb597b833 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 19 Dec 2025 18:14:52 +0100 Subject: [PATCH 62/67] Only plot data in summary if we have it --- src/nomadic/summarize/dashboard/components.py | 59 ++++++++++--------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/src/nomadic/summarize/dashboard/components.py b/src/nomadic/summarize/dashboard/components.py index 4dde9ed..32242ad 100644 --- a/src/nomadic/summarize/dashboard/components.py +++ b/src/nomadic/summarize/dashboard/components.py @@ -238,6 +238,7 @@ def __init__( marker=dict(color=SAMPLE_COLORS[status.value]), ) for status in Status + if status.value in plot_df.columns ] ) @@ -621,34 +622,38 @@ def _update(target_amplicon, col_by): ) plot_df = plot_df.reindex(pos_order) - # Hover statment - customdata = np.stack( - [plot_df["n_mixed"], plot_df["n_mut"], plot_df["n_passed"]], axis=-1 - ) - htemp = "%{y} (%{x})
" - htemp += "Prevalence: %{z:.0f}%
" - htemp += "Samples: %{customdata[2]}
" - htemp += "Mixed: %{customdata[0]}
" - htemp += "Clonal: %{customdata[1]}
" - - plot_data = [ - go.Heatmap( - x=plot_df["prevalence"].columns, - y=plot_df["prevalence"].index, - z=plot_df["prevalence"], - texttemplate="%{z:.0f}%", - customdata=customdata, - zmin=0, - zmax=100, - xgap=1, - ygap=1, - colorscale="Spectral_r", - colorbar=dict(title="", outlinecolor="black", outlinewidth=1), - hoverongaps=False, - hovertemplate=htemp, - name="", + if len(plot_df) > 0: + # Hover statment + customdata = np.stack( + [plot_df["n_mixed"], plot_df["n_mut"], plot_df["n_passed"]], axis=-1 ) - ] + htemp = "%{y} (%{x})
" + htemp += "Prevalence: %{z:.0f}%
" + htemp += "Samples: %{customdata[2]}
" + htemp += "Mixed: %{customdata[0]}
" + htemp += "Clonal: %{customdata[1]}
" + + plot_data = [ + go.Heatmap( + x=plot_df["prevalence"].columns, + y=plot_df["prevalence"].index, + z=plot_df["prevalence"], + texttemplate="%{z:.0f}%", + customdata=customdata, + zmin=0, + zmax=100, + xgap=1, + ygap=1, + colorscale="Spectral_r", + colorbar=dict(title="", outlinecolor="black", outlinewidth=1), + hoverongaps=False, + hovertemplate=htemp, + name="", + ) + ] + else: + plot_data = [] + MAR = 40 fig = go.Figure(plot_data) fig.update_layout( From d752536f02ff9e3a76faf352e5858e3cf1bd23f0 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 19 Dec 2025 18:15:21 +0100 Subject: [PATCH 63/67] Remove print statement --- src/nomadic/summarize/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index e062aa5..b826e7a 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -477,7 +477,6 @@ def main( # Check experiments are complete expts = [check_experiment_outputs(expt_dir) for expt_dir in expt_dirs] - print(expts) log.info(" All experiments are complete.") # Check experiments are consistent From 6aaa86f5e77ed0fb8558d806c45f1fbddad4189d Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 19 Dec 2025 18:15:40 +0100 Subject: [PATCH 64/67] Ensure to only include field samples in metadata --- src/nomadic/summarize/main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index b826e7a..ba8e1df 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -517,7 +517,12 @@ def main( else: # create metadata from experiment meta data files shared_columns = ["sample_id"] + list(shared_columns) - master_metadata = pd.concat([expt.metadata[shared_columns] for expt in expts]) + master_metadata = pd.concat( + [ + expt.metadata.query("sample_type == 'field'")[shared_columns] + for expt in expts + ] + ) master_metadata = master_metadata.astype( {"sample_id": "str"} From 182ce36597efa4c257f993b2ed37dc7f00de742c Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 19 Dec 2025 18:16:01 +0100 Subject: [PATCH 65/67] Exit summary early if we have no field samples --- src/nomadic/summarize/main.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index ba8e1df..0b942aa 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -553,6 +553,10 @@ def main( inventory_metadata.to_csv(f"{output_dir}/inventory.csv", index=False) inventory_metadata = inventory_metadata.query("status != 'unknown'") + if len(inventory_metadata.query("sample_type == 'field'")) == 0: + log.info("No known field samples, exiting...") + return + # Throughput data log.info("Overall sequencing throughput:") throughput_df = compute_throughput(inventory_metadata) From f5f0228b9da76926f5b274adaa1c51fbbddf072c Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Fri, 19 Dec 2025 18:18:10 +0100 Subject: [PATCH 66/67] Ensure we print the experiment that has an metadata error --- src/nomadic/util/experiment.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/nomadic/util/experiment.py b/src/nomadic/util/experiment.py index c921f1d..a92aca5 100644 --- a/src/nomadic/util/experiment.py +++ b/src/nomadic/util/experiment.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from nomadic.util.dirs import produce_dir +from nomadic.util.exceptions import MetadataFormatError from nomadic.util.metadata import MetadataTableParser, ExtendedMetadataTableParser from nomadic.util.regions import RegionBEDParser @@ -228,7 +229,10 @@ def check_experiment_outputs(expt_dir: str) -> ExperimentOutputs: raise FileNotFoundError(f"Experiment directory {expt_dir} does not exist.") # Existence of metadata - parser = find_metadata(expt_dir, Parser=ExtendedMetadataTableParser) + try: + parser = find_metadata(expt_dir, Parser=ExtendedMetadataTableParser) + except MetadataFormatError as e: + raise MetadataFormatError(f"Error in metadata for '{expt_dir}': {e}") metadata = parser.df metadata.insert(0, "expt_name", os.path.basename(expt_dir)) From 645c1e4de21ec39c651d06cfae7cc0a318e369f9 Mon Sep 17 00:00:00 2001 From: Bernd Bohmeier Date: Tue, 6 Jan 2026 16:55:30 +0100 Subject: [PATCH 67/67] More detailed output of what summary is loaded --- src/nomadic/summarize/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/nomadic/summarize/main.py b/src/nomadic/summarize/main.py index 0b942aa..c5c3cb2 100644 --- a/src/nomadic/summarize/main.py +++ b/src/nomadic/summarize/main.py @@ -728,6 +728,7 @@ def view(input_dir: Path, summary_name: str) -> None: """ View the summary dashboard for a given summary """ + print(f'View summary dashboard for "{summary_name}".') settings: Settings = Settings() settings_file = Path(f"{input_dir}/settings.yaml") if settings_file.exists(): @@ -746,7 +747,7 @@ def view(input_dir: Path, summary_name: str) -> None: amplicon_sets = panel_settings.amplicon_sets deletion_genes = panel_settings.deletion_genes - print("Load data...") + print(f"Load data from {input_dir}...") dashboard = BasicSummaryDashboard( summary_name,