diff --git a/index.py b/index.py index ceb699e..a1affba 100644 --- a/index.py +++ b/index.py @@ -16,7 +16,7 @@ import re import copy - +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 ( @@ -117,6 +117,14 @@ id="plate-button-container", className="small", ), + html.Div( + id="phix-control-dropdowns", + className="small", + ), + html.Div( + id="adjust-flagging-conditon-buttons", + className="small", + ), ], className="p-2 small", ), @@ -195,7 +203,9 @@ def render_main_content(): ), width=9, ), - dcc.Store(id="app_data_state", storage_type="memory") + 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"), ], style={"margin-top": "0px", "min-height": "40vh"} ) @@ -277,6 +287,7 @@ def update_ui(token_data, entity_data): 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"), @@ -293,7 +304,7 @@ def update_ui(token_data, entity_data): 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") + State("view-mode-switch", "value"), ], prevent_initial_call=True, ) @@ -307,6 +318,7 @@ def manipulate_plates( standard, phix, library, + flagging_conditions_storage, plate_graph_selections, filenames, app_data_state_json, @@ -317,7 +329,7 @@ def manipulate_plates( datatable_ids, delete_rows_button_ids, plate_graph_ids, - table_view + table_view, ): """ Core callback for all plate management actions (upload, delete, edit, sample lookup). @@ -339,7 +351,7 @@ def manipulate_plates( "Positive Control", "Standard", "PhiX", - "Library" + "Library", } @@ -347,7 +359,6 @@ def manipulate_plates( app_state = AppState.from_json(app_data_state_json) if app_data_state_json else AppState() print("manipulate_plates triggered by:", triggered) - def plate_mapper(plate_ids, objects): """ This takes in a list of dash component ids (which we've passed above as states) @@ -430,6 +441,14 @@ def plate_mapper(plate_ids, objects): 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.") @@ -548,6 +567,11 @@ def update_standard_curve(app_state, row_data, plate_id): 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 @@ -576,23 +600,75 @@ def update_standard_curve(app_state, row_data, plate_id): ] 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"), + ], + [ + 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"), + ], + 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, + stddev_ids, min_nm_ids, max_nm_ids, + min_frag_ids, max_frag_ids, min_mol_ids, max_mol_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 + + 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) + + 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("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_tab): +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 @@ -621,13 +697,17 @@ def prune_missing_tabs(app_data_json, children, active_tab): else: new_children, next_active = [], None - return new_children, next_active, "" + # 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("dynamic-tabs", "active_tab"), + Output("adjust-flagging-conditon-buttons", "children"), + Output("phix-control-dropdowns", "children") ], # TRIGGERS AFTER EVERY "UPLOAD OR DELETE" OPERATION # Upload / Delete from mega function --> table-deletion-trigger --> table-creation-trigger @@ -635,15 +715,17 @@ def prune_missing_tabs(app_data_json, children, active_tab): [ State("app_data_state", "data"), State("dynamic-tabs", "children"), - State("dynamic-tabs", "active_tab") + State("dynamic-tabs", "active_tab"), + State("flagging-conditions-storage-prune-missing-tabs", "data") ], prevent_initial_call=True ) -def append_new_tabs(_, app_data_json, existing_tabs, active_tab): - +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] @@ -655,10 +737,23 @@ def append_new_tabs(_, app_data_json, existing_tabs, active_tab): 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)) + return existing_tabs, (active_tab if active_tab in desired_ids else (desired_ids[-1] if desired_ids else None)), no_update + plates_by_id = {str(p.plate_id): p for p in app.plate_registry} - new_tabs = [make_tab(plates_by_id[pid], app) for pid in to_add] + new_tabs = [] + flag_buttons = [] + phix_dropdowns = [] + for pid in to_add: + if flagging_conditions: + flagging_per_plate = flagging_conditions.get(pid) + else: + flagging_per_plate = {} + tab, flag_button, phix_dropdown = 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) + # preserve order of existing; append new at the end (no reordering of mounted) next_children = existing_tabs + new_tabs @@ -681,7 +776,18 @@ def _id_of(tab_like): else: next_active = None - return next_children, next_active + # 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) + + return next_children, next_active, flag_buttons, phix_dropdowns @@ -819,10 +925,11 @@ def sync_plate_style(cell_evt, row_data, virtual_row_data, fig): 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): +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 @@ -848,14 +955,19 @@ def update_rows_callback(_, app_data_state_json, pcr_store, table_id): 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) + 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 != "sample_id" and df[c].isna().all()] + 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) @@ -866,172 +978,185 @@ def update_rows_callback(_, app_data_state_json, pcr_store, table_id): 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("plate-button-container", "children"), - [Input("app_data_state", "data"), - Input("dynamic-tabs", "active_tab")], - prevent_initial_call=True + Output({"type": "adjust-flagging-btn", "index": ALL}, "style"), + Input("dynamic-tabs", "active_tab"), + State({"type": "adjust-flagging-btn", "index": ALL}, "id"), ) -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 - ), +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 + ] - # --- Adjust Flagging Button --- - dbc.Button( - "Adjust Flagging Condition", - id={"type": "adjust-flagging-btn", "index": plate_id}, - color="secondary", - className="w-100 mb-2 py-2 fw-semibold small shadow-sm", - ), - html.Div( - id={"type": "dummy-flagging-output", "index": plate_id}, - style={"display": "none"}, - ), - ], - className="p-2 bg-light rounded shadow-sm", # optional card-like background - ) @app.callback( - Output({"type": "dummy-flagging-output", "index": MATCH}, "children"), - Input({"type": "adjust-flagging-btn", "index": MATCH}, "n_clicks"), - prevent_initial_call=True + Output({"type": "phix-control-wrapper", "index": ALL}, "style"), + Input("dynamic-tabs", "active_tab"), + State({"type": "phix-control-wrapper", "index": ALL}, "id"), ) -def on_adjust_flagging_clicked(n_clicks): - plate_id = ctx.triggered_id["index"] - print(f"Adjust flagging condition button is pressed for plate {plate_id}") - return "" +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": value}) + + return dropdown_options + + # @app.callback( -# [Output({"type": "flagging-modal", "index": MATCH}, "is_open"), -# Output({"type": "flagging-modal-body", "index": MATCH}, "children")], -# Input({"type": "adjust-flagging-btn", "index": MATCH}, "n_clicks"), -# State("app_data_state", "data"), -# prevent_initial_call=True -# ) -# @app.callback( -# [Output("flagging-modal", "is_open"), -# Output("flagging-modal-body", "children")], -# Input({"type": "adjust-flagging-btn", "index": ALL}, "n_clicks"), -# State("app_data_state", "data"), +# Output("plate-button-container", "children"), +# [Input("app_data_state", "data"), +# Input("dynamic-tabs", "active_tab")], # prevent_initial_call=True # ) -# def show_flagging_modal(n_clicks_list, app_data_json): -# triggered = ctx.triggered_id -# if not isinstance(triggered, dict) or triggered.get("type") != "adjust-flagging-btn": -# raise PreventUpdate - -# plate_id = triggered["index"] -# btn_index = next( -# (i for i, btn in enumerate(ctx.inputs_list[0]) if btn["id"]["index"] == plate_id), -# None +# 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 # ) -# if btn_index is None or n_clicks_list[btn_index] is None or n_clicks_list[btn_index] == 0: -# raise PreventUpdate - -# app_state = AppState.from_json(app_data_json) -# plate = next((p for p in app_state.plate_registry if p.plate_id == plate_id), None) -# if plate is None: -# raise PreventUpdate - -# # Plate type specific inputs -# if plate.plate_type == "qPCR": -# inputs = html.Div([ -# html.Label("Std. Dev:"), -# dcc.Input(type="number", value=0.2, step=0.01, id="qpcr-std-dev", className="form-control"), -# html.Br(), html.Label("Min nM:"), -# dcc.Input(type="number", value=0.8, step=0.01, id="qpcr-min-nm", className="form-control"), -# html.Br(), html.Label("Max nM:"), -# dcc.Input(type="number", value=30, step=0.1, id="qpcr-max-nm", className="form-control"), -# ]) -# elif plate.plate_type == "HSD1000": -# min_frag, max_frag = 180, 900 -# min_molarity, max_molarity = 200, 20000 # in pM -# molarity_unit = "pM" -# molarity_step = 10 -# elif plate.plate_type == "D1000": -# min_frag, max_frag = 180, 900 -# min_molarity, max_molarity = 2, 60 # in nM -# molarity_unit = "nM" -# molarity_step = 0.1 -# elif plate.plate_type == "D5000": -# min_frag, max_frag = 250, 2500 -# min_molarity, max_molarity = 2, 60 # in nM -# molarity_unit = "nM" -# molarity_step = 0.1 - -# if plate.plate_type != "qPCR": -# inputs = html.Div([ -# html.Label("Min Frag length:"), -# dcc.Input(type="number", value=min_frag, step=1, id="ts-min-frag", className="form-control"), -# html.Br(), html.Label("Max Frag length:"), -# dcc.Input(type="number", value=max_frag, step=1, id="ts-max-frag", className="form-control"), -# html.Br(), html.Label(f"Min Molarity ({molarity_unit}):"), -# dcc.Input(type="number", value=min_molarity, step=molarity_step, id="ts-min-nm", className="form-control"), -# html.Br(), html.Label(f"Max Molarity ({molarity_unit}):"), -# dcc.Input(type="number", value=max_molarity, step=molarity_step, id="ts-max-nm", className="form-control"), -# ]) - -# return True, inputs + @app.callback( Output({"type": "flagging-modal", "index": MATCH}, "is_open"), - [ - Input({"type": "flagging-modal-close", "index": MATCH}, "n_clicks"), - Input({"type": "flagging-modal-save", "index": MATCH}, "n_clicks"), - ], - prevent_initial_call=True + 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 close_modal_matched(n_close, n_save): - return False +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( diff --git a/models.py b/models.py index e89a72d..e1fa873 100644 --- a/models.py +++ b/models.py @@ -103,6 +103,7 @@ class AppSpecificPlateWell(BaseModel): 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 diff --git a/utils/flagging_conditions.py b/utils/flagging_conditions.py new file mode 100644 index 0000000..2ab599c --- /dev/null +++ b/utils/flagging_conditions.py @@ -0,0 +1,102 @@ +# utils/flagging_conditions.py +from typing import Dict, Any +import pandas as pd +import re + +def safe_float(v): + """Convert v to float safely — ignores units like 'nM' or 'pM'.""" + if v in [" - ", "-", None, ""]: + return None + + if isinstance(v, (int, float)): + return float(v) + + if isinstance(v, str): + s = v.strip().replace(",", ".") + parts = s.split() + return float(parts[0]) + + 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) + for col in df.columns: + if pat.search(str(col).strip()): + return col + return None + + +def evaluate_flagging_for_df(df: pd.DataFrame, conditions: Dict[str, Any]) -> pd.Series: + """ + Evaluate flagging for one DataFrame and return a Series with text flags. + """ + conditions = conditions or {} + + # thresholds + min_frag = conditions.get("min_frag") + max_frag = conditions.get("max_frag") + min_mol_plate = conditions.get("min_mol") + max_mol_plate = conditions.get("max_mol") + stddev = conditions.get("stddev") + min_nm = conditions.get("min_nm") + max_nm = conditions.get("max_nm") + + avg_col = first_col_startswith(df, "Average Size") + reg_mol_col = first_col_startswith(df, "Region Molarity") + + flags = [] + + for _, row in df.iterrows(): + parts = [] + + # Tapestation: Average Size + if avg_col is not None: + avg = safe_float(row.get(avg_col)) + if avg is not None: + if min_frag is not None and avg < min_frag: + parts.append("average size too low") + if max_frag is not None and avg > max_frag: + parts.append("average size too high") + + # Tapestation: Region Molarity + if reg_mol_col is not None: + rmol = safe_float(row.get(reg_mol_col)) + if rmol is not None: + if min_mol_plate is not None and rmol < min_mol_plate: + parts.append("region molarity too low") + if max_mol_plate is not None and rmol > max_mol_plate: + parts.append("region molarity too high") + + # qPCR: standard error threshold + if "computed_standard_error" in df.columns and stddev is not None: + se = safe_float(row.get("computed_standard_error")) + if se is not None and se > stddev: + parts.append("standard error too high") + + # qPCR: computed molarity in [min_nm, max_nm] + if "computed_molarity" in df.columns: + cm = safe_float(row.get("computed_molarity")) + if cm is not None: + if min_nm is not None and cm < min_nm: + parts.append("computed molarity too low") + if max_nm is not None and cm > max_nm: + parts.append("computed molarity too high") + + flags.append(", ".join(parts)) + + return pd.Series(flags, index=df.index) + + +def collect_flagging_from_all(df: pd.DataFrame, current_flagging: Dict[str, Any]) -> pd.DataFrame: + """ + Single-DF variant used in your tab builder. + Returns a COPY of df with a 'flagging_conditions' column attached. + """ + df_out = df.copy() + df_out["flagging_conditions"] = evaluate_flagging_for_df(df_out, current_flagging) + return df_out diff --git a/utils/plate_render_utils.py b/utils/plate_render_utils.py index aad9fe5..fe57d3e 100644 --- a/utils/plate_render_utils.py +++ b/utils/plate_render_utils.py @@ -7,6 +7,7 @@ from app_state import AppState import plotly.graph_objects as go import datetime +from utils.flagging_conditions import collect_flagging_from_all pd.set_option('future.no_silent_downcasting', True) @@ -47,6 +48,7 @@ def is_numeric(val): 'Concentration', 'Concentration Error', 'Concentration Mean', + 'Color', 'Cq Error', 'EPF', 'Slope', @@ -68,17 +70,16 @@ def is_numeric(val): QPCR_SAMPLE_DILUTION_COEFFICIENT = 10000 - -def make_tab(plate, app_state): +def make_tab(plate, app_state, current_flagging): pid = str(plate.plate_id) - from utils.plate_render_utils import build_tab_body + tab_components, flag_button, phix_dropdown = build_tab_body(plate, app_state, current_flagging) return dbc.Tab( label=plate.plate_type if plate.plate_type != "qPCR" else "qPCR", tab_id=pid, id={"type": "tab", "index": pid}, - children=build_tab_body(plate, app_state), + children=tab_components, style={"paddingRight": "0px"}, - ) + ), flag_button, phix_dropdown def get_tab_id(tab_like): # works for Component or serialized dict from State @@ -284,6 +285,18 @@ def _build_plate_figure_for_plate(plate, app_state): "well_position_id" ].astype(str)) + # Flagging_conditions-based error highlighting + # This will work once we write flagging_conditions into the app_state! + # But it still needs to be tesed + if "flagging_conditions" in df_table.columns: + print("DF TABLE FLAGGING CONDITIONS:",df_table["flagging_conditions"]) + flag_error_wells = set(df_table.loc[ + df_table["flagging_conditions"].notna() & (df_table["flagging_conditions"] != ""), + "well_position_id" + ].astype(str)) + # merge both types of error wells + error_wells |= flag_error_wells + # --- colors: fill + per-well border --- def fill_color(g): return SAMPLE_TYPE_COLORS.get(g, UNASSIGNED_COLOR) if g != "Unassigned" else UNASSIGNED_COLOR @@ -336,7 +349,134 @@ def fill_color(g): return fig -def build_tab_body(plate, app_state): +def build_flagging_body(plate_type: str, index: str, current_flagging): + """ + Build the modal inputs for this plate, prefilling with previously used values if available. + + Returns: + (updated_flagging: dict, body_div: html.Div) + + updated_flagging = the effective flagging values that the user sees. + This guarantees that if current_flagging was empty or missing keys, + we still pass back the defaults. + """ + current_flagging = current_flagging or {} + + if plate_type == "qPCR": + # defaults + stddev_default = 0.2 + min_nm_default = 0.8 + max_nm_default = 30 + + # use saved values if present, otherwise defaults + stddev_val = current_flagging.get("stddev", stddev_default) + min_nm_val = current_flagging.get("min_nm", min_nm_default) + max_nm_val = current_flagging.get("max_nm", max_nm_default) + + # this is now the authoritative config for this plate + updated_flagging = { + "stddev": stddev_val, + "min_nm": min_nm_val, + "max_nm": max_nm_val, + } + + body_div = html.Div([ + dbc.Label("Std. Dev:"), + dcc.Input( + id={"type": "input-stddev", "index": index}, + type="number", + value=stddev_val, + step=0.01, + className="form-control", + ), + html.Br(), dbc.Label("Min nM:"), + dcc.Input( + id={"type": "input-min-nm", "index": index}, + type="number", + value=min_nm_val, + step=0.01, + className="form-control", + ), + html.Br(), dbc.Label("Max nM:"), + dcc.Input( + id={"type": "input-max-nm", "index": index}, + type="number", + value=max_nm_val, + step=0.1, + className="form-control", + ), + ], style={"minWidth": 240}) + + return updated_flagging, body_div + + + elif plate_type in {"HSD1000", "D1000", "D5000"}: + # plate-type defaults + if plate_type == "HSD1000": + molarity_unit, molarity_step = "pM", 10 + min_frag_default, max_frag_default, min_mol_default, max_mol_default = 180, 900, 200, 20000 + elif plate_type == "D5000": + molarity_unit, molarity_step = "nM", 0.1 + min_frag_default, max_frag_default, min_mol_default, max_mol_default = 250, 2500, 2, 60 + else: # D1000 + molarity_unit, molarity_step = "nM", 0.1 + min_frag_default, max_frag_default, min_mol_default, max_mol_default = 180, 900, 2, 60 + + # use saved values if present, otherwise defaults + min_frag_val = current_flagging.get("min_frag", min_frag_default) + max_frag_val = current_flagging.get("max_frag", max_frag_default) + min_mol_val = current_flagging.get("min_mol", min_mol_default) + max_mol_val = current_flagging.get("max_mol", max_mol_default) + + # this becomes the authoritative config for this plate + updated_flagging = { + "min_frag": min_frag_val, + "max_frag": max_frag_val, + "min_mol": min_mol_val, + "max_mol": max_mol_val, + } + + body_div = html.Div([ + dbc.Label("Min Frag length:"), + dcc.Input( + id={"type": "input-min-frag", "index": index}, + type="number", + value=min_frag_val, + step=1, + className="form-control", + ), + html.Br(), dbc.Label("Max Frag length:"), + dcc.Input( + id={"type": "input-max-frag", "index": index}, + type="number", + value=max_frag_val, + step=1, + className="form-control", + ), + html.Br(), dbc.Label(f"Min Molarity ({molarity_unit}):"), + dcc.Input( + id={"type": "input-min-mol", "index": index}, + type="number", + value=min_mol_val, + step=molarity_step, + className="form-control", + ), + html.Br(), dbc.Label(f"Max Molarity ({molarity_unit}):"), + dcc.Input( + id={"type": "input-max-mol", "index": index}, + type="number", + value=max_mol_val, + step=molarity_step, + className="form-control", + ), + ], style={"minWidth": 240}) + + 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. Nothing here creates a dbc.Tab — that's done by make_tab(). @@ -418,12 +558,22 @@ def build_tab_body(plate, app_state): df = build_computed_molarity(app_state, plate, slope, intercept) if is_qpcr: - df = df.dropna(axis=1, how="all") + keep = {"flagging_conditions"} + null_only = [c for c in df.columns if c not in keep and df[c].isna().all()] + df = df.drop(columns=null_only) else: - drop_cols = [c for c in df.columns if c != "sample_id" and df[c].isna().all()] + 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) + current_flagging, flagging_body = build_flagging_body(plate.plate_type, pid, current_flagging) + + #print("df", df.columns) + #print("current_flagging", current_flagging) + + # Adds the flagging conditoins to the df. based on this we do the coloring and warning. + df = collect_flagging_from_all(df, current_flagging) + # error/warn flags for Tapestation-like plates if (not is_qpcr) and ("well_note" in df.columns): df["__error__"] = df["well_note"].isin(["No sample found", "Multiple samples found"]) @@ -432,6 +582,10 @@ def build_tab_body(plate, app_state): 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 + getRowStyle = { "styleConditions": [ { @@ -591,6 +745,67 @@ def _is_num(s): # --- Buttons row --- initial_display = "none" if len(df) <= 1 else "inline-flex" + + + + flag_button = dbc.Button( + "Adjust Flagging Condition", + id={"type": "adjust-flagging-btn", "index": pid}, + color="secondary", + className="ms-2 mb-2 py-2 fw-semibold small shadow-sm", + ) + + # ------------------------------------------------------------------ + # Build the PhiX dropdown + # ------------------------------------------------------------------ + + # Find rows where sample_type == "PhiX" + 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": value}) + + phix_dropdown = html.Div( + [ + dbc.Label( + "Control by PhiX", + className="mt-2 mb-1 text-muted fw-semibold", + ), + dcc.Dropdown( + id={"type": "phix-control-dropdown", "index": pid}, + options=dropdown_options, + placeholder="Select or enter control value...", + className="mb-2 small", + ), + ], + id={"type": "phix-control-wrapper", "index": pid}, + className="p-2 bg-light rounded shadow-sm", + # style will be injected later in append_new_tabs to hide/show + ) + + + buttons_row = html.Div( [ dbc.Button( @@ -623,7 +838,7 @@ def _is_num(s): ], style={"gap": "0.5rem", "alignItems": "center", "paddingInline": "0.75rem", "fontWeight": "500"}, - ), + ), ], style={"display": "flex", "gap": "10px", "marginTop": "10px"}, ) @@ -700,18 +915,19 @@ def _is_num(s): flagging_modal = html.Div([ dbc.Modal( [ - dbc.ModalHeader(dbc.ModalTitle("Adjust Flagging Conditions")), - dbc.ModalBody(id={"type": "flagging-modal-body", "index": pid}), + dbc.ModalHeader("Adjust Flagging"), + dbc.ModalBody(flagging_body, id={"type": "flagging-modal-body", "index": pid}), dbc.ModalFooter([ - dbc.Button("Save", id={"type": "flagging-modal-save", "index": pid}, color="primary"), - dbc.Button("Close", id={"type": "flagging-modal-close", "index": pid}, className="ms-auto"), + dbc.Button("Close", id={"type": "flagging-modal-close", "index": pid}), ]), ], - id={"type": "modal-flagging", "index": pid}, - is_open=False, backdrop="static", + id={"type": "flagging-modal", "index": pid}, + is_open=False, ) ]) + + delete_rows_modal = html.Div([ dbc.Modal( [ @@ -747,4 +963,5 @@ def _is_num(s): dummy = html.Div(id={"type": "dummy-div", "index": pid}, style={"display": "none"}) - return [table_wrap, plate_wrap, buttons_row, delete_plate_modal, flagging_modal, delete_rows_modal, dummy] \ No newline at end of file + tab_components = [table_wrap, plate_wrap, buttons_row, delete_plate_modal, flagging_modal, delete_rows_modal, dummy] + return tab_components, flag_button, phix_dropdown \ No newline at end of file diff --git a/utils/plates_callback_utils.py b/utils/plates_callback_utils.py index 6bdd985..4f8f477 100644 --- a/utils/plates_callback_utils.py +++ b/utils/plates_callback_utils.py @@ -147,6 +147,10 @@ def update_app_state_from_ui(app_state, all_ui_tables): if pw: + # flagging_conditions back into the PlateWell + if "flagging_conditions" in df_ui.columns: + pw.flagging_conditions = row.get("flagging_conditions", "") + if "dilution_factor" in df_ui.columns: dilution_factor = row["dilution_factor"] pw.dilution_factor = dilution_factor diff --git a/utils/warning_utils.py b/utils/warning_utils.py index 9650035..3973341 100644 --- a/utils/warning_utils.py +++ b/utils/warning_utils.py @@ -15,6 +15,8 @@ def collect_missing_sample_warnings(app_state): missing = [] for w in plate.plate_wells: + #print("flagging condition:", w.flagging_conditions) + #print("Well note:", w.well_note) sample = w.get_sample(app_state) sample_type = getattr(sample, "sample_type", None) if (