Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add results inspection table #39

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
241 changes: 197 additions & 44 deletions app/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from dash import clientside_callback
from dash import dcc
from dash import html
from nplinker.metabolomics.spectrum import Spectrum


dash._dash_renderer._set_react_version("18.2.0") # type: ignore
Expand Down Expand Up @@ -126,7 +127,7 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]:
{
"GCF ID": gcf.id,
"# BGCs": len(gcf.bgcs),
"BGC Classes": list(set(gcf_bgc_classes)), # Using set to get unique classes
"BGC Classes": list(set(gcf_bgc_classes)),
"BGC IDs": list(bgc_ids),
"BGC smiles": list(bgc_smiles),
"strains": strains,
Expand All @@ -137,25 +138,34 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]:
processed_data["n_bgcs"][len(gcf.bgcs)] = []
processed_data["n_bgcs"][len(gcf.bgcs)].append(gcf.id)

processed_links: dict[str, Any] = {
"gcf_id": [],
"spectrum_id": [],
"strains": [],
"method": [],
"score": [],
"cutoff": [],
"standardised": [],
}

for link in links.links:
for method, data in link[2].items():
processed_links["gcf_id"].append(link[0].id)
processed_links["spectrum_id"].append(link[1].id)
processed_links["strains"].append([s.id for s in link[1].strains._strains])
processed_links["method"].append(method)
processed_links["score"].append(data.value)
processed_links["cutoff"].append(data.parameter["cutoff"])
processed_links["standardised"].append(data.parameter["standardised"])
if links is not None:
processed_links: dict[str, Any] = {
"gcf_id": [],
"spectrum": [],
"method": [],
"score": [],
"cutoff": [],
"standardised": [],
}

for link in links.links:
if isinstance(link[1], Spectrum): # Then link[0] is a GCF (GCF -> Spectrum)
processed_links["gcf_id"].append(link[0].id)
processed_links["spectrum"].append(
{
"id": link[1].id,
"strains": [s.id for s in link[1].strains._strains],
"precursor_mz": link[1].precursor_mz,
"gnps_id": link[1].gnps_id,
}
)
for method, data in link[2].items():
processed_links["method"].append(method)
processed_links["score"].append(data.value)
processed_links["cutoff"].append(data.parameter["cutoff"])
processed_links["standardised"].append(data.parameter["standardised"])
else:
processed_links = {}

return json.dumps(processed_data), json.dumps(processed_links)
except Exception as e:
Expand All @@ -169,11 +179,12 @@ def process_bgc_class(bgc_class: tuple[str, ...] | None) -> list[str]:
Output("gm-filter-accordion-control", "disabled"),
Output("gm-filter-blocks-id", "data", allow_duplicate=True),
Output("gm-filter-blocks-container", "children", allow_duplicate=True),
Output("gm-table-card-header", "style"),
Output("gm-table-card-body", "style", allow_duplicate=True),
Output("gm-scoring-accordion-control", "disabled"),
Output("gm-scoring-blocks-id", "data", allow_duplicate=True),
Output("gm-scoring-blocks-container", "children", allow_duplicate=True),
Output("gm-table-card-header", "style"),
Output("gm-table-card-body", "style", allow_duplicate=True),
Output("gm-results-button", "disabled"),
Output("mg-tab", "disabled"),
],
[Input("file-store", "data")],
Expand All @@ -186,11 +197,12 @@ def disable_tabs_and_reset_blocks(
bool,
list[str],
list[dmc.Grid],
dict,
dict[str, str],
bool,
list[str],
list[dmc.Grid],
dict,
dict[str, str],
bool,
bool,
]:
"""Manage tab states and reset blocks based on file upload status.
Expand All @@ -203,7 +215,7 @@ def disable_tabs_and_reset_blocks(
"""
if file_path is None:
# Disable the tabs, don't change blocks
return True, True, [], [], True, [], [], {}, {"display": "block"}, True
return True, True, [], [], {}, {"display": "block"}, True, [], [], True, True

# Enable the tabs and reset blocks
gm_filter_initial_block_id = [str(uuid.uuid4())]
Expand All @@ -216,11 +228,12 @@ def disable_tabs_and_reset_blocks(
False,
gm_filter_initial_block_id,
gm_filter_new_blocks,
{},
{"display": "block"},
False,
gm_scoring_initial_block_id,
gm_scoring_new_blocks,
{},
{"display": "block"},
False,
False,
)

Expand Down Expand Up @@ -668,7 +681,6 @@ def gm_table_select_rows(

selected_rows_data = df.iloc[selected_rows]

# TODO: to be removed later when the scoring part will be implemented
output1 = f"Total rows: {len(df)}"
output2 = f"Selected rows: {len(selected_rows)}\nSelected GCF IDs: {', '.join(selected_rows_data['GCF ID'].astype(str))}"

Expand Down Expand Up @@ -731,7 +743,7 @@ def gm_scoring_create_initial_block(block_id: str) -> dmc.Grid:
id={"type": "gm-scoring-dropdown-ids-cutoff-met", "index": block_id},
label="Cutoff",
placeholder="Insert cutoff value as a number",
value="1",
value="0.05",
className="custom-textinput",
)
],
Expand Down Expand Up @@ -850,7 +862,7 @@ def gm_scoring_display_blocks(
},
label="Cutoff",
placeholder="Insert cutoff value as a number",
value="1",
value="0.05",
className="custom-textinput",
),
],
Expand Down Expand Up @@ -896,7 +908,7 @@ def gm_scoring_update_placeholder(
# Callback was not triggered by user interaction, don't change anything
raise dash.exceptions.PreventUpdate
if selected_value == "METCALF":
return ({"display": "block"}, "Cutoff", "1")
return ({"display": "block"}, "Cutoff", "0.05")
else:
# This case should never occur due to the Literal type, but it satisfies mypy
return ({"display": "none"}, "", "")
Expand Down Expand Up @@ -933,37 +945,178 @@ def gm_scoring_apply(
return df


# TODO: add the logic for outputing data in the results table, issue #33
# TODO: Check out data are passed back to the DataTable both here and in the other callback
# Check whether you're doing unnecessary work and uniform the logic
# TODO: (#35) Check whether is possible to squeeze long tooltips, and thus make them persistent, allow user to copy their text
# TODO: (#35) Check whether is possible to include hyperlinks in the tooltips
# TODO: (#35) Check whether is possible to include a plot in the tooltips
# TODO: Add tests
@app.callback(
Input("gm-scoring-apply-button", "n_clicks"),
Output("gm-results-alert", "children"),
Output("gm-results-alert", "is_open"),
Output("gm-results-table", "data"),
Output("gm-results-table", "columns"),
Output("gm-results-table", "tooltip_data"),
Output("gm-results-table-card-body", "style"),
Output("gm-results-table-card-header", "style"),
Input("gm-results-button", "n_clicks"),
Input("gm-table", "derived_virtual_data"),
Input("gm-table", "derived_virtual_selected_rows"),
State("processed-links-store", "data"),
State({"type": "gm-scoring-dropdown-menu", "index": ALL}, "value"),
State({"type": "gm-scoring-radio-items", "index": ALL}, "value"),
State({"type": "gm-scoring-dropdown-ids-cutoff-met", "index": ALL}, "value"),
)
def gm_update_results_datatable(
n_clicks: int | None,
filtered_data: str,
virtual_data: list[dict] | None,
selected_rows: list[int] | None,
processed_links: str,
dropdown_menus: list[str],
radiobuttons: list[str],
cutoffs_met: list[str],
):
) -> tuple[str, bool, list[dict], list[dict], list[dict], dict, dict]:
"""Update the results DataTable based on scoring filters.

Args:
n_clicks: Number of times the "Show Spectra" button has been clicked.
filtered_data: JSON string of filtered data.
n_clicks: Number of times the "Show Results" button has been clicked.
virtual_data: Current filtered data from the GCF table.
selected_rows: Indices of selected rows in the GCF table.
processed_links: JSON string of processed links data.
dropdown_menus: List of selected dropdown menu options.
radiobuttons: List of selected radio button options.
cutoffs_met: List of cutoff values for METCALF method.

Returns:
None
Tuple containing alert message, visibility state, table data and settings, and header style.
"""
triggered_id = ctx.triggered_id

if triggered_id in ["gm-table-select-all-checkbox", "gm-table"]:
return "", False, [], [], [], {"display": "none"}, {"color": "#888888"}

if n_clicks is None:
return "", False, [], [], [], {"display": "none"}, {"color": "#888888"}

if not selected_rows:
return (
"No GCFs selected. Please select GCFs and try again.",
True,
[],
[],
[],
{"display": "none"},
{"color": "#888888"},
)

if not virtual_data:
return "No data available.", True, [], [], [], {"display": "none"}, {"color": "#888888"}

try:
data = json.loads(filtered_data)
df = pd.DataFrame(data)
except (json.JSONDecodeError, KeyError, pd.errors.EmptyDataError):
return
df_results = gm_scoring_apply(df, dropdown_menus, radiobuttons, cutoffs_met)
print(df_results.head())
links_data = json.loads(processed_links)
if len(links_data) == 0:
return (
"No processed links available.",
True,
[],
[],
[],
{"display": "none"},
{"color": "#888888"},
)

# Get selected GCF IDs
selected_gcfs = [virtual_data[i]["GCF ID"] for i in selected_rows]

# Convert links data to DataFrame
links_df = pd.DataFrame(links_data)

# Apply scoring filters
filtered_df = gm_scoring_apply(links_df, dropdown_menus, radiobuttons, cutoffs_met)

# Filter for selected GCFs and aggregate results
results = []
for gcf_id in selected_gcfs:
gcf_links = filtered_df[filtered_df["gcf_id"] == gcf_id]
if not gcf_links.empty:
top_spectrum = gcf_links.loc[gcf_links["score"].idxmax()]
results.append(
{
"GCF ID": gcf_id,
"# Links": len(gcf_links),
"spectrums": gcf_links["spectrum"].tolist(),
"spectrum_scores": gcf_links["score"].tolist(),
"Top 1 Spectrum ID": top_spectrum["spectrum"]["id"],
"top_spectrum_strain": top_spectrum["spectrum"]["strains"],
"top_spectrum_score": top_spectrum["score"],
"top_spectrum_precursor_mz": top_spectrum["spectrum"]["precursor_mz"],
"top_spectrum_gnps_id": top_spectrum["spectrum"]["gnps_id"],
"Average Score": round(gcf_links["score"].mean(), 2),
}
)

if not results:
return (
"No matching links found for selected GCFs.",
True,
[],
[],
[],
{"display": "none"},
{"color": "#888888"},
)

keys_to_remove = [
"spectrums",
"top_spectrum_strain",
"top_spectrum_score",
"top_spectrum_precursor_mz",
"top_spectrum_gnps_id",
"spectrum_scores",
]

results_display = [
{k: v for k, v in result.items() if k not in keys_to_remove} for result in results
]

# Prepare tooltip data
tooltip_data = []
for result in results:
spectrums_table = "| Spectrum ID | Score |\n|------------|--------|\n"
for spectrum, score in zip(result["spectrums"], result["spectrum_scores"]):
spectrums_table += f"| {spectrum['id']} | {score} |\n"

tooltip_data.append(
{
"# Links": {"value": spectrums_table, "type": "markdown"},
"Top 1 Spectrum ID": {
"value": (
f"Spectrum ID: {result['Top 1 Spectrum ID']}\n"
f"Precursor m/z: {result['top_spectrum_precursor_mz']}\n"
f"GNPS ID: {result['top_spectrum_gnps_id']}\n"
f"Top spectrum score: {result['top_spectrum_score']}"
),
"type": "markdown",
},
}
)

columns = [
{"name": "GCF ID", "id": "GCF ID"},
{"name": "# Links", "id": "# Links", "type": "numeric"},
{"name": "Top 1 Spectrum ID", "id": "Top 1 Spectrum ID"},
{"name": "Average Score", "id": "Average Score", "type": "numeric"},
]

return "", False, results_display, columns, tooltip_data, {"display": "block"}, {}

except Exception as e:
return (
f"Error processing results: {str(e)}",
True,
[],
[],
[],
{"display": "none"},
{"color": "#888888"},
)
Loading
Loading