def register_uc_8_1_callbacks(app, plot_service) -> None:
"""
Register all UC-8.1 callbacks with Dash app.
Parameters
----------
app : Dash
Dash application instance.
plot_service : PlotService
Singleton PlotService instance (shared across all callbacks).
Notes
-----
- Registers panel toggle, dropdown initialization, and faceted scatter callbacks
- Refer to official documentation for processing logic details
"""
logger.info("[UC-8.1] Registering callbacks")
# ========================================
# Callback 1: Toggle Informative Panel
# ========================================
@app.callback(
Output("uc-8-1-collapse", "is_open"),
Input("uc-8-1-collapse-button", "n_clicks"),
State("uc-8-1-collapse", "is_open"),
prevent_initial_call=True,
)
def toggle_uc_8_1_info_panel(n_clicks: Optional[int], is_open: bool) -> bool:
"""Toggle UC-8.1 informative panel collapse state."""
if n_clicks:
logger.debug(f"[UC-8.1] Toggling info panel: {is_open} -> {not is_open}")
return not is_open
return is_open
# ========================================
# Callback 2: Initialize Compound Class Dropdown
# ========================================
@app.callback(
Output("uc-8-1-compoundclass-dropdown", "options"),
[
Input("uc-8-1-accordion-group", "active_item"),
Input("merged-result-store", "data"),
],
prevent_initial_call=True,
)
def initialize_uc_8_1_dropdown(
active_item: Optional[str], merged_data: Optional[Dict[str, Any]]
) -> list:
"""
Populate compound class dropdown with available classes.
Parameters
----------
active_item : str, optional
Active accordion item ID.
merged_data : dict, optional
Dictionary containing 'biorempp_df' key.
Returns
-------
list
Dropdown options list.
Notes
-----
- Triggered when accordion opens or data changes
- Extracts unique compound classes from BioRemPP data
- Returns empty list if accordion not active or data unavailable
"""
logger.info(
f"[UC-8.1] [CALLBACK 2] Dropdown init triggered, "
f"active_item: {active_item}"
)
logger.debug(f"[UC-8.1] [CALLBACK 2] merged_data type: {type(merged_data)}")
if isinstance(merged_data, dict):
logger.debug(f"[UC-8.1] [CALLBACK 2] keys: {merged_data.keys()}")
# Only populate when accordion is open
if active_item != "uc-8-1-accordion":
logger.debug("[UC-8.1] [CALLBACK 2] Accordion not active, skip")
return []
if not merged_data or "biorempp_df" not in merged_data:
logger.warning(
"[UC-8.1] [CALLBACK 2] No BioRemPP data available for dropdown"
)
return []
try:
df = pd.DataFrame(merged_data["biorempp_df"])
logger.debug(
f"[UC-8.1] [CALLBACK 2] DataFrame created: "
f"{len(df)} rows, columns: {df.columns.tolist()}"
)
if df.empty:
logger.warning("[UC-8.1] [CALLBACK 2] DataFrame is empty")
return []
# Find compound class column
class_col = _find_column(
df, ["Compound_Class", "compound_class", "CompoundClass", "class"]
)
if not class_col:
logger.warning(
f"[UC-8.1] [CALLBACK 2] Compound class column not found. "
f"Available: {df.columns.tolist()}"
)
return []
# Get unique compound classes
classes = sorted(df[class_col].dropna().unique().tolist())
options = [{"label": c, "value": c} for c in classes]
logger.info(
f"[UC-8.1] [CALLBACK 2] Dropdown populated: " f"{len(classes)} classes"
)
logger.debug(f"[UC-8.1] [CALLBACK 2] Classes: {classes[:5]}...")
return options
except Exception as e:
logger.error(
f"[UC-8.1] [CALLBACK 2] Error initializing dropdown: {e}", exc_info=True
)
return []
# ========================================
# Callback 3: Render Faceted Scatter
# ========================================
@app.callback(
Output("uc-8-1-chart", "children"),
Input("uc-8-1-compoundclass-dropdown", "value"),
State("merged-result-store", "data"),
prevent_initial_call=True,
)
def render_uc_8_1(
selected_class: Optional[str], merged_data: Optional[Dict[str, Any]]
) -> html.Div:
"""
Render UC-8.1 faceted scatter on compound class dropdown selection.
Parameters
----------
selected_class : str, optional
Selected compound class from dropdown.
merged_data : dict, optional
Dictionary containing 'biorempp_df' key.
Returns
-------
html.Div
Container with chart or error message.
Notes
-----
- Filters BioRemPP data by selected compound class
- Groups samples by frozenset of compounds (identical profiles)
- Applies greedy set cover algorithm for minimal group selection
- Calculates unique KO counts per compound for color scaling
- Generates faceted scatter with one subplot per minimized group
"""
logger.info(f"[UC-8.1] [CALLBACK 3] ========== RENDER TRIGGERED ==========")
logger.info(f"[UC-8.1] [CALLBACK 3] selected_class: '{selected_class}'")
logger.debug(f"[UC-8.1] [CALLBACK 3] merged_data type: {type(merged_data)}")
logger.debug(f"[UC-8.1] [CALLBACK 3] is None: {merged_data is None}")
# Validate compound class selection
if not selected_class:
logger.debug("[UC-8.1] No compound class selected")
return _create_info_message(
"Please select a Compound Class from the dropdown above.",
"bi bi-arrow-up-circle",
)
try:
# ========================================
# Step 1: Validate merged_data structure
# ========================================
if not merged_data:
logger.warning("[UC-8.1] merged_data is None or empty")
return _create_error_message(
"No data available. Please upload and process data first.",
"bi bi-exclamation-triangle",
)
if not isinstance(merged_data, dict):
logger.error("[UC-8.1] merged_data is not a dictionary")
return _create_error_message(
"Invalid data structure. Please reload the application.",
"bi bi-x-circle",
)
if "biorempp_df" not in merged_data:
logger.error("[UC-8.1] merged_data does not contain 'biorempp_df' key")
return _create_error_message(
"BioRemPP data not found. This use case requires "
"BioRemPP database.",
"bi bi-database-x",
)
# ========================================
# Step 2: Extract DataFrame
# ========================================
logger.debug("[UC-8.1] Extracting DataFrame from merged_data")
biorempp_data = merged_data["biorempp_df"]
if not biorempp_data:
logger.warning("[UC-8.1] biorempp_df is empty")
return _create_error_message(
"BioRemPP dataset is empty. Please check your input data.",
"bi bi-inbox",
)
df = pd.DataFrame(biorempp_data)
if df.empty:
logger.warning("[UC-8.1] DataFrame is empty after conversion")
return _create_error_message(
"No data available after processing.", "bi bi-inbox"
)
logger.info(
f"[UC-8.1] Processing DataFrame: {len(df)} rows, "
f"{len(df.columns)} columns"
)
# ========================================
# Step 3: Map column names flexibly
# ========================================
col_map = {}
# Sample column
sample_col = _find_column(
df, ["Sample", "sample", "sample_name", "SampleName"]
)
if sample_col:
col_map["sample"] = sample_col
# Compound name column
compound_col = _find_column(
df, ["Compound_Name", "compound_name", "CompoundName", "Compound"]
)
if compound_col:
col_map["compoundname"] = compound_col
# Compound class column
class_col = _find_column(
df, ["Compound_Class", "compound_class", "CompoundClass", "class"]
)
if class_col:
col_map["compoundclass"] = class_col
# KO column (optional, for color)
ko_col = _find_column(df, ["KO", "ko", "ko_id", "KO_ID"])
if ko_col:
col_map["ko"] = ko_col
# ========================================
# Step 4: Validate required columns found
# ========================================
required = ["sample", "compoundname", "compoundclass"]
missing_cols = [col for col in required if col not in col_map]
if missing_cols:
logger.error(
f"[UC-8.1] Missing columns: {missing_cols}. "
f"Available: {df.columns.tolist()}"
)
return _create_error_message(
f"Required columns not found: {', '.join(missing_cols)}. "
f"Available columns: {', '.join(df.columns[:5])}...",
"bi bi-exclamation-octagon",
)
# ========================================
# Step 5: Prepare data
# ========================================
# Rename columns to standard names
rename_map = {v: k for k, v in col_map.items()}
df_work = df.rename(columns=rename_map).copy()
# Filter by selected compound class
df_filtered = df_work[df_work["compoundclass"] == selected_class].copy()
if df_filtered.empty:
logger.warning(f"[UC-8.1] No data for class '{selected_class}'")
return _create_error_message(
f"No data found for compound class: {selected_class}",
"bi bi-search",
)
# Clean data
df_filtered = df_filtered.dropna(subset=["sample", "compoundname"])
for col in ["sample", "compoundname"]:
df_filtered[col] = df_filtered[col].astype(str).str.strip()
# Remove placeholder values
placeholder_values = ["#N/D", "#N/A", "N/D", "", "nan", "None"]
for col in ["sample", "compoundname"]:
df_filtered = df_filtered[~df_filtered[col].isin(placeholder_values)]
if df_filtered.empty:
return _create_error_message(
f"No valid data for compound class: {selected_class}",
"bi bi-funnel",
)
logger.info(
f"[UC-8.1] Filtered to {len(df_filtered)} rows for "
f"class '{selected_class}'"
)
# ========================================
# Step 6: Group samples by compound profile
# ========================================
grouped_df, groups = _group_by_compound_profile(df_filtered)
if grouped_df.empty or not groups:
return _create_error_message(
"No sample groups found after grouping.", "bi bi-diagram-3"
)
logger.info(f"[UC-8.1] Created {len(groups)} groups")
# ========================================
# Step 7: Minimize groups with set cover
# ========================================
minimized_groups = _minimize_groups(grouped_df)
if not minimized_groups:
return _create_error_message(
"Could not determine minimal groups.", "bi bi-exclamation-circle"
)
# Filter to minimized groups only
final_df = grouped_df[grouped_df["_group"].isin(minimized_groups)].copy()
logger.info(f"[UC-8.1] Minimized to {len(minimized_groups)} groups")
# ========================================
# Step 8: Calculate KO counts (for color)
# ========================================
if "ko" in col_map:
ko_counts = _calculate_ko_counts(df_filtered)
if ko_counts is not None:
final_df = final_df.merge(
ko_counts, left_on="compoundname", right_index=True, how="left"
)
final_df["_unique_ko_count"] = (
final_df["_unique_ko_count"].fillna(0).astype(int)
)
else:
final_df["_unique_ko_count"] = 1
else:
final_df["_unique_ko_count"] = 1
# ========================================
# Step 9: Generate plot
# ========================================
fig = _create_frozenset_figure(final_df, minimized_groups, selected_class)
logger.info("[UC-8.1] Faceted scatter generation successful")
# ========================================
# Step 10: Return chart component
# ========================================
n_groups = len(minimized_groups)
n_samples = final_df["sample"].nunique()
n_compounds = final_df["compoundname"].nunique()
# Prepare a safe download filename using canonical helper
selected_class_safe = str(selected_class).replace(" ", "_")
try:
suggested = sanitize_filename(
"UC-8.1", f"minimal_grouping_{selected_class_safe}", "png"
)
except Exception:
suggested = f"minimal_grouping_{selected_class_safe}.png"
base_filename = os.path.splitext(suggested)[0]
return html.Div(
[
# Statistics summary
html.Div(
[
html.Small(
[
html.I(className="bi bi-info-circle me-2"),
f"Minimal Coverage: {n_groups} functional guilds | "
f"{n_samples} samples | {n_compounds} compounds | "
f"Class: {selected_class}",
],
className="text-muted",
)
],
className="mb-2",
),
# Graph container with overflow control
html.Div(
[
dcc.Graph(
id="uc-8-1-graph",
figure=fig,
config={
"displayModeBar": True,
"displaylogo": False,
"responsive": True,
"modeBarButtonsToRemove": [
"pan2d",
"lasso2d",
"select2d",
],
"toImageButtonOptions": {
"format": "svg",
"filename": base_filename,
"height": 600,
"width": 900,
"scale": 6,
},
},
style={
"height": "600px",
"width": "100%",
"minWidth": "100%",
},
className="mt-3",
)
],
style={
"width": "100%",
"overflowX": "auto",
"overflowY": "hidden",
},
),
]
)
except ValueError as ve:
logger.error(
f"[UC-8.1] ValueError during processing: {str(ve)}", exc_info=True
)
return _create_error_message(
f"Data validation error: {str(ve)}", "bi bi-exclamation-triangle"
)
except Exception as e:
logger.error(f"[UC-8.1] Unexpected error: {str(e)}", exc_info=True)
return _create_error_message(
f"An unexpected error occurred: {str(e)}", "bi bi-bug"
)
logger.info("[UC-8.1] All callbacks registered successfully")