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 5c364d6..2551f38 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:
@@ -423,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"),
+ # ])
+ ]),
]
)
@@ -470,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),
@@ -493,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"}
)
@@ -568,21 +625,156 @@ 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"),
],
[
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):
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)
@@ -614,9 +806,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(
{
@@ -655,39 +844,744 @@ def create_report(row_data, app_data_json):
print("Case 2 (Only Tapestation):", len(case_2))
print("Case 3 (Only qPCR):", len(case_3))
- # Build report content here
- report_content = []
+ grid_rows = []
- 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())
+ cell_style = {
+ "border": "1px solid #ccc",
+ "padding": "6px",
+ "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:
+
tapestation_sample = entry["tapestation_sample"]
qpcr_sample = entry["qpcr_sample"]
- # Build report entry for this sample
- sample_div = html.Div(
- [
- html.H5(f"Sample ID: "),
- html.P(f"Tube ID: "),
- html.Ul(
- [
- html.Li(f"Ct Value from qPCR: "),
- 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"phiX adjusted molarity: "),
- html.Li(f"Final Molarity after controlling for fragment length: "),
- ]
- )
+ 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
+
+ # 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
+ 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"
+
+ 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"
+
+ 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"
+
+ 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"
+
+
+ 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 = []
+
+ report_content.append(
+ 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,
+ }
+ )
+ ])
+ )
+
+ ### CASE 2 REPORTING: Samples with only Tapestation data
+
+ 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])
+
+ 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:
- ],
- style={"marginBottom": "20px", "padding": "10px", "border": "1px solid #ccc", "borderRadius": "8px"},
+ 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
+
+ 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(
+ 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,
+ },
)
- report_content.append(sample_div)
+ )
+
+ return html.Div(report_content), html.Div(report_content).to_plotly_json()
+
+
+# @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"
+# }
+
- return html.Div(report_content)
+# 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(
@@ -1037,26 +1931,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
#####################################################
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: