From e326927ab166927d185c942e99c82d0fbedf09f4 Mon Sep 17 00:00:00 2001 From: Marc Zuber Date: Wed, 17 Sep 2025 16:16:47 +0200 Subject: [PATCH 1/8] Removed color from qpcr --- utils/plate_render_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/plate_render_utils.py b/utils/plate_render_utils.py index aad9fe5..6eed70e 100644 --- a/utils/plate_render_utils.py +++ b/utils/plate_render_utils.py @@ -47,6 +47,7 @@ def is_numeric(val): 'Concentration', 'Concentration Error', 'Concentration Mean', + 'Color', 'Cq Error', 'EPF', 'Slope', From 747faccd103d9a8ac3d36133972b79d3afb4374a Mon Sep 17 00:00:00 2001 From: Marc Zuber Date: Tue, 23 Sep 2025 00:01:14 +0200 Subject: [PATCH 2/8] Working on keeping the values for the flagging conditions after uploading or deleting a plate, without writing them into appstate. --- index.py | 227 ++++++++++++++++++++++++++++++------ utils/plate_render_utils.py | 132 ++++++++++++++++++--- 2 files changed, 307 insertions(+), 52 deletions(-) diff --git a/index.py b/index.py index ceb699e..bd77d87 100644 --- a/index.py +++ b/index.py @@ -117,6 +117,12 @@ id="plate-button-container", className="small", ), + html.Div( + id="plate-button-container-2", + className="small", + ), + + ], className="p-2 small", ), @@ -293,7 +299,9 @@ 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"), + State({"type": "input-stddev", "index": ALL}, "value"), + State({"type": "input-stddev", "index": ALL}, "id"), ], prevent_initial_call=True, ) @@ -317,7 +325,9 @@ def manipulate_plates( datatable_ids, delete_rows_button_ids, plate_graph_ids, - table_view + table_view, + stddev_vals, + stddev_ids, ): """ Core callback for all plate management actions (upload, delete, edit, sample lookup). @@ -347,6 +357,8 @@ 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) + print("mega function stddev_vals:", stddev_vals) + print("mega function stddev_ids:", stddev_ids) def plate_mapper(plate_ids, objects): """ @@ -548,6 +560,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 @@ -583,17 +600,20 @@ def update_standard_curve(app_state, row_data, plate_id): [ 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"), ], # 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({"type": "input-stddev", "index": ALL}, "value"), prevent_initial_call=True ) -def prune_missing_tabs(app_data_json, children, active_tab): +def prune_missing_tabs(app_data_json, children, active_tab, inpus_stddev): + + print("prune_missing_tabs input-stddev:", inpus_stddev) 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 [])] @@ -627,7 +647,8 @@ def prune_missing_tabs(app_data_json, children, active_tab): @app.callback( [ Output("dynamic-tabs", "children"), - Output("dynamic-tabs", "active_tab") + Output("dynamic-tabs", "active_tab"), + Output("plate-button-container-2", "children"), ], # TRIGGERS AFTER EVERY "UPLOAD OR DELETE" OPERATION # Upload / Delete from mega function --> table-deletion-trigger --> table-creation-trigger @@ -635,11 +656,38 @@ 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({"type": "input-stddev", "index": ALL}, "value"), + State({"type": "input-stddev", "index": ALL}, "id"), + State({"type": "input-min-nm", "index": ALL}, "value"), + State({"type": "input-min-nm", "index": ALL}, "id"), + State({"type": "input-max-nm", "index": ALL}, "value"), + State({"type": "input-max-nm", "index": ALL}, "id"), + State({"type": "input-min-frag", "index": ALL}, "value"), + State({"type": "input-min-frag", "index": ALL}, "id"), + State({"type": "input-max-frag", "index": ALL}, "value"), + State({"type": "input-max-frag", "index": ALL}, "id"), + State({"type": "input-min-mol", "index": ALL}, "value"), + State({"type": "input-min-mol", "index": ALL}, "id"), + State({"type": "input-max-mol", "index": ALL}, "value"), + State({"type": "input-max-mol", "index": ALL}, "id"), ], 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, + stddev_vals, stddev_ids, + min_nm_vals, min_nm_ids, + max_nm_vals, max_nm_ids, + min_frag_vals, min_frag_ids, + max_frag_vals, max_frag_ids, + min_mol_vals, min_mol_ids, + max_mol_vals, max_mol_ids, +): + + print("stddev_vals:", stddev_vals) + print("stddev_ids:", stddev_ids) # RETURNS an EXACT COPY of existing tabs, plus any new ones, generated # the same as before from the function in plate_render_utils. @@ -655,10 +703,41 @@ 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 + + # Build a dict keyed by plate_id with all known flagging values + flagging_values = {} + + # helper to update the dict + def add_values(values, ids, key): + for val, id_dict in zip(values, ids): + # id_dict["index"] contains the plate_id for this input + pid = id_dict.get("index") + if pid is None: + continue + # ensure a nested dict exists for this plate + flagging_values.setdefault(pid, {}) + # only set if val is not None + flagging_values[pid][key] = val + + add_values(stddev_vals, stddev_ids, "stddev") + add_values(min_nm_vals, min_nm_ids, "min_nm") + add_values(max_nm_vals, max_nm_ids, "max_nm") + add_values(min_frag_vals, min_frag_ids, "min_frag") + add_values(max_frag_vals, max_frag_ids, "max_frag") + add_values(min_mol_vals, min_mol_ids, "min_mol") + add_values(max_mol_vals, max_mol_ids, "max_mol") + print("Flagging values:", flagging_values) + 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 = [] + for pid in to_add: + tab, button = make_tab(plates_by_id[pid], app, current_flagging=flagging_values.get(pid)) + new_tabs.append(tab) + flag_buttons.append(button) + # preserve order of existing; append new at the end (no reordering of mounted) next_children = existing_tabs + new_tabs @@ -681,7 +760,16 @@ 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"} + + return next_children, next_active, flag_buttons @@ -869,6 +957,23 @@ def update_rows_callback(_, app_data_state_json, pcr_store, table_id): return df.to_dict("records") + + + +@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_button(active_tab, all_ids): + return [ + {} if btn_id["index"] == active_tab else {"display": "none"} + for btn_id in all_ids + ] + + + + @app.callback( Output("plate-button-container", "children"), [Input("app_data_state", "data"), @@ -921,32 +1026,55 @@ def update_plate_buttons(app_data_state_json, selected_tab): placeholder="Select or enter control value...", className="mb-2 small", # reduce dropdown text size ), - - # --- 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"), + Output({"type": "flagging-modal", "index": MATCH}, "is_open"), Input({"type": "adjust-flagging-btn", "index": MATCH}, "n_clicks"), - prevent_initial_call=True + 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 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 toggle_flagging_modal(adjust_btn, close_btn, is_open, stddev_vals, stddev_ids,): + triggered = ctx.triggered_id + print("toggle_flagging_moda stddev_vals:", stddev_vals) + print("toggle_flagging_moda stddev_ids:", stddev_ids) + if not isinstance(triggered, dict): + raise PreventUpdate + + if triggered["type"] == "adjust-flagging-btn" and adjust_btn: + return True + elif triggered["type"] == "flagging-modal-close" and close_btn: + return False + return is_open + + + +# @app.callback( +# Output({"type": "dummy-flagging-output", "index": MATCH}, "children"), +# Input({"type": "adjust-flagging-btn", "index": MATCH}, "n_clicks"), +# prevent_initial_call=True +# ) +# 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 "" # @app.callback( # [Output({"type": "flagging-modal", "index": MATCH}, "is_open"), @@ -1022,16 +1150,39 @@ def on_adjust_flagging_clicked(n_clicks): # 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 -) -def close_modal_matched(n_close, n_save): - return False +# @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 +# ) +# def close_modal_matched(n_close, n_save): +# return False + + + + + + + + + + + + + + + + + + + + + + + @app.callback( diff --git a/utils/plate_render_utils.py b/utils/plate_render_utils.py index 6eed70e..b929197 100644 --- a/utils/plate_render_utils.py +++ b/utils/plate_render_utils.py @@ -69,17 +69,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 = 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 def get_tab_id(tab_like): # works for Component or serialized dict from State @@ -337,7 +336,100 @@ def fill_color(g): return fig -def build_tab_body(plate, app_state): + + + +def build_flagging_body(plate_type: str, index: str, current_flagging) -> html.Div: + """ + Construct the flagging modal body for a given plate type. + Default values are defined here instead of in a separate helper. + """ + + print(f"Current flagging for plate {index} of type {plate_type}: {current_flagging}") + if plate_type == "qPCR": + return html.Div([ + dbc.Label("Std. Dev:"), + dcc.Input( + id={"type": "input-stddev", "index": index, "plate_type": plate_type}, + type="number", + value=0.2, + step=0.01, + className="form-control", + ), + html.Br(), dbc.Label("Min nM:"), + dcc.Input( + id={"type": "input-min-nm", "index": index, "plate_type": plate_type}, + type="number", + value=0.8, + step=0.01, + className="form-control", + ), + html.Br(), dbc.Label("Max nM:"), + dcc.Input( + id={"type": "input-max-nm", "index": index, "plate_type": plate_type}, + type="number", + value=30, + step=0.1, + className="form-control", + ), + ], style={"minWidth": 240}) + + elif plate_type in {"HSD1000", "D1000", "D5000"}: + # Set defaults and step sizes based on the plate type + if plate_type == "HSD1000": + molarity_unit, molarity_step = "pM", 10 + min_frag, max_frag, min_mol, max_mol = 180, 900, 200, 20000 + elif plate_type == "D5000": + molarity_unit, molarity_step = "nM", 0.1 + min_frag, max_frag, min_mol, max_mol = 250, 2500, 2, 60 + else: # D1000 + molarity_unit, molarity_step = "nM", 0.1 + min_frag, max_frag, min_mol, max_mol = 180, 900, 2, 60 + + return html.Div([ + dbc.Label("Min Frag length:"), + dcc.Input( + id={"type": "input-min-frag", "index": index, "plate_type": plate_type}, + type="number", + value=min_frag, + step=1, + className="form-control", + ), + html.Br(), dbc.Label("Max Frag length:"), + dcc.Input( + id={"type": "input-max-frag", "index": index, "plate_type": plate_type}, + type="number", + value=max_frag, + step=1, + className="form-control", + ), + html.Br(), dbc.Label(f"Min Molarity ({molarity_unit}):"), + dcc.Input( + id={"type": "input-min-mol", "index": index, "plate_type": plate_type}, + type="number", + value=min_mol, + step=molarity_step, + className="form-control", + ), + html.Br(), dbc.Label(f"Max Molarity ({molarity_unit}):"), + dcc.Input( + id={"type": "input-max-mol", "index": index, "plate_type": plate_type}, + type="number", + value=max_mol, + step=molarity_step, + className="form-control", + ), + ], style={"minWidth": 240}) + + else: + return html.Div("Unknown plate type.", className="text-danger") + + + + + + +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(). @@ -592,6 +684,16 @@ 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", + ) + buttons_row = html.Div( [ dbc.Button( @@ -624,7 +726,7 @@ def _is_num(s): ], style={"gap": "0.5rem", "alignItems": "center", "paddingInline": "0.75rem", "fontWeight": "500"}, - ), + ), ], style={"display": "flex", "gap": "10px", "marginTop": "10px"}, ) @@ -701,18 +803,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(build_flagging_body(plate.plate_type, pid, current_flagging), 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( [ @@ -748,4 +851,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 \ No newline at end of file From 8c3bd1986d6f05ac383ff83d2b2abd3c83104c8d Mon Sep 17 00:00:00 2001 From: Marc Zuber Date: Tue, 21 Oct 2025 19:56:14 +0200 Subject: [PATCH 3/8] updated flagging conditions with dcc store --- index.py | 310 ++++++++++++----------------------- models.py | 1 + utils/flagging_conditions.py | 102 ++++++++++++ utils/plate_render_utils.py | 71 +++++--- 4 files changed, 250 insertions(+), 234 deletions(-) create mode 100644 utils/flagging_conditions.py diff --git a/index.py b/index.py index bd77d87..9f7f384 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 ( @@ -201,7 +201,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"} ) @@ -283,6 +285,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"), @@ -299,9 +302,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({"type": "input-stddev", "index": ALL}, "value"), - State({"type": "input-stddev", "index": ALL}, "id"), + State("view-mode-switch", "value"), ], prevent_initial_call=True, ) @@ -315,6 +316,7 @@ def manipulate_plates( standard, phix, library, + flagging_conditions_storage, plate_graph_selections, filenames, app_data_state_json, @@ -326,8 +328,6 @@ def manipulate_plates( delete_rows_button_ids, plate_graph_ids, table_view, - stddev_vals, - stddev_ids, ): """ Core callback for all plate management actions (upload, delete, edit, sample lookup). @@ -349,7 +349,7 @@ def manipulate_plates( "Positive Control", "Standard", "PhiX", - "Library" + "Library", } @@ -357,9 +357,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) - print("mega function stddev_vals:", stddev_vals) - print("mega function stddev_ids:", stddev_ids) - def plate_mapper(plate_ids, objects): """ This takes in a list of dash component ids (which we've passed above as states) @@ -442,6 +439,28 @@ def plate_mapper(plate_ids, objects): False, "", False, "", None, False, "", [], no_update ) + + elif triggered == "flagging-conditions-storage": + + print("Flagging conditions updated:", flagging_conditions_storage) + collect_flagging_from_all(app_state, flagging_conditions_storage) + # Function to check for the flagging conditions + warnings = collect_missing_sample_warnings(app_state) + show_warning = bool(warnings) + warning_msg = build_warning_msg(warnings) + + return ( + False, "", # alert-fade-too-many-qpcr + app_state.to_json(), # app_data_state + show_warning, warning_msg, # alert-tapestation-sampleid + False, "", # alert-find-libraries-success + False, "", # alert-find-libraries-fail + None, # upload-files.contents + False, "", # alert-fade-duplicate-file + no_update, # table-deletion-trigger.children + no_update # update-rows-trigger-div.children + ) + # --- 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.") @@ -593,27 +612,76 @@ 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({"type": "input-stddev", "index": ALL}, "value"), + State("flagging-conditions-storage", "data"), prevent_initial_call=True ) -def prune_missing_tabs(app_data_json, children, active_tab, inpus_stddev): +def prune_missing_tabs(app_data_json, children, active_tabs, flagging_conditions_storage): - - print("prune_missing_tabs input-stddev:", inpus_stddev) 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 [])] @@ -641,7 +709,9 @@ def prune_missing_tabs(app_data_json, children, active_tab, inpus_stddev): 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( @@ -657,38 +727,11 @@ def prune_missing_tabs(app_data_json, children, active_tab, inpus_stddev): State("app_data_state", "data"), State("dynamic-tabs", "children"), State("dynamic-tabs", "active_tab"), - State({"type": "input-stddev", "index": ALL}, "value"), - State({"type": "input-stddev", "index": ALL}, "id"), - State({"type": "input-min-nm", "index": ALL}, "value"), - State({"type": "input-min-nm", "index": ALL}, "id"), - State({"type": "input-max-nm", "index": ALL}, "value"), - State({"type": "input-max-nm", "index": ALL}, "id"), - State({"type": "input-min-frag", "index": ALL}, "value"), - State({"type": "input-min-frag", "index": ALL}, "id"), - State({"type": "input-max-frag", "index": ALL}, "value"), - State({"type": "input-max-frag", "index": ALL}, "id"), - State({"type": "input-min-mol", "index": ALL}, "value"), - State({"type": "input-min-mol", "index": ALL}, "id"), - State({"type": "input-max-mol", "index": ALL}, "value"), - State({"type": "input-max-mol", "index": ALL}, "id"), + State("flagging-conditions-storage-prune-missing-tabs", "data") ], prevent_initial_call=True ) -def append_new_tabs( - _trigger, - app_data_json, existing_tabs, active_tab, - stddev_vals, stddev_ids, - min_nm_vals, min_nm_ids, - max_nm_vals, max_nm_ids, - min_frag_vals, min_frag_ids, - max_frag_vals, max_frag_ids, - min_mol_vals, min_mol_ids, - max_mol_vals, max_mol_ids, -): - - print("stddev_vals:", stddev_vals) - print("stddev_ids:", stddev_ids) - +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. @@ -705,36 +748,16 @@ def append_new_tabs( # 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 - # Build a dict keyed by plate_id with all known flagging values - flagging_values = {} - - # helper to update the dict - def add_values(values, ids, key): - for val, id_dict in zip(values, ids): - # id_dict["index"] contains the plate_id for this input - pid = id_dict.get("index") - if pid is None: - continue - # ensure a nested dict exists for this plate - flagging_values.setdefault(pid, {}) - # only set if val is not None - flagging_values[pid][key] = val - - add_values(stddev_vals, stddev_ids, "stddev") - add_values(min_nm_vals, min_nm_ids, "min_nm") - add_values(max_nm_vals, max_nm_ids, "max_nm") - add_values(min_frag_vals, min_frag_ids, "min_frag") - add_values(max_frag_vals, max_frag_ids, "max_frag") - add_values(min_mol_vals, min_mol_ids, "min_mol") - add_values(max_mol_vals, max_mol_ids, "max_mol") - print("Flagging values:", flagging_values) - plates_by_id = {str(p.plate_id): p for p in app.plate_registry} new_tabs = [] flag_buttons = [] for pid in to_add: - tab, button = make_tab(plates_by_id[pid], app, current_flagging=flagging_values.get(pid)) + if flagging_conditions: + flagging_per_plate = flagging_conditions.get(pid) + else: + flagging_per_plate = {} + tab, button = make_tab(plates_by_id[pid], app, current_flagging=flagging_per_plate) new_tabs.append(tab) flag_buttons.append(button) @@ -943,7 +966,7 @@ def update_rows_callback(_, app_data_state_json, pcr_store, table_id): 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) @@ -1033,15 +1056,6 @@ def update_plate_buttons(app_data_state_json, selected_tab): - - - - - - - - - @app.callback( Output({"type": "flagging-modal", "index": MATCH}, "is_open"), Input({"type": "adjust-flagging-btn", "index": MATCH}, "n_clicks"), @@ -1051,136 +1065,18 @@ def update_plate_buttons(app_data_state_json, selected_tab): 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,): - triggered = ctx.triggered_id - print("toggle_flagging_moda stddev_vals:", stddev_vals) - print("toggle_flagging_moda stddev_ids:", stddev_ids) - if not isinstance(triggered, dict): - raise PreventUpdate +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 triggered["type"] == "adjust-flagging-btn" and adjust_btn: + if isinstance(tid, dict) and tid.get("type") == "adjust-flagging-btn" and (adjust_btn or 0) > 0: return True - elif triggered["type"] == "flagging-modal-close" and close_btn: + 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": "dummy-flagging-output", "index": MATCH}, "children"), -# Input({"type": "adjust-flagging-btn", "index": MATCH}, "n_clicks"), -# prevent_initial_call=True -# ) -# 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 "" - -# @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"), -# 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 -# ) - -# 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 -# ) -# def close_modal_matched(n_close, n_save): -# return False - - - - - - - - - - - - - - - - - - - - + # First render or unrelated trigger -> keep current state + return is_open 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..7976ddf --- /dev/null +++ b/utils/flagging_conditions.py @@ -0,0 +1,102 @@ +from typing import Dict, Any +import pandas as pd +import re + +def _to_float(v): + try: + if v is None or v == "": + return None + return float(v) + except Exception: + return None + +def _first_col_startswith(df: pd.DataFrame, prefix: str) -> str | None: + """ + Return the first column whose name starts with `prefix` (case-insensitive). + Examples: + "Average Size [bp]" -> match for prefix="Average Size" + "Region Molarity (nmol/L)" -> match for prefix="Region Molarity" + """ + pat = re.compile(rf"^{re.escape(prefix)}\b", flags=re.IGNORECASE) + for col in df.columns: + if isinstance(col, str) and pat.search(col.strip()): + return col + return None + +def evaluate_flagging_for_plate(plate, app_state, conditions: Dict[str, Any]) -> Dict[str, str]: + """ + Evaluate for ONE plate: + - any column starting with "Average Size" vs min_frag/max_frag + - any column starting with "Region Molarity" vs min_mol/max_mol + Writes the combined text onto each PlateWell as `flagging_conditions`. + Returns: { well_position_id -> combined_flag_text_or_empty }. + """ + min_frag = _to_float(conditions.get("min_frag")) + max_frag = _to_float(conditions.get("max_frag")) + min_mol = _to_float(conditions.get("min_mol")) + max_mol = _to_float(conditions.get("max_mol")) + + if all(v is None for v in (min_frag, max_frag, min_mol, max_mol)): + return {} + + df = plate.dataset(app_state, tidy=True).copy() + + # Find the dynamic columns + avg_col = _first_col_startswith(df, "Average Size") + mol_col = _first_col_startswith(df, "Region Molarity") + + # Cast to numeric if present + if avg_col is not None: + df[avg_col] = pd.to_numeric(df[avg_col], errors="coerce") + if mol_col is not None: + df[mol_col] = pd.to_numeric(df[mol_col], errors="coerce") + + flags_by_well: Dict[str, str] = {} + for _, row in df.iterrows(): + well_id = str(row.get("well_position_id")) + parts = [] + + # --- Average Size checks (if column exists) --- + if avg_col is not None: + avg = row.get(avg_col) + if pd.notna(avg): + 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") + + # --- Region Molarity checks (if column exists) --- + if mol_col is not None: + mol = row.get(mol_col) + if pd.notna(mol): + if min_mol is not None and mol < min_mol: + parts.append("region molarity too low") + if max_mol is not None and mol > max_mol: + parts.append("region molarity too high") + + flags_by_well[well_id] = "; ".join(parts) + + # Persist on PlateWell + for well in plate.plate_wells: + wid = str(well.well_position_id) + setattr(well, "flagging_conditions", flags_by_well.get(wid, "")) + + return flags_by_well + + + +def collect_flagging_from_all(app_state, all_conditions: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, str]]: + """ + Apply flagging to all plates that have conditions. + all_conditions: { plate_id(str) -> {min_frag, max_frag, ...} } + """ + results: Dict[str, Dict[str, str]] = {} + if not all_conditions: + return results + + for pid, plate in app_state.plate_by_id.items(): + cond = all_conditions.get(str(pid)) + if cond: + results[str(pid)] = evaluate_flagging_for_plate(plate, app_state, cond) + + return results diff --git a/utils/plate_render_utils.py b/utils/plate_render_utils.py index b929197..5bb3db0 100644 --- a/utils/plate_render_utils.py +++ b/utils/plate_render_utils.py @@ -337,92 +337,107 @@ def fill_color(g): - - def build_flagging_body(plate_type: str, index: str, current_flagging) -> html.Div: """ - Construct the flagging modal body for a given plate type. - Default values are defined here instead of in a separate helper. + Build the modal inputs for this plate, prefilling with previously used values if available. + `current_flagging` is expected to be a dict for *this plate only*, e.g. + {"stddev": 0.28, "min_nm": 0.86, "max_nm": 30.6} for qPCR + {"min_frag": 180, "max_frag": 900, "min_mol": 2, "max_mol": 60} for TS """ + current_flagging = current_flagging or {} - print(f"Current flagging for plate {index} of type {plate_type}: {current_flagging}") if plate_type == "qPCR": + # defaults + stddev_default = 0.2 + min_nm_default = 0.8 + max_nm_default = 30 + + # override with saved values when present + 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) + return html.Div([ dbc.Label("Std. Dev:"), dcc.Input( - id={"type": "input-stddev", "index": index, "plate_type": plate_type}, + id={"type": "input-stddev", "index": index}, type="number", - value=0.2, + value=stddev_val, step=0.01, className="form-control", ), html.Br(), dbc.Label("Min nM:"), dcc.Input( - id={"type": "input-min-nm", "index": index, "plate_type": plate_type}, + id={"type": "input-min-nm", "index": index}, type="number", - value=0.8, + 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, "plate_type": plate_type}, + id={"type": "input-max-nm", "index": index}, type="number", - value=30, + value=max_nm_val, step=0.1, className="form-control", ), ], style={"minWidth": 240}) elif plate_type in {"HSD1000", "D1000", "D5000"}: - # Set defaults and step sizes based on the plate type + # plate-type defaults if plate_type == "HSD1000": molarity_unit, molarity_step = "pM", 10 - min_frag, max_frag, min_mol, max_mol = 180, 900, 200, 20000 + 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, max_frag, min_mol, max_mol = 250, 2500, 2, 60 + 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, max_frag, min_mol, max_mol = 180, 900, 2, 60 + min_frag_default, max_frag_default, min_mol_default, max_mol_default = 180, 900, 2, 60 + + # override with saved values when present + 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) return html.Div([ dbc.Label("Min Frag length:"), dcc.Input( - id={"type": "input-min-frag", "index": index, "plate_type": plate_type}, + id={"type": "input-min-frag", "index": index}, type="number", - value=min_frag, + 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, "plate_type": plate_type}, + id={"type": "input-max-frag", "index": index}, type="number", - value=max_frag, + 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, "plate_type": plate_type}, + id={"type": "input-min-mol", "index": index}, type="number", - value=min_mol, + 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, "plate_type": plate_type}, + id={"type": "input-max-mol", "index": index}, type="number", - value=max_mol, + value=max_mol_val, step=molarity_step, className="form-control", ), ], style={"minWidth": 240}) - else: - return html.Div("Unknown plate type.", className="text-danger") + @@ -511,9 +526,11 @@ def build_tab_body(plate, app_state, current_flagging): 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) From 8220e85ad35683eca6755179e8e2f125d6e0afad Mon Sep 17 00:00:00 2001 From: Marc Zuber Date: Fri, 24 Oct 2025 21:02:56 +0200 Subject: [PATCH 4/8] M10 and M11 - The calculations need to be moved out of the buid_tab_body function --- index.py | 255 +++++++++++++++++++++++---------- utils/flagging_conditions.py | 132 ++++++++--------- utils/plate_render_utils.py | 127 +++++++++++++--- utils/plates_callback_utils.py | 4 + utils/warning_utils.py | 2 + 5 files changed, 361 insertions(+), 159 deletions(-) diff --git a/index.py b/index.py index 9f7f384..b93644c 100644 --- a/index.py +++ b/index.py @@ -118,11 +118,13 @@ className="small", ), html.Div( - id="plate-button-container-2", + id="phix-control-dropdowns", + className="small", + ), + html.Div( + id="adjust-flagging-conditon-buttons", className="small", ), - - ], className="p-2 small", ), @@ -439,27 +441,13 @@ def plate_mapper(plate_ids, objects): False, "", False, "", None, False, "", [], no_update ) - elif triggered == "flagging-conditions-storage": - - print("Flagging conditions updated:", flagging_conditions_storage) - collect_flagging_from_all(app_state, flagging_conditions_storage) - # Function to check for the flagging conditions + 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, [] - return ( - False, "", # alert-fade-too-many-qpcr - app_state.to_json(), # app_data_state - show_warning, warning_msg, # alert-tapestation-sampleid - False, "", # alert-find-libraries-success - False, "", # alert-find-libraries-fail - None, # upload-files.contents - False, "", # alert-fade-duplicate-file - no_update, # table-deletion-trigger.children - no_update # update-rows-trigger-div.children - ) # --- 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: @@ -718,7 +706,8 @@ def prune_missing_tabs(app_data_json, children, active_tabs, flagging_conditions [ Output("dynamic-tabs", "children"), Output("dynamic-tabs", "active_tab"), - Output("plate-button-container-2", "children"), + 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 @@ -735,6 +724,8 @@ def append_new_tabs(_trigger, app_data_json, existing_tabs, active_tab, flagging # 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] @@ -752,14 +743,16 @@ def append_new_tabs(_trigger, app_data_json, existing_tabs, active_tab, flagging plates_by_id = {str(p.plate_id): p for p in app.plate_registry} 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, button = make_tab(plates_by_id[pid], app, current_flagging=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(button) + flag_buttons.append(flag_button) + phix_dropdowns.append(phix_dropdown) # preserve order of existing; append new at the end (no reordering of mounted) @@ -792,7 +785,9 @@ def _id_of(tab_like): # Hide all others btn.style = {"display": "none"} - return next_children, next_active, flag_buttons + #print("app_state for tab append after:", app_data_json) + + return next_children, next_active, flag_buttons, phix_dropdowns @@ -930,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 @@ -959,7 +955,12 @@ 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: @@ -977,18 +978,23 @@ 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({"type": "adjust-flagging-btn", "index": ALL}, "style"), Input("dynamic-tabs", "active_tab"), State({"type": "adjust-flagging-btn", "index": ALL}, "id"), ) -def show_only_active_button(active_tab, all_ids): +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 @@ -996,62 +1002,157 @@ def show_only_active_button(active_tab, 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("plate-button-container", "children"), - [Input("app_data_state", "data"), - Input("dynamic-tabs", "active_tab")], - prevent_initial_call=True + 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 update_plate_buttons(app_data_state_json, selected_tab): +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. + """ + + # safety: if we don't have state or tab yet, just don't touch options + if app_data_state_json is None: + return [] + + # reconstruct full app state + 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") + + # rebuild df in the same logic you use in build_tab_body (lightweight version) + import numpy as np + import pandas as pd + + df_raw = ( + plate.dataset(app_state, tidy=False) + .replace(['None', 'none', 'NaN', 'nan', ''], np.nan) + .infer_objects(copy=False) + ) + + # we need slope/intercept to compute molarity for qPCR plates, like you do + if is_qpcr: + standards_df = df_raw[df_raw['sample_type'] == 'Standard'].copy() + 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) + else: + slope = intercept = None + + df = build_computed_molarity(app_state, plate, slope, intercept) + + # now extract PhiX rows fresh + 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: + region_molarity_col = next( + (col for col in row.index if str(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("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) +# 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 - ) +# # 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 +# ) diff --git a/utils/flagging_conditions.py b/utils/flagging_conditions.py index 7976ddf..2ab599c 100644 --- a/utils/flagging_conditions.py +++ b/utils/flagging_conditions.py @@ -1,102 +1,102 @@ +# utils/flagging_conditions.py from typing import Dict, Any import pandas as pd import re -def _to_float(v): - try: - if v is None or v == "": - return None - return float(v) - except Exception: +def safe_float(v): + """Convert v to float safely — ignores units like 'nM' or 'pM'.""" + if v in [" - ", "-", None, ""]: return None -def _first_col_startswith(df: pd.DataFrame, prefix: str) -> str | None: - """ - Return the first column whose name starts with `prefix` (case-insensitive). - Examples: - "Average Size [bp]" -> match for prefix="Average Size" - "Region Molarity (nmol/L)" -> match for prefix="Region Molarity" - """ + 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 isinstance(col, str) and pat.search(col.strip()): + if pat.search(str(col).strip()): return col return None -def evaluate_flagging_for_plate(plate, app_state, conditions: Dict[str, Any]) -> Dict[str, str]: + +def evaluate_flagging_for_df(df: pd.DataFrame, conditions: Dict[str, Any]) -> pd.Series: """ - Evaluate for ONE plate: - - any column starting with "Average Size" vs min_frag/max_frag - - any column starting with "Region Molarity" vs min_mol/max_mol - Writes the combined text onto each PlateWell as `flagging_conditions`. - Returns: { well_position_id -> combined_flag_text_or_empty }. + Evaluate flagging for one DataFrame and return a Series with text flags. """ - min_frag = _to_float(conditions.get("min_frag")) - max_frag = _to_float(conditions.get("max_frag")) - min_mol = _to_float(conditions.get("min_mol")) - max_mol = _to_float(conditions.get("max_mol")) - - if all(v is None for v in (min_frag, max_frag, min_mol, max_mol)): - return {} + conditions = conditions or {} - df = plate.dataset(app_state, tidy=True).copy() + # 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") - # Find the dynamic columns - avg_col = _first_col_startswith(df, "Average Size") - mol_col = _first_col_startswith(df, "Region Molarity") + avg_col = first_col_startswith(df, "Average Size") + reg_mol_col = first_col_startswith(df, "Region Molarity") - # Cast to numeric if present - if avg_col is not None: - df[avg_col] = pd.to_numeric(df[avg_col], errors="coerce") - if mol_col is not None: - df[mol_col] = pd.to_numeric(df[mol_col], errors="coerce") + flags = [] - flags_by_well: Dict[str, str] = {} for _, row in df.iterrows(): - well_id = str(row.get("well_position_id")) parts = [] - # --- Average Size checks (if column exists) --- + # Tapestation: Average Size if avg_col is not None: - avg = row.get(avg_col) - if pd.notna(avg): + 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") - # --- Region Molarity checks (if column exists) --- - if mol_col is not None: - mol = row.get(mol_col) - if pd.notna(mol): - if min_mol is not None and mol < min_mol: + # 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 is not None and mol > max_mol: + if max_mol_plate is not None and rmol > max_mol_plate: parts.append("region molarity too high") - flags_by_well[well_id] = "; ".join(parts) + # 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") - # Persist on PlateWell - for well in plate.plate_wells: - wid = str(well.well_position_id) - setattr(well, "flagging_conditions", flags_by_well.get(wid, "")) + # 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") - return flags_by_well + flags.append(", ".join(parts)) + return pd.Series(flags, index=df.index) -def collect_flagging_from_all(app_state, all_conditions: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, str]]: +def collect_flagging_from_all(df: pd.DataFrame, current_flagging: Dict[str, Any]) -> pd.DataFrame: """ - Apply flagging to all plates that have conditions. - all_conditions: { plate_id(str) -> {min_frag, max_frag, ...} } + Single-DF variant used in your tab builder. + Returns a COPY of df with a 'flagging_conditions' column attached. """ - results: Dict[str, Dict[str, str]] = {} - if not all_conditions: - return results - - for pid, plate in app_state.plate_by_id.items(): - cond = all_conditions.get(str(pid)) - if cond: - results[str(pid)] = evaluate_flagging_for_plate(plate, app_state, cond) - - return results + 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 5bb3db0..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) @@ -71,14 +72,14 @@ def is_numeric(val): def make_tab(plate, app_state, current_flagging): pid = str(plate.plate_id) - tab_components, flag_button = build_tab_body(plate, app_state, current_flagging) + 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=tab_components, style={"paddingRight": "0px"}, - ), flag_button + ), 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,13 +349,16 @@ def fill_color(g): return fig - -def build_flagging_body(plate_type: str, index: str, current_flagging) -> html.Div: +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. - `current_flagging` is expected to be a dict for *this plate only*, e.g. - {"stddev": 0.28, "min_nm": 0.86, "max_nm": 30.6} for qPCR - {"min_frag": 180, "max_frag": 900, "min_mol": 2, "max_mol": 60} for TS + + 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 {} @@ -352,12 +368,19 @@ def build_flagging_body(plate_type: str, index: str, current_flagging) -> html.D min_nm_default = 0.8 max_nm_default = 30 - # override with saved values when present + # 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) - return html.Div([ + # 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}, @@ -384,6 +407,9 @@ def build_flagging_body(plate_type: str, index: str, current_flagging) -> html.D ), ], style={"minWidth": 240}) + return updated_flagging, body_div + + elif plate_type in {"HSD1000", "D1000", "D5000"}: # plate-type defaults if plate_type == "HSD1000": @@ -396,13 +422,21 @@ def build_flagging_body(plate_type: str, index: str, current_flagging) -> html.D molarity_unit, molarity_step = "nM", 0.1 min_frag_default, max_frag_default, min_mol_default, max_mol_default = 180, 900, 2, 60 - # override with saved values when present + # 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) - return html.Div([ + # 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}, @@ -437,9 +471,7 @@ def build_flagging_body(plate_type: str, index: str, current_flagging) -> html.D ), ], style={"minWidth": 240}) - - - + return updated_flagging, body_div @@ -534,6 +566,14 @@ def build_tab_body(plate, app_state, current_flagging): 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"]) @@ -542,6 +582,10 @@ def build_tab_body(plate, app_state, current_flagging): 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": [ { @@ -711,6 +755,57 @@ def _is_num(s): 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( @@ -821,7 +916,7 @@ def _is_num(s): dbc.Modal( [ dbc.ModalHeader("Adjust Flagging"), - dbc.ModalBody(build_flagging_body(plate.plate_type, pid, current_flagging), id={"type": "flagging-modal-body", "index": pid}), + dbc.ModalBody(flagging_body, id={"type": "flagging-modal-body", "index": pid}), dbc.ModalFooter([ dbc.Button("Close", id={"type": "flagging-modal-close", "index": pid}), ]), @@ -869,4 +964,4 @@ def _is_num(s): dummy = html.Div(id={"type": "dummy-div", "index": pid}, style={"display": "none"}) tab_components = [table_wrap, plate_wrap, buttons_row, delete_plate_modal, flagging_modal, delete_rows_modal, dummy] - return tab_components, flag_button \ No newline at end of file + 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 ( From 3983b69a00af93626cef783cba9892f1ed9ffe46 Mon Sep 17 00:00:00 2001 From: Marc Zuber Date: Fri, 24 Oct 2025 21:23:25 +0200 Subject: [PATCH 5/8] fix the refresh phix dropdown options function --- index.py | 29 +++-------------------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/index.py b/index.py index b93644c..a1affba 100644 --- a/index.py +++ b/index.py @@ -1030,11 +1030,9 @@ def refresh_phix_dropdown_options(app_data_state_json, active_tab, this_dropdown This runs separately from build_tab_body(), so content stays fresh. """ - # safety: if we don't have state or tab yet, just don't touch options if app_data_state_json is None: return [] - # reconstruct full app state app_state = AppState.from_json(app_data_state_json) # which plate does THIS dropdown belong to? @@ -1045,30 +1043,8 @@ def refresh_phix_dropdown_options(app_data_state_json, active_tab, this_dropdown is_qpcr = (plate.plate_type == "qPCR") - # rebuild df in the same logic you use in build_tab_body (lightweight version) - import numpy as np - import pandas as pd + df = plate.dataset(app_state, tidy=False) - df_raw = ( - plate.dataset(app_state, tidy=False) - .replace(['None', 'none', 'NaN', 'nan', ''], np.nan) - .infer_objects(copy=False) - ) - - # we need slope/intercept to compute molarity for qPCR plates, like you do - if is_qpcr: - standards_df = df_raw[df_raw['sample_type'] == 'Standard'].copy() - 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) - else: - slope = intercept = None - - df = build_computed_molarity(app_state, plate, slope, intercept) - - # now extract PhiX rows fresh if "sample_type" in df.columns: phix_rows = df[df["sample_type"] == "PhiX"] else: @@ -1083,8 +1059,9 @@ def refresh_phix_dropdown_options(app_data_state_json, active_tab, this_dropdown 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 str(col).startswith("Region Molarity")), + (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 From 42ce8312b46efb47638354c3bd4d1da91b8a800a Mon Sep 17 00:00:00 2001 From: Marc Zuber Date: Mon, 27 Oct 2025 23:07:25 +0100 Subject: [PATCH 6/8] added phix dropdown option to dcc store component --- index.py | 140 +++++++++++++++++++++++++++++++++--- utils/plate_render_utils.py | 27 +++++-- 2 files changed, 153 insertions(+), 14 deletions(-) diff --git a/index.py b/index.py index a1affba..c199152 100644 --- a/index.py +++ b/index.py @@ -148,10 +148,16 @@ 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"} + ], + style={"margin": "20px"} ) def render_main_content(): @@ -614,6 +620,7 @@ def update_standard_curve(app_state, row_data, plate_id): 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"), @@ -623,14 +630,15 @@ def update_standard_curve(app_state, row_data, plate_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, - stddev_ids, min_nm_ids, max_nm_ids, - min_frag_ids, max_frag_ids, min_mol_ids, max_mol_ids): + 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): @@ -639,6 +647,8 @@ def add(values, ids, key, 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) @@ -647,7 +657,9 @@ def add(values, ids, key, out): 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 @@ -707,7 +719,9 @@ def prune_missing_tabs(app_data_json, children, active_tabs, flagging_conditions Output("dynamic-tabs", "children"), Output("dynamic-tabs", "active_tab"), Output("adjust-flagging-conditon-buttons", "children"), - Output("phix-control-dropdowns", "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 @@ -744,16 +758,27 @@ def append_new_tabs(_trigger, app_data_json, existing_tabs, active_tab, flagging 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 = make_tab(plates_by_id[pid], app, current_flagging=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 @@ -787,7 +812,32 @@ def _id_of(tab_like): #print("app_state for tab append after:", app_data_json) - return next_children, next_active, flag_buttons, phix_dropdowns + 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, + ) @@ -918,6 +968,8 @@ def sync_plate_style(cell_evt, row_data, virtual_row_data, fig): + + @app.callback( Output({"type": "datatable-plate", "index": MATCH}, "rowData"), Input("update-rows-trigger-div", "children"), # broadcast trigger @@ -988,6 +1040,76 @@ def update_rows_callback(_, app_data_state_json, pcr_store, table_id, current_fl +@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"), @@ -1069,7 +1191,7 @@ def refresh_phix_dropdown_options(app_data_state_json, active_tab, this_dropdown 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}) + dropdown_options.append({"label": label, "value": well}) return dropdown_options diff --git a/utils/plate_render_utils.py b/utils/plate_render_utils.py index fe57d3e..48784c5 100644 --- a/utils/plate_render_utils.py +++ b/utils/plate_render_utils.py @@ -72,14 +72,14 @@ def is_numeric(val): def make_tab(plate, app_state, current_flagging): pid = str(plate.plate_id) - tab_components, flag_button, phix_dropdown = build_tab_body(plate, app_state, current_flagging) + tab_components, flag_button, phix_dropdown, flagging_warnings = 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=tab_components, style={"paddingRight": "0px"}, - ), flag_button, phix_dropdown + ), flag_button, phix_dropdown, flagging_warnings def get_tab_id(tab_like): # works for Component or serialized dict from State @@ -565,6 +565,11 @@ def build_tab_body(plate, app_state, current_flagging): 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) + + # get the phix value for this plate from current_flagging before we loose it in build_flagging_body + print("current_flagging before build_flagging_body:", current_flagging) + phix_value = (current_flagging or {}).get("phix_control_well_position", "") + print("Phix value for plate", pid, "is", phix_value) current_flagging, flagging_body = build_flagging_body(plate.plate_type, pid, current_flagging) @@ -582,10 +587,21 @@ def build_tab_body(plate, app_state, current_flagging): df["__error__"] = False df["__warning__"] = False + flagging_warning_rows = [] + if "flagging_conditions" in df.columns: has_flag_issue = df["flagging_conditions"].astype(str).str.strip() != "" df["__error__"] = df["__error__"] | has_flag_issue + # collect human-readable warnings for this plate + if has_flag_issue.any(): + # we keep it simple: 1 warning per plate, like: + # "Plate : One or more wells violate the flagging thresholds." + flagging_warning_rows = [ + f"Plate {plate.plate_id}: One or more wells exceed the defined flagging thresholds." + ] + + getRowStyle = { "styleConditions": [ { @@ -784,7 +800,7 @@ def _is_num(s): 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}) + dropdown_options.append({"label": label, "value": well}) phix_dropdown = html.Div( [ @@ -795,17 +811,18 @@ def _is_num(s): dcc.Dropdown( id={"type": "phix-control-dropdown", "index": pid}, options=dropdown_options, + value=phix_value, 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( @@ -964,4 +981,4 @@ def _is_num(s): dummy = html.Div(id={"type": "dummy-div", "index": pid}, style={"display": "none"}) 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 + return tab_components, flag_button, phix_dropdown, flagging_warning_rows \ No newline at end of file From 4ae4b2cebf2800ec586a339cb676e52d63aafd7f Mon Sep 17 00:00:00 2001 From: Marc Zuber Date: Tue, 28 Oct 2025 14:27:48 +0100 Subject: [PATCH 7/8] added red color to plate view --- index.py | 11 +++++++++++ utils/plate_render_utils.py | 37 ------------------------------------- 2 files changed, 11 insertions(+), 37 deletions(-) diff --git a/index.py b/index.py index c199152..32fc13e 100644 --- a/index.py +++ b/index.py @@ -918,6 +918,17 @@ def sync_plate_style(cell_evt, row_data, virtual_row_data, fig): 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): diff --git a/utils/plate_render_utils.py b/utils/plate_render_utils.py index 48784c5..193494e 100644 --- a/utils/plate_render_utils.py +++ b/utils/plate_render_utils.py @@ -270,47 +270,12 @@ def _build_plate_figure_for_plate(plate, app_state): if w in df["well"].values and st: df.loc[df["well"] == w, "group"] = st - # --- error/warning border mapping from plate dataset (mirror AG Grid logic) --- - error_wells, warn_wells = set(), set() - - df_table = plate.dataset(app_state, tidy=False) - # Only non-qPCR have well_note-based flags in your grid code - if getattr(plate, "plate_type", "") != "qPCR" and ("well_note" in df_table.columns): - error_wells = set(df_table.loc[ - df_table["well_note"].isin(["No sample found", "Multiple samples found"]), - "well_position_id" - ].astype(str)) - warn_wells = set(df_table.loc[ - df_table["well_note"] == "Unable to Update", - "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 marker_colors = [fill_color(g) for g in df["group"]] - line_colors = [] line_widths = [] - for w in df["well"]: - if w in error_wells: - line_colors.append(ERROR_BORDER); line_widths.append(3) - elif w in warn_wells: - line_colors.append(WARN_BORDER); line_widths.append(3) - else: - line_colors.append("#222"); line_widths.append(BORDER_PX) # --- figure --- fig = go.Figure( @@ -567,9 +532,7 @@ def build_tab_body(plate, app_state, current_flagging): df = df.drop(columns=drop_cols) # get the phix value for this plate from current_flagging before we loose it in build_flagging_body - print("current_flagging before build_flagging_body:", current_flagging) phix_value = (current_flagging or {}).get("phix_control_well_position", "") - print("Phix value for plate", pid, "is", phix_value) current_flagging, flagging_body = build_flagging_body(plate.plate_type, pid, current_flagging) From ff55b0211a2f65890c7ac7987f999b0644159114 Mon Sep 17 00:00:00 2001 From: Marc Zuber Date: Tue, 28 Oct 2025 15:23:49 +0100 Subject: [PATCH 8/8] little cleaning --- index.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/index.py b/index.py index 32fc13e..307a8f2 100644 --- a/index.py +++ b/index.py @@ -141,20 +141,8 @@ 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-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"}