From 01090e12e8e4bc11693eb7ee4ae4e80f2711abcd Mon Sep 17 00:00:00 2001 From: Griffin Date: Fri, 28 Nov 2025 17:06:17 +0100 Subject: [PATCH 1/2] full refactor + bfabric integration --- app.py | 4 + backend/__init__.py | 2 + backend/app_state.py | 55 + backend/models.py | 628 +++++ callbacks/__init__.py | 13 + callbacks/alert_callbacks.py | 68 + callbacks/authentication_callbacks.py | 18 + callbacks/backend_manager.py | 283 +++ callbacks/clientside_callbacks.py | 24 + callbacks/flagging_conditions_callbacks.py | 70 + callbacks/frontend_manager.py | 383 +++ callbacks/generic_callbacks.py | 138 ++ callbacks/modal_callbacks.py | 86 + callbacks/phiX_callbacks.py | 80 + callbacks/plate_merge_callbacks.py | 615 +++++ callbacks/qpcr_callbacks.py | 62 + callbacks/report_callbacks.py | 123 + callbacks/submission_callbacks.py | 314 +++ components/__init__.py | 3 + components/documentation_content.py | 186 ++ components/generic_components.py | 34 + components/main_components.py | 260 +++ index.py | 2442 +------------------- make_reference.py | 2 +- utils/api_call_utils.py | 4 +- utils/flagging_conditions.py | 3 - utils/plate_render_utils.py | 4 +- utils/plates_callback_utils.py | 257 +- utils/warning_utils.py | 2 +- 29 files changed, 3721 insertions(+), 2442 deletions(-) create mode 100644 app.py create mode 100644 backend/__init__.py create mode 100644 backend/app_state.py create mode 100644 backend/models.py create mode 100644 callbacks/__init__.py create mode 100644 callbacks/alert_callbacks.py create mode 100644 callbacks/authentication_callbacks.py create mode 100644 callbacks/backend_manager.py create mode 100644 callbacks/clientside_callbacks.py create mode 100644 callbacks/flagging_conditions_callbacks.py create mode 100644 callbacks/frontend_manager.py create mode 100644 callbacks/generic_callbacks.py create mode 100644 callbacks/modal_callbacks.py create mode 100644 callbacks/phiX_callbacks.py create mode 100644 callbacks/plate_merge_callbacks.py create mode 100644 callbacks/qpcr_callbacks.py create mode 100644 callbacks/report_callbacks.py create mode 100644 callbacks/submission_callbacks.py create mode 100644 components/__init__.py create mode 100644 components/documentation_content.py create mode 100644 components/generic_components.py create mode 100644 components/main_components.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..5111475 --- /dev/null +++ b/app.py @@ -0,0 +1,4 @@ +import bfabric_web_apps + +# Initialize the Dash app +app = bfabric_web_apps.create_app() \ No newline at end of file diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..da33dea --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,2 @@ +from .app_state import AppState +from .models import Plate, PlateWell, Sample, Measurement, AppSpecificFile \ No newline at end of file diff --git a/backend/app_state.py b/backend/app_state.py new file mode 100644 index 0000000..cfcd6f4 --- /dev/null +++ b/backend/app_state.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel, Field +from typing import List, Dict, Any +from .models import Plate, PlateWell, Sample + + +class AppState(BaseModel): + + plate_registry: List[Plate] = Field(default_factory=list) + sample_registry: List[Sample] = Field(default_factory=list) + uploaded_files_base64: Dict[str, str] = Field(default_factory=dict) # dict key is filename/id, value is base64 encoded content + + + # to and from json need to respect the model structure + def to_json(self) -> str: + return self.model_dump_json() + + + @classmethod + def from_json(cls, json_str: str) -> "AppState": + return cls.model_validate_json(json_str) + + + @property + def plate_well_by_id(self) -> Dict[str, Dict[str, PlateWell]]: + out = {} + for plate in self.plate_registry: + out[plate.plate_id] = {well.well_position_id: well for well in plate.plate_wells} + return out + + + @property + def plate_by_id(self) -> Dict[str, Plate]: + return {plate.plate_id: plate for plate in self.plate_registry} + + + @property + def sample_by_id(self) -> Dict[str, Sample]: + return {sample.file_sample_identifier: sample for sample in self.sample_registry} + + + @property + def all_samples_by_bfabric_id(self) -> Dict[str, List[Sample]]: + return {sample.sample_id: sample for sample in self.sample_registry if sample.sample_id} + + + @property + def platewells_by_sample_id(self) -> Dict[str, List[PlateWell]]: + result = {} + for plate in self.plate_registry: + for well in plate.plate_wells: + sid = well.get_sample(self).file_sample_identifier + if sid: + result.setdefault(sid, []).append(well) + return result + diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..1bd2cd1 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,628 @@ +from pydantic import BaseModel, Field, PrivateAttr +from typing import List, Dict, Any, Optional, Set +import pandas as pd +import math +import io +import re +import numpy as np +from enum import Enum + +# Signature columns for file type detection +SIGNATURE_COLUMNS = { + "QPCR": { + "Sample Name", "Gene Name", "Cq", "Concentration", "Position", "Call" + }, + "HSD1000": { + "FileName", "WellId", "Sample Description", "From [bp]", "To [bp]", "Conc. [pg/µl]" + }, + "D1000": { + "FileName", "WellId", "Sample Description", "From [bp]", "To [bp]", "Conc. [ng/µl]" + }, + "D5000": { + "FileName", "WellId", "Sample Description", "From [bp]", "To [bp]", "Conc. [ng/µl]" + }, +} + +###################################################### +# SampleType Enum +###################################################### +class SampleType(str, Enum): + NEGATIVE_CONTROL = "Negative Control" + POSITIVE_CONTROL = "Positive Control" + STANDARD = "Standard" + PHIX = "PhiX" + LIBRARY = "Library" + + + +##################################################### +# Sample Class +##################################################### + +class Sample(BaseModel): + sample_id: Optional[str] = "" + sample_name: Optional[str] = "" + container: Optional[str] = "" + file_sample_identifier: Optional[str] = "" # File-specific column for identifying the sample + sample_type: Optional[SampleType] = SampleType.LIBRARY + computed_molarity: Optional[float | str] = " - " # Computed molarity for qPCR samples + computed_standard_error: Optional[float | str] = " - " # Standard error for qPCR samples + + 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 +##################################################### + +class MeasurementType(BaseModel): + id: str + name: str + + +##################################################### +# Unit Classes +##################################################### + +class Unit(BaseModel): + id: str + symbol: str + description: Optional[str] = "" + + +##################################################### +# Measurement Class +##################################################### + +class Measurement(BaseModel): + measurement_type: MeasurementType + value: Any + unit: Unit + plate_well_id: str + sample_id: str + plate_id: str + + def get_plate_well(self, app_state): + return app_state.plate_well_by_id.get(self.plate_id).get(self.plate_well_id) + + + def get_plate(self, app_state): + return app_state.plate_by_id.get(self.plate_id) + + +###################################################### +# PlateWell Class +###################################################### + +# App Specific Plate Well +class AppSpecificPlateWell(BaseModel): + + region_comment: Optional[str] = "" # Tapestation + automatic_region: Optional[str] = "" # Tapestation + replicate_group: Optional[str] = "" # qPCR + # sample_description: Optional[str] = "" # Tube ID + well_note: Optional[str] = "" # sample found or not + flagging_conditions: Optional[str] = "" + dilution_factor: Optional[float] = 1 + + + + +class PlateWell(AppSpecificPlateWell): + + well_position_id: str # e.g., "A1", "B2", etc. + plate_id: str + measurements: List[Measurement] = Field(default_factory=list) + sample_identifier: str + + def get_plate(self, app_state): + return app_state.plate_by_id.get(self.plate_id) + + + def get_sample(self, app_state): + # print("Searching for sample_identifier:", self.sample_identifier, " in ", app_state.sample_by_id.keys()) + return app_state.sample_by_id.get(self.sample_identifier) + + + @property + def file_sample_identifier(self) -> Optional[str]: + """Returns the file-specific identifier of the sample in this well.""" + # return self.sample.file_sample_identifier + return self.sample_identifier + + @file_sample_identifier.setter + def file_sample_identifier(self, value): + # self.sample.file_sample_identifier = value + self.sample_identifier = value + + +####################################################### +# Plate Class +####################################################### + +# Clarify! Plate needs to reference the AppSpecificPlateWell and not the base PlateWell + +class Plate(BaseModel): + plate_id: str + plate_type: str + plate_wells: List["PlateWell"] = Field(default_factory=list) # Use string for forward ref + file_name: Optional[str] = "" # Tapestation only + + @property + def wells_by_position(self) -> Dict[str, "PlateWell"]: + # Fast access to wells by position (e.g., "A1", "B2", etc.) + return {well.well_position_id: well for well in self.plate_wells} + + def get_well_by_position(self, position: str) -> Optional["PlateWell"]: + # Convenience lookup + return self.wells_by_position.get(position) + + def to_json(self) -> str: + return self.model_dump_json() + + def get_samples(self, app_state) -> List[Sample]: + sample_ids = {well.sample_identifier for well in self.plate_wells} + return [app_state.sample_by_id[sid] for sid in sample_ids if sid in app_state.sample_by_id] + + def get_sample_by_well_position(self, position: str, app_state): + well = self.get_well_by_position(position) + if well: + return well.get_sample(app_state) + return None + + @classmethod + def from_json(cls, json_str: str) -> "Plate": + return cls.model_validate_json(json_str) + + + @property + def all_measurements(self) -> List[Measurement]: + return [m for well in self.plate_wells for m in well.measurements] + + + def dataset(self, app_state, tidy: bool = False) -> pd.DataFrame: + all_measurement_types = sorted( + set( + m.measurement_type.name + for well in self.plate_wells + for m in well.measurements + ) + ) + well_attrs = set() + sample_attrs = set() + for well in self.plate_wells: + well_attrs.update(well.model_dump().keys()) + sample_attrs.update(well.get_sample(app_state).model_dump().keys()) + well_attrs.discard('measurements') + records = [] + for well in self.plate_wells: + well_data = well.model_dump() + # sample_data = well_data.pop('sample', {}) + sample_data = well.get_sample(app_state).model_dump() + measurements = well_data.pop('measurements', []) + row = { + "plate_id": self.plate_id, + "plate_type": self.plate_type, + } + for k in well_attrs: + row[k] = well_data.get(k, "") + for k in sample_attrs: + row[k] = sample_data.get(k, "") + # Add measurements as columns (using .name for column header) + for mt in all_measurement_types: + value = "" + for m in measurements: + m_type_name = m['measurement_type']['name'] if isinstance(m, dict) else m.measurement_type.name + if m_type_name == mt: + value = m['value'] if isinstance(m, dict) else m.value + break + row[mt] = value + records.append(row) + return pd.DataFrame(records) + + + @classmethod + def from_file(cls, input_file: "File", app_state) -> "Plate": + """ + Create a Plate instance from a file. + The file should be a CSV or Excel file with the appropriate structure. + """ + df = input_file.to_dataframe() + + # 1) Detect how many plates are defined in the file, by referencing the number of unique values in the pre-defined plate column. + # 2) For each plate, assign plate-specific attributes (generically) + # 3) For each unique sample, create a Sample instance and add it to the samples list. + # 4) For each row associated with the sample: + # 5) For each measurement column, create a Measurement instance and add it to the measurements list. + # 6) For each PlateWell column, create a PlateWell instance and add it to the plate_wells list. + + + # 1) Detect unique plates if plate_colname is set + if input_file.plate_colname: # TODO: + plate_ids = df[input_file.plate_colname].unique() + unique_plates = [ + Plate( + plate_id=plate_id, + plate_type=input_file.file_type_name, + file_name=input_file.file_name + ) + for plate_id in plate_ids + ] + else: + unique_plates = [ + Plate( + plate_id=input_file.file_name, + plate_type=input_file.file_type_name, + file_name=input_file.file_name + ) + ] + + for plate in unique_plates: + plate_rows = df + if input_file.plate_colname: + plate_rows = df[df[input_file.plate_colname] == plate.plate_id] + + for sample_id, df_slice in plate_rows.groupby(input_file.file_sample_identifier): + sample = Sample( + sample_id="", + sample_name="", + container="", + file_sample_identifier=str(sample_id), + ) + + app_state.sample_registry.append(sample) + + # print("created sample", sample) + for well_position, well_df in df_slice.groupby(input_file.well_position_id): + well_kwargs = {} + if input_file.plate_specific_columns: + for colname, (attr_name, default_value) in input_file.plate_specific_columns.items(): + val = well_df[colname].iloc[0] if colname in well_df.columns else default_value + well_kwargs[attr_name] = str(val) + + plate_well = PlateWell( + well_position_id=well_position, + plate_id=plate.plate_id, + sample_identifier=sample.file_sample_identifier, + measurements=[], + **well_kwargs + ) + + if input_file.measurement_map: + for measurement_colname, (measurement_type, unit) in input_file.measurement_map.items(): + if measurement_colname in well_df.columns: + measurement = Measurement( + measurement_type=measurement_type, + value=safe_float(well_df[measurement_colname].iloc[0]), + unit=unit, + plate_well_id=well_position, + sample_id=sample.sample_id, + plate_id = plate.plate_id + ) + plate_well.measurements.append(measurement) + + plate.plate_wells.append(plate_well) + + plates = unique_plates[0] if len(unique_plates) == 1 else unique_plates + app_state.plate_registry += unique_plates + + # print("Created plate:", plates.plate_id) + # print("Plate wells:", len(plates.plate_wells)) + # # print("Plate samples:", len(plates.samples)) + + # print(app_state.sample_registry) + + return app_state, plates + +######################################################## +# PlateType Class | Not Part of the app_data_state +######################################################## + +class PlateType(BaseModel): + id: int + name: str + + +class PlateTypes: + + class D1000: + name = "D1000" + identifier = "Tapestaion" + + + +######################################################## +# Helper Functions +######################################################## + +def safe_float(val): + try: + out = float(str(val).replace(",", ".")) + if math.isnan(out): + return None + return out + except (ValueError, TypeError): + pass + + # Handle numpy types here + if isinstance(val, np.generic): + return val.item() + + # Handle obvious nans + if val in [float("nan"), "nan", "NaN"]: + return None + return val + + + + + +###################################################### +# App Specific Part +###################################################### + + +class PlateFile: + """Base class for all plate import file helpers. Not part of ontology.""" + def __init__( + self, + file_name: str, + raw_data: str, + file_type_name, + plate_colname, + sample_id_column, + file_sample_identifier, + sample_container_column, + well_position_id, + plate_specific_columns, + measurement_map, + ): + self.file_name = file_name + self.raw_data = raw_data + self.file_type_name = file_type_name # D1000, D5000 etc + self.plate_colname = plate_colname #If there are multibe plates in one file + self.sample_id_column = sample_id_column # Sample Id + self.file_sample_identifier = file_sample_identifier # File-specific column for identifying the sample + self.sample_container_column = sample_container_column # sample container + self.well_position_id = well_position_id #Well position + self.plate_specific_columns = plate_specific_columns # For colums which are plate speific + self.measurement_map = measurement_map # Storage for the plate measuremnts + + + def to_dataframe(self): + name = self.file_name.lower() + if name.endswith(('.xlsx', '.xls')): + raise ValueError(f"Excel files are not supported: {name}. Please upload a CSV or TXT file.") + elif name.endswith(('.csv', '.txt')): + df = pd.read_csv(io.StringIO(self.raw_data), sep=None, engine="python", encoding="utf-8-sig") + df.columns = [c.replace('\ufeff', '').strip() for c in df.columns] + return df + else: + raise ValueError(f"Unsupported file type: {name}") + + + @classmethod + def create_from_raw_data(cls, name: str, raw_data: str): + """ + Factory method to create a PlateFile instance based on the file name. + It detects the file type and initializes the appropriate subclass. + """ + raise NotImplementedError("This should be implemented in an app-specific subclass.") + + +# Needs to be more general! +class AppSpecificFile(PlateFile): + + def __init__(*args, **kwargs): + raise NotImplementedError("Use the factory method create_from_raw_data to instantiate AppSpecificFile.") + + + @classmethod + def create_from_app_state(cls, app_state): + raise NotImplementedError("This has not yet been implemented.") + + + @classmethod + def create_from_raw_data(cls, name: str, raw_data: str): + """ + Factory method to create an AppSpecificFile instance based on the file name. + It detects the file type and initializes the appropriate subclass. + """ + + file_type = cls.detect_file_type_from_content(raw_data, name) + + match file_type: + case "D1000" | "D5000": + return PlateFile( + file_name=name, + raw_data=raw_data, + file_type_name="D1000" if file_type == "D1000" else "D5000", + plate_colname=None, # If all rows in file are for one plate; otherwise, set to "FileName" if multiple plates per file + sample_id_column="", # Will be populated later + file_sample_identifier="Sample Description", # Tube ID for identifing samples + sample_container_column="", # Will be populated later + well_position_id="WellId", + plate_specific_columns={ + "Region Comment": ("region_comment", ""), + "Automatic Region": ("automatic_region", ""), + }, + measurement_map={ + "From [bp]": ( + MeasurementType(id="from_bp", name="From [bp]"), + Unit(id="bp", symbol="bp") + ), + "To [bp]": ( + MeasurementType(id="to_bp", name="To [bp]"), + Unit(id="bp", symbol="bp") + ), + "Average Size [bp]": ( + MeasurementType(id="avg_size", name="Average Size [bp]"), + Unit(id="bp", symbol="bp") + ), + "Conc. [ng/µl]": ( + MeasurementType(id="conc_ngul", name="Conc. [ng/µl]"), + Unit(id="ng/µl", symbol="ng/µl") + ), + "Region Molarity [nmol/l]": ( + MeasurementType(id="region_molarity", name="Region Molarity [nmol/l]"), + Unit(id="nmol/l", symbol="nmol/l") + ), + "% of Total": ( + MeasurementType(id="pct_total", name="% of Total"), + Unit(id="pct", symbol="%") + ), + "Area": ( + MeasurementType(id="area", name="Area"), + Unit(id="rfu", symbol="RFU") + ), + } + ) + case "HSD1000": + return PlateFile( + file_name=name, + raw_data=raw_data, + file_type_name="HSD1000", + plate_colname=None, + sample_id_column="", + file_sample_identifier="Sample Description", + sample_container_column="", + well_position_id="WellId", + plate_specific_columns={ + "Region Comment": ("region_comment", ""), + }, + measurement_map={ + "From [bp]": ( + MeasurementType(id="from_bp", name="From [bp]"), + Unit(id="bp", symbol="bp") + ), + "To [bp]": ( + MeasurementType(id="to_bp", name="To [bp]"), + Unit(id="bp", symbol="bp") + ), + "Average Size [bp]": ( + MeasurementType(id="avg_size", name="Average Size [bp]"), + Unit(id="bp", symbol="bp") + ), + "Conc. [pg/µl]": ( + MeasurementType(id="conc_pg_ul", name="Conc. [pg/µl]"), + Unit(id="pg/µl", symbol="pg/µl") + ), + "Region Molarity [pmol/l]": ( + MeasurementType(id="region_molarity_pmol", name="Region Molarity [pmol/l]"), + Unit(id="pmol/l", symbol="pmol/l") + ), + "% of Total": ( + MeasurementType(id="pct_total", name="% of Total"), + Unit(id="pct", symbol="%") + ), + } + ) + case "QPCR": + return PlateFile( + file_name=name, + raw_data=raw_data, + file_type_name="qPCR", + plate_colname=None, # One plate per file; if you want to split by another column, set it here + sample_id_column="", # Not used or set to a column name if needed later + file_sample_identifier="Sample Name", # This identifies each unique sample in the qPCR file + sample_container_column="", # Not used + well_position_id="Position", # The column that contains well positions (A1, B2, etc) + plate_specific_columns={ + "Replicate Group": ("replicate_group", ""), # Maps 'Replicate Group' column to the 'replicate_group' PlateWell attr + }, + measurement_map={ + "Color": ( + MeasurementType(id="color", name="Color"), + Unit(id="", symbol="") + ), + "Cq": ( + MeasurementType(id="cq", name="Cq"), + Unit(id="", symbol="") + ), + "Concentration": ( + MeasurementType(id="conc", name="Concentration"), + Unit(id="mol/l", symbol="mol/l") + ), + "Call": ( + MeasurementType(id="call", name="Call"), + Unit(id="", symbol="") + ), + "Standard": ( + MeasurementType(id="standard", name="Standard"), + Unit(id="", symbol="") + ), + "Cq Mean": ( + MeasurementType(id="cq_mean", name="Cq Mean"), + Unit(id="", symbol="") + ), + "Cq Error": ( + MeasurementType(id="cq_error", name="Cq Error"), + Unit(id="", symbol="") + ), + "Concentration Mean": ( + MeasurementType(id="conc_mean", name="Concentration Mean"), + Unit(id="mol/l", symbol="mol/l") + ), + "Concentration Error": ( + MeasurementType(id="conc_err", name="Concentration Error"), + Unit(id="", symbol="") + ), + "Slope": ( + MeasurementType(id="slope", name="Slope"), + Unit(id="", symbol="") + ), + "EPF": ( + MeasurementType(id="epf", name="EPF"), + Unit(id="", symbol="") + ), + } + ) + + + case _: + raise ValueError(f"Unknown file type for file: {name}. Please check the file name or implement a new file type handler.") + + @classmethod + def detect_file_type_from_content(cls, raw_data: str, file_name: str = "") -> str: + try: + df = pd.read_csv(io.StringIO(raw_data), sep=None, engine="python", encoding="utf-8-sig") + df.columns = [c.replace('\ufeff', '').strip() for c in df.columns] + file_columns = set(df.columns) + except Exception as e: + raise ValueError( + "File is not in the correct format (CSV/TXT with a valid delimiter). " + "Please upload only qPCR or Tapestation files (D1000, D5000, HSD1000). " + f"Details: {str(e)}" + ) from e + + if SIGNATURE_COLUMNS["QPCR"] <= file_columns: + return "QPCR" + if SIGNATURE_COLUMNS["HSD1000"] <= file_columns: + return "HSD1000" + if SIGNATURE_COLUMNS["D1000"] <= file_columns: + clean_name = file_name.lower() + if "d5000" in clean_name: + return "D5000" + elif "d1000" in clean_name: + return "D1000" + else: + return "D1000" + raise ValueError( + "File is not in the correct format. Please upload only qPCR or Tapestation files (D1000, D5000, HSD1000)." + ) + + + + def to_dataframe(self): + return self.file_type.to_dataframe() diff --git a/callbacks/__init__.py b/callbacks/__init__.py new file mode 100644 index 0000000..d5f7999 --- /dev/null +++ b/callbacks/__init__.py @@ -0,0 +1,13 @@ +from .alert_callbacks import * +from .authentication_callbacks import * +from .backend_manager import * +from .clientside_callbacks import * +from .flagging_conditions_callbacks import * +from .frontend_manager import * +from .generic_callbacks import * +from .modal_callbacks import * +from .phiX_callbacks import * +from .plate_merge_callbacks import * +from .qpcr_callbacks import * +from .report_callbacks import * +from .submission_callbacks import * \ No newline at end of file diff --git a/callbacks/alert_callbacks.py b/callbacks/alert_callbacks.py new file mode 100644 index 0000000..e4f9441 --- /dev/null +++ b/callbacks/alert_callbacks.py @@ -0,0 +1,68 @@ +from app import app + +from dash import Input, Output, State, ALL, html + +@app.callback( + [ + Output("alert-flagging-conditions", "is_open", allow_duplicate=True), + Output("alert-flagging-conditions", "children", allow_duplicate=True), + ], + Input({"type": "datatable-plate", "index": ALL}, "rowData"), + State({"type": "datatable-plate", "index": ALL}, "id"), + prevent_initial_call=True, +) +def update_flagging_alert(all_rowdata_per_plate, all_table_ids): + """ + Build the global 'flagging conditions' alert based on the CURRENT contents + of every plate table. + + all_rowdata_per_plate: list[ list[ rowDict, ... ] ], one entry per plate table + all_table_ids: list[ {"type":"datatable-plate","index":}, ... ], + parallel to all_rowdata_per_plate + """ + + messages = [] + + # pair up each table's id with its rows + for table_id_obj, rows in zip(all_table_ids, all_rowdata_per_plate): + plate_id = str(table_id_obj.get("index")) + + if not rows: + continue + + # Find wells that currently violate flagging_conditions on this plate + violating_wells = [] + for r in rows: + # r is each row dict (the records from df.to_dict("records")) + flag_text = str(r.get("flagging_conditions") or "").strip() + sample_type = r.get("sample_type") + well_id = r.get("well_position_id") + + if ( + flag_text != "" and + sample_type in ("Library", "Positive Control") + ): + violating_wells.append(str(well_id)) + + if violating_wells: + # unique & sorted for pretty output + wells_str = ", ".join(sorted(set(violating_wells))) + messages.append( + f"Plate {plate_id}: wells {wells_str} exceed the defined flagging thresholds." + ) + + # no violations --> hide the alert + if not messages: + return False, "" + + # build a nice bullet list + items = [html.Li(msg) for msg in messages] + items.append( + html.Li( + "To resolve these warnings: Adjust 'Flagging Condition' thresholds or inspect those wells.", + style={"listStyleType": "none", "marginTop": "10px"} + ) + ) + alert_children = html.Ul(items) + + return True, alert_children \ No newline at end of file diff --git a/callbacks/authentication_callbacks.py b/callbacks/authentication_callbacks.py new file mode 100644 index 0000000..f89827e --- /dev/null +++ b/callbacks/authentication_callbacks.py @@ -0,0 +1,18 @@ +from app import app +from components import no_auth, render_main_content + +from dash import Input, Output, State, html + + +@app.callback( + [ + Output('upload-files', 'disabled'), + Output('main-content', 'children'), + ], + [Input('token_data', 'data')], + [State('entity', 'data')] +) +def update_ui(token_data, entity_data): + if not token_data or not entity_data: + return True, html.Div(no_auth, style={"padding": "40px"}) + return False, render_main_content() \ No newline at end of file diff --git a/callbacks/backend_manager.py b/callbacks/backend_manager.py new file mode 100644 index 0000000..0ecca43 --- /dev/null +++ b/callbacks/backend_manager.py @@ -0,0 +1,283 @@ +from app import app +from dash import Input, Output, State, ctx, no_update, ALL, html +from backend import AppState + +from utils.plates_callback_utils import ( + update_app_state_from_ui, + handle_plate_delete, + handle_plate_upload, + plate_mapper +) +from utils.warning_utils import collect_missing_sample_warnings, build_warning_msg +import copy +import dash + +# Mega Funciton +@app.callback( + [ + Output("alert-fade-too-many-qpcr", "is_open"), + Output("alert-fade-too-many-qpcr", "children"), + Output("app_data_state", "data"), + Output("alert-tapestation-sampleid", "is_open"), + Output("alert-tapestation-sampleid", "children"), + Output("alert-find-libraries-success", "is_open"), + Output("alert-find-libraries-success", "children"), + Output("alert-find-libraries-fail", "is_open"), + Output("alert-find-libraries-fail", "children"), + Output("upload-files", "contents"), + Output("alert-fade-duplicate-file", "is_open"), + Output("alert-fade-duplicate-file", "children"), + Output("table-deletion-trigger", "children"), + Output("update-rows-trigger-div", "children") + ], + [ + Input("upload-files", "contents"), + Input({"type": "confirm-delete-plate", "index": ALL}, "n_clicks"), + Input({"type": "datatable-plate", "index": ALL}, "cellValueChanged"), + Input({"type": "confirm-delete-rows", "index": ALL}, "n_clicks"), + Input({"type": "Negative Control", "index": ALL}, "n_clicks"), + Input({"type": "Positive Control", "index": ALL}, "n_clicks"), + Input({"type": "Standard", "index": ALL}, "n_clicks"), + Input({"type": "PhiX", "index": ALL}, "n_clicks"), + Input({"type": "Library", "index": ALL}, "n_clicks"), + Input("flagging-conditions-storage", "data"), + ], + [ + State({"type": "plate-graph", "index": ALL}, "selectedData"), + State("upload-files", "filename"), + State("app_data_state", "data"), + State({"type": "datatable-plate", "index": ALL}, "selectedRows"), + State({"type": "datatable-plate", "index": ALL}, "rowData"), + State("dynamic-tabs", "active_tab"), + + # We add these states to be able to map plate_ids to the correct elements, with 100% certainty # + # NOTE: (Note to Marc in case I forget to tell you in person) -- Dash does not guaruntee order of ALL states/inputs, so we always need to remember this and be careful! + # Use MATCH instead of ALL wherever possible. + State({"type": "confirm-delete-plate", "index": ALL}, "id"), + State({"type": "datatable-plate", "index": ALL}, "id"), + State({"type": "confirm-delete-rows", "index": ALL}, "id"), + State({"type":"plate-graph", "index":ALL}, "id"), + State("view-mode-switch", "value"), + ], + prevent_initial_call=True, +) +def manipulate_plates( + contents, + delete_plate_buttons, + cell_edits, + delete_rows_buttons, + negative_control, + positive_control, + standard, + phix, + library, + flagging_conditions_storage, + plate_graph_selections, + filenames, + app_data_state_json, + selected_rows_all, + all_ui_tables, + active_tab, + delete_button_ids, + datatable_ids, + delete_rows_button_ids, + plate_graph_ids, + table_view, +): + """ + Core callback for all plate management actions (upload, delete, edit, sample lookup). + + Handles: + 1. **Upload:** Adds new qPCR or Tapestation plates from user-uploaded files (CSV/TXT). + 2. **Delete:** Removes a selected plate (by plate_id). + 3. **Edit:** Syncs manual sample ID edits from the DataTable UI into the Plate objects (single source of truth). + 4. **Sample Lookup:** Uses the 'Find Libraries' button to query sample metadata from B-Fabric based on current sample IDs. + + Marc I am sorry I have completely changed all of this!! I'll need to re-write this docstring later. + """ + + POSSIBLE_TRIGGERS = { + "confirm-delete-plate", + "confirm-delete-rows", + "datatable-plate", + "Negative Control", + "Positive Control", + "Standard", + "PhiX", + "Library", + } + + + triggered = ctx.triggered_id + app_state = AppState.from_json(app_data_state_json) if app_data_state_json else AppState() + + print("manipulate_plates triggered by:", triggered) + + # --- Handle Plate Upload --- + if triggered == "upload-files": + # --- Deduplication logic --- + duplicates = [] + files_to_upload = [] + filenames_to_upload = [] + for content, fname in zip(contents, filenames): + if fname in app_state.uploaded_files_base64: + duplicates.append(fname) + else: + files_to_upload.append(content) + filenames_to_upload.append(fname) + + if duplicates: + warnings = collect_missing_sample_warnings(app_state) + show_warning = bool(warnings) + warning_msg = build_warning_msg(warnings) + + if len(duplicates) == 1: + duplicate_msg = f"Upload failed: The file {duplicates[0]} has already been uploaded." + else: + duplicate_msg = html.Div([ + html.Strong("Warning: Upload not successful!"), + html.Br(), + "The following file(s) have already been uploaded:", + html.Ul([html.Li(fname) for fname in duplicates]) + ]) + + return ( + False, "", app_state.to_json(), show_warning, warning_msg, + False, "", False, "", None, + True, duplicate_msg, no_update, no_update + ) + + + temp_state = copy.deepcopy(app_state) # Work on a copy first; only commit if all uploads succeed! + if files_to_upload: + for content, fname in zip(files_to_upload, filenames_to_upload): + upload_status, temp_state, failure_msg = handle_plate_upload(content, fname, temp_state) + if upload_status: + temp_state.uploaded_files_base64[fname] = content + else: + warnings = collect_missing_sample_warnings(app_state) + show_warning = bool(warnings) + warning_msg = build_warning_msg(warnings) + return ( + True, failure_msg, + app_state.to_json(), show_warning, warning_msg, + False, "", False, "", None, False, "", no_update, no_update + ) + + app_state = temp_state + warnings = collect_missing_sample_warnings(app_state) + show_warning = bool(warnings) + warning_msg = build_warning_msg(warnings) + + return ( + False, "", app_state.to_json(), show_warning, warning_msg, + False, "", False, "", None, False, "", [], no_update + ) + + elif triggered == "flagging-conditions-storage": + update_app_state_from_ui(app_state, all_ui_tables) + warnings = collect_missing_sample_warnings(app_state) + show_warning = bool(warnings) + warning_msg = build_warning_msg(warnings) + return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", no_update, [] + + + # --- Check that we're not getting any unexpected triggers... this shouldn't happen, but better to raise an error rather than hide it --- + elif type(triggered) != dash._utils.AttributeDict or triggered.get("type") not in POSSIBLE_TRIGGERS: + raise Exception("ERROR RAISED INTENTIONALLY - No relevant trigger found.") + + # --- Handle the case where our input is a dictionary (meaning it's one of: delete plate, delete rows, or datatable edit) --- + else: + + # We only use plate id for indexing + trigger_type = triggered.get("type") + plate_id = triggered.get("index") + + if plate_id != active_tab: + raise Exception("ERROR RAISED INTENTIONALLY - Triggered plate does not match active tab... major underlying issue to resolve.") + + # Here we create maps of plate_id -> object to garuntee correct mapping of actions to plates + # Just commenting out the ones we don't need for now. + # delete_button_plate_map = plate_mapper(delete_button_ids, delete_plate_buttons) + # cell_value_changed_map = plate_mapper(datatable_ids, cell_edits) + # confirm_delete_rows_map = plate_mapper(delete_rows_button_ids, delete_rows_buttons) + selected_rows_map = plate_mapper(datatable_ids, selected_rows_all) + selected_data_map = plate_mapper(plate_graph_ids, plate_graph_selections) + # row_data_map = plate_mapper(datatable_ids, all_ui_tables) + + # --- Handle Plate Deletion --- + if trigger_type == "confirm-delete-plate": + handle_plate_delete(plate_id, app_state) + warnings = collect_missing_sample_warnings(app_state) + show_warning = bool(warnings) + warning_msg = build_warning_msg(warnings) + return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", [], no_update + + + # --- Handle Row Deletion (confirmed via modal) --- + elif trigger_type == "confirm-delete-rows": + + if table_view: + selected_rows = selected_rows_map.get(plate_id) + plate = app_state.plate_by_id.get(plate_id) + if selected_rows: + to_remove = {str(r.get("well_position_id")) for r in selected_rows} + plate.plate_wells = [ + w for w in plate.plate_wells + if str(w.well_position_id) not in to_remove + ] + + else: # Plate View + selected_data = selected_data_map.get(plate_id) + plate = app_state.plate_by_id.get(plate_id) + if not selected_data: + selected_wells = [] + else: + selected_wells = [p['customdata'][0] for p in selected_data['points']] + if selected_wells: + to_remove = set(selected_wells) + plate.plate_wells = [ + w for w in plate.plate_wells + if str(w.well_position_id) not in to_remove + ] + + warnings = collect_missing_sample_warnings(app_state) + show_warning = bool(warnings) + warning_msg = build_warning_msg(warnings) + + return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", no_update, [] + + + # --- Handle Manual Edits --- + elif trigger_type == "datatable-plate": + update_app_state_from_ui(app_state, all_ui_tables) + warnings = collect_missing_sample_warnings(app_state) + show_warning = bool(warnings) + warning_msg = build_warning_msg(warnings) + return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", no_update, [] + + + elif trigger_type in {"Negative Control", "Positive Control", "Standard", "PhiX", "Library"}: + sample_type = trigger_type + plate = app_state.plate_by_id.get(plate_id) + if not plate: + raise Exception("ERROR RAISED INTENTIONALLY - No plate found for given plate_id... major underlying issue to resolve.") + + if table_view: + raise Exception("ERROR RAISED INTENTIONALLY - Sample type assignment from buttons only exist in Plate View!!! (how could this even happen???)... major underlying issue to resolve.") + else: # Plate View + selected_data = selected_data_map.get(plate_id) + if selected_data and "points" in selected_data: + selected_well_ids = {p['customdata'][0] for p in selected_data['points']} + for well in plate.plate_wells: + if str(well.well_position_id) in selected_well_ids: + well.get_sample(app_state).sample_type = sample_type + + + + warnings = collect_missing_sample_warnings(app_state) + show_warning = bool(warnings) + warning_msg = build_warning_msg(warnings) + return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", no_update, [] + + raise Exception("ERROR RAISED INTENTIONALLY - Reached end of manipulate_plates without handling trigger... major underlying issue to resolve.") diff --git a/callbacks/clientside_callbacks.py b/callbacks/clientside_callbacks.py new file mode 100644 index 0000000..02fe3e8 --- /dev/null +++ b/callbacks/clientside_callbacks.py @@ -0,0 +1,24 @@ +from index import app + +from dash import Input, Output + +############################################################################## +##### JS INJECTION CALLBACK +############################################################################## + +app.clientside_callback( + """ + function(code) { + try { + eval(code); + console.log("Client-side JS injected and executed"); + return "Executed"; + } catch(err) { + console.error("ERROR: JS eval failed:", err); + return "Failed"; + } + } + """, + Output("register-dnd-js", "data"), + Input("register-dnd-js", "data"), +) diff --git a/callbacks/flagging_conditions_callbacks.py b/callbacks/flagging_conditions_callbacks.py new file mode 100644 index 0000000..f511c19 --- /dev/null +++ b/callbacks/flagging_conditions_callbacks.py @@ -0,0 +1,70 @@ + +from app import app + +from dash import Input, Output, State, ALL + + +@app.callback( + Output({"type": "adjust-flagging-btn", "index": ALL}, "style"), + Input("dynamic-tabs", "active_tab"), + State({"type": "adjust-flagging-btn", "index": ALL}, "id"), +) +def show_only_active_flagging_button(active_tab, all_ids): + return [ + {} if btn_id["index"] == active_tab else {"display": "none"} + for btn_id in all_ids + ] + + + +@app.callback( + Output("flagging-conditions-storage", "data"), + [ + Input({"type": "input-stddev", "index": ALL}, "value"), + Input({"type": "input-min-nm", "index": ALL}, "value"), + Input({"type": "input-max-nm", "index": ALL}, "value"), + Input({"type": "input-min-frag", "index": ALL}, "value"), + Input({"type": "input-max-frag", "index": ALL}, "value"), + Input({"type": "input-min-mol", "index": ALL}, "value"), + Input({"type": "input-max-mol", "index": ALL}, "value"), + Input({"type": "phix-control-dropdown", "index": ALL}, "value"), + ], + [ + State({"type": "input-stddev", "index": ALL}, "id"), + State({"type": "input-min-nm", "index": ALL}, "id"), + State({"type": "input-max-nm", "index": ALL}, "id"), + State({"type": "input-min-frag", "index": ALL}, "id"), + State({"type": "input-max-frag", "index": ALL}, "id"), + State({"type": "input-min-mol", "index": ALL}, "id"), + State({"type": "input-max-mol", "index": ALL}, "id"), + State({"type": "phix-control-dropdown", "index": ALL}, "id"), + ], + prevent_initial_call=True +) + +def collect_flagging_conditions(stddev_vals, min_nm_vals, max_nm_vals, + min_frag_vals, max_frag_vals, min_mol_vals, max_mol_vals, + phix_vals, stddev_ids, min_nm_ids, max_nm_ids, + min_frag_ids, max_frag_ids, min_mol_ids, max_mol_ids, phix_ids ): + + def add(values, ids, key, out): + for value, id_dict in zip(values, ids): + pid = id_dict.get("index") + if pid not in out: + out[pid] = {} + out[pid][key] = value + + print("Collecting flagging conditions...", phix_vals) + + flagging_conditions = {} + add(stddev_vals, stddev_ids, "stddev", flagging_conditions) + add(min_nm_vals, min_nm_ids, "min_nm", flagging_conditions) + add(max_nm_vals, max_nm_ids, "max_nm", flagging_conditions) + add(min_frag_vals, min_frag_ids, "min_frag", flagging_conditions) + add(max_frag_vals, max_frag_ids, "max_frag", flagging_conditions) + add(min_mol_vals, min_mol_ids, "min_mol", flagging_conditions) + add(max_mol_vals, max_mol_ids, "max_mol", flagging_conditions) + add(phix_vals, phix_ids, "phix_control_well_position", flagging_conditions) + + print("Collected flagging conditions:", flagging_conditions) + return flagging_conditions diff --git a/callbacks/frontend_manager.py b/callbacks/frontend_manager.py new file mode 100644 index 0000000..0713fa8 --- /dev/null +++ b/callbacks/frontend_manager.py @@ -0,0 +1,383 @@ +from app import app + +from dash import Input, Output, State, no_update, html, ALL, MATCH +from dash.exceptions import PreventUpdate +import numpy as np +import copy +import pandas as pd +from backend import AppState +from utils.plate_render_utils import ( + make_tab, + get_tab_id, + DEFAULT_BORDER, + WARN_BORDER, + ERROR_BORDER, + BORDER_PX, + MARKER_PX, + SAMPLE_TYPE_COLORS, + UNASSIGNED_COLOR, + EMPTY_FILL, + EMPTY_BORDER, + EMPTY_BORDER_PX, + make_tab, + get_tab_id, + build_computed_molarity +) + +from utils.flagging_conditions import collect_flagging_from_all + +@app.callback( + [ + Output("dynamic-tabs", "children", allow_duplicate=True), + Output("dynamic-tabs", "active_tab", allow_duplicate=True), + Output("table-creation-trigger", "children"), + Output("flagging-conditions-storage-prune-missing-tabs", "data"), + ], + # TRIGGERS AFTER EVERY "UPLOAD OR DELETE" OPERATION + # Upload / Delete from mega function --> table-deletion-trigger --> table-creation-trigger + Input("table-deletion-trigger", "children"), + State("dynamic-tabs", "children"), + State("dynamic-tabs", "active_tab"), + State("flagging-conditions-storage", "data"), + prevent_initial_call=True +) +def prune_missing_tabs(app_data_json, children, active_tabs, flagging_conditions_storage): + + children = children or [] + app = AppState.from_json(app_data_json) if app_data_json else None + desired_ids = [str(p.plate_id) for p in (app.plate_registry if app else [])] + + # Current tabs (serialized dicts). Keep order to avoid remount of survivors. + new_children = [t for t in children if get_tab_id(t) in desired_ids] + + new_children, next_active = [], None + + return new_children, next_active, "", flagging_conditions_storage + + +@app.callback( + [ + Output("dynamic-tabs", "children"), + Output("dynamic-tabs", "active_tab"), + Output("adjust-flagging-conditon-buttons", "children"), + Output("phix-control-dropdowns", "children"), + Output("alert-flagging-conditions", "is_open", allow_duplicate=True), + Output("alert-flagging-conditions", "children", allow_duplicate=True), + ], + # TRIGGERS AFTER EVERY "UPLOAD OR DELETE" OPERATION + # Upload / Delete from mega function --> table-deletion-trigger --> table-creation-trigger + Input("table-creation-trigger", "children"), + [ + State("app_data_state", "data"), + State("dynamic-tabs", "children"), + State("dynamic-tabs", "active_tab"), + State("flagging-conditions-storage-prune-missing-tabs", "data") + ], + prevent_initial_call=True +) +def append_new_tabs(_trigger, app_data_json, existing_tabs, active_tab, flagging_conditions): + # RETURNS an EXACT COPY of existing tabs, plus any new ones, generated + # the same as before from the function in plate_render_utils. + + #print("app_state for tab append before:", app_data_json) + + existing_tabs = existing_tabs or [] + app = AppState.from_json(app_data_json) + desired_ids = [str(p.plate_id) for p in app.plate_registry] + + # Get the tab ids for existing tabs + mounted_ids = [get_tab_id(t) for t in existing_tabs] + + # append only missing + to_add = [pid for pid in desired_ids if pid not in mounted_ids] + if not to_add: + # nothing to add; keep everything untouched + return existing_tabs, (active_tab if active_tab in desired_ids else (desired_ids[-1] if desired_ids else None)), no_update, no_update, no_update, no_update + + + plates_by_id = {str(p.plate_id): p for p in app.plate_registry} + new_tabs = [] + flag_buttons = [] + phix_dropdowns = [] + aggregated_flagging_warnings = [] + for pid in to_add: + if flagging_conditions: + flagging_per_plate = flagging_conditions.get(pid) + else: + flagging_per_plate = {} + + tab, flag_button, phix_dropdown, plate_flagging_warnings = make_tab( + plates_by_id[pid], + app, + current_flagging=flagging_per_plate + ) + + new_tabs.append(tab) + flag_buttons.append(flag_button) + phix_dropdowns.append(phix_dropdown) + + if plate_flagging_warnings: + aggregated_flagging_warnings.extend(plate_flagging_warnings) + + + + # preserve order of existing; append new at the end (no reordering of mounted) + next_children = existing_tabs + new_tabs + + # Compute the valid ids actually mounted now + def _id_of(tab_like): + return getattr(tab_like, "tab_id", None) or ((tab_like or {}).get("props", {}) or {}).get("tab_id") + + mounted_now = [_id_of(t) for t in next_children] + + # Robust active tab choice: + if active_tab in mounted_now: + next_active = active_tab + elif to_add: + # Focus the most recently added tab (last appended) — feels right after an upload + next_active = to_add[-1] + elif mounted_now: + # Nothing new; pick last existing (or pick first if you prefer) + next_active = mounted_now[-1] + else: + next_active = None + + # Before returning, set visibility style on each button + for btn in flag_buttons: + if btn.id["index"] == next_active: + # Make the active one visible + btn.style = {} + else: + # Hide all others + btn.style = {"display": "none"} + + #print("app_state for tab append after:", app_data_json) + + if aggregated_flagging_warnings: + # turn warnings into
  • list items + list_items = [html.Li(msg) for msg in aggregated_flagging_warnings] + + # add the instruction at the bottom, same styling pattern as build_warning_msg + list_items.append( + html.Li( + "To resolve these warnings: Check wells highlighted in red. Adjust your flagging thresholds (Adjust Flagging Condition) or review those wells manually.", + style={"listStyleType": "none", "marginTop": "10px"} + ) + ) + + alert_is_open = True + alert_children = html.Ul(list_items) + else: + alert_is_open = False + alert_children = "" + + return ( + next_children, + next_active, + flag_buttons, + phix_dropdowns, + alert_is_open, + alert_children, + ) + + + +# This function toggles visibility between the plate graph and the data table based on the switch state. +# e.g. if you change to plate view, table view is hidden and vice versa. +@app.callback( + Output({"type": "tab-table-wrap", "index": ALL}, "style"), + Output({"type": "tab-plate-wrap", "index": ALL}, "style"), + Output({"type": "plate-sample-type-dropdown", "index": ALL}, "style"), + Output({"type": "plate-sample-type-dropdown", "index": ALL}, "disabled"), + Input("view-mode-switch", "value"), + State({"type": "tab-table-wrap", "index": ALL}, "style"), + State({"type": "tab-plate-wrap", "index": ALL}, "style"), +) +def toggle_table_vs_plate(table_view, table_styles, plate_styles): + # When no tabs exist yet, Dash passes empty lists; handle gracefully. + n = len(table_styles or []) + if n == 0: + return [], [], [], [] + # preserve other style keys; only flip display + def show(s): return {**(s or {}), "display": "block"} + def hide(s): return {**(s or {}), "display": "none"} + if table_view: + return [show(s) for s in table_styles], [hide(s) for s in plate_styles], [hide(s) for s in plate_styles], [True]*n + else: + return [hide(s) for s in table_styles], [show(s) for s in plate_styles], [show(s) for s in plate_styles], [False]*n + + +# This function syncs the plate graph's visual state (well fills, borders, selection overlay) based on DataTable edits and selection. +@app.callback( + Output({"type": "plate-graph", "index": MATCH}, "figure"), + Input({"type": "datatable-plate", "index": MATCH}, "cellValueChanged"), + Input({"type": "datatable-plate", "index": MATCH}, "rowData"), + Input({"type": "datatable-plate", "index": MATCH}, "virtualRowData"), + State({"type": "plate-graph", "index": MATCH}, "figure"), + prevent_initial_call=True, +) +def sync_plate_style(cell_evt, row_data, virtual_row_data, fig): + if not fig or "data" not in fig or not fig["data"]: + return no_update + fig = copy.deepcopy(fig) + + # presence from rowData + df_presence = pd.DataFrame(row_data or []) + wells_present = set(df_presence.get("well_position_id", pd.Series([], dtype=str)).astype(str)) + + # flags & sample_type (prefer virtual for in-grid edits) + rows_flags = virtual_row_data if virtual_row_data is not None else row_data + df_flags = pd.DataFrame(rows_flags or []) + + # inline override so just-edited value is visible immediately + if isinstance(cell_evt, dict) and cell_evt: + col_id = str(cell_evt.get("colId", "")).strip() + row_d = cell_evt.get("data", {}) or {} + wpid = str(row_d.get("well_position_id", "")).strip() + if wpid: + if df_flags.empty: + df_flags = pd.DataFrame([{"well_position_id": wpid}]) + mask = (df_flags.get("well_position_id", pd.Series([], dtype=str)).astype(str) == wpid) + if not mask.any(): + df_flags = pd.concat([df_flags, pd.DataFrame([{"well_position_id": wpid}])], ignore_index=True) + mask = (df_flags["well_position_id"].astype(str) == wpid) + if col_id == "sample_type": + df_flags.loc[mask, "sample_type"] = cell_evt.get("newValue", row_d.get("sample_type")) + if col_id == "well_note": + df_flags.loc[mask, "well_note"] = cell_evt.get("newValue", row_d.get("well_note")) + + # error/warn sets + if {"well_note", "well_position_id"}.issubset(df_flags.columns): + error_wells = set(df_flags.loc[ + df_flags["well_note"].isin(["No sample found","Multiple samples found"]), + "well_position_id" + ].astype(str)) + warn_wells = set(df_flags.loc[ + df_flags["well_note"]=="Unable to Update", + "well_position_id" + ].astype(str)) + else: + error_wells, warn_wells = set(), set() + + # Add flagging_condition-based error wells + flag_error_wells = set() + if {"flagging_conditions", "well_position_id"}.issubset(df_flags.columns): + flag_error_wells = set( + df_flags.loc[ + df_flags["flagging_conditions"].notna() & (df_flags["flagging_conditions"].astype(str) != ""), + "well_position_id" + ].astype(str) + ) + error_wells |= flag_error_wells + + # sample types + sample_type_map = {} + if {"sample_type","well_position_id"}.issubset(df_flags.columns): + for w, t in zip(df_flags["well_position_id"].astype(str), df_flags["sample_type"]): + if pd.isna(t): continue + sample_type_map[w] = (str(t).strip() or None) + + base = fig["data"][0] + wells = [cd[0] for cd in base["customdata"]] + + sizes, fills, line_colors, line_widths, customdata = [], [], [], [], [] + for w in wells: + present = (w in wells_present) + if not present: + sizes.append(MARKER_PX) + fills.append(EMPTY_FILL) + line_colors.append(EMPTY_BORDER) + line_widths.append(EMPTY_BORDER_PX) + customdata.append([w, "Empty"]) + continue + + st = (sample_type_map.get(w) or "Unassigned").strip() + fill_color = SAMPLE_TYPE_COLORS.get(st, UNASSIGNED_COLOR) if st != "Unassigned" else UNASSIGNED_COLOR + sizes.append(MARKER_PX) + fills.append(fill_color) + customdata.append([w, st]) + + if w in error_wells and st in ("Library", "Positive Control"): + line_colors.append(ERROR_BORDER); line_widths.append(3) + elif w in warn_wells and st in ("Library", "Positive Control"): + line_colors.append(WARN_BORDER); line_widths.append(3) + else: + line_colors.append(DEFAULT_BORDER); line_widths.append(BORDER_PX) + + base["marker"]["size"] = sizes + base["marker"]["color"] = fills + base["marker"]["line"]["color"] = line_colors + base["marker"]["line"]["width"] = line_widths + base["customdata"] = customdata + + # keep selection rendering enabled + base["selected"] = dict(marker=dict(size=MARKER_PX + 6, opacity=1)) + base["unselected"] = dict(marker=dict(opacity=0.45)) + + # ensure only one trace + fig["data"] = [base] + return fig + + + + + +@app.callback( + Output({"type": "datatable-plate", "index": MATCH}, "rowData"), + Input("update-rows-trigger-div", "children"), # broadcast trigger + Input("app_data_state", "data"), # source of truth + Input({"type": "pcr-curve-store", "index": MATCH}, "data"), # for qPCR plates, to show efficiency etc + # Input({"type": "phix-control-dropdown", "index": MATCH}, "value"), # to highlight the selected phix control value + State({"type": "datatable-plate", "index": MATCH}, "id"), # gives us the plate_id (via id["index"]) + State("flagging-conditions-storage", "data"), # to apply flagging conditions + prevent_initial_call=True +) +# def update_rows_callback(_, app_data_state_json, pcr_store, phix_control_value, table_id): +def update_rows_callback(_, app_data_state_json, pcr_store, table_id, current_flagging): + + if not app_data_state_json or not table_id: + raise PreventUpdate + + plate_id = str(table_id["index"]) + app_state = AppState.from_json(app_data_state_json) + + # find the plate by invariant key + plate = app_state.plate_by_id.get(plate_id) + + slope, intercept = pcr_store.get("slope"), pcr_store.get("intercept") if pcr_store else (None, None) + + phix_correction_factor = 1.0 + + if not plate: + return None + + is_qpcr = (plate.plate_type == "qPCR") + + df = plate.dataset(app_state, tidy=False).replace(['None', 'none', 'NaN', 'nan', ''], np.nan).infer_objects(copy=False) + df = build_computed_molarity(app_state, plate, slope, intercept, phix_correction_factor) + per_plate_flagging = current_flagging.get(plate_id, {}) + df = collect_flagging_from_all(df, per_plate_flagging) + + if "__error__" not in df.columns: + df["__error__"] = False + + if is_qpcr: + df = df.dropna(axis=1, how="all") + else: + # drop all-NaN columns except 'sample_id' + drop_cols = [c for c in df.columns if c not in {"sample_id","flagging_conditions","well_note"} and df[c].isna().all()] + if drop_cols: + df = df.drop(columns=drop_cols) + + if "well_note" in df.columns: + df["__error__"] = df["well_note"].isin(["No sample found", "Multiple samples found"]) + df["__warning__"] = df["well_note"] == "Unable to Update" + else: + df["__error__"] = False + df["__warning__"] = False + + if "flagging_conditions" in df.columns: + has_flag_issue = df["flagging_conditions"].astype(str).str.strip() != "" + df["__error__"] = df["__error__"] | has_flag_issue + + return df.to_dict("records") diff --git a/callbacks/generic_callbacks.py b/callbacks/generic_callbacks.py new file mode 100644 index 0000000..d0be2b8 --- /dev/null +++ b/callbacks/generic_callbacks.py @@ -0,0 +1,138 @@ +""" +Reusable UI Components and Core Functions for Bfabric App +========================================================= + +IMPORTANT: DO NOT MODIFY THIS FILE +---------------------------------- +This module contains essential components and core functionalities for the Bfabric web app. +It is a foundational part of the system, and any changes to this file may disrupt functionality +or compatibility with other modules. + +This module includes: + - Initialization of the Dash app instance. + - Callbacks for authentication and URL parameter processing. + - Callback for bug report handling. + - Content to display for authenticated and unauthenticated users. +""" + +# Required Imports +# ---------------- +from dash import Input, Output, State +from bfabric_web_apps import ( + create_app, + process_url_and_token, + submit_bug_report, + populate_workunit_details, + get_redis_queue_layout +) +from dash import html +from app import app + +# Application Initialization +# --------------------------- +# Create the Dash app instance. + + +# Callbacks +# --------- + +## URL and Token Processing +# -------------------------- +@app.callback( + [ + Output('token', 'data'), # Store authentication token. + Output('token_data', 'data'), # Store token metadata. + Output('entity', 'data'), # Store entity data. + Output('app_data', 'data'), # Store app data. + Output('session-details', 'children'), # Update session details. + ], + [Input('url', 'search')] # Extract token from URL parameters. +) +def generic_process_url_and_token(url_params): + """ + Handles URL parameter processing and manages authentication. + + Parameters: + url_params (str): URL parameters containing the token. + + Returns: + tuple: Data for token, token metadata, entity, page title, and session details. + """ + + # return process_url_and_token(url_params) + token, token_data, entity_data, app_data, page_title, session_details, dynamic_link = process_url_and_token(url_params) + + return token, token_data, entity_data, app_data, session_details + +## Bug Report Handling +# --------------------- +@app.callback( + [ + Output("alert-fade-bug-success", "is_open"), # Show success alert. + Output("alert-fade-bug-fail", "is_open") # Show failure alert. + ], + [Input("submit-bug-report", "n_clicks")], # Detect button clicks. + [ + State("bug-description", "value"), # Bug description input. + State("token", "data"), # Authentication token. + State("entity", "data") # Entity metadata. + ], + prevent_initial_call=True # Prevent callback on initial load. +) +def generic_handle_bug_report(n_clicks, bug_description, token, entity_data): + """ + Handles the submission of bug reports by delegating to the `submit_bug_report` function. + + Parameters: + n_clicks (int): Number of times the submit button was clicked. + bug_description (str): Description of the bug provided by the user. + token (dict): Authentication token data. + entity_data (dict): Metadata about the authenticated entity. + + Returns: + tuple: Success and failure alert states. + """ + return submit_bug_report(n_clicks, bug_description, token, entity_data) + + +# Adding workunit details +# --------------------- +@app.callback( + Output("workunits-content", "children"), + [ + Input("token_data", "data"), + Input("refresh-workunits", "children") + ] +) +def get_workunit_details(token_data, dummy): + """ + Get workunit details for the authenticated user. + + Parameters: + token (dict): Authentication token data. + + Returns: + tuple: Workunit details. + """ + return populate_workunit_details(token_data) + + +@app.callback( + Output("page-content-queue-children", "children"), + [ + Input("token_data", "data"), + Input("queue-interval", "n_intervals") + ] +) +def get_queue_details(token_data, interval): + """ + Get queue details for the authenticated user. + + Parameters: + token (dict): Authentication token data. + + Returns: + tuple: Queue details. + """ + return get_redis_queue_layout() + diff --git a/callbacks/modal_callbacks.py b/callbacks/modal_callbacks.py new file mode 100644 index 0000000..67ead8e --- /dev/null +++ b/callbacks/modal_callbacks.py @@ -0,0 +1,86 @@ +from app import app +from dash import Input, Output, State, ctx, no_update, MATCH, ALL +from dash.exceptions import PreventUpdate + + +@app.callback( + Output({"type": "flagging-modal", "index": MATCH}, "is_open"), + Input({"type": "adjust-flagging-btn", "index": MATCH}, "n_clicks"), + Input({"type": "flagging-modal-close", "index": MATCH}, "n_clicks"), + State({"type": "flagging-modal", "index": MATCH}, "is_open"), + State({"type": "input-stddev", "index": ALL}, "value"), + State({"type": "input-stddev", "index": ALL}, "id"), + prevent_initial_call=True, +) +def toggle_flagging_modal(adjust_btn, close_btn, is_open, stddev_vals, stddev_ids): + + is_open = bool(is_open) + tid = getattr(ctx, "triggered_id", None) + + if isinstance(tid, dict) and tid.get("type") == "adjust-flagging-btn" and (adjust_btn or 0) > 0: + return True + if isinstance(tid, dict) and tid.get("type") == "flagging-modal-close" and (close_btn or 0) > 0: + return False + + return is_open + + + + +@app.callback( + Output({"type": "modal-delete-rows", "index": MATCH}, "is_open"), + [ + Input({"type": "delete-rows-btn", "index": MATCH}, "n_clicks"), + Input({"type": "cancel-delete-rows", "index": MATCH}, "n_clicks"), + Input({"type": "confirm-delete-rows", "index": MATCH}, "n_clicks"), + ], + prevent_initial_call=True +) +def toggle_delete_rows_modal(n_open, n_cancel, n_confirm): + + triggered = ctx.triggered_id + if not isinstance(triggered, dict) or triggered.get("type") not in ["delete-rows-btn", "cancel-delete-rows", "confirm-delete-rows"]: + raise PreventUpdate + + if triggered["type"] == "delete-rows-btn" and n_open: + return True + elif triggered["type"] in ["cancel-delete-rows", "confirm-delete-rows"] and (n_cancel or n_confirm): + return False + return no_update + + +@app.callback( + Output({"type": "modal-delete-plate", "index": MATCH}, "is_open"), + [ + Input({"type": "delete-plate-btn", "index": MATCH}, "n_clicks"), + Input({"type": "cancel-delete-plate", "index": MATCH}, "n_clicks"), + Input({"type": "confirm-delete-plate", "index": MATCH}, "n_clicks"), + ], + prevent_initial_call=True +) +def toggle_delete_plate_modal(n_open, n_cancel, n_confirm): + print("toggle_delete_plate_modal called by:", ctx.triggered_id) + triggered = ctx.triggered_id + if not isinstance(triggered, dict) or triggered.get("type") not in ["delete-plate-btn", "cancel-delete-plate", "confirm-delete-plate"]: + raise PreventUpdate + + if triggered["type"] == "delete-plate-btn" and n_open: + return True + elif triggered["type"] in ["cancel-delete-plate", "confirm-delete-plate"] and (n_cancel or n_confirm): + return False + return no_update + + +@app.callback( + Output("adjust-merge-scheme-modal", "is_open"), + [ + Input("merge-plates-button-1", "n_clicks"), + Input("close-adjust-merge-scheme", "n_clicks"), + ], +) +def toggle_merge_modal(open_clicks, close_clicks): + if ctx.triggered_id == "merge-plates-button-1" and open_clicks: + return True + elif ctx.triggered_id == "close-adjust-merge-scheme" and close_clicks: + return False + return False diff --git a/callbacks/phiX_callbacks.py b/callbacks/phiX_callbacks.py new file mode 100644 index 0000000..693488d --- /dev/null +++ b/callbacks/phiX_callbacks.py @@ -0,0 +1,80 @@ + +from app import app + +from backend import AppState +import pandas as pd +from dash import Input, Output, State, MATCH, ALL + + + +@app.callback( + Output({"type": "phix-control-wrapper", "index": ALL}, "style"), + Input("dynamic-tabs", "active_tab"), + State({"type": "phix-control-wrapper", "index": ALL}, "id"), +) +def show_only_active_phix_drop(active_tab, all_ids): + return [ + {} if btn_id["index"] == active_tab else {"display": "none"} + for btn_id in all_ids + ] + + + + +@app.callback( + Output({"type": "phix-control-dropdown", "index": MATCH}, "options"), + [ + Input("app_data_state", "data"), + Input("dynamic-tabs", "active_tab"), + ], + State({"type": "phix-control-dropdown", "index": MATCH}, "id"), + prevent_initial_call=True, +) +def refresh_phix_dropdown_options(app_data_state_json, active_tab, this_dropdown_id): + """ + Keeps the PhiX dropdown OPTIONS up to date for each plate. + This runs separately from build_tab_body(), so content stays fresh. + """ + + if app_data_state_json is None: + return [] + + app_state = AppState.from_json(app_data_state_json) + + # which plate does THIS dropdown belong to? + pid_for_this_dropdown = this_dropdown_id["index"] + plate = app_state.plate_by_id.get(pid_for_this_dropdown) + if plate is None: + return [] + + is_qpcr = (plate.plate_type == "qPCR") + + df = plate.dataset(app_state, tidy=False) + + if "sample_type" in df.columns: + phix_rows = df[df["sample_type"] == "PhiX"] + else: + phix_rows = pd.DataFrame() + + dropdown_options = [] + plate_type_display = "qPCR" if is_qpcr else "TS" + + for _, row in phix_rows.iterrows(): + well = row.get("well_position_id") + + if is_qpcr: + colum_value = row.get("Cq") + else: + # dynamically find "Region Molarity ..." column + region_molarity_col = next( + (col for col in row.index if col.startswith("Region Molarity")), + None + ) + colum_value = row.get(region_molarity_col) if region_molarity_col else None + + label = f"{plate_type_display} - PhiX - {well} - {colum_value}" + value = "" if colum_value is None else str(colum_value) + + dropdown_options.append({"label": label, "value": well}) + + return dropdown_options diff --git a/callbacks/plate_merge_callbacks.py b/callbacks/plate_merge_callbacks.py new file mode 100644 index 0000000..9539470 --- /dev/null +++ b/callbacks/plate_merge_callbacks.py @@ -0,0 +1,615 @@ + +from app import app + +from dash import Input, Output, State, html, ctx, no_update +from dash.exceptions import PreventUpdate +from backend import AppState +import dash_ag_grid as dag +import numpy as np +from dash import ALL +import dash_bootstrap_components as dbc +from utils.plates_callback_utils import ( + render_dnd_aggrid, + plate_mapper, + render_sample_bar +) +from utils.plate_render_utils import ( + SAMPLE_TYPE_COLORS, + UNASSIGNED_COLOR +) + +@app.callback( + Output("merge-modal-body", "children"), + [ + Input("app_data_state", "data"), + ], + [ + State("used-samples-store", "data"), + ] +) +def refresh_merge_modal(app_data, used_samples): + """ + Re-render the entire merge modal when global app state or used samples change. + """ + + sample_groups = { + "Negative Control": [], + "Positive Control": [], + "PhiX": [], + "Standard": [], + "Library": [], + } + + if not app_data: + raise PreventUpdate + + if app_data: + app_data_state = AppState.from_json(app_data) + else: + raise PreventUpdate + + OPEN = True + qpcr_plate = None + + for plate in app_data_state.plate_registry: + if plate.plate_type == "qPCR": + qpcr_plate = plate + break + + for plate in app_data_state.plate_registry: + if plate.plate_type in ("D1000", "HSD1000", "D5000"): + OPEN = False + break + + merge_message = dbc.Alert( + "No Tapestation plates to merge. If this is intended, you can continue by clicking 'merge plates', otherwise upload a tapestation plate to continue merging.", + color="warning", + is_open=OPEN, + dismissable=True, + style={"marginBottom": "15px"} + ) + + if qpcr_plate: + for well in qpcr_plate.plate_wells: + sample = well.get_sample(app_data_state) + sample_groups[sample.sample_type].append(well.well_position_id) + + new_merge_body = html.Div( + [ + dbc.Row([ + merge_message, + html.Div(id="sample-bar-container"), + render_dnd_aggrid(sample_groups=sample_groups), + ]), + + html.Hr(), + dbc.Button( + "Merge and Review", + id="close-adjust-merge-scheme", + className="ms-auto", + n_clicks=0, + ), + ], + className="p-3", + ) + + return new_merge_body + + +@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, 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) + + grid_rows = [] + + column_defs = [ + {"headerName": "Sample", "field": "Sample"}, + {"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"] + + 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: + phiX_computed_molarity = float(row["computed_molarity"].split(" ")[0]) + phiX_dilution_factor = float(row["dilution_factor"]) + unadjusted_phiX_computed_molarity = phiX_computed_molarity * phiX_dilution_factor + phiX_computed_molarity = unadjusted_phiX_computed_molarity * QUANT_BIO_STANDARD_SIZE_BP / 500 # Assuming average size of 500 bp for PhiX + phiX_computed_molarity = f"{phiX_computed_molarity} nM" + + # 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: + 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" + + 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 if final_diluted_molarity is not None else None + formatted_phix_adjusted_diluted_molarity = f"{phix_adjusted_diluted_molarity:.2f} nM" if phix_adjusted_diluted_molarity is not None else "N/A" + + sample_identifier = qpcr_sample.file_sample_identifier + + grid_rows.append({ + "Sample": sample_identifier, + "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, + }) + + def safe_numeric_key(v): + digits = ''.join(ch for ch in v if ch.isdigit()) + try: + return int(digits) + except (ValueError, TypeError): + return int(100) + + grid_rows = sorted(grid_rows, key=lambda r: safe_numeric_key(r.get("Sample"))) + + 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": False, + "filter": False, + "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: + + 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, + }, + ) + ) + + return html.Div(report_content), html.Div(report_content).to_plotly_json() + + +@app.callback( + Output("used-samples-store", "data"), + Output("plate-grid", "rowData"), + Input("plate-grid", "cellValueChanged"), + Input("app_data_state", "data"), + State("plate-grid", "rowData"), +) +def update_qpcr_grid_assignments(cell_event, app_data_json, row_data): + + if ctx.triggered_id == "app_data_state": + return [], no_update + + if not row_data or not cell_event: + raise PreventUpdate + + if isinstance(cell_event, list): + cell_event = cell_event[-1] + + col_id = cell_event.get("colId") + row_label = cell_event.get("data", {}).get("Row") + if not col_id or not row_label: + raise PreventUpdate + + sample_name = cell_event.get("newValue") or cell_event.get("value") or "" + old_value = cell_event.get("oldValue") + well_id = f"{row_label}{col_id.replace('Col', '')}" + + # Load qPCR plate from AppState + 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) + if not qpcr_plate: + raise PreventUpdate + + # Build mapping of sample_identifier --> list of wells + identifier_to_wells = {} + for well in qpcr_plate.plate_wells: + identifier_to_wells.setdefault(well.sample_identifier, []).append(well.well_position_id) + + # Determine which identifier this changed well belongs to + target_identifier = None + for well in qpcr_plate.plate_wells: + if well.well_position_id == well_id: + target_identifier = well.sample_identifier + break + + if not target_identifier: + raise PreventUpdate + + replicate_wells = set(identifier_to_wells.get(target_identifier, [])) + + # CASE 1: Dropping a new sample --> fill all replicate wells + if sample_name.strip(): + for row in row_data: + for col_key in [k for k in row if k.startswith("Col")]: + current_well = f"{row['Row']}{col_key.replace('Col', '')}" + if current_well in replicate_wells: + row[col_key] = sample_name + if "_colorMap" not in row: + row["_colorMap"] = {} + # Copy color if present + color = cell_event.get("color") or row.get("_colorMap", {}).get(col_id) + row["_colorMap"][col_key] = color or "#43a047" + + # CASE 2: Double-click removal --> clear all replicate wells + else: + for row in row_data: + for col_key in [k for k in row if k.startswith("Col")]: + current_well = f"{row['Row']}{col_key.replace('Col', '')}" + if current_well in replicate_wells: + row[col_key] = "" + if "_colorMap" in row: + row["_colorMap"].pop(col_key, None) + + # Compute used samples + used = { + val for row in row_data for k, val in row.items() + if k.startswith("Col") and val + } + + return sorted(list(used)), row_data + + + + + + +@app.callback( + Output("sample-bar-container", "children"), + Input("used-samples-store", "data"), + Input("app_data_state", "data"), +) +def update_sample_bar(used_samples, app_data_json): + + if not app_data_json: + raise PreventUpdate + app_state = AppState.from_json(app_data_json) + samples = [] + + for plate in app_state.plate_registry: + if plate.plate_type == "qPCR": + continue + else: + plate_samples = [] + for well_position in plate.plate_wells: + sample = well_position.get_sample(app_state) + plate_samples.append({ + "name": sample.file_sample_identifier, + "pos": well_position.well_position_id, + "color": SAMPLE_TYPE_COLORS.get(sample.sample_type, UNASSIGNED_COLOR) + }) + samples.append({ + "plate": plate.plate_id, + "samples": plate_samples + }) + + + return render_sample_bar(samples=samples, used_samples=used_samples) + + +@app.callback( + [Output("merge-plates-button-1", "disabled")], + [ + Input("app_data_state", "data"), + Input("alert-tapestation-sampleid", "is_open"), + Input("alert-fade-too-many-qpcr", "is_open"), + Input("alert-fade-duplicate-file", "is_open"), + Input("alert-find-libraries-fail", "is_open"), + Input("alert-find-libraries-success", "is_open"), + ], +) +def toggle_merge_button(app_data_json, tapestation_warning, too_many_qpcr, duplicate_file, find_libraries_fail, find_libraries_success): + app_state = AppState.from_json(app_data_json) if app_data_json else None + if not app_state: + return [True] + + has_tapestation = any(p.plate_type in ("D1000", "HSD1000", "D5000") for p in app_state.plate_registry) + has_qpcr = sum(1 for p in app_state.plate_registry if p.plate_type == "qPCR") == 1 + has_warnings = tapestation_warning or too_many_qpcr or duplicate_file or find_libraries_fail or find_libraries_success + + return [not (has_tapestation and has_qpcr and not has_warnings)] diff --git a/callbacks/qpcr_callbacks.py b/callbacks/qpcr_callbacks.py new file mode 100644 index 0000000..d03f78c --- /dev/null +++ b/callbacks/qpcr_callbacks.py @@ -0,0 +1,62 @@ +from app import app + +from dash import Input, Output, State, MATCH, no_update, html +from backend import AppState +import numpy as np +import pandas as pd +from utils.plate_render_utils import build_standard_curve_figure + + +@app.callback( + Output({"type": "pcr-curve-graph", "index": MATCH}, "figure"), + Output({"type": "pcr-curve-table", "index": MATCH}, "children"), + Output({"type": "pcr-curve-store", "index": MATCH}, "data"), + Input("app_data_state", "data"), + State({"type": "datatable-plate", "index": MATCH}, "rowData"), + State({"type": "datatable-plate", "index": MATCH}, "id"), + prevent_initial_call=True, +) +def update_standard_curve(app_state, row_data, plate_id): + + if not row_data: + return no_update, no_update, no_update + + else: + app_state = AppState.from_json(app_state) + plate = app_state.plate_by_id.get(plate_id.get("index")) + + if plate is None: + return no_update, no_update, no_update + + + df = plate.dataset(app_state, tidy=True).replace(['None', 'none', 'NaN', 'nan', ''], np.nan).infer_objects(copy=False) + + # grab only samples which are defined as standards + standards_df = df[df['sample_type'] == 'Standard'].copy() + + # ensure numeric and drop rows with missing/invalid Standard or Cq + standards_df["Standard"] = pd.to_numeric(standards_df["Standard"], errors="coerce") + standards_df = standards_df.loc[standards_df["Standard"].notna() & standards_df["Cq"].notna()] + + pcr_fig, slope, intercept, efficiency, r_squared = build_standard_curve_figure(standards_df) + store_data = { + "slope": slope, + "intercept": intercept, + "efficiency": efficiency, + "r_squared": r_squared + } + + pcr_table_children = [ + html.Tr([html.Th("PCR Efficiency"), html.Th("R-Squared"), html.Th("Slope"), html.Th("Intercept")]), + html.Tr([ + html.Td(f"{efficiency:.1f}%"), + html.Td(f"{r_squared:.3f}"), + html.Td(f"{slope:.3f}"), + html.Td(f"{intercept:.3f}") + ]) + ] + return pcr_fig, pcr_table_children, store_data + + + + diff --git a/callbacks/report_callbacks.py b/callbacks/report_callbacks.py new file mode 100644 index 0000000..fa9ea78 --- /dev/null +++ b/callbacks/report_callbacks.py @@ -0,0 +1,123 @@ +from app import app + +from dash import dcc, Input, Output, State +from dash.exceptions import PreventUpdate + + +@app.callback( + 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 + + 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", []) + 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}: ": + if i + 1 < len(flat): + return flat[i + 1] + return "unknown" + + username = extract_field("User Name") + job_id = extract_field("Job ID") + + 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) + + 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) + + "
    " + ) + + 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)} + + + """ + + 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) + diff --git a/callbacks/submission_callbacks.py b/callbacks/submission_callbacks.py new file mode 100644 index 0000000..07ce8c0 --- /dev/null +++ b/callbacks/submission_callbacks.py @@ -0,0 +1,314 @@ +from app import app + +from dash import dcc, Input, Output, State, ctx +from dash.exceptions import PreventUpdate +from bfabric_web_apps import run_main_job, get_power_user_wrapper, get_logger +from datetime import datetime as dt +from backend import AppState + + +from bfabric_web_apps.utils.run_main_pipeline import ( + local_access, + g_req_copy, + scp_copy, + ssh_move, + GSTORE_REMOTE_PATH, + SCRATCH_PATH, + TRX_LOGIN, + TRX_SSH_KEY, + URL, +) + + +@app.callback( + Output("review-modal", "is_open"), + Input("close-adjust-merge-scheme", "n_clicks"), + Input("submit-review", "n_clicks"), + State("review-modal", "is_open"), + 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"), + State('url', 'search'), + State('token_data', 'data'), + State('app_data_state', 'data'), + prevent_initial_call=True, +) +def download_modal_report( + open_clicks, + close_clicks, + is_open, + report_data, + case1_rowData, + case1_columnDefs, + case2_rowData, + case2_columnDefs, + session_details, + url, + token_data, + app_data_state_json +): + print("Submission callback triggered") + if ctx.triggered_id == "close-adjust-merge-scheme" and open_clicks: + return True + + if not report_data: + raise PreventUpdate + + app_state = AppState.from_json(app_data_state_json) if app_data_state_json else AppState() + + all_container_ids = [] + + for sample in app_state.sample_registry: + if sample.container: + all_container_ids.append(sample.container) + + all_container_ids = list(set(all_container_ids)) + + print("All container IDs:", all_container_ids) + + 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", []) + 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}: ": + if i + 1 < len(flat): + return flat[i + 1] + return "unknown" + + username = extract_field("User Name") + job_id = extract_field("Job ID") + + 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) + + 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) + + "
    " + ) + + 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)} + + + """ + + safe_user = str(username).replace(" ", "_") + safe_job = str(job_id).replace(" ", "_") + filename = f"qPCR_Tapestation_Report_{safe_user}_Job{safe_job}.html" + + with open(filename, "+w", encoding="utf-8") as f: + f.write(html_string) + print(f"Wrote report to {filename}") + + def aggrid_to_dataset_dict(rowData, columnDefs, key): + """ + Convert AG-Grid rowData + columnDefs into a dataset dictionary + with column-driven arrays. + + key = dataset name (e.g. job_id or "case1") + """ + # Ensure column order matches the UI + fields = [col["field"] for col in columnDefs] + + dataset = {col: [] for col in fields} + + for row in rowData: + for field in fields: + dataset[field].append(row.get(field)) + + return {key: dataset} + + container_id = 2220 + + dataset_dict = aggrid_to_dataset_dict(case1_rowData, case1_columnDefs, str(container_id)) + dataset_dict.update(aggrid_to_dataset_dict(case2_rowData, case2_columnDefs, str(container_id))) + + print("running main job") + + #run_main_job( + # files_as_byte_strings = {}, + # bash_commands = [], + # resource_paths = {filename: container_id}, + #attachment_paths = {filename: filename}, + #token = url, + #service_id = 0, + #charge = [], + #dataset_dict = dataset_dict, + #) + + wrapper = get_power_user_wrapper(token_data) + logger = get_logger(token_data) + + def _create_attachment_link(wrapper, logger, entity_class, entity_id, file_name, folder_name): + """Creates an attachment link in B-Fabric for the attached file.""" + + url = f"{URL}/{folder_name}/{file_name}" + timestamped_filename = f"{dt.now().strftime('%Y-%m-%d_%H:%M:%S')}_{file_name}" + + data = { + "name": timestamped_filename, + "parentclassname": entity_class, + "parentid": entity_id, + "url": url + } + + try: + link_result = wrapper.save("link", data) + if link_result: + success_msg = f"Attachment link created for '{file_name}': {url}" + logger.log_operation("Success | ORIGIN: run_main_job function", success_msg, params=None, flush_logs=True) + print(success_msg) + else: + raise ValueError("Attachment link creation failed") + except Exception as e: + error_msg = f"Failed to create attachment link for '{file_name}': {e}" + logger.log_operation("Error | ORIGIN: run_main_job function", error_msg, params=None, flush_logs=True) + print(error_msg) + + + + def _attach_gstore_files_to_entities_as_link( + entity_ids, + entity_class, + logger, + attachment_paths, + wrapper + ): + + + """ + Attaches files to a B-Fabric entity by copying them to the FGCZ storage and creating an API link. + + Args: + token_data (dict): Authentication token data. + logger: Logger instance for logging operations. + attachment_paths (dict): Dictionary mapping source file paths to their corresponding file names. + + Returns: + None + """ + + FIXED_CONTAINER_ID = 2220 + + # Check if we have access to the FGCZ server + local = local_access(GSTORE_REMOTE_PATH) + + # Process each attachment + for source_path, file_name in attachment_paths.items(): + if not source_path or not file_name: + logger.log_operation("Error | ORIGIN: run_main_job function", f"Missing required attachment details: {source_path} -> {file_name}", params=None, flush_logs=True) + print(f"Error: Missing required attachment details: {source_path} -> {file_name}") + continue + + try: + # Define entity folder + # entity_folder = f"{entity_class}_{entity_id}" if entity_class and entity_id else "unknown_entity" + entity_folder = f"{entity_class}_{FIXED_CONTAINER_ID}" + final_remote_path = f"{GSTORE_REMOTE_PATH}/{entity_folder}/" + + print("local access:", local) + print("source path:", source_path) + print("file name:", file_name) + print("final remote path:", final_remote_path) + + if local: # We have direct access → Copy directly + g_req_copy(source_path, final_remote_path) + + else: # We don't have direct access → Send to migration folder first + remote_tmp_path = f"{SCRATCH_PATH}/{file_name}" + scp_copy(source_path, TRX_LOGIN, TRX_SSH_KEY, remote_tmp_path) + print("scp copy done:") + + # Move to final location + ssh_move(TRX_LOGIN, TRX_SSH_KEY, remote_tmp_path, final_remote_path) + print("ssh move done:") + + for entity_id in entity_ids: + # Log success + success_msg = f"Successfully attached '{file_name}' to {entity_class} (ID={entity_id})" + print(success_msg) + + # Step 3: Create API link + _create_attachment_link(wrapper, logger, entity_class, entity_id, file_name, entity_folder) + + except Exception as e: + error_msg = f"Exception while processing '{file_name}': {e}" + logger.log_operation("Error | ORIGIN: run_main_job function", error_msg, params=None, flush_logs=True) + print(error_msg) + + print("main job complete") + return False + + _attach_gstore_files_to_entities_as_link( + entity_ids=all_container_ids, + entity_class="container", + logger=logger, + attachment_paths={filename: filename}, + wrapper=wrapper + ) + diff --git a/components/__init__.py b/components/__init__.py new file mode 100644 index 0000000..72bbf8c --- /dev/null +++ b/components/__init__.py @@ -0,0 +1,3 @@ +from .documentation_content import documentation_content +from .main_components import plate_mapper, render_main_content, app_specific_layout, render_dnd_aggrid, render_sample_bar +from .generic_components import no_auth, auth \ No newline at end of file diff --git a/components/documentation_content.py b/components/documentation_content.py new file mode 100644 index 0000000..cc1179e --- /dev/null +++ b/components/documentation_content.py @@ -0,0 +1,186 @@ +from dash import html + +# Here we define the documentation content for the app. +documentation_content = [ + html.H2("NGS QC App Documentation"), + + html.P("This application allows you to upload, review, and merge data from Tapestation runs and qPCR measurements in order to compute final molarity values and submit them back to B-Fabric."), + + html.Br(), + + html.H3("Workflow Overview"), + html.Ul([ + html.Li("Upload Tapestation and qPCR data."), + html.Li("Perform quality control and resolve warnings if needed."), + html.Li("Merge qPCR and Tapestation measurements into final molarity values."), + html.Li("Review and submit results back to B-Fabric.") + ]), + + html.Br(), + + # --- STEP 1 --- + html.H3("1. Upload Data"), + + html.P([ + "You may upload multiple Tapestation (D1000, D5000, HS D1000) files and ", + html.B("only one qPCR file.") + ]), + + html.P( + "Files can be uploaded by clicking the upload area or dragging files into it. " + "Each uploaded file automatically creates a new tab in the UI and is converted into a plate object " + "with both table and plate views for inspection and processing. " + "You can switch between the table and plate view using the Plate Controls toggle in the sidebar." + ), + + + html.Br(), + + # TAPESTATION + html.H4("Tapestation Upload"), + html.Ul([ + html.Li("The app automatically attempts to match well positions to B-Fabric sample IDs based on tube identifiers."), + html.Li("If sample IDs cannot be resolved, a warning message will guide you to enter the missing information."), + html.Li("Flagging conditions (min/max fragment length and min/max molarity) are applied automatically."), + html.Li("Any wells that fail these flagging conditions are highlighted in red and listed in the warning panel."), + html.Li("Only wells with sample type 'Library' or 'Positive Control' are marked red or generate warnings."), + ]), + + html.P("Sample types for Tapestation plates are inferred automatically based on sample descriptions:"), + html.Ul([ + html.Li("Descriptions containing 'Negative control' or 'Neg' → Negative Control"), + html.Li("Descriptions containing '2220' → Positive Control"), + html.Li("Descriptions containing any variation of 'PhiX' → PhiX"), + html.Li("All other samples → Library") + ]), + + html.Br(), + + # qPCR + html.H4("qPCR Upload"), + html.Ul([ + html.Li("Triplicates are automatically grouped and analyzed to compute PCR efficiency, slope, intercept, and R²."), + html.Li("Flagging conditions are based on standard deviation and computed molarity thresholds."), + html.Li("Wells that fail these flagging conditions are highlighted in red and shown in the warning panel with explanatory text."), + html.Li("Only wells with sample type 'Library' or 'Positive Control' are marked red or generate warnings."), + ]), + + html.P("Sample types for qPCR plates are inferred automatically based on well plate position:"), + html.Ul([ + html.Li("Wells A01–F03 → Standards"), + html.Li("Wells G01–H03 → Negative Controls"), + html.Li("The final triplicate group → PhiX") + ]), + + html.Br(), + + # --- STEP 2 --- + html.H3("2. Quality Control and Warning Resolution"), + + html.P( + "Before merging, the data should be validated and corrected if needed. " + "The application may display warnings when sample information is missing or when measurements fall outside the defined flagging conditions." + ), + + html.Br(), + + html.H4("Warning Types"), + html.Ul([ + html.Li( + html.B("Missing Sample IDs:"), + ), + html.Ul([ + html.Li("Displayed when a well cannot be matched to a B-Fabric sample."), + html.Li("Example: Tapestation-1: No sample found at wells D7, H7."), + html.Li("To resolve: Enter the correct sample_id directly into the 'sample_id' column. The app will fetch sample information from B-Fabric and update the row automatically."), + ]), + html.Br(), + html.Li( + html.B("Flagging Condition Warnings:"), + ), + html.Ul([ + html.Li("Displayed when a measurement is outside defined thresholds."), + html.Li("Example: Plate qPCR: wells A4, A5, A6 exceed the defined flagging thresholds."), + html.Li("To resolve: Inspect the wells or adjust the flagging thresholds using the 'Adjust Flagging Conditions' button in the sidebar."), + ]), + ]), + + html.Br(), + + html.H4("Adjusting Data"), + html.Ul([ + html.Li("Sample types can be reassigned manually (Library, Negative Control, Positive Control, PhiX, and Standard. Note that 'Standard' applies only to qPCR plates)."), + html.Li("You can remove individual wells (rows) or delete entire plates if they should not be considered."), + ]), + + html.Br(), + + html.H4("PhiX Normalization"), + html.P( + "If a plate contains PhiX wells, these can be used as normalization references. " + "Select the desired PhiX measurement using the 'Control by PhiX' dropdown in the sidebar." + ), + + html.Br(), + + + html.Br(), + + # --- STEP 3 --- + html.H3("3. Merge Data and Compute Final Results"), + + html.P( + "Once at least one Tapestation plate and one qPCR plate have been uploaded and all warnings have been resolved, " + "the data can be merged to compute final molarity values." + ), + + html.Br(), + + html.H4("Preparing Plates for Merging"), + html.Ul([ + html.Li("Click the 'Prepare Plates for Merging' button to open the merge view."), + html.Li("The merge view shows both the qPCR plate and the selected Tapestation plate."), + html.Li("Samples are matched by dragging wells from the Tapestation plate onto the corresponding wells in the qPCR plate."), + html.Li("If you want to remove a sample from the qPCR plate, double-click the well to return it to the Tapestation plate."), + ]), + + html.P( + "Continue arranging wells until all desired samples from the Tapestation plate have been matched to their qPCR measurements." + ), + + html.Br(), + html.H4("Merge and Review"), + html.P( + "After arranging the plates, click 'Merge and Review' to open the Review Analysis Results panel in the 'Merged' tab." + ), + + html.P( + "This view summarizes all samples that have both Tapestation and qPCR measurements. " + "For each matched sample, the application displays the intermediate and final calculations used to determine the final molarity." + ), + + html.Ul([ + html.Li("Sample ID and Tube ID"), + html.Li("Ct value from qPCR"), + html.Li("Computed molarity based on the qPCR standard curve"), + html.Li("qPCR dilution factor"), + html.Li("Tapestation average size check"), + html.Li("PhiX-adjusted molarity"), + html.Li("Final molarity after fragment length") + ]), + + + html.Br(), + + + # --- STEP 4 --- + html.H3("4. Review and Submit Results to B-Fabric"), + html.P("Before submission, the app checks for potential data overwrites and missing sample information."), + html.Br(), + +html.H3("Documentation and Support"), +html.P( + "If you encounter any issues or have general feedback, please use the 'Report a Bug' tab to contact us." +), + +] diff --git a/components/generic_components.py b/components/generic_components.py new file mode 100644 index 0000000..fb88b7d --- /dev/null +++ b/components/generic_components.py @@ -0,0 +1,34 @@ +""" +Reusable UI Components and Core Functions for Bfabric App +========================================================= + +IMPORTANT: DO NOT MODIFY THIS FILE +---------------------------------- +This module contains essential components and core functionalities for the Bfabric web app. +It is a foundational part of the system, and any changes to this file may disrupt functionality +or compatibility with other modules. + +This module includes: + - Initialization of the Dash app instance. + - Callbacks for authentication and URL parameter processing. + - Callback for bug report handling. + - Content to display for authenticated and unauthenticated users. +""" + +from dash import html + +# UI Components +# -------------- + +## Unauthenticated User Content +# ------------------------------ +# Message displayed to users who are not authenticated. +no_auth = [ + html.P("You are not currently logged into an active session. Please log into bfabric to continue:"), + html.A('Login to Bfabric', href='https://fgcz-bfabric.uzh.ch/bfabric/') # Link to the Bfabric login page. +] + +## Placeholder for Authenticated User Content +# -------------------------------------------- +# Dynamic content displayed to authenticated users. +auth = [html.Div(id="auth-div")] diff --git a/components/main_components.py b/components/main_components.py new file mode 100644 index 0000000..2206e75 --- /dev/null +++ b/components/main_components.py @@ -0,0 +1,260 @@ + +from dash import html, dcc +import dash_bootstrap_components as dbc +import dash_ag_grid as dag + +from assets.js_interface import DRAGDROP_JS +from utils.plates_callback_utils import render_dnd_aggrid, plate_mapper, render_sample_bar + +merge_body = html.Div( + [ + dcc.Store(id="register-dnd-js", data=DRAGDROP_JS), + dbc.Container( + dbc.Col( + [ + dbc.Row([ + html.Div(id="sample-bar-container"), + render_dnd_aggrid(), # static grid + ]), + dbc.Row([ + html.Hr(), + dbc.Button("Merge and Review", id="close-adjust-merge-scheme", className="ms-auto", n_clicks=0), + ]), + ], + className="p-3", + ) + ), + ] +) + + +review_body = html.Div( + [ + dbc.Container( + dbc.Col( + [ + dbc.Row([ + html.Div(id="review-container"), + ]), + ], + className="p-3", + ) + ), + ] +) + + +sidebar = html.Div( + [ + dbc.Card( + [ + dbc.CardHeader( + "Upload Tapestation and qPCR Files", + className="fw-semibold text-muted small py-2", + ), + dbc.CardBody( + [ + dcc.Upload( + id="upload-files", + children=html.Div( + [ + html.Span("Drag & Drop or ", className="small"), + html.A("Select Files", className="text-decoration-underline small"), + ] + ), + style={ + "width": "100%", + "height": "70px", + "lineHeight": "70px", + "borderWidth": "1px", + "borderStyle": "dashed", + "borderRadius": "0.4rem", + "textAlign": "center", + "fontSize": "1rem", + }, + className="mb-2 bg-light-subtle border-secondary-subtle", + multiple=True, + ), + ], + className="p-2 small", + ), + ], + className="shadow-sm mb-3", + ), + + dbc.Card( + [ + dbc.CardHeader( + "Plate Controls", + className="fw-semibold text-muted small py-2", + ), + dbc.CardBody( + [ + html.Div( + [ + html.Span( + "Plate View -", + className="fw-semibold", + style={"padding-top": "4px","padding-right": "8px"} + ), + dbc.Switch( + id="view-mode-switch", + value=True, + className="mx-2", + style={"transform": "scale(1.5)","padding-top": "4px"}, + ), + html.Span("- Table View", className="fw-semibold"), + ], + className="d-flex justify-content-center align-items-center mb-3", + ), + html.Div(id="plate-button-container", className="small"), + ], + className="p-2 small", + ), + ], + className="shadow-sm", + ), + + dbc.Card( + [ + dbc.CardHeader( + "Merge Plates", + className="fw-semibold text-muted small py-2", + ), + dbc.CardBody( + [ + html.Div( + id="tooltip-div", + children=[ + dbc.Button( + "Prepare Plates for Merging", + id="merge-plates-button-1", + color="primary", + className="w-100", + disabled=True, + ), + ], + ), + dbc.Tooltip( + "Merging is available once at least one Tapestation and one qPCR plate have been uploaded, and all warnings have been resolved.", + target="tooltip-div", + placement="bottom", + ), + html.Div( + id="phix-control-dropdowns", + className="small", + ), + html.Div( + id="adjust-flagging-conditon-buttons", + className="small", + ), + + ], + className="p-2 small", + ), + ], + 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", + ) + ]), + ] +) + +alerts = html.Div( + [ + dbc.Alert("Error: More than one qPCR file uploaded. Please upload only one.", color="danger",id="alert-fade-too-many-qpcr", dismissable=False, is_open=False), + dbc.Alert(id="alert-find-libraries-fail", color="danger", is_open=False, dismissable=True, children=""), + dbc.Alert(id="alert-fade-duplicate-file", color="danger", is_open=False, dismissable=True, children=""), + dbc.Alert(id="alert-tapestation-sampleid", color="warning", is_open=False, dismissable=False, children=""), + dbc.Alert(id="alert-flagging-conditions", color="warning", is_open=False, dismissable=False, children=""), + dbc.Alert(id="alert-find-libraries-success", color="success", is_open=False, dismissable=True, children=""), + ], + style={"margin": "20px"} +) + +def render_main_content(): + return [ + html.Div( + id="dynamic-tabs-container", + children=[dbc.Tabs(id="dynamic-tabs", active_tab=None, children=[])], + ) + ] + +app_specific_layout = dbc.Row( + id="page-content-main", + children=[ + html.Div(id="table-creation-trigger"), + html.Div(id="table-deletion-trigger"), + html.Div(id="update-rows-trigger-div"), + dcc.Store(id="used-samples-store", data=[]), + dbc.Modal( + id="adjust-merge-scheme-modal", + size="xl", + is_open=False, + children=[ + dbc.ModalHeader(dbc.ModalTitle("Adjust Merging Scheme")), + dbc.ModalBody(id="merge-modal-body", children=merge_body), + ] + ), + dbc.Modal( + id="review-modal", + size="xl", + is_open=False, + 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.Button("Download Html Report", id="download-report-button", className="ms-2", n_clicks=0), + dcc.Download(id="download-report-data"), + ], + ), + ] + ), + dcc.Loading(alerts), + dbc.Col( + html.Div( + id="sidebar", + children=sidebar, + style={"border-right": "2px solid #d4d7d9","height": "100%","padding": "8px","font-size": "16px"}, + ), + width=3, + ), + dbc.Col( + html.Div( + id="main-content", + children=render_main_content(), + style={"margin-top": "0vh","margin-left": "0vw","margin-right": "2vw","margin-bottom": "2vw","font-size": "16px"}, + ), + width=9, + ), + 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"} +) \ No newline at end of file diff --git a/index.py b/index.py index 94162c3..e34eaf2 100644 --- a/index.py +++ b/index.py @@ -1,2446 +1,32 @@ -import dash -from dash import Input, Output, State, html, dcc, ctx, ALL, no_update, MATCH -import dash_ag_grid as dag -import dash_bootstrap_components as dbc + import bfabric_web_apps -from generic.callbacks import app -from generic.components import no_auth -import numpy as np -import base64 -import io import pandas as pd -from models import Plate, PlateWell, Measurement -from app_state import AppState -import json -from dash.exceptions import PreventUpdate -import re -import copy - -from documentation_content import documentation_content - -from utils.flagging_conditions import collect_flagging_from_all -from utils.plates_callback_utils import update_app_state_from_ui, handle_plate_delete, handle_plate_upload, find_and_populate_samples_by_sample_id -from utils.warning_utils import collect_missing_sample_warnings, build_warning_msg -from utils.plate_render_utils import ( - DEFAULT_BORDER, - WARN_BORDER, - ERROR_BORDER, - BORDER_PX, - MARKER_PX, - SAMPLE_TYPE_COLORS, - UNASSIGNED_COLOR, - EMPTY_FILL, - EMPTY_BORDER, - EMPTY_BORDER_PX, - make_tab, - get_tab_id, - build_standard_curve_figure, - build_computed_molarity +from callbacks import * # imports app, with all callbacks registerred. +from components import ( + documentation_content, + app_specific_layout ) -from assets.js_interface import DRAGDROP_JS +# Set global pandas options pd.set_option('future.no_silent_downcasting', True) -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: - sample_groups = { - "Negative Control": [], - "Positive Control": [], - "PhiX": [], - "Standard": [], - "Library": [], - } - - rows = list("ABCDEFGH") - columns = list(range(1, 13)) - - well_type_map = { - well: group - for group, wells in sample_groups.items() - for well in wells - } - editable_groups = {"Library", "Positive Control"} - - rowData = [] - for r in rows: - row = {"Row": r} - for c in columns: - well = f"{r}{c}" - group = well_type_map.get(well) - if group in ("PhiX", "Standard", "Negative Control"): - label = {"PhiX": "PhiX", "Standard": "STD", "Negative Control": "NEG"}[group] - row[f"Col{c}"] = label - else: - row[f"Col{c}"] = "" - rowData.append(row) - - columnDefs = [{"headerName": "Row", "field": "Row", "editable": False, "width": 70}] - for c in columns: - columnDefs.append({ - "headerName": f"{c}", - "field": f"Col{c}", - "cellRenderer": "DragDropRenderer", - "editable": False, - "width": 70, - "cellRendererParams": { - "wellTypes": well_type_map, - "col": c, - "editableGroups": list(editable_groups) - }, - }) - - grid_width = (len(columns) * 70) + 100 # 12 columns * 70px + row header padding - - grid_section = html.Div( - [ - html.H5( - "qPCR Plate", - style={ - "textAlign": "center", - "fontWeight": "bold", - "marginBottom": "10px", - "marginTop": "10px", - }, - ), - html.Div( - dag.AgGrid( - id="plate-grid", - rowData=rowData, - columnDefs=columnDefs, - defaultColDef={"resizable": True, "sortable": False}, - dashGridOptions={"domLayout": "autoHeight"}, - style={ - "width": f"{grid_width}px", - "minWidth": f"{grid_width}px", - "margin": "0 auto", - "boxShadow": "0 2px 6px rgba(0,0,0,0.1)", - "borderRadius": "8px", - }, - className="ag-theme-alpine", - dangerously_allow_code=True, - ), - style={"textAlign": "center"}, - ), - ], - style={ - "display": "flex", - "flexDirection": "column", - "alignItems": "center", - "justifyContent": "center", - "padding": "10px 0px 20px 0px", - }, - ) - - return grid_section - - -def render_sample_bar(samples=[], used_samples=[]): - """ - Render multiple microplates of draggable samples in a horizontally scrollable top bar. - - Parameters - ---------- - samples : list[dict] - Each dict represents a plate with keys: - { - "plate": "Plate_1", - "samples": [ - {"name": "Sample_1", "pos": "A1", "color": "#aaf"}, - ... - ] - } - - used_samples : list[str] - Names of samples already placed in the AG grid; these are greyed out. - """ - - rows = list("ABCDEFGH") - columns = list(range(1, 13)) - - def make_plate(plate_name, plate_samples): - """Render a single microplate with labeled wells.""" - - # Map well position → sample dict - sample_map = {s["pos"]: s for s in plate_samples} - - plate_rows = [] - for r in rows: - row_cells = [] - for c in columns: - pos = f"{r}{c}" - sample = sample_map.get(pos) - if not sample: - cell = html.Div( - "", - className="empty-well", - style={ - "width": "22px", - "height": "22px", - "borderRadius": "50%", - "border": "1px solid #ccc", - "backgroundColor": "#fff", - "margin": "2px", - }, - ) - else: - name = sample["name"] - color = sample.get("color", "#f8f9fa") - is_used = name in used_samples - cell = html.Div( - "", - draggable="true" if not is_used else "false", - **{"data-sample": name, "data-color": color}, - title=f"{name} ({pos})", - style={ - "width": "22px", - "height": "22px", - "borderRadius": "50%", - "border": "1px solid #333", - "backgroundColor": color if not is_used else "#ddd", - "opacity": 1.0 if not is_used else 0.5, - "cursor": "grab" if not is_used else "not-allowed", - "margin": "2px", - }, - ) - row_cells.append(cell) - - plate_rows.append( - html.Div( - [html.Div(r, style={"width": "12px", "textAlign": "right", "marginRight": "4px"})] + row_cells, - style={"display": "flex", "alignItems": "center"}, - ) - ) - - column_labels = html.Div( - [html.Div("", style={"width": "16px"})] - + [ - html.Div( - str(c), - style={"width": "22px", "textAlign": "center", "margin": "2px", "fontSize": "10px"}, - ) - for c in columns - ], - style={"display": "flex", "marginLeft": "16px", "marginBottom": "4px"}, - ) - - return html.Div( - [ - html.Div( - plate_name, - style={ - "textAlign": "center", - "fontWeight": "bold", - "marginBottom": "4px", - "fontSize": "12px", - }, - ), - column_labels, - html.Div(plate_rows, style={"display": "flex", "flexDirection": "column"}), - ], - className="plate-card", - style={ - "flex": "0 0 auto", - "padding": "8px", - "marginRight": "16px", - "border": "1px solid #ccc", - "borderRadius": "8px", - "backgroundColor": "#fafafa", - }, - ) - - # Make all plates, side-by-side - plate_components = [make_plate(p["plate"], p["samples"]) for p in samples] - - return html.Div( - plate_components, - id="sample-bar", - style={ - "display": "flex", - "overflowX": "auto", - "gap": "12px", - "padding": "10px 4px", - "marginBottom": "20px", - "whiteSpace": "nowrap", - }, - ) - - - - -############################################################################## -##### UI Components (unchanged except merge_body) -############################################################################## - -merge_body = html.Div( - [ - dcc.Store(id="register-dnd-js", data=DRAGDROP_JS), - dbc.Container( - dbc.Col( - [ - dbc.Row([ - html.Div(id="sample-bar-container"), - render_dnd_aggrid(), # static grid - ]), - dbc.Row([ - html.Hr(), - dbc.Button("Merge and Review", id="close-adjust-merge-scheme", className="ms-auto", n_clicks=0), - ]), - ], - className="p-3", - ) - ), - ] -) - - -review_body = html.Div( - [ - dbc.Container( - dbc.Col( - [ - dbc.Row([ - html.Div(id="review-container"), - ]), - ], - className="p-3", - ) - ), - ] -) - - -sidebar = html.Div( - [ - dbc.Card( - [ - dbc.CardHeader( - "Upload Tapestation and qPCR Files", - className="fw-semibold text-muted small py-2", - ), - dbc.CardBody( - [ - dcc.Upload( - id="upload-files", - children=html.Div( - [ - html.Span("Drag & Drop or ", className="small"), - html.A("Select Files", className="text-decoration-underline small"), - ] - ), - style={ - "width": "100%", - "height": "70px", - "lineHeight": "70px", - "borderWidth": "1px", - "borderStyle": "dashed", - "borderRadius": "0.4rem", - "textAlign": "center", - "fontSize": "1rem", - }, - className="mb-2 bg-light-subtle border-secondary-subtle", - multiple=True, - ), - ], - className="p-2 small", - ), - ], - className="shadow-sm mb-3", - ), - - dbc.Card( - [ - dbc.CardHeader( - "Plate Controls", - className="fw-semibold text-muted small py-2", - ), - dbc.CardBody( - [ - html.Div( - [ - html.Span( - "Plate View -", - className="fw-semibold", - style={"padding-top": "4px","padding-right": "8px"} - ), - dbc.Switch( - id="view-mode-switch", - value=True, - className="mx-2", - style={"transform": "scale(1.5)","padding-top": "4px"}, - ), - html.Span("- Table View", className="fw-semibold"), - ], - className="d-flex justify-content-center align-items-center mb-3", - ), - html.Div(id="plate-button-container", className="small"), - ], - className="p-2 small", - ), - ], - className="shadow-sm", - ), - - dbc.Card( - [ - dbc.CardHeader( - "Merge Plates", - className="fw-semibold text-muted small py-2", - ), - dbc.CardBody( - [ - html.Div( - id="tooltip-div", - children=[ - dbc.Button( - "Prepare Plates for Merging", - id="merge-plates-button-1", - color="primary", - className="w-100", - disabled=True, - ), - ], - ), - dbc.Tooltip( - "Merging is available once at least one Tapestation and one qPCR plate have been uploaded, and all warnings have been resolved.", - target="tooltip-div", - placement="bottom", - ), - html.Div( - id="phix-control-dropdowns", - className="small", - ), - html.Div( - id="adjust-flagging-conditon-buttons", - className="small", - ), - - ], - className="p-2 small", - ), - ], - 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"), - # ]) - ]), - ] -) - -alerts = html.Div( - [ - dbc.Alert("Error: More than one qPCR file uploaded. Please upload only one.", color="danger",id="alert-fade-too-many-qpcr", dismissable=False, is_open=False), - dbc.Alert(id="alert-find-libraries-fail", color="danger", is_open=False, dismissable=True, children=""), - dbc.Alert(id="alert-fade-duplicate-file", color="danger", is_open=False, dismissable=True, children=""), - dbc.Alert(id="alert-tapestation-sampleid", color="warning", is_open=False, dismissable=False, children=""), - dbc.Alert(id="alert-flagging-conditions", color="warning", is_open=False, dismissable=False, children=""), - dbc.Alert(id="alert-find-libraries-success", color="success", is_open=False, dismissable=True, children=""), - ], - style={"margin": "20px"} -) - -def render_main_content(): - return [ - html.Div( - id="dynamic-tabs-container", - children=[dbc.Tabs(id="dynamic-tabs", active_tab=None, children=[])], - ) - ] - -app_specific_layout = dbc.Row( - id="page-content-main", - children=[ - html.Div(id="table-creation-trigger"), - html.Div(id="table-deletion-trigger"), - html.Div(id="update-rows-trigger-div"), - dcc.Store(id="used-samples-store", data=[]), - dbc.Modal( - id="adjust-merge-scheme-modal", - size="xl", - is_open=False, - children=[ - dbc.ModalHeader(dbc.ModalTitle("Adjust Merging Scheme")), - dbc.ModalBody(id="merge-modal-body", children=merge_body), - ] - ), - dbc.Modal( - id="review-modal", - size="xl", - is_open=False, - 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.Button("Download Html Report", id="download-report-button", className="ms-2", n_clicks=0), - dcc.Download(id="download-report-data"), - ], - ), - ] - ), - dcc.Loading(alerts), - dbc.Col( - html.Div( - id="sidebar", - children=sidebar, - style={"border-right": "2px solid #d4d7d9","height": "100%","padding": "8px","font-size": "16px"}, - ), - width=3, - ), - dbc.Col( - html.Div( - id="main-content", - children=render_main_content(), - style={"margin-top": "0vh","margin-left": "0vw","margin-right": "2vw","margin-bottom": "2vw","font-size": "16px"}, - ), - width=9, - ), - 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"} -) - app_title = "NGS QC Analysis Application" +# Build layout with native bfabric_web_apps function app.layout = bfabric_web_apps.get_static_layout( base_title=app_title, main_content=app_specific_layout, documentation_content=documentation_content, - layout_config={"workunits": False, "queue": False, "bug": True}, + layout_config={"workunits": True, "queue": False, "bug": True}, include_header=False ) -############################################################################## -##### JS INJECTION CALLBACK -############################################################################## - -app.clientside_callback( - """ - function(code) { - try { - eval(code); - console.log("✅ Client-side JS injected and executed"); - return "✅ Executed"; - } catch(err) { - console.error("❌ JS eval failed:", err); - return "❌ Failed"; - } - } - """, - Output("register-dnd-js", "data"), - Input("register-dnd-js", "data"), -) - - -################################################################################### -######## Callbacks -################################################################################### - - -# Check if the user is logged in -@app.callback( - [ - Output('upload-files', 'disabled'), - Output('main-content', 'children'), - ], - [Input('token_data', 'data')], - [State('entity', 'data')] -) -def update_ui(token_data, entity_data): - if not token_data or not entity_data: - return True, html.Div(no_auth, style={"padding": "40px"}) - return False, render_main_content() - - -@app.callback( - Output("review-modal", "is_open"), - [ - Input("close-adjust-merge-scheme", "n_clicks"), - Input("submit-review", "n_clicks"), - ], - [ - State("review-modal", "is_open"), - ], -) -def toggle_review_modal(open_clicks, close_clicks, is_open): - if ctx.triggered_id == "close-adjust-merge-scheme" and open_clicks: - return True - elif ctx.triggered_id == "submit-review" and close_clicks: - return False - return is_open - - -@app.callback( - 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, 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)) - - grid_rows = [] - - cell_style = { - "border": "1px solid #ccc", - "padding": "6px", - "textAlign": "center" - } - - column_defs = [ - {"headerName": "Sample", "field": "Sample"}, - {"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"] - - 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) - unadjusted_phiX_computed_molarity = phiX_computed_molarity * phiX_dilution_factor - print("Step 4: ", phiX_computed_molarity) - phiX_computed_molarity = unadjusted_phiX_computed_molarity * QUANT_BIO_STANDARD_SIZE_BP / 500 # Assuming average size of 500 bp for PhiX - print("Step 5: ", phiX_computed_molarity) - phiX_computed_molarity = f"{phiX_computed_molarity} nM" - print("Step 6: ", 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: - 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" - - 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 if final_diluted_molarity is not None else None - formatted_phix_adjusted_diluted_molarity = f"{phix_adjusted_diluted_molarity:.2f} nM" if phix_adjusted_diluted_molarity is not None else "N/A" - - sample_identifier = qpcr_sample.file_sample_identifier - print("SAMPLE ID:", sample_identifier) - grid_rows.append({ - "Sample": sample_identifier, - "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, - }) - - def safe_numeric_key(v): - digits = ''.join(ch for ch in v if ch.isdigit()) - try: - return int(digits) - except (ValueError, TypeError): - return int(100) - - grid_rows = sorted(grid_rows, key=lambda r: safe_numeric_key(r.get("Sample"))) - - 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": False, - "filter": False, - "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: - - 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, - }, - ) - ) - - return html.Div(report_content), html.Div(report_content).to_plotly_json() - - - -@app.callback( - Output("used-samples-store", "data"), - Output("plate-grid", "rowData"), - Input("plate-grid", "cellValueChanged"), - Input("app_data_state", "data"), - State("plate-grid", "rowData"), -) -def update_qpcr_grid_assignments(cell_event, app_data_json, row_data): - - if ctx.triggered_id == "app_data_state": - return [], no_update - - if not row_data or not cell_event: - raise dash.exceptions.PreventUpdate - - # AG Grid may batch events — handle last one - if isinstance(cell_event, list): - cell_event = cell_event[-1] - - col_id = cell_event.get("colId") - row_label = cell_event.get("data", {}).get("Row") - if not col_id or not row_label: - raise dash.exceptions.PreventUpdate - - sample_name = cell_event.get("newValue") or cell_event.get("value") or "" - old_value = cell_event.get("oldValue") - well_id = f"{row_label}{col_id.replace('Col', '')}" - print(f"📍 cellValueChanged on {well_id}: {old_value} → {sample_name!r}") - - # Load qPCR plate from AppState - 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) - if not qpcr_plate: - print("⚠️ No qPCR plate found in AppState") - raise dash.exceptions.PreventUpdate - - # Build mapping of sample_identifier → list of wells - identifier_to_wells = {} - for well in qpcr_plate.plate_wells: - identifier_to_wells.setdefault(well.sample_identifier, []).append(well.well_position_id) - - # Determine which identifier this changed well belongs to - target_identifier = None - for well in qpcr_plate.plate_wells: - if well.well_position_id == well_id: - target_identifier = well.sample_identifier - break - - if not target_identifier: - print(f"⚠️ No sample_identifier for well {well_id}") - raise dash.exceptions.PreventUpdate - - replicate_wells = set(identifier_to_wells.get(target_identifier, [])) - print(f"🧬 Replicate wells for {target_identifier}: {replicate_wells}") - - # ---------------------------------------------------------------------- - # CASE 1: Dropping a new sample → fill all replicate wells - # ---------------------------------------------------------------------- - if sample_name.strip(): - for row in row_data: - for col_key in [k for k in row if k.startswith("Col")]: - current_well = f"{row['Row']}{col_key.replace('Col', '')}" - if current_well in replicate_wells: - row[col_key] = sample_name - if "_colorMap" not in row: - row["_colorMap"] = {} - # Copy color if present - color = cell_event.get("color") or row.get("_colorMap", {}).get(col_id) - row["_colorMap"][col_key] = color or "#43a047" - - print(f"✅ Assigned {sample_name} to all replicate wells ({len(replicate_wells)})") - - # ---------------------------------------------------------------------- - # CASE 2: Double-click removal → clear all replicate wells - # ---------------------------------------------------------------------- - else: - print(f"🧹 Clearing all replicate wells for identifier {target_identifier}") - for row in row_data: - for col_key in [k for k in row if k.startswith("Col")]: - current_well = f"{row['Row']}{col_key.replace('Col', '')}" - if current_well in replicate_wells: - row[col_key] = "" - if "_colorMap" in row: - row["_colorMap"].pop(col_key, None) - - # ---------------------------------------------------------------------- - # Compute used samples - # ---------------------------------------------------------------------- - used = { - val for row in row_data for k, val in row.items() - if k.startswith("Col") and val - } - - return sorted(list(used)), row_data - - - - -@app.callback( - Output("merge-modal-body", "children"), - [ - Input("app_data_state", "data"), - ], - [ - State("used-samples-store", "data"), - ] -) -def refresh_merge_modal(app_data, used_samples): - """ - Re-render the entire merge modal when global app state or used samples change. - """ - - sample_groups = { - "Negative Control": [], - "Positive Control": [], - "PhiX": [], - "Standard": [], - "Library": [], - } - - if not app_data: - raise dash.exceptions.PreventUpdate - - if app_data: - app_data_state = AppState.from_json(app_data) - else: - raise dash.exceptions.PreventUpdate - - OPEN = True - qpcr_plate = None - - for plate in app_data_state.plate_registry: - if plate.plate_type == "qPCR": - qpcr_plate = plate - break - - for plate in app_data_state.plate_registry: - if plate.plate_type in ("D1000", "HSD1000", "D5000"): - OPEN = False - break - - merge_message = dbc.Alert( - "No Tapestation plates to merge. If this is intended, you can continue by clicking 'merge plates', otherwise upload a tapestation plate to continue merging.", - color="warning", - is_open=OPEN, - dismissable=True, - style={"marginBottom": "15px"} - ) - - if qpcr_plate: - for well in qpcr_plate.plate_wells: - sample = well.get_sample(app_data_state) - sample_groups[sample.sample_type].append(well.well_position_id) - - new_merge_body = html.Div( - [ - dbc.Row([ - merge_message, - html.Div(id="sample-bar-container"), - render_dnd_aggrid(sample_groups=sample_groups), - ]), - - html.Hr(), - dbc.Button( - "Merge and Review", - id="close-adjust-merge-scheme", - className="ms-auto", - n_clicks=0, - ), - ], - className="p-3", - ) - - return new_merge_body - - -@app.callback( - Output("sample-bar-container", "children"), - Input("used-samples-store", "data"), - Input("app_data_state", "data"), -) -def update_sample_bar(used_samples, app_data_json): - - if not app_data_json: - raise dash.exceptions.PreventUpdate - app_state = AppState.from_json(app_data_json) - samples = [] - - for plate in app_state.plate_registry: - if plate.plate_type == "qPCR": - continue - else: - plate_samples = [] - for well_position in plate.plate_wells: - sample = well_position.get_sample(app_state) - plate_samples.append({ - "name": sample.file_sample_identifier, - "pos": well_position.well_position_id, - "color": SAMPLE_TYPE_COLORS.get(sample.sample_type, UNASSIGNED_COLOR) - }) - samples.append({ - "plate": plate.plate_id, - "samples": plate_samples - }) - - - return render_sample_bar(samples=samples, used_samples=used_samples) - - -@app.callback( - [Output("merge-plates-button-1", "disabled")], - [ - Input("app_data_state", "data"), - Input("alert-tapestation-sampleid", "is_open"), - Input("alert-fade-too-many-qpcr", "is_open"), - Input("alert-fade-duplicate-file", "is_open"), - Input("alert-find-libraries-fail", "is_open"), - Input("alert-find-libraries-success", "is_open"), - ], -) -def toggle_merge_button(app_data_json, tapestation_warning, too_many_qpcr, duplicate_file, find_libraries_fail, find_libraries_success): - app_state = AppState.from_json(app_data_json) if app_data_json else None - if not app_state: - return [True] - - has_tapestation = any(p.plate_type in ("D1000", "HSD1000", "D5000") for p in app_state.plate_registry) - has_qpcr = sum(1 for p in app_state.plate_registry if p.plate_type == "qPCR") == 1 - has_warnings = tapestation_warning or too_many_qpcr or duplicate_file or find_libraries_fail or find_libraries_success - - return [not (has_tapestation and has_qpcr and not has_warnings)] - - -@app.callback( - Output("adjust-merge-scheme-modal", "is_open"), - [ - Input("merge-plates-button-1", "n_clicks"), - Input("close-adjust-merge-scheme", "n_clicks"), - ], -) -def toggle_merge_modal(open_clicks, close_clicks): - if ctx.triggered_id == "merge-plates-button-1" and open_clicks: - return True - elif ctx.triggered_id == "close-adjust-merge-scheme" and close_clicks: - return False - return False - - -# Mega Funciton -@app.callback( - [ - Output("alert-fade-too-many-qpcr", "is_open"), - Output("alert-fade-too-many-qpcr", "children"), - Output("app_data_state", "data"), - Output("alert-tapestation-sampleid", "is_open"), - Output("alert-tapestation-sampleid", "children"), - Output("alert-find-libraries-success", "is_open"), - Output("alert-find-libraries-success", "children"), - Output("alert-find-libraries-fail", "is_open"), - Output("alert-find-libraries-fail", "children"), - Output("upload-files", "contents"), - Output("alert-fade-duplicate-file", "is_open"), - Output("alert-fade-duplicate-file", "children"), - Output("table-deletion-trigger", "children"), - Output("update-rows-trigger-div", "children") - ], - [ - Input("upload-files", "contents"), - Input({"type": "confirm-delete-plate", "index": ALL}, "n_clicks"), - Input({"type": "datatable-plate", "index": ALL}, "cellValueChanged"), - Input({"type": "confirm-delete-rows", "index": ALL}, "n_clicks"), - Input({"type": "Negative Control", "index": ALL}, "n_clicks"), - Input({"type": "Positive Control", "index": ALL}, "n_clicks"), - Input({"type": "Standard", "index": ALL}, "n_clicks"), - Input({"type": "PhiX", "index": ALL}, "n_clicks"), - Input({"type": "Library", "index": ALL}, "n_clicks"), - Input("flagging-conditions-storage", "data"), - ], - [ - State({"type": "plate-graph", "index": ALL}, "selectedData"), - State("upload-files", "filename"), - State("app_data_state", "data"), - State({"type": "datatable-plate", "index": ALL}, "selectedRows"), - State({"type": "datatable-plate", "index": ALL}, "rowData"), - State("dynamic-tabs", "active_tab"), - - # We add these states to be able to map plate_ids to the correct elements, with 100% certainty # - # NOTE: (Note to Marc in case I forget to tell you in person) -- Dash does not guaruntee order of ALL states/inputs, so we always need to remember this and be careful! - # Use MATCH instead of ALL wherever possible. - State({"type": "confirm-delete-plate", "index": ALL}, "id"), - State({"type": "datatable-plate", "index": ALL}, "id"), - State({"type": "confirm-delete-rows", "index": ALL}, "id"), - State({"type":"plate-graph", "index":ALL}, "id"), - State("view-mode-switch", "value"), - ], - prevent_initial_call=True, -) -def manipulate_plates( - contents, - delete_plate_buttons, - cell_edits, - delete_rows_buttons, - negative_control, - positive_control, - standard, - phix, - library, - flagging_conditions_storage, - plate_graph_selections, - filenames, - app_data_state_json, - selected_rows_all, - all_ui_tables, - active_tab, - delete_button_ids, - datatable_ids, - delete_rows_button_ids, - plate_graph_ids, - table_view, -): - """ - Core callback for all plate management actions (upload, delete, edit, sample lookup). - - Handles: - 1. **Upload:** Adds new qPCR or Tapestation plates from user-uploaded files (CSV/TXT). - 2. **Delete:** Removes a selected plate (by plate_id). - 3. **Edit:** Syncs manual sample ID edits from the DataTable UI into the Plate objects (single source of truth). - 4. **Sample Lookup:** Uses the 'Find Libraries' button to query sample metadata from B-Fabric based on current sample IDs. - - Marc I am sorry I have completely changed all of this!! I'll need to re-write this docstring later. - """ - - POSSIBLE_TRIGGERS = { - "confirm-delete-plate", - "confirm-delete-rows", - "datatable-plate", - "Negative Control", - "Positive Control", - "Standard", - "PhiX", - "Library", - } - - - triggered = ctx.triggered_id - app_state = AppState.from_json(app_data_state_json) if app_data_state_json else AppState() - - print("manipulate_plates triggered by:", triggered) - - # --- Handle Plate Upload --- - if triggered == "upload-files": - # --- Deduplication logic --- - duplicates = [] - files_to_upload = [] - filenames_to_upload = [] - for content, fname in zip(contents, filenames): - if fname in app_state.uploaded_files_base64: - duplicates.append(fname) - else: - files_to_upload.append(content) - filenames_to_upload.append(fname) - - if duplicates: - warnings = collect_missing_sample_warnings(app_state) - show_warning = bool(warnings) - warning_msg = build_warning_msg(warnings) - - if len(duplicates) == 1: - duplicate_msg = f"Upload failed: The file {duplicates[0]} has already been uploaded." - else: - duplicate_msg = html.Div([ - html.Strong("Warning: Upload not successful!"), - html.Br(), - "The following file(s) have already been uploaded:", - html.Ul([html.Li(fname) for fname in duplicates]) - ]) - - return ( - False, "", app_state.to_json(), show_warning, warning_msg, - False, "", False, "", None, - True, duplicate_msg, no_update, no_update - ) - - - temp_state = copy.deepcopy(app_state) # Work on a copy first; only commit if all uploads succeed! - if files_to_upload: - for content, fname in zip(files_to_upload, filenames_to_upload): - upload_status, temp_state, failure_msg = handle_plate_upload(content, fname, temp_state) - if upload_status: - temp_state.uploaded_files_base64[fname] = content - else: - warnings = collect_missing_sample_warnings(app_state) - show_warning = bool(warnings) - warning_msg = build_warning_msg(warnings) - return ( - True, failure_msg, - app_state.to_json(), show_warning, warning_msg, - False, "", False, "", None, False, "", no_update, no_update - ) - - app_state = temp_state - warnings = collect_missing_sample_warnings(app_state) - show_warning = bool(warnings) - warning_msg = build_warning_msg(warnings) - - return ( - False, "", app_state.to_json(), show_warning, warning_msg, - False, "", False, "", None, False, "", [], no_update - ) - - elif triggered == "flagging-conditions-storage": - update_app_state_from_ui(app_state, all_ui_tables) - warnings = collect_missing_sample_warnings(app_state) - show_warning = bool(warnings) - warning_msg = build_warning_msg(warnings) - return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", no_update, [] - - - # --- Check that we're not getting any unexpected triggers... this shouldn't happen, but better to raise an error rather than hide it --- - elif type(triggered) != dash._utils.AttributeDict or triggered.get("type") not in POSSIBLE_TRIGGERS: - raise Exception("ERROR RAISED INTENTIONALLY - No relevant trigger found.") - - # --- Handle the case where our input is a dictionary (meaning it's one of: delete plate, delete rows, or datatable edit) --- - else: - - # We only use plate id for indexing - trigger_type = triggered.get("type") - plate_id = triggered.get("index") - - if plate_id != active_tab: - raise Exception("ERROR RAISED INTENTIONALLY - Triggered plate does not match active tab... major underlying issue to resolve.") - - # Here we create maps of plate_id -> object to garuntee correct mapping of actions to plates - # Just commenting out the ones we don't need for now. - # delete_button_plate_map = plate_mapper(delete_button_ids, delete_plate_buttons) - # cell_value_changed_map = plate_mapper(datatable_ids, cell_edits) - # confirm_delete_rows_map = plate_mapper(delete_rows_button_ids, delete_rows_buttons) - selected_rows_map = plate_mapper(datatable_ids, selected_rows_all) - selected_data_map = plate_mapper(plate_graph_ids, plate_graph_selections) - # row_data_map = plate_mapper(datatable_ids, all_ui_tables) - - # --- Handle Plate Deletion --- - if trigger_type == "confirm-delete-plate": - handle_plate_delete(plate_id, app_state) - warnings = collect_missing_sample_warnings(app_state) - show_warning = bool(warnings) - warning_msg = build_warning_msg(warnings) - return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", [], no_update - - - # --- Handle Row Deletion (confirmed via modal) --- - elif trigger_type == "confirm-delete-rows": - - if table_view: - selected_rows = selected_rows_map.get(plate_id) - plate = app_state.plate_by_id.get(plate_id) - if selected_rows: - to_remove = {str(r.get("well_position_id")) for r in selected_rows} - plate.plate_wells = [ - w for w in plate.plate_wells - if str(w.well_position_id) not in to_remove - ] - - else: # Plate View - selected_data = selected_data_map.get(plate_id) - plate = app_state.plate_by_id.get(plate_id) - if not selected_data: - selected_wells = [] - else: - selected_wells = [p['customdata'][0] for p in selected_data['points']] - if selected_wells: - to_remove = set(selected_wells) - plate.plate_wells = [ - w for w in plate.plate_wells - if str(w.well_position_id) not in to_remove - ] - - warnings = collect_missing_sample_warnings(app_state) - show_warning = bool(warnings) - warning_msg = build_warning_msg(warnings) - - return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", no_update, [] - - - # --- Handle Manual Edits --- - elif trigger_type == "datatable-plate": - update_app_state_from_ui(app_state, all_ui_tables) - warnings = collect_missing_sample_warnings(app_state) - show_warning = bool(warnings) - warning_msg = build_warning_msg(warnings) - return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", no_update, [] - - - elif trigger_type in {"Negative Control", "Positive Control", "Standard", "PhiX", "Library"}: - sample_type = trigger_type - plate = app_state.plate_by_id.get(plate_id) - if not plate: - raise Exception("ERROR RAISED INTENTIONALLY - No plate found for given plate_id... major underlying issue to resolve.") - - if table_view: - raise Exception("ERROR RAISED INTENTIONALLY - Sample type assignment from buttons only exist in Plate View!!! (how could this even happen???)... major underlying issue to resolve.") - else: # Plate View - selected_data = selected_data_map.get(plate_id) - if selected_data and "points" in selected_data: - selected_well_ids = {p['customdata'][0] for p in selected_data['points']} - for well in plate.plate_wells: - if str(well.well_position_id) in selected_well_ids: - well.get_sample(app_state).sample_type = sample_type - - - - warnings = collect_missing_sample_warnings(app_state) - show_warning = bool(warnings) - warning_msg = build_warning_msg(warnings) - return False, "", app_state.to_json(), show_warning, warning_msg, False, "", False, "", None, False, "", no_update, [] - - raise Exception("ERROR RAISED INTENTIONALLY - Reached end of manipulate_plates without handling trigger... major underlying issue to resolve.") - - -@app.callback( - Output({"type": "pcr-curve-graph", "index": MATCH}, "figure"), - Output({"type": "pcr-curve-table", "index": MATCH}, "children"), - Output({"type": "pcr-curve-store", "index": MATCH}, "data"), - Input("app_data_state", "data"), - State({"type": "datatable-plate", "index": MATCH}, "rowData"), - State({"type": "datatable-plate", "index": MATCH}, "id"), - prevent_initial_call=True, -) -def update_standard_curve(app_state, row_data, plate_id): - - if not row_data: - return no_update, no_update, no_update - - else: - app_state = AppState.from_json(app_state) - plate = app_state.plate_by_id.get(plate_id.get("index")) - - if plate is None: - return no_update, no_update, no_update - - - df = plate.dataset(app_state, tidy=True).replace(['None', 'none', 'NaN', 'nan', ''], np.nan).infer_objects(copy=False) - - # grab only samples which are defined as standards - standards_df = df[df['sample_type'] == 'Standard'].copy() - - # ensure numeric and drop rows with missing/invalid Standard or Cq - standards_df["Standard"] = pd.to_numeric(standards_df["Standard"], errors="coerce") - standards_df = standards_df.loc[standards_df["Standard"].notna() & standards_df["Cq"].notna()] - - pcr_fig, slope, intercept, efficiency, r_squared = build_standard_curve_figure(standards_df) - store_data = { - "slope": slope, - "intercept": intercept, - "efficiency": efficiency, - "r_squared": r_squared - } - - pcr_table_children = [ - html.Tr([html.Th("PCR Efficiency"), html.Th("R-Squared"), html.Th("Slope"), html.Th("Intercept")]), - html.Tr([ - html.Td(f"{efficiency:.1f}%"), - html.Td(f"{r_squared:.3f}"), - html.Td(f"{slope:.3f}"), - html.Td(f"{intercept:.3f}") - ]) - ] - return pcr_fig, pcr_table_children, store_data - - - - - -@app.callback( - Output("flagging-conditions-storage", "data"), - [ - Input({"type": "input-stddev", "index": ALL}, "value"), - Input({"type": "input-min-nm", "index": ALL}, "value"), - Input({"type": "input-max-nm", "index": ALL}, "value"), - Input({"type": "input-min-frag", "index": ALL}, "value"), - Input({"type": "input-max-frag", "index": ALL}, "value"), - Input({"type": "input-min-mol", "index": ALL}, "value"), - Input({"type": "input-max-mol", "index": ALL}, "value"), - Input({"type": "phix-control-dropdown", "index": ALL}, "value"), - ], - [ - State({"type": "input-stddev", "index": ALL}, "id"), - State({"type": "input-min-nm", "index": ALL}, "id"), - State({"type": "input-max-nm", "index": ALL}, "id"), - State({"type": "input-min-frag", "index": ALL}, "id"), - State({"type": "input-max-frag", "index": ALL}, "id"), - State({"type": "input-min-mol", "index": ALL}, "id"), - State({"type": "input-max-mol", "index": ALL}, "id"), - State({"type": "phix-control-dropdown", "index": ALL}, "id"), - ], - prevent_initial_call=True -) - -def collect_flagging_conditions(stddev_vals, min_nm_vals, max_nm_vals, - min_frag_vals, max_frag_vals, min_mol_vals, max_mol_vals, - phix_vals, stddev_ids, min_nm_ids, max_nm_ids, - min_frag_ids, max_frag_ids, min_mol_ids, max_mol_ids, phix_ids ): - - def add(values, ids, key, out): - for value, id_dict in zip(values, ids): - pid = id_dict.get("index") - if pid not in out: - out[pid] = {} - out[pid][key] = value - - print("Collecting flagging conditions...", phix_vals) - - flagging_conditions = {} - add(stddev_vals, stddev_ids, "stddev", flagging_conditions) - add(min_nm_vals, min_nm_ids, "min_nm", flagging_conditions) - add(max_nm_vals, max_nm_ids, "max_nm", flagging_conditions) - add(min_frag_vals, min_frag_ids, "min_frag", flagging_conditions) - add(max_frag_vals, max_frag_ids, "max_frag", flagging_conditions) - add(min_mol_vals, min_mol_ids, "min_mol", flagging_conditions) - add(max_mol_vals, max_mol_ids, "max_mol", flagging_conditions) - add(phix_vals, phix_ids, "phix_control_well_position", flagging_conditions) - - print("Collected flagging conditions:", flagging_conditions) - return flagging_conditions - - - - -@app.callback( - [ - Output("dynamic-tabs", "children", allow_duplicate=True), - Output("dynamic-tabs", "active_tab", allow_duplicate=True), - Output("table-creation-trigger", "children"), - Output("flagging-conditions-storage-prune-missing-tabs", "data"), - ], - # TRIGGERS AFTER EVERY "UPLOAD OR DELETE" OPERATION - # Upload / Delete from mega function --> table-deletion-trigger --> table-creation-trigger - Input("table-deletion-trigger", "children"), - State("dynamic-tabs", "children"), - State("dynamic-tabs", "active_tab"), - State("flagging-conditions-storage", "data"), - prevent_initial_call=True -) -def prune_missing_tabs(app_data_json, children, active_tabs, flagging_conditions_storage): - - children = children or [] - app = AppState.from_json(app_data_json) if app_data_json else None - desired_ids = [str(p.plate_id) for p in (app.plate_registry if app else [])] - - # Current tabs (serialized dicts). Keep order to avoid remount of survivors. - old_ids = [get_tab_id(t) for t in children] - new_children = [t for t in children if get_tab_id(t) in desired_ids] - remaining_ids = [get_tab_id(t) for t in new_children] - - # Decide next active tab - if remaining_ids: - if active_tab in remaining_ids: - next_active = active_tab - else: - # Prefer nearest left neighbor from the old ordering; fallback to last remaining - next_active = None - if active_tab in old_ids: - i = old_ids.index(active_tab) - for cand in reversed(old_ids[:i]): - if cand in remaining_ids: - next_active = cand - break - if next_active is None: - next_active = remaining_ids[-1] - else: - new_children, next_active = [], None - - # Build a per-plate flagging snapshot from the ALL inputs - - return new_children, next_active, "", flagging_conditions_storage - - -@app.callback( - [ - Output("dynamic-tabs", "children"), - Output("dynamic-tabs", "active_tab"), - Output("adjust-flagging-conditon-buttons", "children"), - Output("phix-control-dropdowns", "children"), - Output("alert-flagging-conditions", "is_open", allow_duplicate=True), - Output("alert-flagging-conditions", "children", allow_duplicate=True), - ], - # TRIGGERS AFTER EVERY "UPLOAD OR DELETE" OPERATION - # Upload / Delete from mega function --> table-deletion-trigger --> table-creation-trigger - Input("table-creation-trigger", "children"), - [ - State("app_data_state", "data"), - State("dynamic-tabs", "children"), - State("dynamic-tabs", "active_tab"), - State("flagging-conditions-storage-prune-missing-tabs", "data") - ], - prevent_initial_call=True -) -def append_new_tabs(_trigger, app_data_json, existing_tabs, active_tab, flagging_conditions): - # RETURNS an EXACT COPY of existing tabs, plus any new ones, generated - # the same as before from the function in plate_render_utils. - - #print("app_state for tab append before:", app_data_json) - - existing_tabs = existing_tabs or [] - app = AppState.from_json(app_data_json) - desired_ids = [str(p.plate_id) for p in app.plate_registry] - - # Get the tab ids for existing tabs - mounted_ids = [get_tab_id(t) for t in existing_tabs] - - # append only missing - to_add = [pid for pid in desired_ids if pid not in mounted_ids] - if not to_add: - # nothing to add; keep everything untouched - return existing_tabs, (active_tab if active_tab in desired_ids else (desired_ids[-1] if desired_ids else None)), no_update, no_update, no_update, no_update - - - plates_by_id = {str(p.plate_id): p for p in app.plate_registry} - new_tabs = [] - flag_buttons = [] - phix_dropdowns = [] - aggregated_flagging_warnings = [] - for pid in to_add: - if flagging_conditions: - flagging_per_plate = flagging_conditions.get(pid) - else: - flagging_per_plate = {} - - tab, flag_button, phix_dropdown, plate_flagging_warnings = make_tab( - plates_by_id[pid], - app, - current_flagging=flagging_per_plate - ) - - new_tabs.append(tab) - flag_buttons.append(flag_button) - phix_dropdowns.append(phix_dropdown) - - if plate_flagging_warnings: - aggregated_flagging_warnings.extend(plate_flagging_warnings) - - - - # preserve order of existing; append new at the end (no reordering of mounted) - next_children = existing_tabs + new_tabs - - # Compute the valid ids actually mounted now - def _id_of(tab_like): - return getattr(tab_like, "tab_id", None) or ((tab_like or {}).get("props", {}) or {}).get("tab_id") - - mounted_now = [_id_of(t) for t in next_children] - - # Robust active tab choice: - if active_tab in mounted_now: - next_active = active_tab - elif to_add: - # Focus the most recently added tab (last appended) — feels right after an upload - next_active = to_add[-1] - elif mounted_now: - # Nothing new; pick last existing (or pick first if you prefer) - next_active = mounted_now[-1] - else: - next_active = None - - # Before returning, set visibility style on each button - for btn in flag_buttons: - if btn.id["index"] == next_active: - # Make the active one visible - btn.style = {} - else: - # Hide all others - btn.style = {"display": "none"} - - #print("app_state for tab append after:", app_data_json) - - if aggregated_flagging_warnings: - # turn warnings into
  • list items - list_items = [html.Li(msg) for msg in aggregated_flagging_warnings] - - # add the instruction at the bottom, same styling pattern as build_warning_msg - list_items.append( - html.Li( - "To resolve these warnings: Check wells highlighted in red. Adjust your flagging thresholds (Adjust Flagging Condition) or review those wells manually.", - style={"listStyleType": "none", "marginTop": "10px"} - ) - ) - - alert_is_open = True - alert_children = html.Ul(list_items) - else: - alert_is_open = False - alert_children = "" - - return ( - next_children, - next_active, - flag_buttons, - phix_dropdowns, - alert_is_open, - alert_children, - ) - - - -# This function toggles visibility between the plate graph and the data table based on the switch state. -# e.g. if you change to plate view, table view is hidden and vice versa. -@app.callback( - Output({"type": "tab-table-wrap", "index": ALL}, "style"), - Output({"type": "tab-plate-wrap", "index": ALL}, "style"), - Output({"type": "plate-sample-type-dropdown", "index": ALL}, "style"), - Output({"type": "plate-sample-type-dropdown", "index": ALL}, "disabled"), - Input("view-mode-switch", "value"), - State({"type": "tab-table-wrap", "index": ALL}, "style"), - State({"type": "tab-plate-wrap", "index": ALL}, "style"), -) -def toggle_table_vs_plate(table_view, table_styles, plate_styles): - # When no tabs exist yet, Dash passes empty lists; handle gracefully. - n = len(table_styles or []) - if n == 0: - return [], [], [], [] - # preserve other style keys; only flip display - def show(s): return {**(s or {}), "display": "block"} - def hide(s): return {**(s or {}), "display": "none"} - if table_view: - return [show(s) for s in table_styles], [hide(s) for s in plate_styles], [hide(s) for s in plate_styles], [True]*n - else: - return [hide(s) for s in table_styles], [show(s) for s in plate_styles], [show(s) for s in plate_styles], [False]*n - - -# This function syncs the plate graph's visual state (well fills, borders, selection overlay) based on DataTable edits and selection. -@app.callback( - Output({"type": "plate-graph", "index": MATCH}, "figure"), - Input({"type": "datatable-plate", "index": MATCH}, "cellValueChanged"), - Input({"type": "datatable-plate", "index": MATCH}, "rowData"), - Input({"type": "datatable-plate", "index": MATCH}, "virtualRowData"), - State({"type": "plate-graph", "index": MATCH}, "figure"), - prevent_initial_call=True, -) -def sync_plate_style(cell_evt, row_data, virtual_row_data, fig): - if not fig or "data" not in fig or not fig["data"]: - return no_update - fig = copy.deepcopy(fig) - - # presence from rowData - df_presence = pd.DataFrame(row_data or []) - wells_present = set(df_presence.get("well_position_id", pd.Series([], dtype=str)).astype(str)) - - # flags & sample_type (prefer virtual for in-grid edits) - rows_flags = virtual_row_data if virtual_row_data is not None else row_data - df_flags = pd.DataFrame(rows_flags or []) - - # inline override so just-edited value is visible immediately - if isinstance(cell_evt, dict) and cell_evt: - col_id = str(cell_evt.get("colId", "")).strip() - row_d = cell_evt.get("data", {}) or {} - wpid = str(row_d.get("well_position_id", "")).strip() - if wpid: - if df_flags.empty: - df_flags = pd.DataFrame([{"well_position_id": wpid}]) - mask = (df_flags.get("well_position_id", pd.Series([], dtype=str)).astype(str) == wpid) - if not mask.any(): - df_flags = pd.concat([df_flags, pd.DataFrame([{"well_position_id": wpid}])], ignore_index=True) - mask = (df_flags["well_position_id"].astype(str) == wpid) - if col_id == "sample_type": - df_flags.loc[mask, "sample_type"] = cell_evt.get("newValue", row_d.get("sample_type")) - if col_id == "well_note": - df_flags.loc[mask, "well_note"] = cell_evt.get("newValue", row_d.get("well_note")) - - # error/warn sets - if {"well_note", "well_position_id"}.issubset(df_flags.columns): - error_wells = set(df_flags.loc[ - df_flags["well_note"].isin(["No sample found","Multiple samples found"]), - "well_position_id" - ].astype(str)) - warn_wells = set(df_flags.loc[ - df_flags["well_note"]=="Unable to Update", - "well_position_id" - ].astype(str)) - else: - error_wells, warn_wells = set(), set() - - # Add flagging_condition-based error wells - flag_error_wells = set() - if {"flagging_conditions", "well_position_id"}.issubset(df_flags.columns): - flag_error_wells = set( - df_flags.loc[ - df_flags["flagging_conditions"].notna() & (df_flags["flagging_conditions"].astype(str) != ""), - "well_position_id" - ].astype(str) - ) - error_wells |= flag_error_wells - - # sample types - sample_type_map = {} - if {"sample_type","well_position_id"}.issubset(df_flags.columns): - for w, t in zip(df_flags["well_position_id"].astype(str), df_flags["sample_type"]): - if pd.isna(t): continue - sample_type_map[w] = (str(t).strip() or None) - - base = fig["data"][0] - wells = [cd[0] for cd in base["customdata"]] - - sizes, fills, line_colors, line_widths, customdata = [], [], [], [], [] - for w in wells: - present = (w in wells_present) - if not present: - sizes.append(MARKER_PX) - fills.append(EMPTY_FILL) - line_colors.append(EMPTY_BORDER) - line_widths.append(EMPTY_BORDER_PX) - customdata.append([w, "Empty"]) - continue - - st = (sample_type_map.get(w) or "Unassigned").strip() - fill_color = SAMPLE_TYPE_COLORS.get(st, UNASSIGNED_COLOR) if st != "Unassigned" else UNASSIGNED_COLOR - sizes.append(MARKER_PX) - fills.append(fill_color) - customdata.append([w, st]) - - if w in error_wells and st in ("Library", "Positive Control"): - line_colors.append(ERROR_BORDER); line_widths.append(3) - elif w in warn_wells and st in ("Library", "Positive Control"): - line_colors.append(WARN_BORDER); line_widths.append(3) - else: - line_colors.append(DEFAULT_BORDER); line_widths.append(BORDER_PX) - - base["marker"]["size"] = sizes - base["marker"]["color"] = fills - base["marker"]["line"]["color"] = line_colors - base["marker"]["line"]["width"] = line_widths - base["customdata"] = customdata - - # keep selection rendering enabled - base["selected"] = dict(marker=dict(size=MARKER_PX + 6, opacity=1)) - base["unselected"] = dict(marker=dict(opacity=0.45)) - - # ensure only one trace - fig["data"] = [base] - return fig - - - - - -@app.callback( - Output({"type": "datatable-plate", "index": MATCH}, "rowData"), - Input("update-rows-trigger-div", "children"), # broadcast trigger - Input("app_data_state", "data"), # source of truth - Input({"type": "pcr-curve-store", "index": MATCH}, "data"), # for qPCR plates, to show efficiency etc - # Input({"type": "phix-control-dropdown", "index": MATCH}, "value"), # to highlight the selected phix control value - State({"type": "datatable-plate", "index": MATCH}, "id"), # gives us the plate_id (via id["index"]) - State("flagging-conditions-storage", "data"), # to apply flagging conditions - prevent_initial_call=True -) -# def update_rows_callback(_, app_data_state_json, pcr_store, phix_control_value, table_id): -def update_rows_callback(_, app_data_state_json, pcr_store, table_id, current_flagging): - - if not app_data_state_json or not table_id: - raise PreventUpdate - - plate_id = str(table_id["index"]) - app_state = AppState.from_json(app_data_state_json) - - # find the plate by invariant key - plate = app_state.plate_by_id.get(plate_id) - - slope, intercept = pcr_store.get("slope"), pcr_store.get("intercept") if pcr_store else (None, None) - - phix_correction_factor = 1.0 - - # if phix_control_value is None: - # phix_correction_factor = 1.0 - # else: - # phix_correction_factor = 10 / float(phix_control_value) - - if not plate: - return None - - is_qpcr = (plate.plate_type == "qPCR") - - df = plate.dataset(app_state, tidy=False).replace(['None', 'none', 'NaN', 'nan', ''], np.nan).infer_objects(copy=False) - df = build_computed_molarity(app_state, plate, slope, intercept, phix_correction_factor) - per_plate_flagging = current_flagging.get(plate_id, {}) - df = collect_flagging_from_all(df, per_plate_flagging) - - if "__error__" not in df.columns: - df["__error__"] = False - - # Keep columns tidy, but do not drop keys AG Grid needs (e.g., well_position_id if you use getRowId) - if is_qpcr: - df = df.dropna(axis=1, how="all") - else: - # drop all-NaN columns except 'sample_id' - drop_cols = [c for c in df.columns if c not in {"sample_id","flagging_conditions","well_note"} and df[c].isna().all()] - if drop_cols: - df = df.drop(columns=drop_cols) - - if "well_note" in df.columns: - df["__error__"] = df["well_note"].isin(["No sample found", "Multiple samples found"]) - df["__warning__"] = df["well_note"] == "Unable to Update" - else: - df["__error__"] = False - df["__warning__"] = False - - if "flagging_conditions" in df.columns: - has_flag_issue = df["flagging_conditions"].astype(str).str.strip() != "" - df["__error__"] = df["__error__"] | has_flag_issue - - return df.to_dict("records") - - - - - -@app.callback( - [ - Output("alert-flagging-conditions", "is_open", allow_duplicate=True), - Output("alert-flagging-conditions", "children", allow_duplicate=True), - ], - # We re-run this alert logic whenever rowData changes - Input({"type": "datatable-plate", "index": ALL}, "rowData"), - State({"type": "datatable-plate", "index": ALL}, "id"), - prevent_initial_call=True, -) -def update_flagging_alert(all_rowdata_per_plate, all_table_ids): - """ - Build the global 'flagging conditions' alert based on the CURRENT contents - of every plate table. - - all_rowdata_per_plate: list[ list[ rowDict, ... ] ], one entry per plate table - all_table_ids: list[ {"type":"datatable-plate","index":}, ... ], - parallel to all_rowdata_per_plate - """ - - messages = [] - - # pair up each table's id with its rows - for table_id_obj, rows in zip(all_table_ids, all_rowdata_per_plate): - plate_id = str(table_id_obj.get("index")) - - if not rows: - continue - - # Find wells that currently violate flagging_conditions on this plate - violating_wells = [] - for r in rows: - # r is each row dict (the records from df.to_dict("records")) - flag_text = str(r.get("flagging_conditions") or "").strip() - sample_type = r.get("sample_type") - well_id = r.get("well_position_id") - - if ( - flag_text != "" and - sample_type in ("Library", "Positive Control") - ): - violating_wells.append(str(well_id)) - - if violating_wells: - # unique & sorted for pretty output - wells_str = ", ".join(sorted(set(violating_wells))) - messages.append( - f"Plate {plate_id}: wells {wells_str} exceed the defined flagging thresholds." - ) - - # no violations → hide the alert - if not messages: - return False, "" - - # build a nice bullet list - items = [html.Li(msg) for msg in messages] - items.append( - html.Li( - "To resolve these warnings: Adjust 'Flagging Condition' thresholds or inspect those wells.", - style={"listStyleType": "none", "marginTop": "10px"} - ) - ) - alert_children = html.Ul(items) - - return True, alert_children - - - - - - -@app.callback( - Output({"type": "adjust-flagging-btn", "index": ALL}, "style"), - Input("dynamic-tabs", "active_tab"), - State({"type": "adjust-flagging-btn", "index": ALL}, "id"), -) -def show_only_active_flagging_button(active_tab, all_ids): - return [ - {} if btn_id["index"] == active_tab else {"display": "none"} - for btn_id in all_ids - ] - - - -@app.callback( - Output({"type": "phix-control-wrapper", "index": ALL}, "style"), - Input("dynamic-tabs", "active_tab"), - State({"type": "phix-control-wrapper", "index": ALL}, "id"), -) -def show_only_active_phix_drop(active_tab, all_ids): - return [ - {} if btn_id["index"] == active_tab else {"display": "none"} - for btn_id in all_ids - ] - - - -@app.callback( - Output({"type": "phix-control-dropdown", "index": MATCH}, "options"), - [ - Input("app_data_state", "data"), - Input("dynamic-tabs", "active_tab"), - ], - State({"type": "phix-control-dropdown", "index": MATCH}, "id"), - prevent_initial_call=True, -) -def refresh_phix_dropdown_options(app_data_state_json, active_tab, this_dropdown_id): - """ - Keeps the PhiX dropdown OPTIONS up to date for each plate. - This runs separately from build_tab_body(), so content stays fresh. - """ - - if app_data_state_json is None: - return [] - - app_state = AppState.from_json(app_data_state_json) - - # which plate does THIS dropdown belong to? - pid_for_this_dropdown = this_dropdown_id["index"] - plate = app_state.plate_by_id.get(pid_for_this_dropdown) - if plate is None: - return [] - - is_qpcr = (plate.plate_type == "qPCR") - - df = plate.dataset(app_state, tidy=False) - - if "sample_type" in df.columns: - phix_rows = df[df["sample_type"] == "PhiX"] - else: - phix_rows = pd.DataFrame() - - dropdown_options = [] - plate_type_display = "qPCR" if is_qpcr else "TS" - - for _, row in phix_rows.iterrows(): - well = row.get("well_position_id") - - if is_qpcr: - colum_value = row.get("Cq") - else: - # dynamically find "Region Molarity ..." column - region_molarity_col = next( - (col for col in row.index if col.startswith("Region Molarity")), - None - ) - colum_value = row.get(region_molarity_col) if region_molarity_col else None - - label = f"{plate_type_display} - PhiX - {well} - {colum_value}" - value = "" if colum_value is None else str(colum_value) - - dropdown_options.append({"label": label, "value": well}) - - return dropdown_options - - - -# @app.callback( -# Output("plate-button-container", "children"), -# [Input("app_data_state", "data"), -# Input("dynamic-tabs", "active_tab")], -# prevent_initial_call=True -# ) -# def update_plate_buttons(app_data_state_json, selected_tab): - -# if selected_tab is None or app_data_state_json is None: -# return -# else: -# plate_id = selected_tab -# app_state = AppState.from_json(app_data_state_json) -# plate = app_state.plate_by_id.get(plate_id) -# df = plate.dataset(app_state, tidy=False) - -# # Only consider rows where sample_type == "PhiX" -# if "sample_type" in df.columns: -# phix_rows = df[df["sample_type"] == "PhiX"] -# else: -# phix_rows = pd.DataFrame() - -# # Prepare dropdown options -# dropdown_options = [] -# plate_type = plate.plate_type -# for _, row in phix_rows.iterrows(): -# well = row.get("well_position_id") -# if plate_type.lower() == "qpcr": -# colum_value = row.get("Cq") -# plate_type_display = "qPCR" -# else: -# # Finds the col for Region Molarity dynamically no mater what the unit is in the ending -# region_molarity_col = next((col for col in row.index if col.startswith("Region Molarity"))) -# colum_value = row.get(region_molarity_col) -# plate_type_display = "TS" -# label = f"{plate_type_display} - PhiX - {well} - {colum_value}" -# value = str(colum_value) -# dropdown_options.append({"label": label, "value": value}) - -# return html.Div( -# [ -# # --- Control by PhiX Dropdown --- -# dbc.Label( -# "Control by PhiX", -# className="mt-2 mb-1 text-muted fw-semibold", -# ), -# dcc.Dropdown( -# id={"type": "phix-control-dropdown", "index": plate_id}, -# options=dropdown_options, -# placeholder="Select or enter control value...", -# className="mb-2 small", # reduce dropdown text size -# ), -# ], -# className="p-2 bg-light rounded shadow-sm", # optional card-like background -# ) - - - - -@app.callback( - Output({"type": "flagging-modal", "index": MATCH}, "is_open"), - Input({"type": "adjust-flagging-btn", "index": MATCH}, "n_clicks"), - Input({"type": "flagging-modal-close", "index": MATCH}, "n_clicks"), - State({"type": "flagging-modal", "index": MATCH}, "is_open"), - State({"type": "input-stddev", "index": ALL}, "value"), - State({"type": "input-stddev", "index": ALL}, "id"), - prevent_initial_call=True, -) -def toggle_flagging_modal(adjust_btn, close_btn, is_open, stddev_vals, stddev_ids): - # Coalesce None -> False so we always return a boolean - is_open = bool(is_open) - tid = getattr(ctx, "triggered_id", None) - - if isinstance(tid, dict) and tid.get("type") == "adjust-flagging-btn" and (adjust_btn or 0) > 0: - return True - if isinstance(tid, dict) and tid.get("type") == "flagging-modal-close" and (close_btn or 0) > 0: - return False - - # First render or unrelated trigger -> keep current state - return is_open - - - - -@app.callback( - Output({"type": "modal-delete-rows", "index": MATCH}, "is_open"), - [ - Input({"type": "delete-rows-btn", "index": MATCH}, "n_clicks"), - Input({"type": "cancel-delete-rows", "index": MATCH}, "n_clicks"), - Input({"type": "confirm-delete-rows", "index": MATCH}, "n_clicks"), - ], - prevent_initial_call=True -) -def toggle_delete_rows_modal(n_open, n_cancel, n_confirm): - triggered = ctx.triggered_id - print("toggle_delete_rows_modal triggered by:", triggered) - if not isinstance(triggered, dict) or triggered.get("type") not in ["delete-rows-btn", "cancel-delete-rows", "confirm-delete-rows"]: - raise PreventUpdate - - if triggered["type"] == "delete-rows-btn" and n_open: - return True - elif triggered["type"] in ["cancel-delete-rows", "confirm-delete-rows"] and (n_cancel or n_confirm): - return False - return no_update - - -@app.callback( - Output({"type": "modal-delete-plate", "index": MATCH}, "is_open"), - [ - Input({"type": "delete-plate-btn", "index": MATCH}, "n_clicks"), - Input({"type": "cancel-delete-plate", "index": MATCH}, "n_clicks"), - Input({"type": "confirm-delete-plate", "index": MATCH}, "n_clicks"), - ], - prevent_initial_call=True -) -def toggle_delete_plate_modal(n_open, n_cancel, n_confirm): - print("toggle_delete_plate_modal called by:", ctx.triggered_id) - triggered = ctx.triggered_id - if not isinstance(triggered, dict) or triggered.get("type") not in ["delete-plate-btn", "cancel-delete-plate", "confirm-delete-plate"]: - raise PreventUpdate - - if triggered["type"] == "delete-plate-btn" and n_open: - return True - elif triggered["type"] in ["cancel-delete-plate", "confirm-delete-plate"] and (n_cancel or n_confirm): - return False - return no_update - -# Here we run the app on the specified host and port. +# Run the app if __name__ == "__main__": - app.run(debug=bfabric_web_apps.DEBUG, port=bfabric_web_apps.PORT, host=bfabric_web_apps.HOST) + app.run( + debug=bfabric_web_apps.DEBUG, + port=bfabric_web_apps.PORT, + host=bfabric_web_apps.HOST + ) diff --git a/make_reference.py b/make_reference.py index 2fa36ec..c952dc8 100644 --- a/make_reference.py +++ b/make_reference.py @@ -16,7 +16,7 @@ import pandas as pd import os import io -from models import Plate +from backend.models import Plate # Map folder names to plate types PLATE_TYPE_MAP = { diff --git a/utils/api_call_utils.py b/utils/api_call_utils.py index 2de73f4..02a836e 100644 --- a/utils/api_call_utils.py +++ b/utils/api_call_utils.py @@ -1,7 +1,7 @@ -from models import Plate, Measurement, Sample +from backend.models import Plate, Measurement, Sample import bfabric_web_apps from bfabric_web_apps.objects.BfabricInterface import bfabric_interface -from models import Plate, Measurement +from backend.models import Plate, Measurement def safe_str(val): if val is None: diff --git a/utils/flagging_conditions.py b/utils/flagging_conditions.py index 2ab599c..63c6fee 100644 --- a/utils/flagging_conditions.py +++ b/utils/flagging_conditions.py @@ -19,9 +19,6 @@ def safe_float(v): return None - - - def first_col_startswith(df, prefix): """Return the first column whose name starts with the given prefix.""" pat = re.compile(rf"^{re.escape(prefix)}\b", flags=re.IGNORECASE) diff --git a/utils/plate_render_utils.py b/utils/plate_render_utils.py index 707eccd..73f1550 100644 --- a/utils/plate_render_utils.py +++ b/utils/plate_render_utils.py @@ -4,7 +4,7 @@ import numpy as np import pandas as pd from typing import List -from app_state import AppState +from backend import AppState import plotly.graph_objects as go import datetime from utils.flagging_conditions import collect_flagging_from_all @@ -439,8 +439,6 @@ def build_flagging_body(plate_type: str, index: str, current_flagging): return updated_flagging, body_div - - def build_tab_body(plate, app_state, current_flagging): """ Build the tab *children* for one plate. Returns a Python list of components. diff --git a/utils/plates_callback_utils.py b/utils/plates_callback_utils.py index 4f8f477..49f4a63 100644 --- a/utils/plates_callback_utils.py +++ b/utils/plates_callback_utils.py @@ -1,12 +1,261 @@ import pandas as pd import base64 -import io import pandas as pd -from dash import html -from models import Plate, PlateWell, Measurement, AppSpecificFile, Sample -from app_state import AppState +from backend.models import Plate, AppSpecificFile, Sample from .api_call_utils import populate_samples_by_identifier_lookup, safe_str from bfabric_web_apps.objects.BfabricInterface import bfabric_interface +from dash import html +import dash_ag_grid as dag + + +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: + sample_groups = { + "Negative Control": [], + "Positive Control": [], + "PhiX": [], + "Standard": [], + "Library": [], + } + + rows = list("ABCDEFGH") + columns = list(range(1, 13)) + + well_type_map = { + well: group + for group, wells in sample_groups.items() + for well in wells + } + editable_groups = {"Library", "Positive Control"} + + rowData = [] + for r in rows: + row = {"Row": r} + for c in columns: + well = f"{r}{c}" + group = well_type_map.get(well) + if group in ("PhiX", "Standard", "Negative Control"): + label = {"PhiX": "PhiX", "Standard": "STD", "Negative Control": "NEG"}[group] + row[f"Col{c}"] = label + else: + row[f"Col{c}"] = "" + rowData.append(row) + + columnDefs = [{"headerName": "Row", "field": "Row", "editable": False, "width": 70}] + for c in columns: + columnDefs.append({ + "headerName": f"{c}", + "field": f"Col{c}", + "cellRenderer": "DragDropRenderer", + "editable": False, + "width": 70, + "cellRendererParams": { + "wellTypes": well_type_map, + "col": c, + "editableGroups": list(editable_groups) + }, + }) + + grid_width = (len(columns) * 70) + 100 # 12 columns * 70px + row header padding + + grid_section = html.Div( + [ + html.H5( + "qPCR Plate", + style={ + "textAlign": "center", + "fontWeight": "bold", + "marginBottom": "10px", + "marginTop": "10px", + }, + ), + html.Div( + dag.AgGrid( + id="plate-grid", + rowData=rowData, + columnDefs=columnDefs, + defaultColDef={"resizable": True, "sortable": False}, + dashGridOptions={"domLayout": "autoHeight"}, + style={ + "width": f"{grid_width}px", + "minWidth": f"{grid_width}px", + "margin": "0 auto", + "boxShadow": "0 2px 6px rgba(0,0,0,0.1)", + "borderRadius": "8px", + }, + className="ag-theme-alpine", + dangerously_allow_code=True, + ), + style={"textAlign": "center"}, + ), + ], + style={ + "display": "flex", + "flexDirection": "column", + "alignItems": "center", + "justifyContent": "center", + "padding": "10px 0px 20px 0px", + }, + ) + + return grid_section + + +def render_sample_bar(samples=[], used_samples=[]): + """ + Render multiple microplates of draggable samples in a horizontally scrollable top bar. + + Parameters + ---------- + samples : list[dict] + Each dict represents a plate with keys: + { + "plate": "Plate_1", + "samples": [ + {"name": "Sample_1", "pos": "A1", "color": "#aaf"}, + ... + ] + } + + used_samples : list[str] + Names of samples already placed in the AG grid; these are greyed out. + """ + + rows = list("ABCDEFGH") + columns = list(range(1, 13)) + + def make_plate(plate_name, plate_samples): + """Render a single microplate with labeled wells.""" + + # Map well position → sample dict + sample_map = {s["pos"]: s for s in plate_samples} + + plate_rows = [] + for r in rows: + row_cells = [] + for c in columns: + pos = f"{r}{c}" + sample = sample_map.get(pos) + if not sample: + cell = html.Div( + "", + className="empty-well", + style={ + "width": "22px", + "height": "22px", + "borderRadius": "50%", + "border": "1px solid #ccc", + "backgroundColor": "#fff", + "margin": "2px", + }, + ) + else: + name = sample["name"] + color = sample.get("color", "#f8f9fa") + is_used = name in used_samples + cell = html.Div( + "", + draggable="true" if not is_used else "false", + **{"data-sample": name, "data-color": color}, + title=f"{name} ({pos})", + style={ + "width": "22px", + "height": "22px", + "borderRadius": "50%", + "border": "1px solid #333", + "backgroundColor": color if not is_used else "#ddd", + "opacity": 1.0 if not is_used else 0.5, + "cursor": "grab" if not is_used else "not-allowed", + "margin": "2px", + }, + ) + row_cells.append(cell) + + plate_rows.append( + html.Div( + [html.Div(r, style={"width": "12px", "textAlign": "right", "marginRight": "4px"})] + row_cells, + style={"display": "flex", "alignItems": "center"}, + ) + ) + + column_labels = html.Div( + [html.Div("", style={"width": "16px"})] + + [ + html.Div( + str(c), + style={"width": "22px", "textAlign": "center", "margin": "2px", "fontSize": "10px"}, + ) + for c in columns + ], + style={"display": "flex", "marginLeft": "16px", "marginBottom": "4px"}, + ) + + return html.Div( + [ + html.Div( + plate_name, + style={ + "textAlign": "center", + "fontWeight": "bold", + "marginBottom": "4px", + "fontSize": "12px", + }, + ), + column_labels, + html.Div(plate_rows, style={"display": "flex", "flexDirection": "column"}), + ], + className="plate-card", + style={ + "flex": "0 0 auto", + "padding": "8px", + "marginRight": "16px", + "border": "1px solid #ccc", + "borderRadius": "8px", + "backgroundColor": "#fafafa", + }, + ) + + # Make all plates, side-by-side + plate_components = [make_plate(p["plate"], p["samples"]) for p in samples] + + return html.Div( + plate_components, + id="sample-bar", + style={ + "display": "flex", + "overflowX": "auto", + "gap": "12px", + "padding": "10px 4px", + "marginBottom": "20px", + "whiteSpace": "nowrap", + }, + ) + + def find_and_populate_samples_by_sample_id( app_state, diff --git a/utils/warning_utils.py b/utils/warning_utils.py index 3973341..55da316 100644 --- a/utils/warning_utils.py +++ b/utils/warning_utils.py @@ -1,6 +1,6 @@ import dash from dash import html -from app_state import AppState +from backend.app_state import AppState def collect_missing_sample_warnings(app_state): From e86fd382ad073c524902fddb8e0958303254ab1c Mon Sep 17 00:00:00 2001 From: Griffin Date: Fri, 28 Nov 2025 17:27:46 +0100 Subject: [PATCH 2/2] added loading to submission flow --- callbacks/plate_merge_callbacks.py | 7 +++++-- callbacks/submission_callbacks.py | 7 +++++-- components/main_components.py | 32 ++++++++++++++++-------------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/callbacks/plate_merge_callbacks.py b/callbacks/plate_merge_callbacks.py index 9539470..b965e15 100644 --- a/callbacks/plate_merge_callbacks.py +++ b/callbacks/plate_merge_callbacks.py @@ -1,7 +1,7 @@ from app import app -from dash import Input, Output, State, html, ctx, no_update +from dash import Input, Output, State, html, ctx, no_update, dcc from dash.exceptions import PreventUpdate from backend import AppState import dash_ag_grid as dag @@ -470,7 +470,10 @@ def safe_numeric_key(v): ) ) - return html.Div(report_content), html.Div(report_content).to_plotly_json() + report_content_dashboard = report_content + report_content_dashboard.append(dcc.Loading(html.Div(id="review-modal-spinner", children=[]))) + + return html.Div(report_content_dashboard), html.Div(report_content).to_plotly_json() @app.callback( diff --git a/callbacks/submission_callbacks.py b/callbacks/submission_callbacks.py index 07ce8c0..c844afd 100644 --- a/callbacks/submission_callbacks.py +++ b/callbacks/submission_callbacks.py @@ -22,6 +22,7 @@ @app.callback( Output("review-modal", "is_open"), + Output("review-modal-spinner", "children"), Input("close-adjust-merge-scheme", "n_clicks"), Input("submit-review", "n_clicks"), State("review-modal", "is_open"), @@ -52,7 +53,7 @@ def download_modal_report( ): print("Submission callback triggered") if ctx.triggered_id == "close-adjust-merge-scheme" and open_clicks: - return True + return True, [] if not report_data: raise PreventUpdate @@ -88,7 +89,7 @@ def flatten(c): if isinstance(child, dict) and child.get("props", {}).get("children") == f"{label}: ": if i + 1 < len(flat): return flat[i + 1] - return "unknown" + return "unknown", [] username = extract_field("User Name") job_id = extract_field("Job ID") @@ -312,3 +313,5 @@ def _attach_gstore_files_to_entities_as_link( wrapper=wrapper ) + return False, [] + diff --git a/components/main_components.py b/components/main_components.py index 2206e75..06ae007 100644 --- a/components/main_components.py +++ b/components/main_components.py @@ -218,21 +218,23 @@ def render_main_content(): dbc.ModalBody(id="merge-modal-body", children=merge_body), ] ), - dbc.Modal( - id="review-modal", - size="xl", - is_open=False, - 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.Button("Download Html Report", id="download-report-button", className="ms-2", n_clicks=0), - dcc.Download(id="download-report-data"), - ], - ), - ] + dcc.Loading( + dbc.Modal( + id="review-modal", + size="xl", + is_open=False, + 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.Button("Download Html Report", id="download-report-button", className="ms-2", n_clicks=0), + dcc.Download(id="download-report-data"), + ], + ), + ] + ), ), dcc.Loading(alerts), dbc.Col(