diff --git a/index.py b/index.py index 1a25c9e..fa46416 100644 --- a/index.py +++ b/index.py @@ -32,7 +32,6 @@ EMPTY_BORDER_PX, make_tab, get_tab_id, - SAMPLE_TYPE_OPTIONS ) pd.set_option('future.no_silent_downcasting', True) @@ -530,6 +529,8 @@ def plate_mapper(plate_ids, objects): if str(well.well_position_id) in selected_well_ids: well.get_sample(app_state).sample_type = sample_type + + warnings = collect_missing_sample_warnings(app_state) show_warning = bool(warnings) warning_msg = build_warning_msg(warnings) @@ -748,9 +749,9 @@ def sync_plate_style(cell_evt, row_data, virtual_row_data, fig): fills.append(fill_color) customdata.append([w, st]) - if w in error_wells: + if w in error_wells and st in ("Library", "Positive Control"): line_colors.append(ERROR_BORDER); line_widths.append(3) - elif w in warn_wells: + elif w in warn_wells and st in ("Library", "Positive Control"): line_colors.append(WARN_BORDER); line_widths.append(3) else: line_colors.append(DEFAULT_BORDER); line_widths.append(BORDER_PX) @@ -796,8 +797,6 @@ def update_rows_callback(_, app_data_state_json, table_id): df = plate.dataset(app_state, tidy=False).replace(['None', 'none', 'NaN', 'nan', ''], np.nan).infer_objects(copy=False) - print("Plate dataset: ", df.head(3)) - # 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") @@ -1047,7 +1046,6 @@ def toggle_delete_plate_modal(n_open, n_cancel, n_confirm): return False return no_update - # Here we run the app on the specified host and port. if __name__ == "__main__": app.run(debug=bfabric_web_apps.DEBUG, port=bfabric_web_apps.PORT, host=bfabric_web_apps.HOST) diff --git a/utils/api_call_utils.py b/utils/api_call_utils.py index e9a3770..2de73f4 100644 --- a/utils/api_call_utils.py +++ b/utils/api_call_utils.py @@ -21,14 +21,14 @@ def format_tube_id(val): return "/".join(parts) return val -def initialize_sample_attributes(plate: Plate, app_state): +def populate_samples_by_identifier_lookup(plate: Plate, app_state): B = bfabric_interface.get_wrapper() formatted_tube_ids = [] for well in plate.plate_wells: tube_id = str(getattr(well, "file_sample_identifier", "")).strip() - print("tube_id", tube_id) + if tube_id: formatted = format_tube_id(tube_id) formatted_tube_ids.append(formatted) diff --git a/utils/plate_render_utils.py b/utils/plate_render_utils.py index 1aba284..040e1b7 100644 --- a/utils/plate_render_utils.py +++ b/utils/plate_render_utils.py @@ -10,15 +10,6 @@ pd.set_option('future.no_silent_downcasting', True) -# Mapping of sample types to their display options -SAMPLE_TYPE_OPTIONS = [ - {"label": "Negative Control", "value": "Negative Control"}, - {"label": "Positive Control", "value": "Positive Control"}, - {"label": "Standard", "value": "Standard"}, - {"label": "PhiX", "value": "PhiX"}, - {"label": "Library", "value": "Library"}, -] - SAMPLE_TYPE_COLORS = { "Negative Control": "#ef5350", # red "Positive Control": "#2979ff", # blue @@ -174,6 +165,23 @@ def build_tab_body(plate, app_state): import dash_bootstrap_components as dbc import dash_ag_grid as dag + # Dynamic options for the sample_type dropdown: + if plate.plate_type == "qPCR": + SAMPLE_TYPE_OPTIONS = [ + {"label": "Negative Control", "value": "Negative Control"}, + {"label": "Positive Control", "value": "Positive Control"}, + {"label": "Standard", "value": "Standard"}, + {"label": "PhiX", "value": "PhiX"}, + {"label": "Library", "value": "Library"}, + ] + else: + SAMPLE_TYPE_OPTIONS = [ + {"label": "Negative Control", "value": "Negative Control"}, + {"label": "Positive Control", "value": "Positive Control"}, + {"label": "PhiX", "value": "PhiX"}, + {"label": "Library", "value": "Library"}, + ] + pid = str(plate.plate_id) # normalize once; reuse everywhere is_qpcr = (plate.plate_type == "qPCR") @@ -199,8 +207,22 @@ def build_tab_body(plate, app_state): getRowStyle = { "styleConditions": [ - {"condition": "params.data.__error__ === true", "style": {"backgroundColor": "#ffcccc"}}, - {"condition": "params.data.__warning__ === true", "style": {"backgroundColor": "#f9f490"}}, + { + "condition": ( + "params.data.__error__ === true && " + + "params.data.sample_type !== 'Negative Control' && " + + "params.data.sample_type !== 'PhiX'" + ), + "style": {"backgroundColor": "#ffcccc"}, + }, + { + "condition": ( + "params.data.__warning__ === true && " + + "params.data.sample_type !== 'Negative Control' && " + + "params.data.sample_type !== 'PhiX'" + ), + "style": {"backgroundColor": "#f9f490"}, + }, ] } @@ -238,17 +260,29 @@ def _is_num(s): "cellEditorParams": {"values": [opt["value"] for opt in SAMPLE_TYPE_OPTIONS]}, "singleClickEdit": True, "cellStyle": { - "function": """(params) => { - const colors = { - 'Negative Control': '#ef5350', - 'Positive Control': '#2979ff', - 'Standard': '#153677', - 'PhiX': '#ffd600', - 'Library': '#43a047' - }; - const c = colors[params.value] || ''; - return { backgroundColor: c, color: (params.value==='PhiX'?'black':'white') }; - }""" + "styleConditions": [ + { + "condition": "params.value === 'Negative Control'", + "style": {"backgroundColor": "rgb(247,169,168)", "color": "black", "borderRadius": "10px"}, + }, + { + "condition": "params.value === 'Positive Control'", + "style": {"backgroundColor": "rgb(148,188,255)", "color": "black", "borderRadius": "10px"}, + }, + { + "condition": "params.value === 'Standard'", + "style": {"backgroundColor": "rgb(138,154,187)", "color": "black", "borderRadius": "10px"}, + }, + { + "condition": "params.value === 'PhiX'", + "style": {"backgroundColor": "rgb(255,235,127)", "color": "black", "borderRadius": "10px"}, + }, + { + "condition": "params.value === 'Library'", + "style": {"backgroundColor": "rgb(161,207,163)", "color": "black", "borderRadius": "10px"}, + }, + ], + "defaultStyle": {"backgroundColor": "white", "color": "black"}, }, "cellRenderer": { "function": {"code": """ @@ -269,7 +303,9 @@ def _is_num(s): # ensure sample_id is editable and right after sample_type col_sample_id = { "field": "sample_id", - "editable": True, + "editable": { + "function": "params.data.sample_type === 'Library' || params.data.sample_type === 'Positive Control'" + }, "filter": "agTextColumnFilter", "valueFormatter": {"function": "params.value == null ? '' : params.value"}, } @@ -422,6 +458,24 @@ def _is_num(s): ) ]) + + delete_plate_modal = html.Div([ + dbc.Modal( + [ + dbc.ModalHeader(dbc.ModalTitle("Delete Plate")), + dbc.ModalBody( + "Are you sure you want to delete this plate? This will remove all associated wells and their data. This action cannot be undone." + ), + dbc.ModalFooter([ + dbc.Button("Delete", id={"type": "confirm-delete-plate", "index": pid}, color="danger"), + dbc.Button("Cancel", id={"type": "cancel-delete-plate", "index": pid}, className="ms-auto"), + ]), + ], + id={"type": "modal-delete-plate", "index": pid}, + is_open=False, backdrop="static", + ) + ]) + 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 diff --git a/utils/plates_callback_utils.py b/utils/plates_callback_utils.py index b771999..8501221 100644 --- a/utils/plates_callback_utils.py +++ b/utils/plates_callback_utils.py @@ -5,7 +5,7 @@ from dash import html from models import Plate, PlateWell, Measurement, AppSpecificFile, Sample from app_state import AppState -from .api_call_utils import initialize_sample_attributes, safe_str +from .api_call_utils import populate_samples_by_identifier_lookup, safe_str from bfabric_web_apps.objects.BfabricInterface import bfabric_interface def find_and_populate_samples_by_sample_id( @@ -45,12 +45,14 @@ def find_and_populate_samples_by_sample_id( sample_name = safe_str(bfabric_sample.get("name")) container_id = safe_str(bfabric_sample.get("container", {}).get("id")) well.well_note = "Sample found" + sample_type = well.get_sample(app_state).sample_type new_sample = Sample( sample_id=sample_id, sample_name=sample_name, container=container_id, - file_sample_identifier=tube_id + file_sample_identifier=tube_id, + sample_type=sample_type ) app_state.sample_registry.append(new_sample) well.file_sample_identifier = tube_id @@ -71,11 +73,14 @@ def handle_plate_upload(contents, filenames, app_state): uploaded_file = AppSpecificFile.create_from_raw_data(name=name, raw_data=decoded_str) app_state, plate = Plate.from_file(input_file=uploaded_file, app_state=app_state) + # here we make the new functon to automatically populate sample_type + app_state, plate = populate_sample_types(app_state, plate) + if plate.plate_type in ("D1000", "D5000", "HSD1000"): - plate = initialize_sample_attributes(plate, app_state) + plate = populate_samples_by_identifier_lookup(plate, app_state) + #print("intial app state sample_registry:", app_state.sample_registry) # print("intial app state plate_registry:", app_state.plate_registry) - # print("Added plate:", plate.plate_id) @@ -142,14 +147,59 @@ def update_app_state_from_ui(app_state, all_ui_tables): if sample: if sample.sample_type != sample_type: sample.sample_type = sample_type - # print(f"Updated well {well_pos} with sample_type: {sample_type}") + break if sample_id: - if sample.sample_id != sample_id: + if sample.sample_id != sample_id and sample.sample_type in ("Library", "Positive Control"): find_and_populate_samples_by_sample_id(app_state, sample_id, well=pw) break - # print(f"Updated well {well_pos} with sample_id: {sample_id}, sample_type: {sample_type}") +def populate_sample_types(app_state, plate): + if plate.plate_type.lower() == "qpcr": + for well in plate.plate_wells: + position = well.well_position_id + row = position[0] + col = int(position[1:]) + + # Only consider columns 1, 4, 7, 10 + if col not in (1, 4, 7, 10): + continue + + # Assign sample types based on postion + if "A" <= row <= "F" and 1 <= col <= 3: + sample_type = "Standard" + elif row in ("G", "H") and 1 <= col <= 3: + sample_type = "Negative Control" + else: + sample_type = "Library" + + sample = well.get_sample(app_state) + sample.sample_type = sample_type + + return app_state, plate + + + else: + for well in plate.plate_wells: + desc = well.sample_identifier + desc_lower = desc.lower() + + if ( + "negative control" in desc_lower + or "neg" in desc_lower + ): + sample_type = "Negative Control" + elif "2220" in desc_lower: + sample_type = "Positive Control" + elif "phi" in desc_lower: + sample_type = "PhiX" + else: + sample_type = "Library" + + sample = well.get_sample(app_state) + sample.sample_type = sample_type + + return app_state, plate diff --git a/utils/warning_utils.py b/utils/warning_utils.py index 30740a8..3746a0f 100644 --- a/utils/warning_utils.py +++ b/utils/warning_utils.py @@ -12,7 +12,17 @@ def collect_missing_sample_warnings(app_state): # Tab label, just like in UI tab_name = f"Tapestation-{tapestation_index} ({plate.plate_id})" tapestation_index += 1 - missing = [w for w in plate.plate_wells if getattr(w, "well_note", None) in ("No sample found", "Multiple samples found")] + + missing = [] + for w in plate.plate_wells: + sample = w.get_sample(app_state) + sample_type = getattr(sample, "sample_type", None) + if ( + getattr(w, "well_note", None) in ("No sample found", "Multiple samples found") + and sample_type in ("Library", "Positive Control") + ): + missing.append(w) + if missing: for note in set(w.well_note for w in missing): wells = [w.well_position_id for w in missing if w.well_note == note]