From f56d5398a7c942fdf9b65cefbff140f784bd1b55 Mon Sep 17 00:00:00 2001 From: Griffin Date: Thu, 30 Oct 2025 16:10:47 +0100 Subject: [PATCH 1/5] creating report items (phix control coefficient) --- assets/js_interface.py | 2 +- index.py | 112 +++++++++++++++++++++++++++++------------ models.py | 12 ++++- 3 files changed, 93 insertions(+), 33 deletions(-) diff --git a/assets/js_interface.py b/assets/js_interface.py index 40f6de8..8eae0ea 100644 --- a/assets/js_interface.py +++ b/assets/js_interface.py @@ -8,7 +8,7 @@ const well = params.data.Row + String(col); const wellType = wellTypes[well]; - const isEditable = editableGroups.includes(wellType) || wellType === undefined; + const isEditable = editableGroups.includes(wellType); const labelMap = { "PhiX": "PhiX", "Standard": "STD", "Negative Control": "NEG" }; const labelText = wellType && !isEditable ? (labelMap[wellType] || wellType) : params.value || ""; diff --git a/index.py b/index.py index b02e2b1..f314b51 100644 --- a/index.py +++ b/index.py @@ -41,9 +41,30 @@ pd.set_option('future.no_silent_downcasting', True) -############################################################################## -##### WORKING DRAG/DROP SETUP -############################################################################## + +def plate_mapper(plate_ids, objects): + """ + This takes in a list of dash component ids (which we've passed above as states) + and a list of indexed components (e.g. delete buttons, datatables, etc) + and returns a mapping of plate_id -> object for easy lookup. + """ + + mapping = {} + plate_ids = [p.get("index") for p in plate_ids] + + if not plate_ids or not objects: + raise Exception("ERROR RAISED INTENTIONALLY - No plate ids or objects found... major underlying issue to resolve.") + + if len(plate_ids) != len(objects): + raise Exception("ERROR RAISED INTENTIONALLY - Mismatch in lengths of plate ids and objects... major underlying issue to resolve.") + + for pid, obj in zip(plate_ids, objects): + mapping[pid] = obj + + return mapping + + + def render_dnd_aggrid(sample_groups=None): """Render only the 8x12 grid (static).""" if sample_groups is None: @@ -587,15 +608,27 @@ def toggle_review_modal(open_clicks, close_clicks, is_open): ], [ State("app_data_state", "data"), + State({"type":"datatable-plate", "index": ALL}, "rowData"), + State({"type":"datatable-plate", "index": ALL}, "id"), + State({"type": "phix-control-dropdown", "index": ALL}, "value"), + State({"type": "phix-control-dropdown", "index": ALL}, "id") ], ) -def create_report(row_data, app_data_json): +def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix_values, phix_ids): + + print("phix_values:", phix_values) + print("phix_ids:", phix_ids) + print("all_ui_table_ids:", all_ui_table_ids) app_state = AppState.from_json(app_data_json) qpcr_plate = next((p for p in app_state.plate_registry if p.plate_type == "qPCR"), None) column_indices = [f'Col{i}' for i in list(range(1, 13))] + phix_values_by_plate = plate_mapper(phix_ids, phix_values) + datatable_by_plate = plate_mapper(all_ui_table_ids, all_ui_tables) + qpcr_table_data = datatable_by_plate.get(qpcr_plate.plate_id, []) + # Now we cover three cases: # Case_1: Sample is in row_data, and in all_samples # (e.g. is on both the Tapestation and qPCR plates) @@ -627,9 +660,6 @@ def create_report(row_data, app_data_json): if row[col_index] == "": - print("WELL INDEX: ", well_index) - print("QPCR SAMPLE: ", qpcr_sample) - if qpcr_sample.file_sample_identifier not in already_assigned: case_3.append( { @@ -676,20 +706,60 @@ def create_report(row_data, app_data_json): report_content.append(html.Hr()) for entry in case_1: + tapestation_sample = entry["tapestation_sample"] qpcr_sample = entry["qpcr_sample"] + tapestation_measurements = tapestation_sample.get_measurements(app_state) + qpcr_measurements = qpcr_sample.get_measurements(app_state) + + qpcr_phix_plate_well = qpcr_plate.get_well_by_position(phix_values_by_plate.get(qpcr_plate.plate_id)) + + if qpcr_phix_plate_well is None: + qpcr_control_coefficient = 1 # No adjustment, because there's no PhiX control assigned + else: + qpcr_phix_sample = qpcr_phix_plate_well.get_sample(app_state) + + phiX_computed_molarity = None + + for row in qpcr_table_data: + well_pos = row.get("well_position_id") + if well_pos == qpcr_phix_plate_well.well_position_id: + phiX_computed_molarity = row["computed_molarity"] + if phiX_computed_molarity is None: + raise Exception("ERROR RAISED INTENTIONALLY - Could not find computed molarity for PhiX control sample... major underlying issue to resolve.") + else: + phiX_nM = str(phiX_computed_molarity.split(" ")[0]) + qpcr_control_coefficient = 10 / float(phiX_nM) # Assuming target is 10 nM + print("qpcr_control_coefficient:", qpcr_control_coefficient) + + # Here we average the EXISTING Ct values, as some of the triplicates may have been deleted. + ct_value_measurements = [m for m in qpcr_measurements if m.measurement_type.id == "cq"] + ct_value = np.mean([m.value for m in ct_value_measurements]) if ct_value_measurements else None + str_ct_value = f"{ct_value:.2f}" if ct_value is not None else "N/A" + + #computed_molarity_measurements = [] # TODO fill with row data + #qpcr_dilution_factor = [] # TODO fill with row data + + # Here we take the average of all avg sizes measured for a single sample ID (there may be multiple). + average_size_measurements = [m for m in tapestation_measurements if m.measurement_type.id == "avg_size"] + average_size = np.mean([m.value for m in average_size_measurements]) if average_size_measurements else None + str_average_size = f"{average_size:.1f} bp" if average_size is not None else "N/A" + + + final_molarity = 1 # TODO Compute this from all the above values + # Build report entry for this sample sample_div = html.Div( [ - html.H5(f"Sample ID: "), - html.P(f"Tube ID: "), + html.H5(f"Tube ID: {tapestation_sample.file_sample_identifier}"), + html.P(f"Sample ID: {tapestation_sample.sample_id}"), html.Ul( [ - html.Li(f"Ct Value from qPCR: "), + html.Li(f"Ct Value from qPCR: {str_ct_value}"), html.Li(f"Computed Molarity from Standard Curve: "), html.Li(f"qpcr Dilution Factor: "), - html.Li(f"Tapestation avg. size in range: "), + html.Li(f"Tapestation avg. size in range: {str_average_size}"), html.Li(f"phiX adjusted molarity: "), html.Li(f"Final Molarity after controlling for fragment length: "), ] @@ -1050,26 +1120,6 @@ def manipulate_plates( app_state = AppState.from_json(app_data_state_json) if app_data_state_json else AppState() print("manipulate_plates triggered by:", triggered) - def plate_mapper(plate_ids, objects): - """ - This takes in a list of dash component ids (which we've passed above as states) - and a list of indexed components (e.g. delete buttons, datatables, etc) - and returns a mapping of plate_id -> object for easy lookup. - """ - - mapping = {} - plate_ids = [p.get("index") for p in plate_ids] - - if not plate_ids or not objects: - raise Exception("ERROR RAISED INTENTIONALLY - No plate ids or objects found... major underlying issue to resolve.") - - if len(plate_ids) != len(objects): - raise Exception("ERROR RAISED INTENTIONALLY - Mismatch in lengths of plate ids and objects... major underlying issue to resolve.") - - for pid, obj in zip(plate_ids, objects): - mapping[pid] = obj - - return mapping # --- Handle Plate Upload --- if triggered == "upload-files": diff --git a/models.py b/models.py index 44c2826..1bd2cd1 100644 --- a/models.py +++ b/models.py @@ -51,7 +51,17 @@ class Sample(BaseModel): def get_plate_wells(self, app_state): return app_state.platewells_by_sample_id.get(self.file_sample_identifier, []) - + def get_measurements(self, app_state): + plate_wells = self.get_plate_wells(app_state) + measurements = [] + for well in plate_wells: + measurements.extend(well.measurements) + return measurements + + def get_measurement_by_type(self, app_state, measurement_type_name): + measurements = self.get_measurements(app_state) + return [m for m in measurements if m.measurement_type.name == measurement_type_name] + ##################################################### # MeasurementType Class ##################################################### From 9005eb8da7ab3a4034cfdcf07268255a6985dca6 Mon Sep 17 00:00:00 2001 From: Griffin Date: Thu, 30 Oct 2025 21:18:35 +0100 Subject: [PATCH 2/5] covered most of case 1 of merging plates --- index.py | 57 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/index.py b/index.py index f314b51..0a7fd9a 100644 --- a/index.py +++ b/index.py @@ -714,40 +714,60 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix qpcr_measurements = qpcr_sample.get_measurements(app_state) qpcr_phix_plate_well = qpcr_plate.get_well_by_position(phix_values_by_plate.get(qpcr_plate.plate_id)) - + + molarity_dilutions = [] + if qpcr_phix_plate_well is None: qpcr_control_coefficient = 1 # No adjustment, because there's no PhiX control assigned else: qpcr_phix_sample = qpcr_phix_plate_well.get_sample(app_state) - phiX_computed_molarity = None + phiX_computed_molarity = None - for row in qpcr_table_data: - well_pos = row.get("well_position_id") + for row in qpcr_table_data: + print(row) + well_pos = row.get("well_position_id") + if qpcr_phix_plate_well is not None: if well_pos == qpcr_phix_plate_well.well_position_id: phiX_computed_molarity = row["computed_molarity"] - if phiX_computed_molarity is None: - raise Exception("ERROR RAISED INTENTIONALLY - Could not find computed molarity for PhiX control sample... major underlying issue to resolve.") - else: - phiX_nM = str(phiX_computed_molarity.split(" ")[0]) + if qpcr_sample.file_sample_identifier == row.get("sample_identifier"): + # Grab each well's computed molarity and dilution factor + dilution_factor = row.get("dilution_factor") + computed_molarity = row.get("computed_molarity") + float_computed_molarity = float(computed_molarity.split(" ")[0]) if computed_molarity is not None else None + molarity_dilutions.append( (row.get("well_position_id"), qpcr_sample.file_sample_identifier, float_computed_molarity, dilution_factor) ) + + if phiX_computed_molarity is None: + # raise Exception("ERROR RAISED INTENTIONALLY - Could not find computed molarity for PhiX control sample... major underlying issue to resolve.") + qpcr_control_coefficient = 1 + else: + phiX_nM = str(phiX_computed_molarity.split(" ")[0]) + if float(phiX_nM) == 0: + qpcr_control_coefficient = 1 + else: qpcr_control_coefficient = 10 / float(phiX_nM) # Assuming target is 10 nM - print("qpcr_control_coefficient:", qpcr_control_coefficient) # Here we average the EXISTING Ct values, as some of the triplicates may have been deleted. ct_value_measurements = [m for m in qpcr_measurements if m.measurement_type.id == "cq"] ct_value = np.mean([m.value for m in ct_value_measurements]) if ct_value_measurements else None str_ct_value = f"{ct_value:.2f}" if ct_value is not None else "N/A" - - #computed_molarity_measurements = [] # TODO fill with row data - #qpcr_dilution_factor = [] # TODO fill with row data + + qpcr_dilution_factors = molarity_dilutions + dilution_factor_str = ", ".join([f"{elt[0]}: {elt[3]}" for elt in qpcr_dilution_factors]) # Here we take the average of all avg sizes measured for a single sample ID (there may be multiple). average_size_measurements = [m for m in tapestation_measurements if m.measurement_type.id == "avg_size"] average_size = np.mean([m.value for m in average_size_measurements]) if average_size_measurements else None str_average_size = f"{average_size:.1f} bp" if average_size is not None else "N/A" + final_molarities = np.mean([elt[2] for elt in molarity_dilutions if elt[2] is not None]) if molarity_dilutions else None + final_formatted_molarity = f"{final_molarities:.2f} nM" if final_molarities is not None else "N/A" - final_molarity = 1 # TODO Compute this from all the above values + final_diluted_molarities = [elt[2] * elt[3] for elt in molarity_dilutions if elt[2] is not None and elt[3] is not None] + final_diluted_molarity = np.mean(final_diluted_molarities) if final_diluted_molarities else None + formatted_final_diluted_molarity = f"{final_diluted_molarity:.2f} nM" if final_diluted_molarity is not None else "N/A" + + phix_string = f"Well {qpcr_phix_plate_well.well_position_id}: {phiX_computed_molarity}" if qpcr_phix_plate_well is not None else "No PhiX control assigned." # Build report entry for this sample sample_div = html.Div( @@ -757,11 +777,12 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix html.Ul( [ html.Li(f"Ct Value from qPCR: {str_ct_value}"), - html.Li(f"Computed Molarity from Standard Curve: "), - html.Li(f"qpcr Dilution Factor: "), + html.Li(f"PhiX control: {phix_string}"), + html.Li(f"Computed Molarity from Standard Curve: {final_formatted_molarity}"), + html.Li(f"qpcr Dilution Factors: {dilution_factor_str}"), + html.Li(f"Dilution-adjusted Molarity: {formatted_final_diluted_molarity}"), html.Li(f"Tapestation avg. size in range: {str_average_size}"), - html.Li(f"phiX adjusted molarity: "), - html.Li(f"Final Molarity after controlling for fragment length: "), + html.Li(f"Final Molarity after controlling for fragment length: {'???'}"), # TODO Calculate this ] ) @@ -770,6 +791,8 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix ) report_content.append(sample_div) + # TODO : Handle case_2 and case_3 similarly + return html.Div(report_content) From 55d4c18ea18590f4f36b3ab832fd3e3c49436f0c Mon Sep 17 00:00:00 2001 From: Griffin Date: Mon, 3 Nov 2025 17:09:14 +0100 Subject: [PATCH 3/5] added case 2 to report, added pmol measurements to computed molarity function --- index.py | 334 +++++++++++++++++++++++++++++++----- utils/plate_render_utils.py | 2 +- 2 files changed, 296 insertions(+), 40 deletions(-) diff --git a/index.py b/index.py index 0a7fd9a..ce544df 100644 --- a/index.py +++ b/index.py @@ -616,10 +616,6 @@ def toggle_review_modal(open_clicks, close_clicks, is_open): ) def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix_values, phix_ids): - print("phix_values:", phix_values) - print("phix_ids:", phix_ids) - print("all_ui_table_ids:", all_ui_table_ids) - app_state = AppState.from_json(app_data_json) qpcr_plate = next((p for p in app_state.plate_registry if p.plate_type == "qPCR"), None) @@ -698,12 +694,82 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix print("Case 2 (Only Tapestation):", len(case_2)) print("Case 3 (Only qPCR):", len(case_3)) - # Build report content here - report_content = [] + table_rows = [] + + table_header = html.Thead( + [ + html.Tr([ + html.Th(id="tube-id-header", children="Tube ID"), + # html.Th(id="sample-id-header", children="Sample ID"), + html.Th(id="ct-value-header", children="Ct"), + html.Th(id="phix-control-header", children="PhiX"), + html.Th(id="computed-molarity-header", children="Computed Molarity"), + html.Th(id="dilution-factors-header", children="Dilution Factors"), + html.Th(id="dilution-adjusted-molarity-header", children="Dilution-adjusted nM"), + # html.Th(id="avg-size-header", children="Avg. Size (bp)"), + html.Th(id="phix-adjusted-molarity-header", children="PhiX-adjusted nM"), + html.Th(id="final-molarity-header", children="Final Molarity"), + ]), + dbc.Tooltip( + "The unique identifier for the sample tube.", + target="tube-id-header", + placement="top", + ), + # dbc.Tooltip( + # "The sample id in B-Fabric.", + # target="sample-id-header", + # placement="top", + # ), + dbc.Tooltip( + "The average of all measurements of this sample's Ct value from the qPCR measurements.", + target="ct-value-header", + placement="top", + ), + dbc.Tooltip( + "The PhiX control well and its computed molarity from the qPCR run.", + target="phix-control-header", + placement="top", + ), + dbc.Tooltip( + "The computed molarity based on the standard curve from the qPCR measurements.", + target="computed-molarity-header", + placement="top", + ), + dbc.Tooltip( + "The dilution factors applied to this sample in the qPCR measurements.", + target="dilution-factors-header", + placement="top", + ), + dbc.Tooltip( + "The molarity adjusted for dilution factors.", + target="dilution-adjusted-molarity-header", + placement="top", + ), + # dbc.Tooltip( + # "The average fragment size measured by Tapestation.", + # target="avg-size-header", + # placement="top", + # ), + dbc.Tooltip( + "The molarity adjusted using the PhiX control.", + target="phix-adjusted-molarity-header", + placement="top", + ), + dbc.Tooltip( + "The final molarity adjusted for fragment length.", + target="final-molarity-header", + placement="top", + ), + + ] + ) + + cell_style = { + "border": "1px solid #ccc", + "padding": "6px", + "textAlign": "center" + } - report_content.append(html.H4("Samples with both Tapestation and qPCR Data")) - report_content.append(html.P(f"Total Samples: {len(case_1)}")) - report_content.append(html.Hr()) for entry in case_1: @@ -716,22 +782,26 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix qpcr_phix_plate_well = qpcr_plate.get_well_by_position(phix_values_by_plate.get(qpcr_plate.plate_id)) molarity_dilutions = [] - - if qpcr_phix_plate_well is None: - qpcr_control_coefficient = 1 # No adjustment, because there's no PhiX control assigned - else: - qpcr_phix_sample = qpcr_phix_plate_well.get_sample(app_state) - phiX_computed_molarity = None for row in qpcr_table_data: - print(row) - well_pos = row.get("well_position_id") + + well_pos = row.get("well_position_id") + if qpcr_phix_plate_well is not None: if well_pos == qpcr_phix_plate_well.well_position_id: - phiX_computed_molarity = row["computed_molarity"] + print("Step 1: ", row) + phiX_computed_molarity = float(row["computed_molarity"].split(" ")[0]) + print("Step 2: ", phiX_computed_molarity) + phiX_dilution_factor = float(row["dilution_factor"]) + print("Step 3: ", phiX_dilution_factor) + phiX_computed_molarity *= phiX_dilution_factor + print("Step 4: ", phiX_computed_molarity) + phiX_computed_molarity = f"{phiX_computed_molarity} nM" + print("Step 5: ", phiX_computed_molarity) + + # Construct molarity_dilutions list if qpcr_sample.file_sample_identifier == row.get("sample_identifier"): - # Grab each well's computed molarity and dilution factor dilution_factor = row.get("dilution_factor") computed_molarity = row.get("computed_molarity") float_computed_molarity = float(computed_molarity.split(" ")[0]) if computed_molarity is not None else None @@ -740,6 +810,7 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix if phiX_computed_molarity is None: # raise Exception("ERROR RAISED INTENTIONALLY - Could not find computed molarity for PhiX control sample... major underlying issue to resolve.") qpcr_control_coefficient = 1 + phiX_nM = "N/A" # No control assigned else: phiX_nM = str(phiX_computed_molarity.split(" ")[0]) if float(phiX_nM) == 0: @@ -767,31 +838,216 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix final_diluted_molarity = np.mean(final_diluted_molarities) if final_diluted_molarities else None formatted_final_diluted_molarity = f"{final_diluted_molarity:.2f} nM" if final_diluted_molarity is not None else "N/A" - phix_string = f"Well {qpcr_phix_plate_well.well_position_id}: {phiX_computed_molarity}" if qpcr_phix_plate_well is not None else "No PhiX control assigned." + phix_string = f"Well {qpcr_phix_plate_well.well_position_id}: {phiX_computed_molarity}" if qpcr_phix_plate_well is not None else "N/A" + + phix_adjusted_diluted_molarity = final_diluted_molarity * qpcr_control_coefficient + formatted_phix_adjusted_diluted_molarity = f"{phix_adjusted_diluted_molarity:.2f} nM" if phix_adjusted_diluted_molarity is not None else "N/A" + + row = html.Tr([ + html.Td(tapestation_sample.file_sample_identifier, style=cell_style), + # html.Td(tapestation_sample.sample_id, style=cell_style), + html.Td(str_ct_value, style=cell_style), + html.Td(phix_string, style=cell_style), + html.Td(final_formatted_molarity, style=cell_style), + html.Td(dilution_factor_str, style=cell_style), + html.Td(formatted_final_diluted_molarity, style=cell_style), + # html.Td(str_average_size, style=cell_style), + html.Td(formatted_phix_adjusted_diluted_molarity, style=cell_style), + html.Td("???", style=cell_style), # Placeholder for final molarity after fragment length adjustment + ]) + table_rows.append(row) + + report_content = [] + + report_content.append(html.H4("Samples with both Tapestation and qPCR Data")) + report_content.append(html.P(f"Total Samples: {len(case_1)}")) + report_content.append(html.Hr()) - # Build report entry for this sample - sample_div = html.Div( + report_content.append( + html.Table( [ - html.H5(f"Tube ID: {tapestation_sample.file_sample_identifier}"), - html.P(f"Sample ID: {tapestation_sample.sample_id}"), - html.Ul( - [ - html.Li(f"Ct Value from qPCR: {str_ct_value}"), - html.Li(f"PhiX control: {phix_string}"), - html.Li(f"Computed Molarity from Standard Curve: {final_formatted_molarity}"), - html.Li(f"qpcr Dilution Factors: {dilution_factor_str}"), - html.Li(f"Dilution-adjusted Molarity: {formatted_final_diluted_molarity}"), - html.Li(f"Tapestation avg. size in range: {str_average_size}"), - html.Li(f"Final Molarity after controlling for fragment length: {'???'}"), # TODO Calculate this - ] - ) - + table_header, + html.Tbody(table_rows) ], - style={"marginBottom": "20px", "padding": "10px", "border": "1px solid #ccc", "borderRadius": "8px"}, + style={ + "width": "100%", + "borderCollapse": "collapse", + "marginTop": "10px", + "border": "1px solid #999" + } ) - report_content.append(sample_div) + ) + + report_content.append(html.Br()) + report_content.append(html.H4("Samples with only Tapestation Data")) + report_content.append(html.P(f"Total Samples: {len(case_2)}")) + report_content.append(html.Hr()) - # TODO : Handle case_2 and case_3 similarly + ### CASE 2 REPORTING: Samples with only Tapestation data + + case2_rows = [] + case2_table_header = html.Thead( + [ + html.Tr([ + html.Th(id="tube-id-header-2", children="Tube ID"), + html.Th(id="sample-id-header-2", children="Sample ID"), + html.Th(id="ts-molarity-header-2", children="TS Molarity"), + html.Th(id="dilution-factors-header-2", children="Dilution Factors"), + html.Th(id="dilution-adjusted-molarity-header-2", children="Dilution-adjusted nM"), + html.Th(id="avg-size-header-2", children="Avg. Size (bp)"), + html.Th(id="phix-adjusted-molarity-header-2", children="PhiX-adjusted Molarity") + ]), + dbc.Tooltip( + "The unique identifier for the sample tube.", + target="tube-id-header-2", + placement="top", + ), + dbc.Tooltip( + "The sample id in B-Fabric.", + target="sample-id-header-2", + placement="top", + ), + dbc.Tooltip( + "The computed molarity from the Tapestation measurements.", + target="ts-molarity-header-2", + placement="top", + ), + dbc.Tooltip( + "The dilution factors applied to this sample in the Tapestation measurements.", + target="dilution-factors-header-2", + placement="top", + ), + dbc.Tooltip( + "The molarity adjusted for dilution factors.", + target="dilution-adjusted-molarity-header-2", + placement="top", + ), + dbc.Tooltip( + "The average fragment size measured by Tapestation.", + target="avg-size-header-2", + placement="top", + ), + dbc.Tooltip( + "The final molarity adjusted using the PhiX control coefficient.", + target="phix-adjusted-molarity-header-2", + placement="top", + ), + ] + ) + + unique_sample_identifiers_case2 = set([entry["tapestation_sample"].file_sample_identifier for entry in case_2]) + + table_measurements = { + k : { + "tube_id": [], + "sample_id": [], + "computed_molarity": [], + "dilution_factor": [], + "dilution_adjusted_nM": [], + "avg_size": [], + "phix_adjusted_molarity": [], + "well_position_id": [] + } for k in unique_sample_identifiers_case2 + } + + for entry in case_2: + + tapestation_sample = entry["tapestation_sample"] + tapestation_measurements = tapestation_sample.get_measurements(app_state) + + tapestation_phix_plate_well = None + + molarity_dilutions = [] + phiX_computed_molarity = None + + for plate_id in datatable_by_plate.keys(): + + plate = app_state.plate_by_id.get(plate_id) + plate_table_data = datatable_by_plate.get(plate_id, []) + + if plate.plate_type == "qPCR": + continue + + tapestation_phix_plate_well = plate.get_well_by_position(phix_values_by_plate.get(plate.plate_id)) + + if tapestation_phix_plate_well is not None: + for row in plate_table_data: + well_pos = row.get("well_position_id") + if well_pos == tapestation_phix_plate_well.well_position_id: + tapestation_phix_dilution_factor = tapestation_phix_plate_well.dilution_factor + phiX_computed_molarity = float(row.get("computed_molarity").split(" ")[0]) + diluted_tapestation_phiX_molarity = phiX_computed_molarity * tapestation_phix_dilution_factor + break + else: + tapestation_phix_dilution_factor = 1 + diluted_tapestation_phiX_molarity = phiX_computed_molarity + + for row in plate_table_data: + + well_pos = row.get("well_position_id") + + # Construct molarity_dilutions list + if tapestation_sample.file_sample_identifier == row.get("sample_identifier"): + + tube_id = tapestation_sample.file_sample_identifier + sample_id = tapestation_sample.sample_id + well_position_id = row.get("well_position_id") + computed_molarity = row.get("computed_molarity") + float_computed_molarity = float(computed_molarity.split(" ")[0]) if computed_molarity is not None else None + dilution_factor = row.get("dilution_factor") + dilution_adjusted_nM = float_computed_molarity * dilution_factor if float_computed_molarity is not None and dilution_factor is not None else None + avg_size = row["Average Size [bp]"] + phix_adjusted_molarity = dilution_adjusted_nM * (10 / diluted_tapestation_phiX_molarity) if diluted_tapestation_phiX_molarity not in (0, None) else dilution_adjusted_nM + + table_measurements[tube_id]["tube_id"].append(tube_id) + table_measurements[tube_id]["sample_id"].append(sample_id) + table_measurements[tube_id]["computed_molarity"].append(float_computed_molarity) + table_measurements[tube_id]["dilution_factor"].append(dilution_factor) + table_measurements[tube_id]["dilution_adjusted_nM"].append(dilution_adjusted_nM) + table_measurements[tube_id]["avg_size"].append(avg_size) + table_measurements[tube_id]["phix_adjusted_molarity"].append(phix_adjusted_molarity) + table_measurements[tube_id]["well_position_id"].append(well_position_id) + + for sample_id, measures in table_measurements.items(): + + dilution_factors = [v for v in measures["dilution_factor"] if v is not None] + computed_molarities = [v for v in measures["computed_molarity"] if v is not None] + + # the dot product of the molarities and dilution factors + dilution_adjusted_nM_values = [molarity * factor for molarity, factor in zip(computed_molarities, dilution_factors)] + avg_dilution_adjusted_nM = np.mean(dilution_adjusted_nM_values) if dilution_adjusted_nM_values else None + + dilution_factor_string = ", ".join([f"{measures['well_position_id'][i]}: {df}" for i, df in enumerate(dilution_factors)]) + + avg_computed_molarity = np.mean(computed_molarities) if computed_molarities else None + avg_size = np.mean([v for v in measures["avg_size"] if v is not None]) if measures["avg_size"] else None + avg_phix_adjusted_molarity = np.mean([v for v in measures["phix_adjusted_molarity"] if v is not None]) if measures["phix_adjusted_molarity"] else None + + row = html.Tr([ + html.Td(measures["tube_id"][0], style=cell_style), + html.Td(measures["sample_id"][0], style=cell_style), + html.Td(f"{avg_computed_molarity:.2f} nM" if avg_computed_molarity is not None else "N/A", style=cell_style), + html.Td(dilution_factor_string, style=cell_style), + html.Td(f"{avg_dilution_adjusted_nM:.2f} nM" if avg_dilution_adjusted_nM is not None else "N/A", style=cell_style), + html.Td(f"{avg_size:.1f} bp" if avg_size is not None else "N/A", style=cell_style), + html.Td(f"{avg_phix_adjusted_molarity:.2f} nM" if avg_phix_adjusted_molarity is not None else "N/A", style=cell_style), + ]) + case2_rows.append(row) + + report_content.append( + html.Table( + [ + case2_table_header, + html.Tbody(case2_rows) + ], + style={ + "width": "100%", + "borderCollapse": "collapse", + "marginTop": "10px", + "border": "1px solid #999" + } + ) + ) return html.Div(report_content) diff --git a/utils/plate_render_utils.py b/utils/plate_render_utils.py index 193494e..707eccd 100644 --- a/utils/plate_render_utils.py +++ b/utils/plate_render_utils.py @@ -141,7 +141,7 @@ def build_computed_molarity(app_state, plate, slope, intercept, phix_correction= sample_specific_molarity_values = [] for plate_well in sample.get_plate_wells(app_state): for measurement in plate_well.measurements: - if measurement.measurement_type.id == "region_molarity" and measurement.value is not None: + if (measurement.measurement_type.id == "region_molarity" or measurement.measurement_type.id == "region_molarity_pmol") and measurement.value is not None: if is_numeric(measurement.value): sample_specific_molarity_values.append(measurement.value / unit_multiplier_map.get(measurement.unit.id, 1)) if sample_specific_molarity_values: From 9fc2e386be473fafb3a3cca679b782ccfb7d6870 Mon Sep 17 00:00:00 2001 From: Griffin Date: Wed, 5 Nov 2025 17:02:27 +0100 Subject: [PATCH 4/5] added dash ag grid to final merge table --- index.py | 754 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 580 insertions(+), 174 deletions(-) diff --git a/index.py b/index.py index ce544df..9c74da6 100644 --- a/index.py +++ b/index.py @@ -444,7 +444,36 @@ def make_plate(plate_name, plate_samples): ), ], className="shadow-sm mt-3 mb-3", - ) + ), + + dbc.Card([ + dbc.CardHeader("Feedback", className="fw-semibold text-muted small py-2"), + dbc.CardBody( + [ + html.P( + [ + "This app is currently under development. ", + "Please report any issues or feedback ", + "using ", + html.A( + "this form", + href="https://forms.gle/ZxYJR5ugJNxa1mAX6", + target="_blank", + className="text-decoration-underline", + ), + ".", + ], + className="small", + ), + ], + className="p-2 small", + ) + # dbc.CardBody([ + # html.P("This app is currently under development,"), + # html.P("please report any issues or feedback here:"), + # html.A("https://forms.gle/ZxYJR5ugJNxa1mAX6"), + # ]) + ]), ] ) @@ -694,75 +723,7 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix print("Case 2 (Only Tapestation):", len(case_2)) print("Case 3 (Only qPCR):", len(case_3)) - table_rows = [] - - table_header = html.Thead( - [ - html.Tr([ - html.Th(id="tube-id-header", children="Tube ID"), - # html.Th(id="sample-id-header", children="Sample ID"), - html.Th(id="ct-value-header", children="Ct"), - html.Th(id="phix-control-header", children="PhiX"), - html.Th(id="computed-molarity-header", children="Computed Molarity"), - html.Th(id="dilution-factors-header", children="Dilution Factors"), - html.Th(id="dilution-adjusted-molarity-header", children="Dilution-adjusted nM"), - # html.Th(id="avg-size-header", children="Avg. Size (bp)"), - html.Th(id="phix-adjusted-molarity-header", children="PhiX-adjusted nM"), - html.Th(id="final-molarity-header", children="Final Molarity"), - ]), - dbc.Tooltip( - "The unique identifier for the sample tube.", - target="tube-id-header", - placement="top", - ), - # dbc.Tooltip( - # "The sample id in B-Fabric.", - # target="sample-id-header", - # placement="top", - # ), - dbc.Tooltip( - "The average of all measurements of this sample's Ct value from the qPCR measurements.", - target="ct-value-header", - placement="top", - ), - dbc.Tooltip( - "The PhiX control well and its computed molarity from the qPCR run.", - target="phix-control-header", - placement="top", - ), - dbc.Tooltip( - "The computed molarity based on the standard curve from the qPCR measurements.", - target="computed-molarity-header", - placement="top", - ), - dbc.Tooltip( - "The dilution factors applied to this sample in the qPCR measurements.", - target="dilution-factors-header", - placement="top", - ), - dbc.Tooltip( - "The molarity adjusted for dilution factors.", - target="dilution-adjusted-molarity-header", - placement="top", - ), - # dbc.Tooltip( - # "The average fragment size measured by Tapestation.", - # target="avg-size-header", - # placement="top", - # ), - dbc.Tooltip( - "The molarity adjusted using the PhiX control.", - target="phix-adjusted-molarity-header", - placement="top", - ), - dbc.Tooltip( - "The final molarity adjusted for fragment length.", - target="final-molarity-header", - placement="top", - ), - - ] - ) + grid_rows = [] cell_style = { "border": "1px solid #ccc", @@ -770,6 +731,20 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix "textAlign": "center" } + column_defs = [ + {"headerName": "Tube ID", "field": "Tube ID", "tooltipField": "Tube ID"}, + {"headerName": "Ct", "field": "Ct", "tooltipValueGetter": {"function": "return 'Average Ct value from qPCR'"}}, + {"headerName": "nM (no adjustment)", "field": "Computed Molarity", "tooltipValueGetter": {"function": "return 'Computed molarity from qPCR measurements without any adjustments'"}}, + {"headerName": "Dilution Factors", "field": "Dilution Factors", "tooltipValueGetter": {"function": "return 'Dilution factors applied to this sample'"}}, + {"headerName": "Dilution-adjusted nM", "field": "Dilution-adjusted nM", "tooltipValueGetter": {"function": "return 'Molarity adjusted for dilution factors'"}}, + {"headerName": "Library Size", "field": "Avg Size [bp]", "tooltipValueGetter": {"function": "return 'Average fragment size measured by Tapestation'"}}, + {"headerName": "Size-adjusted nM", "field": "Size-adjusted nM", "tooltipValueGetter": {"function": "return 'Molarity adjusted for average library size'"}}, + {"headerName": "PhiX + Size-adjusted nM", "field": "Final Molarity", "tooltipValueGetter": {"function": "return 'Final size-adjusted molarity adjusted using PhiX control coefficient'"}}, + {"headerName": "Only PhiX-adjusted nM", "field": "PhiX-adjusted nM", "tooltipValueGetter": {"function": "return 'Molarity adjusted only using PhiX control coefficient'"}}, + {"headerName": "Tapestation nM", "field": "TS Molarity", "tooltipValueGetter": {"function": "return 'Computed molarity from Tapestation measurements'"}}, + ] + + QUANT_BIO_STANDARD_SIZE_BP = 426 # Standard size for entry in case_1: @@ -831,6 +806,10 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix average_size = np.mean([m.value for m in average_size_measurements]) if average_size_measurements else None str_average_size = f"{average_size:.1f} bp" if average_size is not None else "N/A" + print("Average Size:", str_average_size) + print("Average Size (raw):", average_size) + print("Average Size Measurements:", average_size_measurements) + final_molarities = np.mean([elt[2] for elt in molarity_dilutions if elt[2] is not None]) if molarity_dilutions else None final_formatted_molarity = f"{final_molarities:.2f} nM" if final_molarities is not None else "N/A" @@ -838,102 +817,70 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix final_diluted_molarity = np.mean(final_diluted_molarities) if final_diluted_molarities else None formatted_final_diluted_molarity = f"{final_diluted_molarity:.2f} nM" if final_diluted_molarity is not None else "N/A" + size_adjusted_molarity = final_diluted_molarity * QUANT_BIO_STANDARD_SIZE_BP / average_size if final_diluted_molarity is not None and average_size is not None else None + formatted_size_adjusted_molarity = f"{size_adjusted_molarity:.2f} nM" if size_adjusted_molarity is not None else "N/A" + + size_and_phix_adjusted_molarity = size_adjusted_molarity * qpcr_control_coefficient if size_adjusted_molarity is not None else None + formatted_size_and_phix_adjusted_molarity = f"{size_and_phix_adjusted_molarity:.2f} nM" if size_and_phix_adjusted_molarity is not None else "N/A" + phix_string = f"Well {qpcr_phix_plate_well.well_position_id}: {phiX_computed_molarity}" if qpcr_phix_plate_well is not None else "N/A" phix_adjusted_diluted_molarity = final_diluted_molarity * qpcr_control_coefficient formatted_phix_adjusted_diluted_molarity = f"{phix_adjusted_diluted_molarity:.2f} nM" if phix_adjusted_diluted_molarity is not None else "N/A" - row = html.Tr([ - html.Td(tapestation_sample.file_sample_identifier, style=cell_style), - # html.Td(tapestation_sample.sample_id, style=cell_style), - html.Td(str_ct_value, style=cell_style), - html.Td(phix_string, style=cell_style), - html.Td(final_formatted_molarity, style=cell_style), - html.Td(dilution_factor_str, style=cell_style), - html.Td(formatted_final_diluted_molarity, style=cell_style), - # html.Td(str_average_size, style=cell_style), - html.Td(formatted_phix_adjusted_diluted_molarity, style=cell_style), - html.Td("???", style=cell_style), # Placeholder for final molarity after fragment length adjustment - ]) - table_rows.append(row) - report_content = [] + grid_rows.append({ + "Tube ID": tapestation_sample.file_sample_identifier, + "Ct": str_ct_value, + "Computed Molarity": final_formatted_molarity, + "Dilution Factors": dilution_factor_str, + "Dilution-adjusted nM": formatted_final_diluted_molarity, + "Avg Size [bp]": str_average_size, + "Size-adjusted nM": formatted_size_adjusted_molarity, + "Final Molarity": formatted_size_and_phix_adjusted_molarity, + "PhiX-adjusted nM": formatted_phix_adjusted_diluted_molarity, + "TS Molarity": str_tapestation_molarity, + }) - report_content.append(html.H4("Samples with both Tapestation and qPCR Data")) - report_content.append(html.P(f"Total Samples: {len(case_1)}")) - report_content.append(html.Hr()) + report_content = [] report_content.append( - html.Table( - [ - table_header, - html.Tbody(table_rows) - ], - style={ - "width": "100%", - "borderCollapse": "collapse", - "marginTop": "10px", - "border": "1px solid #999" - } - ) + html.Div([ + html.H4("Samples with both Tapestation and qPCR Data"), + html.P(f"Total Samples: {len(case_1)}"), + html.Hr(), + dag.AgGrid( + id="case1-grid", + columnDefs=column_defs, + rowData=grid_rows, + defaultColDef={ + "sortable": True, + "filter": True, + "resizable": True, + "tooltipComponentParams": {"style": {"backgroundColor": "#f8f9fa"}}, + }, + style={"width": "100%"}, + dashGridOptions={ + "domLayout": "autoHeight", + "animateRows": True, + } + ) + ]) ) - report_content.append(html.Br()) - report_content.append(html.H4("Samples with only Tapestation Data")) - report_content.append(html.P(f"Total Samples: {len(case_2)}")) - report_content.append(html.Hr()) - ### CASE 2 REPORTING: Samples with only Tapestation data - case2_rows = [] - case2_table_header = html.Thead( - [ - html.Tr([ - html.Th(id="tube-id-header-2", children="Tube ID"), - html.Th(id="sample-id-header-2", children="Sample ID"), - html.Th(id="ts-molarity-header-2", children="TS Molarity"), - html.Th(id="dilution-factors-header-2", children="Dilution Factors"), - html.Th(id="dilution-adjusted-molarity-header-2", children="Dilution-adjusted nM"), - html.Th(id="avg-size-header-2", children="Avg. Size (bp)"), - html.Th(id="phix-adjusted-molarity-header-2", children="PhiX-adjusted Molarity") - ]), - dbc.Tooltip( - "The unique identifier for the sample tube.", - target="tube-id-header-2", - placement="top", - ), - dbc.Tooltip( - "The sample id in B-Fabric.", - target="sample-id-header-2", - placement="top", - ), - dbc.Tooltip( - "The computed molarity from the Tapestation measurements.", - target="ts-molarity-header-2", - placement="top", - ), - dbc.Tooltip( - "The dilution factors applied to this sample in the Tapestation measurements.", - target="dilution-factors-header-2", - placement="top", - ), - dbc.Tooltip( - "The molarity adjusted for dilution factors.", - target="dilution-adjusted-molarity-header-2", - placement="top", - ), - dbc.Tooltip( - "The average fragment size measured by Tapestation.", - target="avg-size-header-2", - placement="top", - ), - dbc.Tooltip( - "The final molarity adjusted using the PhiX control coefficient.", - target="phix-adjusted-molarity-header-2", - placement="top", - ), - ] - ) + case2_column_defs = [ + {"headerName": "Tube ID", "field": "Tube ID", "tooltipField": "Tube ID"}, + {"headerName": "Sample ID", "field": "Sample ID", "tooltipValueGetter": {"function": "return 'Sample ID in B-Fabric'"}}, + {"headerName": "TS Molarity", "field": "TS Molarity", "tooltipValueGetter": {"function": "return 'Computed molarity from Tapestation measurements'"}}, + {"headerName": "Dilution Factors", "field": "Dilution Factors", "tooltipValueGetter": {"function": "return 'Dilution factors applied to this sample'"}}, + {"headerName": "Dilution-adjusted nM", "field": "Dilution-adjusted nM", "tooltipValueGetter": {"function": "return 'Molarity adjusted for dilution factors'"}}, + {"headerName": "Avg Size [bp]", "field": "Avg. Size (bp)", "tooltipValueGetter": {"function": "return 'Average fragment size measured by Tapestation'"}}, + {"headerName": "PhiX-adjusted Molarity", "field": "PhiX-adjusted Molarity", "tooltipValueGetter": {"function": "return 'Final molarity adjusted using PhiX control coefficient'"}}, + ] + + case2_grid_rows = [] unique_sample_identifiers_case2 = set([entry["tapestation_sample"].file_sample_identifier for entry in case_2]) @@ -1023,35 +970,494 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix avg_size = np.mean([v for v in measures["avg_size"] if v is not None]) if measures["avg_size"] else None avg_phix_adjusted_molarity = np.mean([v for v in measures["phix_adjusted_molarity"] if v is not None]) if measures["phix_adjusted_molarity"] else None - row = html.Tr([ - html.Td(measures["tube_id"][0], style=cell_style), - html.Td(measures["sample_id"][0], style=cell_style), - html.Td(f"{avg_computed_molarity:.2f} nM" if avg_computed_molarity is not None else "N/A", style=cell_style), - html.Td(dilution_factor_string, style=cell_style), - html.Td(f"{avg_dilution_adjusted_nM:.2f} nM" if avg_dilution_adjusted_nM is not None else "N/A", style=cell_style), - html.Td(f"{avg_size:.1f} bp" if avg_size is not None else "N/A", style=cell_style), - html.Td(f"{avg_phix_adjusted_molarity:.2f} nM" if avg_phix_adjusted_molarity is not None else "N/A", style=cell_style), - ]) - case2_rows.append(row) + case2_grid_rows.append({ + "Tube ID": measures["tube_id"][0], + "Sample ID": measures["sample_id"][0], + "TS Molarity": f"{avg_computed_molarity:.2f} nM" if avg_computed_molarity is not None else "N/A", + "Dilution Factors": dilution_factor_string, + "Dilution-adjusted nM": f"{avg_dilution_adjusted_nM:.2f} nM" if avg_dilution_adjusted_nM is not None else "N/A", + "Avg Size [bp]": f"{avg_size:.1f} bp" if avg_size is not None else "N/A", + "PhiX-adjusted Molarity": f"{avg_phix_adjusted_molarity:.2f} nM" if avg_phix_adjusted_molarity is not None else "N/A", + }) + + report_content.append(html.Br()) + report_content.append(html.H4("Samples with only Tapestation Data")) + report_content.append(html.P(f"Total Samples: {len(case_2)}")) + report_content.append(html.Hr()) report_content.append( - html.Table( - [ - case2_table_header, - html.Tbody(case2_rows) - ], - style={ - "width": "100%", - "borderCollapse": "collapse", - "marginTop": "10px", - "border": "1px solid #999" - } + dag.AgGrid( + id="case2-grid", + columnDefs=case2_column_defs, + rowData=case2_grid_rows, + defaultColDef={ + "sortable": True, + "filter": True, + "resizable": True, + "tooltipComponentParams": {"style": {"backgroundColor": "#f8f9fa"}}, + }, + style={"width": "100%"}, + dashGridOptions={ + "domLayout": "autoHeight", + "animateRows": True, + }, ) ) return html.Div(report_content) +# @app.callback( +# Output("review-modal-body", "children"), +# [ +# Input("plate-grid", "rowData"), +# ], +# [ +# State("app_data_state", "data"), +# State({"type":"datatable-plate", "index": ALL}, "rowData"), +# State({"type":"datatable-plate", "index": ALL}, "id"), +# State({"type": "phix-control-dropdown", "index": ALL}, "value"), +# State({"type": "phix-control-dropdown", "index": ALL}, "id") +# ], +# ) +# def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix_values, phix_ids): + +# app_state = AppState.from_json(app_data_json) +# qpcr_plate = next((p for p in app_state.plate_registry if p.plate_type == "qPCR"), None) + +# column_indices = [f'Col{i}' for i in list(range(1, 13))] + +# phix_values_by_plate = plate_mapper(phix_ids, phix_values) +# datatable_by_plate = plate_mapper(all_ui_table_ids, all_ui_tables) +# qpcr_table_data = datatable_by_plate.get(qpcr_plate.plate_id, []) + +# # Now we cover three cases: +# # Case_1: Sample is in row_data, and in all_samples +# # (e.g. is on both the Tapestation and qPCR plates) +# # Case_2: Sample is in all_samples but not in row_data +# # (e.g. it is on the Tapestation plate, but NOT the qPCR) +# # Case_3: Sample is ONLY in the qPCR plate, and not in the Tapestation +# # (e.g. it is assigned as a library or pos control, but no row_data entry) + +# case_1 = [] # Both qPCR AND Tapestation +# case_2 = [] # Only Tapestation +# case_3 = [] # Only qPCR (NOT CLEAR WHAT TO DO IN THIS CASE, AS THERE WILL BE NO SAMPLE ID OR TUBE ID) + +# already_assigned = [] +# for row in row_data: +# row_index = row['Row'] +# for col_index in column_indices: + +# if row[col_index] in ("NEG", "STD", "PhiX"): +# continue + +# well_index = f"{row_index}{col_index.replace('Col','')}" +# well_sample_identifier = row[col_index] + +# tapestation_sample = app_state.sample_by_id.get(well_sample_identifier) +# qpcr_sample = qpcr_plate.get_sample_by_well_position(well_index, app_state) + +# if qpcr_sample is None: +# continue + +# if row[col_index] == "": + +# if qpcr_sample.file_sample_identifier not in already_assigned: +# case_3.append( +# { +# "tapestation_sample": None, +# "qpcr_sample": qpcr_sample, +# } +# ) +# already_assigned.append(qpcr_sample.file_sample_identifier) + +# elif well_sample_identifier not in already_assigned: +# case_1.append( +# { +# "tapestation_sample": tapestation_sample, +# "qpcr_sample": qpcr_sample, +# } +# ) +# already_assigned.append(well_sample_identifier) +# else: +# continue + +# for plate in app_state.plate_registry: +# if plate.plate_type == "qPCR": +# continue +# for sample in plate.get_samples(app_state): +# if sample.file_sample_identifier not in already_assigned and sample.sample_type in ("Library", "Positive Control"): +# case_2.append( +# { +# "tapestation_sample": sample, +# "qpcr_sample": None, +# } +# ) +# already_assigned.append(sample.file_sample_identifier) + + +# print("Case 1 (Both qPCR and Tapestation):", len(case_1)) +# print("Case 2 (Only Tapestation):", len(case_2)) +# print("Case 3 (Only qPCR):", len(case_3)) + +# table_rows = [] + +# table_header = html.Thead( +# [ +# html.Tr([ +# html.Th(id="tube-id-header", children="Tube ID"), +# # html.Th(id="sample-id-header", children="Sample ID"), +# html.Th(id="ct-value-header", children="Ct"), +# html.Th(id="phix-control-header", children="PhiX"), +# html.Th(id="computed-molarity-header", children="Computed Molarity"), +# html.Th(id="dilution-factors-header", children="Dilution Factors"), +# html.Th(id="dilution-adjusted-molarity-header", children="Dilution-adjusted nM"), +# # html.Th(id="avg-size-header", children="Avg. Size (bp)"), +# html.Th(id="phix-adjusted-molarity-header", children="PhiX-adjusted nM"), +# html.Th(id="final-molarity-header", children="Final Molarity"), +# ]), +# dbc.Tooltip( +# "The unique identifier for the sample tube.", +# target="tube-id-header", +# placement="top", +# ), +# # dbc.Tooltip( +# # "The sample id in B-Fabric.", +# # target="sample-id-header", +# # placement="top", +# # ), +# dbc.Tooltip( +# "The average of all measurements of this sample's Ct value from the qPCR measurements.", +# target="ct-value-header", +# placement="top", +# ), +# dbc.Tooltip( +# "The PhiX control well and its computed molarity from the qPCR run.", +# target="phix-control-header", +# placement="top", +# ), +# dbc.Tooltip( +# "The computed molarity based on the standard curve from the qPCR measurements.", +# target="computed-molarity-header", +# placement="top", +# ), +# dbc.Tooltip( +# "The dilution factors applied to this sample in the qPCR measurements.", +# target="dilution-factors-header", +# placement="top", +# ), +# dbc.Tooltip( +# "The molarity adjusted for dilution factors.", +# target="dilution-adjusted-molarity-header", +# placement="top", +# ), +# # dbc.Tooltip( +# # "The average fragment size measured by Tapestation.", +# # target="avg-size-header", +# # placement="top", +# # ), +# dbc.Tooltip( +# "The molarity adjusted using the PhiX control.", +# target="phix-adjusted-molarity-header", +# placement="top", +# ), +# dbc.Tooltip( +# "The final molarity adjusted for fragment length.", +# target="final-molarity-header", +# placement="top", +# ), + +# ] +# ) + +# cell_style = { +# "border": "1px solid #ccc", +# "padding": "6px", +# "textAlign": "center" +# } + + +# for entry in case_1: + +# tapestation_sample = entry["tapestation_sample"] +# qpcr_sample = entry["qpcr_sample"] + +# tapestation_measurements = tapestation_sample.get_measurements(app_state) +# qpcr_measurements = qpcr_sample.get_measurements(app_state) + +# qpcr_phix_plate_well = qpcr_plate.get_well_by_position(phix_values_by_plate.get(qpcr_plate.plate_id)) + +# molarity_dilutions = [] +# phiX_computed_molarity = None + +# for row in qpcr_table_data: + +# well_pos = row.get("well_position_id") + +# if qpcr_phix_plate_well is not None: +# if well_pos == qpcr_phix_plate_well.well_position_id: +# print("Step 1: ", row) +# phiX_computed_molarity = float(row["computed_molarity"].split(" ")[0]) +# print("Step 2: ", phiX_computed_molarity) +# phiX_dilution_factor = float(row["dilution_factor"]) +# print("Step 3: ", phiX_dilution_factor) +# phiX_computed_molarity *= phiX_dilution_factor +# print("Step 4: ", phiX_computed_molarity) +# phiX_computed_molarity = f"{phiX_computed_molarity} nM" +# print("Step 5: ", phiX_computed_molarity) + +# # Construct molarity_dilutions list +# if qpcr_sample.file_sample_identifier == row.get("sample_identifier"): +# dilution_factor = row.get("dilution_factor") +# computed_molarity = row.get("computed_molarity") +# float_computed_molarity = float(computed_molarity.split(" ")[0]) if computed_molarity is not None else None +# molarity_dilutions.append( (row.get("well_position_id"), qpcr_sample.file_sample_identifier, float_computed_molarity, dilution_factor) ) + +# if phiX_computed_molarity is None: +# # raise Exception("ERROR RAISED INTENTIONALLY - Could not find computed molarity for PhiX control sample... major underlying issue to resolve.") +# qpcr_control_coefficient = 1 +# phiX_nM = "N/A" # No control assigned +# else: +# phiX_nM = str(phiX_computed_molarity.split(" ")[0]) +# if float(phiX_nM) == 0: +# qpcr_control_coefficient = 1 +# else: +# qpcr_control_coefficient = 10 / float(phiX_nM) # Assuming target is 10 nM + +# # Here we average the EXISTING Ct values, as some of the triplicates may have been deleted. +# ct_value_measurements = [m for m in qpcr_measurements if m.measurement_type.id == "cq"] +# ct_value = np.mean([m.value for m in ct_value_measurements]) if ct_value_measurements else None +# str_ct_value = f"{ct_value:.2f}" if ct_value is not None else "N/A" + +# qpcr_dilution_factors = molarity_dilutions +# dilution_factor_str = ", ".join([f"{elt[0]}: {elt[3]}" for elt in qpcr_dilution_factors]) + +# # Here we take the average of all avg sizes measured for a single sample ID (there may be multiple). +# average_size_measurements = [m for m in tapestation_measurements if m.measurement_type.id == "avg_size"] +# average_size = np.mean([m.value for m in average_size_measurements]) if average_size_measurements else None +# str_average_size = f"{average_size:.1f} bp" if average_size is not None else "N/A" + +# final_molarities = np.mean([elt[2] for elt in molarity_dilutions if elt[2] is not None]) if molarity_dilutions else None +# final_formatted_molarity = f"{final_molarities:.2f} nM" if final_molarities is not None else "N/A" + +# final_diluted_molarities = [elt[2] * elt[3] for elt in molarity_dilutions if elt[2] is not None and elt[3] is not None] +# final_diluted_molarity = np.mean(final_diluted_molarities) if final_diluted_molarities else None +# formatted_final_diluted_molarity = f"{final_diluted_molarity:.2f} nM" if final_diluted_molarity is not None else "N/A" + +# phix_string = f"Well {qpcr_phix_plate_well.well_position_id}: {phiX_computed_molarity}" if qpcr_phix_plate_well is not None else "N/A" + +# phix_adjusted_diluted_molarity = final_diluted_molarity * qpcr_control_coefficient +# formatted_phix_adjusted_diluted_molarity = f"{phix_adjusted_diluted_molarity:.2f} nM" if phix_adjusted_diluted_molarity is not None else "N/A" + +# row = html.Tr([ +# html.Td(tapestation_sample.file_sample_identifier, style=cell_style), +# # html.Td(tapestation_sample.sample_id, style=cell_style), +# html.Td(str_ct_value, style=cell_style), +# html.Td(phix_string, style=cell_style), +# html.Td(final_formatted_molarity, style=cell_style), +# html.Td(dilution_factor_str, style=cell_style), +# html.Td(formatted_final_diluted_molarity, style=cell_style), +# # html.Td(str_average_size, style=cell_style), +# html.Td(formatted_phix_adjusted_diluted_molarity, style=cell_style), +# html.Td("???", style=cell_style), # Placeholder for final molarity after fragment length adjustment +# ]) +# table_rows.append(row) + +# report_content = [] + +# report_content.append(html.H4("Samples with both Tapestation and qPCR Data")) +# report_content.append(html.P(f"Total Samples: {len(case_1)}")) +# report_content.append(html.Hr()) + +# report_content.append( +# html.Table( +# [ +# table_header, +# html.Tbody(table_rows) +# ], +# style={ +# "width": "100%", +# "borderCollapse": "collapse", +# "marginTop": "10px", +# "border": "1px solid #999" +# } +# ) +# ) + +# report_content.append(html.Br()) +# report_content.append(html.H4("Samples with only Tapestation Data")) +# report_content.append(html.P(f"Total Samples: {len(case_2)}")) +# report_content.append(html.Hr()) + +# ### CASE 2 REPORTING: Samples with only Tapestation data + +# case2_rows = [] +# case2_table_header = html.Thead( +# [ +# html.Tr([ +# html.Th(id="tube-id-header-2", children="Tube ID"), +# html.Th(id="sample-id-header-2", children="Sample ID"), +# html.Th(id="ts-molarity-header-2", children="TS Molarity"), +# html.Th(id="dilution-factors-header-2", children="Dilution Factors"), +# html.Th(id="dilution-adjusted-molarity-header-2", children="Dilution-adjusted nM"), +# html.Th(id="avg-size-header-2", children="Avg. Size (bp)"), +# html.Th(id="phix-adjusted-molarity-header-2", children="PhiX-adjusted Molarity") +# ]), +# dbc.Tooltip( +# "The unique identifier for the sample tube.", +# target="tube-id-header-2", +# placement="top", +# ), +# dbc.Tooltip( +# "The sample id in B-Fabric.", +# target="sample-id-header-2", +# placement="top", +# ), +# dbc.Tooltip( +# "The computed molarity from the Tapestation measurements.", +# target="ts-molarity-header-2", +# placement="top", +# ), +# dbc.Tooltip( +# "The dilution factors applied to this sample in the Tapestation measurements.", +# target="dilution-factors-header-2", +# placement="top", +# ), +# dbc.Tooltip( +# "The molarity adjusted for dilution factors.", +# target="dilution-adjusted-molarity-header-2", +# placement="top", +# ), +# dbc.Tooltip( +# "The average fragment size measured by Tapestation.", +# target="avg-size-header-2", +# placement="top", +# ), +# dbc.Tooltip( +# "The final molarity adjusted using the PhiX control coefficient.", +# target="phix-adjusted-molarity-header-2", +# placement="top", +# ), +# ] +# ) + +# unique_sample_identifiers_case2 = set([entry["tapestation_sample"].file_sample_identifier for entry in case_2]) + +# table_measurements = { +# k : { +# "tube_id": [], +# "sample_id": [], +# "computed_molarity": [], +# "dilution_factor": [], +# "dilution_adjusted_nM": [], +# "avg_size": [], +# "phix_adjusted_molarity": [], +# "well_position_id": [] +# } for k in unique_sample_identifiers_case2 +# } + +# for entry in case_2: + +# tapestation_sample = entry["tapestation_sample"] +# tapestation_measurements = tapestation_sample.get_measurements(app_state) + +# tapestation_phix_plate_well = None + +# molarity_dilutions = [] +# phiX_computed_molarity = None + +# for plate_id in datatable_by_plate.keys(): + +# plate = app_state.plate_by_id.get(plate_id) +# plate_table_data = datatable_by_plate.get(plate_id, []) + +# if plate.plate_type == "qPCR": +# continue + +# tapestation_phix_plate_well = plate.get_well_by_position(phix_values_by_plate.get(plate.plate_id)) + +# if tapestation_phix_plate_well is not None: +# for row in plate_table_data: +# well_pos = row.get("well_position_id") +# if well_pos == tapestation_phix_plate_well.well_position_id: +# tapestation_phix_dilution_factor = tapestation_phix_plate_well.dilution_factor +# phiX_computed_molarity = float(row.get("computed_molarity").split(" ")[0]) +# diluted_tapestation_phiX_molarity = phiX_computed_molarity * tapestation_phix_dilution_factor +# break +# else: +# tapestation_phix_dilution_factor = 1 +# diluted_tapestation_phiX_molarity = phiX_computed_molarity + +# for row in plate_table_data: + +# well_pos = row.get("well_position_id") + +# # Construct molarity_dilutions list +# if tapestation_sample.file_sample_identifier == row.get("sample_identifier"): + +# tube_id = tapestation_sample.file_sample_identifier +# sample_id = tapestation_sample.sample_id +# well_position_id = row.get("well_position_id") +# computed_molarity = row.get("computed_molarity") +# float_computed_molarity = float(computed_molarity.split(" ")[0]) if computed_molarity is not None else None +# dilution_factor = row.get("dilution_factor") +# dilution_adjusted_nM = float_computed_molarity * dilution_factor if float_computed_molarity is not None and dilution_factor is not None else None +# avg_size = row["Average Size [bp]"] +# phix_adjusted_molarity = dilution_adjusted_nM * (10 / diluted_tapestation_phiX_molarity) if diluted_tapestation_phiX_molarity not in (0, None) else dilution_adjusted_nM + +# table_measurements[tube_id]["tube_id"].append(tube_id) +# table_measurements[tube_id]["sample_id"].append(sample_id) +# table_measurements[tube_id]["computed_molarity"].append(float_computed_molarity) +# table_measurements[tube_id]["dilution_factor"].append(dilution_factor) +# table_measurements[tube_id]["dilution_adjusted_nM"].append(dilution_adjusted_nM) +# table_measurements[tube_id]["avg_size"].append(avg_size) +# table_measurements[tube_id]["phix_adjusted_molarity"].append(phix_adjusted_molarity) +# table_measurements[tube_id]["well_position_id"].append(well_position_id) + +# for sample_id, measures in table_measurements.items(): + +# dilution_factors = [v for v in measures["dilution_factor"] if v is not None] +# computed_molarities = [v for v in measures["computed_molarity"] if v is not None] + +# # the dot product of the molarities and dilution factors +# dilution_adjusted_nM_values = [molarity * factor for molarity, factor in zip(computed_molarities, dilution_factors)] +# avg_dilution_adjusted_nM = np.mean(dilution_adjusted_nM_values) if dilution_adjusted_nM_values else None + +# dilution_factor_string = ", ".join([f"{measures['well_position_id'][i]}: {df}" for i, df in enumerate(dilution_factors)]) + +# avg_computed_molarity = np.mean(computed_molarities) if computed_molarities else None +# avg_size = np.mean([v for v in measures["avg_size"] if v is not None]) if measures["avg_size"] else None +# avg_phix_adjusted_molarity = np.mean([v for v in measures["phix_adjusted_molarity"] if v is not None]) if measures["phix_adjusted_molarity"] else None + +# row = html.Tr([ +# html.Td(measures["tube_id"][0], style=cell_style), +# html.Td(measures["sample_id"][0], style=cell_style), +# html.Td(f"{avg_computed_molarity:.2f} nM" if avg_computed_molarity is not None else "N/A", style=cell_style), +# html.Td(dilution_factor_string, style=cell_style), +# html.Td(f"{avg_dilution_adjusted_nM:.2f} nM" if avg_dilution_adjusted_nM is not None else "N/A", style=cell_style), +# html.Td(f"{avg_size:.1f} bp" if avg_size is not None else "N/A", style=cell_style), +# html.Td(f"{avg_phix_adjusted_molarity:.2f} nM" if avg_phix_adjusted_molarity is not None else "N/A", style=cell_style), +# ]) +# case2_rows.append(row) + +# report_content.append( +# html.Table( +# [ +# case2_table_header, +# html.Tbody(case2_rows) +# ], +# style={ +# "width": "100%", +# "borderCollapse": "collapse", +# "marginTop": "10px", +# "border": "1px solid #999" +# } +# ) +# ) + +# return html.Div(report_content) + + @app.callback( Output("used-samples-store", "data"), Output("plate-grid", "rowData"), From 66582590c043b07b3d5051093e945bfb847b4174 Mon Sep 17 00:00:00 2001 From: Griffin Date: Tue, 11 Nov 2025 14:17:55 +0100 Subject: [PATCH 5/5] added automatic generation of html reports --- index.py | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 5 deletions(-) diff --git a/index.py b/index.py index 9c74da6..d902c47 100644 --- a/index.py +++ b/index.py @@ -15,7 +15,7 @@ from dash.exceptions import PreventUpdate import re import copy - +# import pdfkit from utils.flagging_conditions import collect_flagging_from_all @@ -520,7 +520,13 @@ def render_main_content(): children=[ dbc.ModalHeader(dbc.ModalTitle("Review Analysis Results")), dbc.ModalBody(id="review-modal-body", children=review_body), - dbc.ModalFooter(dbc.Button("Finalize and Submit (this action is final!)", id="submit-review", className="ms-auto", n_clicks=0)), + dbc.ModalFooter( + [ + dbc.Button("Finalize and Submit (this action is final!)", id="submit-review", className="ms-auto", n_clicks=0), + dbc.Button("Download Html Report", id="download-report-button", className="ms-2", n_clicks=0), + dcc.Download(id="download-report-data"), + ], + ), ] ), dcc.Loading(alerts), @@ -543,6 +549,7 @@ def render_main_content(): dcc.Store(id="app_data_state", storage_type="memory"), dcc.Store(id="flagging-conditions-storage", storage_type="memory"), dcc.Store(id="flagging-conditions-storage-prune-missing-tabs", storage_type="memory"), + dcc.Store(id="report-content-store", storage_type="memory"), ], style={"margin-top": "0px", "min-height": "40vh"} ) @@ -631,7 +638,134 @@ def toggle_review_modal(open_clicks, close_clicks, is_open): @app.callback( - Output("review-modal-body", "children"), + Output("download-report-data", "data"), + Input("download-report-button", "n_clicks"), + State("report-content-store", "data"), + State("case1-grid", "rowData"), + State("case1-grid", "columnDefs"), + State("case2-grid", "rowData"), + State("case2-grid", "columnDefs"), + State("session-details", "children"), + prevent_initial_call=True, +) +def download_modal_report( + n_clicks, + report_data, + case1_rowData, + case1_columnDefs, + case2_rowData, + case2_columnDefs, + session_details +): + if not report_data: + raise PreventUpdate + + # --- Parse username and job ID from session_details --- + def extract_field(label): + """Find the text value following the Label: entry.""" + for item in session_details: + if not isinstance(item, dict): + continue + props = item.get("props", {}) + children = props.get("children", []) + # Flatten nested children lists + flat = [] + def flatten(c): + if isinstance(c, list): + for x in c: flatten(x) + else: + flat.append(c) + flatten(children) + + for i, child in enumerate(flat): + if isinstance(child, dict) and child.get("props", {}).get("children") == f"{label}: ": + # Next element in the flattened list should be the value + if i + 1 < len(flat): + return flat[i + 1] + return "unknown" + + username = extract_field("User Name") + job_id = extract_field("Job ID") + + # --- Convert session details to a readable HTML block --- + def render_session_html(session_children): + def render_child(child): + if isinstance(child, dict): + t = child.get("type", "") + props = child.get("props", {}) + inner = render_child(props.get("children")) + if t == "B": + return f"{inner}" + elif t == "Br": + return "
" + else: + return inner + elif isinstance(child, list): + return "".join(render_child(c) for c in child) + elif isinstance(child, str): + return child + return "" + return render_child(session_children) + + session_html = render_session_html(session_details) + + # --- Table builder for AgGrid data --- + def aggrid_to_html_table(rowData, columnDefs): + headers = [col["headerName"] for col in columnDefs] + html_rows = ["" + "".join(f"{h}" for h in headers) + ""] + for row in rowData: + html_rows.append( + "" + "".join(f"{row.get(col['field'], '')}" for col in columnDefs) + "" + ) + return ( + "" + + "".join(html_rows) + + "
" + ) + + # --- Build report HTML --- + html_parts = [ + "

Session Details

", + f"
{session_html}
", + "

Samples with both Tapestation and qPCR Data

", + f"

Total Samples: {len(case1_rowData) if case1_rowData else 0}

", + aggrid_to_html_table(case1_rowData, case1_columnDefs), + "
", + "

Samples with only Tapestation Data

", + f"

Total Samples: {len(case2_rowData) if case2_rowData else 0}

", + aggrid_to_html_table(case2_rowData, case2_columnDefs), + ] + + html_string = f""" + + + + qPCR / Tapestation Report + + + + {''.join(html_parts)} + + + """ + + # --- Construct filename with username + job ID --- + safe_user = str(username).replace(" ", "_") + safe_job = str(job_id).replace(" ", "_") + filename = f"qPCR_Tapestation_Report_{safe_user}_Job{safe_job}.html" + + return dcc.send_string(html_string, filename=filename) + +@app.callback( + [ + Output("review-modal-body", "children"), + Output("report-content-store", "data"), + ], [ Input("plate-grid", "rowData"), ], @@ -793,6 +927,11 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix else: qpcr_control_coefficient = 10 / float(phiX_nM) # Assuming target is 10 nM + # Tapestation molarity + tapestation_computed_molarities = [elt[2] for elt in molarity_dilutions if elt[2] is not None] + tapestation_molarity = np.mean(tapestation_computed_molarities) if tapestation_computed_molarities else None + str_tapestation_molarity = f"{tapestation_molarity:.2f} nM" if tapestation_molarity is not None else "N/A" + # Here we average the EXISTING Ct values, as some of the triplicates may have been deleted. ct_value_measurements = [m for m in qpcr_measurements if m.measurement_type.id == "cq"] ct_value = np.mean([m.value for m in ct_value_measurements]) if ct_value_measurements else None @@ -876,7 +1015,7 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix {"headerName": "TS Molarity", "field": "TS Molarity", "tooltipValueGetter": {"function": "return 'Computed molarity from Tapestation measurements'"}}, {"headerName": "Dilution Factors", "field": "Dilution Factors", "tooltipValueGetter": {"function": "return 'Dilution factors applied to this sample'"}}, {"headerName": "Dilution-adjusted nM", "field": "Dilution-adjusted nM", "tooltipValueGetter": {"function": "return 'Molarity adjusted for dilution factors'"}}, - {"headerName": "Avg Size [bp]", "field": "Avg. Size (bp)", "tooltipValueGetter": {"function": "return 'Average fragment size measured by Tapestation'"}}, + {"headerName": "Avg Size [bp]", "field": "Avg Size [bp]", "tooltipValueGetter": {"function": "return 'Average fragment size measured by Tapestation'"}}, {"headerName": "PhiX-adjusted Molarity", "field": "PhiX-adjusted Molarity", "tooltipValueGetter": {"function": "return 'Final molarity adjusted using PhiX control coefficient'"}}, ] @@ -1004,7 +1143,7 @@ def create_report(row_data, app_data_json, all_ui_tables, all_ui_table_ids, phix ) ) - return html.Div(report_content) + return html.Div(report_content), html.Div(report_content).to_plotly_json() # @app.callback(