dqa / streamlit_app_adjustable.py
rediRDM's picture
Upload 150 files
56ed4c5 verified
Raw
History Blame Contribute Delete
56.7 kB
"""
WESAD Quality Metrics Dashboard - Adjustable Thresholds Version
Combined features + UI-adjustable thresholds for dynamic analysis
"""
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from pathlib import Path
import json
from datetime import datetime
import time
from typing import Dict, List, Any, Optional, Tuple
import base64
import yaml
# Import our data layer functions
from utils.results_loader import (
find_latest_full_report,
load_full_results,
summarize_users,
get_layer_details,
get_report_metadata
)
# Page configuration
st.set_page_config(
page_title="WESAD Quality Dashboard - Adjustable Thresholds",
page_icon="🎚️",
layout="wide",
initial_sidebar_state="expanded"
)
# Enhanced CSS for professional styling
st.markdown("""
<style>
/* Main container styling */
.main {
padding: 1rem;
}
/* Metric cards with gradient */
.stMetric {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
border-radius: 15px;
color: white;
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.stMetric label {
color: white !important;
font-weight: 600;
}
/* Alert styling */
.alert-high {
background: linear-gradient(to right, #ffebee, #ffffff);
border-left: 5px solid #f44336;
padding: 15px;
margin: 10px 0;
border-radius: 5px;
}
.alert-medium {
background: linear-gradient(to right, #fff3e0, #ffffff);
border-left: 5px solid #ff9800;
padding: 15px;
margin: 10px 0;
border-radius: 5px;
}
/* Quality badges */
.quality-excellent {
background: #4caf50;
color: white;
padding: 5px 10px;
border-radius: 20px;
font-weight: bold;
}
/* Expander styling */
.streamlit-expanderHeader {
background: #f8f9fa;
border-radius: 10px;
font-weight: 600;
}
/* Button styling */
.stButton > button {
background: linear-gradient(45deg, #2196F3 30%, #21CBF3 90%);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 5px;
font-weight: 600;
transition: all 0.3s;
}
.stButton > button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 10px rgba(0,0,0,0.2);
}
/* Threshold sliders */
.threshold-section {
background: #f8f9fa;
padding: 10px;
border-radius: 10px;
margin: 10px 0;
}
</style>
""", unsafe_allow_html=True)
def load_default_thresholds() -> Dict:
"""Load default thresholds from YAML or use hardcoded defaults"""
config_path = Path("config/alerts.example.yaml")
if config_path.exists():
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
return config
else:
# Hardcoded defaults if file doesn't exist
return {
'global': {'overall_score_min': 0.80},
'layer1': {'any_validator_score_min': 0.80},
'layer2': {
'dlr_max': 0.10,
'mdr_max': 0.20,
'scr_min': 0.80,
'wear_time_pct_min': 60.0,
'plausibility_pct_min': 80.0
},
'layer3': {
'ppg_quality_min': 0.80,
'eda_wrist_quality_min': 0.80,
'eda_chest_quality_min': 0.80,
'acc_quality_min': 0.80,
'temp_wrist_quality_min': 0.70,
'temp_chest_quality_min': 0.85,
'cross_modality_quality_min': 0.80
}
}
def initialize_threshold_state():
"""Initialize threshold values in session state if not present"""
if 'thresholds' not in st.session_state:
st.session_state.thresholds = load_default_thresholds()
if 'threshold_preset' not in st.session_state:
st.session_state.threshold_preset = 'Custom'
def evaluate_alerts_with_custom_thresholds(summary: Dict, layer_details: Dict, thresholds: Dict) -> List[Dict]:
"""Evaluate alerts using custom thresholds from UI"""
alerts = []
user_id = summary['user_id']
# Global overall score check
if summary.get('overall_mean_score'):
min_score = thresholds['global']['overall_score_min']
if summary['overall_mean_score'] < min_score:
alerts.append({
'user_id': user_id,
'layer': 'Global',
'metric': 'Overall Score',
'value': summary['overall_mean_score'],
'threshold': min_score,
'severity': 'HIGH',
'message': f"Overall score {summary['overall_mean_score']:.3f} below threshold {min_score}"
})
# Layer 1 checks
if summary.get('layer1_score'):
min_score = thresholds['layer1']['any_validator_score_min']
if summary['layer1_score'] < min_score:
alerts.append({
'user_id': user_id,
'layer': 'Layer 1',
'metric': 'Data Integrity',
'value': summary['layer1_score'],
'threshold': min_score,
'severity': 'HIGH',
'message': f"Layer 1 score {summary['layer1_score']:.3f} below threshold {min_score}"
})
# Layer 2 checks
if 'layer2' in layer_details and user_id in layer_details['layer2']:
l2_data = layer_details['layer2'][user_id]
# DLR check
dlr = l2_data.get('data_loss_ratio', {}).get('overall_dlr', 0)
if dlr > thresholds['layer2']['dlr_max']:
alerts.append({
'user_id': user_id,
'layer': 'Layer 2',
'metric': 'Data Loss Ratio',
'value': dlr,
'threshold': thresholds['layer2']['dlr_max'],
'severity': 'HIGH',
'message': f"DLR {dlr:.2%} exceeds threshold {thresholds['layer2']['dlr_max']:.0%}"
})
# MDR check
mdr = l2_data.get('missing_data_ratio', {}).get('overall_mdr', 0)
if mdr > thresholds['layer2']['mdr_max']:
alerts.append({
'user_id': user_id,
'layer': 'Layer 2',
'metric': 'Missing Data Ratio',
'value': mdr,
'threshold': thresholds['layer2']['mdr_max'],
'severity': 'MEDIUM',
'message': f"MDR {mdr:.2%} exceeds threshold {thresholds['layer2']['mdr_max']:.0%}"
})
# Wear time check
wear_time = l2_data.get('wear_time', {}).get('wear_time_percentage', 100)
if wear_time < thresholds['layer2']['wear_time_pct_min']:
alerts.append({
'user_id': user_id,
'layer': 'Layer 2',
'metric': 'Wear Time',
'value': wear_time,
'threshold': thresholds['layer2']['wear_time_pct_min'],
'severity': 'MEDIUM',
'message': f"Wear time {wear_time:.1f}% below threshold {thresholds['layer2']['wear_time_pct_min']:.0f}%"
})
# Layer 3 checks
if 'layer3' in layer_details and 'users' in layer_details['layer3']:
if user_id in layer_details['layer3']['users']:
l3_data = layer_details['layer3']['users'][user_id]
signal_quality = l3_data.get('signal_quality', {})
# PPG check
if 'ppg' in signal_quality and 'summary' in signal_quality['ppg']:
ppg_score = signal_quality['ppg']['summary'].get('overall_quality_score', 0)
if ppg_score < thresholds['layer3']['ppg_quality_min']:
alerts.append({
'user_id': user_id,
'layer': 'Layer 3',
'metric': 'PPG Quality',
'value': ppg_score,
'threshold': thresholds['layer3']['ppg_quality_min'],
'severity': 'MEDIUM',
'message': f"PPG quality {ppg_score:.3f} below threshold {thresholds['layer3']['ppg_quality_min']}"
})
# ACC check
if 'acc' in signal_quality and 'summary' in signal_quality['acc']:
acc_score = signal_quality['acc']['summary'].get('quality_score', 0)
if acc_score < thresholds['layer3']['acc_quality_min']:
alerts.append({
'user_id': user_id,
'layer': 'Layer 3',
'metric': 'ACC Quality',
'value': acc_score,
'threshold': thresholds['layer3']['acc_quality_min'],
'severity': 'MEDIUM',
'message': f"ACC quality {acc_score:.3f} below threshold {thresholds['layer3']['acc_quality_min']}"
})
return alerts
@st.cache_data(ttl=30)
def load_dashboard_data(force_refresh: bool = False):
"""Load and cache the quality assessment data"""
if force_refresh:
st.cache_data.clear()
report_path = find_latest_full_report()
if not report_path:
return None, None, None
results = load_full_results(report_path)
summaries = summarize_users(results)
metadata = get_report_metadata(report_path, results)
return results, summaries, metadata
def render_threshold_controls() -> Dict:
"""Render threshold adjustment controls in sidebar"""
st.sidebar.divider()
st.sidebar.subheader("🎚️ Adjustable Thresholds")
# Preset selector
preset = st.sidebar.selectbox(
"Threshold Preset:",
["Custom", "Strict (High Quality)", "Standard (Default)", "Lenient (Low Bar)"],
index=0,
help="Select a preset or customize individual thresholds"
)
# Apply preset
if preset == "Strict (High Quality)":
st.session_state.thresholds = {
'global': {'overall_score_min': 0.90},
'layer1': {'any_validator_score_min': 0.95},
'layer2': {
'dlr_max': 0.05,
'mdr_max': 0.10,
'scr_min': 0.90,
'wear_time_pct_min': 80.0,
'plausibility_pct_min': 90.0
},
'layer3': {
'ppg_quality_min': 0.90,
'eda_wrist_quality_min': 0.85,
'eda_chest_quality_min': 0.85,
'acc_quality_min': 0.90,
'temp_wrist_quality_min': 0.80,
'temp_chest_quality_min': 0.90,
'cross_modality_quality_min': 0.85
}
}
elif preset == "Standard (Default)":
st.session_state.thresholds = load_default_thresholds()
elif preset == "Lenient (Low Bar)":
st.session_state.thresholds = {
'global': {'overall_score_min': 0.60},
'layer1': {'any_validator_score_min': 0.60},
'layer2': {
'dlr_max': 0.25,
'mdr_max': 0.35,
'scr_min': 0.60,
'wear_time_pct_min': 40.0,
'plausibility_pct_min': 60.0
},
'layer3': {
'ppg_quality_min': 0.60,
'eda_wrist_quality_min': 0.60,
'eda_chest_quality_min': 0.60,
'acc_quality_min': 0.60,
'temp_wrist_quality_min': 0.50,
'temp_chest_quality_min': 0.65,
'cross_modality_quality_min': 0.60
}
}
# Individual threshold controls
with st.sidebar.expander("🎯 Global Threshold", expanded=False):
st.session_state.thresholds['global']['overall_score_min'] = st.slider(
"Overall Score Min",
min_value=0.0,
max_value=1.0,
value=st.session_state.thresholds['global']['overall_score_min'],
step=0.05,
format="%.2f"
)
with st.sidebar.expander("πŸ“‹ Layer 1 Thresholds", expanded=False):
st.session_state.thresholds['layer1']['any_validator_score_min'] = st.slider(
"Validator Score Min",
min_value=0.0,
max_value=1.0,
value=st.session_state.thresholds['layer1']['any_validator_score_min'],
step=0.05,
format="%.2f"
)
with st.sidebar.expander("πŸ“Š Layer 2 Thresholds", expanded=False):
col1, col2 = st.columns(2)
with col1:
st.session_state.thresholds['layer2']['dlr_max'] = st.number_input(
"DLR Max",
min_value=0.0,
max_value=1.0,
value=st.session_state.thresholds['layer2']['dlr_max'],
step=0.05,
format="%.2f"
)
st.session_state.thresholds['layer2']['mdr_max'] = st.number_input(
"MDR Max",
min_value=0.0,
max_value=1.0,
value=st.session_state.thresholds['layer2']['mdr_max'],
step=0.05,
format="%.2f"
)
st.session_state.thresholds['layer2']['scr_min'] = st.number_input(
"SCR Min",
min_value=0.0,
max_value=1.0,
value=st.session_state.thresholds['layer2']['scr_min'],
step=0.05,
format="%.2f"
)
with col2:
st.session_state.thresholds['layer2']['wear_time_pct_min'] = st.number_input(
"Wear Time %",
min_value=0.0,
max_value=100.0,
value=st.session_state.thresholds['layer2']['wear_time_pct_min'],
step=5.0,
format="%.0f"
)
st.session_state.thresholds['layer2']['plausibility_pct_min'] = st.number_input(
"Plausibility %",
min_value=0.0,
max_value=100.0,
value=st.session_state.thresholds['layer2']['plausibility_pct_min'],
step=5.0,
format="%.0f"
)
with st.sidebar.expander("πŸ“‘ Layer 3 Thresholds", expanded=False):
# PPG and ACC
col1, col2 = st.columns(2)
with col1:
st.session_state.thresholds['layer3']['ppg_quality_min'] = st.slider(
"PPG Min",
0.0, 1.0,
st.session_state.thresholds['layer3']['ppg_quality_min'],
0.05, format="%.2f", key="ppg_thresh"
)
st.session_state.thresholds['layer3']['acc_quality_min'] = st.slider(
"ACC Min",
0.0, 1.0,
st.session_state.thresholds['layer3']['acc_quality_min'],
0.05, format="%.2f", key="acc_thresh"
)
# EDA
with col2:
st.session_state.thresholds['layer3']['eda_wrist_quality_min'] = st.slider(
"EDA Wrist",
0.0, 1.0,
st.session_state.thresholds['layer3']['eda_wrist_quality_min'],
0.05, format="%.2f", key="eda_w_thresh"
)
st.session_state.thresholds['layer3']['eda_chest_quality_min'] = st.slider(
"EDA Chest",
0.0, 1.0,
st.session_state.thresholds['layer3']['eda_chest_quality_min'],
0.05, format="%.2f", key="eda_c_thresh"
)
# Temperature
st.session_state.thresholds['layer3']['temp_wrist_quality_min'] = st.slider(
"Temp Wrist Min",
0.0, 1.0,
st.session_state.thresholds['layer3']['temp_wrist_quality_min'],
0.05, format="%.2f", key="temp_w_thresh"
)
st.session_state.thresholds['layer3']['temp_chest_quality_min'] = st.slider(
"Temp Chest Min",
0.0, 1.0,
st.session_state.thresholds['layer3']['temp_chest_quality_min'],
0.05, format="%.2f", key="temp_c_thresh"
)
# Save/Load threshold configuration
st.sidebar.divider()
col1, col2 = st.sidebar.columns(2)
with col1:
if st.button("πŸ’Ύ Save Config"):
config_file = Path("config") / f"thresholds_{datetime.now().strftime('%Y%m%d_%H%M%S')}.yaml"
config_file.parent.mkdir(exist_ok=True)
with open(config_file, 'w') as f:
yaml.dump(st.session_state.thresholds, f)
st.success(f"Saved to {config_file.name}")
with col2:
if st.button("πŸ“₯ Export Config"):
yaml_str = yaml.dump(st.session_state.thresholds)
b64 = base64.b64encode(yaml_str.encode()).decode()
href = f'<a href="data:application/yaml;base64,{b64}" download="thresholds.yaml">Download YAML</a>'
st.sidebar.markdown(href, unsafe_allow_html=True)
return st.session_state.thresholds
def render_enhanced_sidebar(metadata: Dict, summaries: List[Dict]) -> tuple:
"""Enhanced sidebar with threshold controls"""
st.sidebar.title("🎚️ Dashboard Controls")
# Add branding
st.sidebar.markdown("""
<div style="text-align: center; padding: 10px;">
<h4 style="color: #667eea;">WESAD Quality Metrics</h4>
<p style="font-size: 12px;">Adjustable Thresholds Edition</p>
</div>
""", unsafe_allow_html=True)
# Report information
st.sidebar.subheader("πŸ“ Report Information")
if metadata:
st.sidebar.info(f"""
**File:** {metadata['filename']}
**Modified:** {metadata['modified_time'][:19]}
**Size:** {metadata['file_size_mb']:.1f} MB
**Users:** {metadata['user_count']}
""")
# Refresh button
if st.sidebar.button("πŸ”„ Refresh Data", help="Reload the latest quality assessment"):
st.cache_data.clear()
st.rerun()
st.sidebar.divider()
# User selection
st.sidebar.subheader("πŸ‘₯ User Selection")
all_users = [s['user_id'] for s in summaries]
# Quick select buttons
col1, col2 = st.sidebar.columns(2)
with col1:
if st.button("Select All"):
st.session_state['selected_users'] = all_users
with col2:
if st.button("Clear All"):
st.session_state['selected_users'] = []
selected_users = st.sidebar.multiselect(
"Select users to analyze:",
options=all_users,
default=st.session_state.get('selected_users', all_users),
key='user_selector'
)
st.session_state['selected_users'] = selected_users
# Get adjustable thresholds
thresholds = render_threshold_controls()
# Display settings
st.sidebar.divider()
st.sidebar.subheader("βš™οΈ Display Settings")
show_raw_data = st.sidebar.checkbox("Show raw data tables", value=False)
expand_all = st.sidebar.checkbox("Expand all user sections", value=False)
show_threshold_lines = st.sidebar.checkbox("Show threshold lines in charts", value=True)
return selected_users, thresholds, show_raw_data, expand_all, show_threshold_lines
def render_overview_with_thresholds(summaries: List[Dict], selected_users: List[str], thresholds: Dict, show_lines: bool):
"""Overview tab with threshold visualization"""
st.header("πŸ“ˆ Quality Metrics Overview")
# Filter summaries based on dynamic thresholds
min_quality = thresholds['global']['overall_score_min']
filtered_summaries = [
s for s in summaries
if s['user_id'] in selected_users
and (s['overall_mean_score'] or 0) >= min_quality
]
# Display current threshold
st.info(f"🎚️ Current Overall Quality Threshold: **{min_quality:.2f}**")
if not filtered_summaries:
st.warning(f"No users meet the current threshold ({min_quality:.2f})")
st.write(f"Users below threshold: {len([s for s in summaries if s['user_id'] in selected_users])} total")
else:
# KPI Cards
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric(
label="Meeting Threshold",
value=len(filtered_summaries),
delta=f"of {len([s for s in summaries if s['user_id'] in selected_users])} selected"
)
with col2:
avg_score = sum(s['overall_mean_score'] for s in filtered_summaries) / len(filtered_summaries)
st.metric(
label="Mean Score (Above Threshold)",
value=f"{avg_score:.3f}",
delta=f"Threshold: {min_quality:.2f}"
)
with col3:
below_threshold = len([s for s in summaries if s['user_id'] in selected_users and (s['overall_mean_score'] or 0) < min_quality])
st.metric(
label="Below Threshold",
value=below_threshold,
delta="Need attention" if below_threshold > 0 else "All pass",
delta_color="inverse" if below_threshold > 0 else "normal"
)
with col4:
# Count alerts based on current thresholds
total_alerts = 0
for s in summaries:
if s['user_id'] in selected_users:
if s['overall_mean_score'] and s['overall_mean_score'] < min_quality:
total_alerts += 1
st.metric(
label="Active Alerts",
value=total_alerts,
delta="Based on current thresholds"
)
st.divider()
# Visualization with threshold lines
if len([s for s in summaries if s['user_id'] in selected_users]) > 0:
col1, col2 = st.columns(2)
with col1:
st.subheader("πŸ“Š Score Distribution vs Threshold")
# Prepare data for all selected users (not just filtered)
all_selected = [s for s in summaries if s['user_id'] in selected_users]
scores = [s['overall_mean_score'] for s in all_selected if s['overall_mean_score']]
fig = go.Figure()
# Add histogram
fig.add_trace(go.Histogram(
x=scores,
nbinsx=20,
name='Score Distribution',
marker_color='lightblue',
opacity=0.7
))
# Add threshold line
if show_lines:
fig.add_vline(
x=min_quality,
line_dash="dash",
line_color="red",
annotation_text=f"Threshold ({min_quality:.2f})"
)
fig.update_layout(
xaxis_title="Overall Quality Score",
yaxis_title="Count",
showlegend=True,
height=400
)
st.plotly_chart(fig, use_container_width=True, key="overview_histogram_adjustable")
with col2:
st.subheader("πŸ“ˆ Layer Scores vs Thresholds")
# Create box plot with threshold lines
layer_data = []
for s in all_selected:
if s['layer1_score']:
layer_data.append({'Layer': 'Layer 1', 'Score': s['layer1_score']})
if s['layer2_score']:
layer_data.append({'Layer': 'Layer 2', 'Score': s['layer2_score']})
if s['layer3_score']:
layer_data.append({'Layer': 'Layer 3', 'Score': s['layer3_score']})
if layer_data:
df_box = pd.DataFrame(layer_data)
fig = px.box(df_box, x='Layer', y='Score', color='Layer')
# Add threshold lines for each layer
if show_lines:
fig.add_hline(
y=thresholds['layer1']['any_validator_score_min'],
line_dash="dash",
line_color="gray",
annotation_text="L1 Threshold"
)
# For L2, we use an approximate threshold (inverse of DLR/MDR)
l2_approx = 1 - thresholds['layer2']['dlr_max']
fig.add_hline(
y=l2_approx,
line_dash="dot",
line_color="gray",
annotation_text="L2 Approx"
)
fig.add_hline(
y=thresholds['layer3']['ppg_quality_min'],
line_dash="dashdot",
line_color="gray",
annotation_text="L3 Threshold"
)
fig.update_layout(showlegend=False, height=400)
st.plotly_chart(fig, use_container_width=True, key="overview_box_adjustable")
# User table with threshold indicators
st.subheader("πŸ‘₯ User Quality Summary")
if len([s for s in summaries if s['user_id'] in selected_users]) > 0:
df_display = pd.DataFrame([{
'User': s['user_id'],
'Layer 1': f"{s['layer1_score']:.3f}" if s['layer1_score'] else 'N/A',
'Layer 2': f"{s['layer2_score']:.3f}" if s['layer2_score'] else 'N/A',
'Layer 3': f"{s['layer3_score']:.3f}" if s['layer3_score'] else 'N/A',
'Overall': f"{s['overall_mean_score']:.3f}" if s['overall_mean_score'] else 'N/A',
'Status': 'βœ… Pass' if s['overall_mean_score'] and s['overall_mean_score'] >= min_quality else '❌ Fail',
'Margin': f"{(s['overall_mean_score'] - min_quality):.3f}" if s['overall_mean_score'] else 'N/A'
} for s in summaries if s['user_id'] in selected_users])
# Color code based on threshold
st.dataframe(
df_display,
use_container_width=True,
hide_index=True,
column_config={
"Status": st.column_config.TextColumn(
"Threshold Status",
help=f"Based on threshold {min_quality:.2f}"
),
"Margin": st.column_config.TextColumn(
"Margin",
help="Distance from threshold (positive = pass)"
)
}
)
def render_layer1_with_thresholds(results: Dict, selected_users: List[str], thresholds: Dict, expand_all: bool):
"""Layer 1 with threshold indicators"""
st.header("πŸ” Layer 1: Data Integrity")
threshold = thresholds['layer1']['any_validator_score_min']
st.info(f"🎚️ Current Layer 1 Threshold: **{threshold:.2f}**")
if 'layer1' not in results:
st.warning("No Layer 1 data available")
return
layer1_data = results['layer1']
# Overall statistics
col1, col2, col3, col4 = st.columns(4)
with col1:
avg_score = sum(layer1_data[u].get('overall_score', 0) for u in selected_users if u in layer1_data) / len(selected_users)
st.metric("Average Layer 1 Score", f"{avg_score:.3f}")
with col2:
perfect_count = sum(1 for u in selected_users if u in layer1_data and layer1_data[u].get('overall_score', 0) == 1.0)
st.metric("Perfect Scores", f"{perfect_count}/{len(selected_users)}")
with col3:
above_threshold = sum(1 for u in selected_users if u in layer1_data and layer1_data[u].get('overall_score', 0) >= threshold)
st.metric("Above Threshold", f"{above_threshold}/{len(selected_users)}")
with col4:
issues_count = sum(len(layer1_data[u].get('issues', [])) for u in selected_users if u in layer1_data)
st.metric("Total Issues", issues_count)
st.divider()
# Detailed per-user sections
for user_id in selected_users:
if user_id not in layer1_data:
continue
user_data = layer1_data[user_id]
overall_score = user_data.get('overall_score', 0)
# Status based on threshold
if overall_score >= threshold:
status_icon = "βœ…"
status_text = "Pass"
else:
status_icon = "❌"
status_text = f"Below threshold ({threshold:.2f})"
with st.expander(f"{status_icon} User: {user_id} - Score: {overall_score:.3f} - {status_text}", expanded=expand_all):
col1, col2 = st.columns([1, 2])
with col1:
st.metric("Overall Score", f"{overall_score:.3f}")
st.metric("Distance from Threshold", f"{overall_score - threshold:+.3f}")
# Validator scores
st.write("**πŸ“‹ Validator Scores:**")
validators = ['readme_validator', 'quest_validator', 'pkl_metadata_validator',
'file_format_validator', 'structural_validator']
for validator in validators:
if validator in user_data:
v_data = user_data[validator]
score = v_data.get('score', 0)
passed = v_data.get('passed', False)
status = "βœ…" if score >= threshold else "⚠️"
validator_name = validator.replace('_', ' ').title()
st.write(f"{status} {validator_name}: {score:.2f}")
with col2:
st.write("**πŸ” Validation Details:**")
total_issues = 0
for validator in validators:
if validator in user_data:
issues = user_data[validator].get('issues', [])
if issues:
total_issues += len(issues)
st.warning(f"{validator}: {len(issues)} issues")
for issue in issues[:3]:
st.text(f" β€’ {issue}")
if len(issues) > 3:
st.text(f" ... and {len(issues)-3} more")
if total_issues == 0:
st.success("βœ… No validation issues found!")
else:
st.error(f"⚠️ Total issues: {total_issues}")
def render_layer2_with_thresholds(results: Dict, selected_users: List[str], thresholds: Dict, expand_all: bool):
"""Layer 2 with threshold indicators"""
st.header("πŸ“Š Layer 2: Data Completeness")
# Show current thresholds
st.info(f"""🎚️ **Current Layer 2 Thresholds:**
β€’ DLR Max: {thresholds['layer2']['dlr_max']:.0%} | MDR Max: {thresholds['layer2']['mdr_max']:.0%}
β€’ SCR Min: {thresholds['layer2']['scr_min']:.0%} | Wear Time Min: {thresholds['layer2']['wear_time_pct_min']:.0f}%
β€’ Plausibility Min: {thresholds['layer2']['plausibility_pct_min']:.0f}%""")
if 'layer2' not in results:
st.warning("No Layer 2 data available")
return
layer2_data = results['layer2']
# Overall statistics
col1, col2, col3, col4 = st.columns(4)
with col1:
avg_score = sum(layer2_data[u].get('overall_score', 0) for u in selected_users if u in layer2_data) / len(selected_users)
st.metric("Average Layer 2 Score", f"{avg_score:.3f}")
with col2:
avg_dlr = sum(layer2_data[u].get('data_loss_ratio', {}).get('overall_dlr', 0) for u in selected_users if u in layer2_data) / len(selected_users)
color = "normal" if avg_dlr <= thresholds['layer2']['dlr_max'] else "inverse"
st.metric("Avg Data Loss Ratio", f"{avg_dlr:.2%}", delta="Pass" if avg_dlr <= thresholds['layer2']['dlr_max'] else "Fail", delta_color=color)
with col3:
avg_mdr = sum(layer2_data[u].get('missing_data_ratio', {}).get('overall_mdr', 0) for u in selected_users if u in layer2_data) / len(selected_users)
color = "normal" if avg_mdr <= thresholds['layer2']['mdr_max'] else "inverse"
st.metric("Avg Missing Data Ratio", f"{avg_mdr:.2%}", delta="Pass" if avg_mdr <= thresholds['layer2']['mdr_max'] else "Fail", delta_color=color)
with col4:
avg_wear = sum(layer2_data[u].get('wear_time', {}).get('wear_time_percentage', 0) for u in selected_users if u in layer2_data) / len(selected_users)
color = "normal" if avg_wear >= thresholds['layer2']['wear_time_pct_min'] else "inverse"
st.metric("Avg Wear Time", f"{avg_wear:.1f}%", delta="Pass" if avg_wear >= thresholds['layer2']['wear_time_pct_min'] else "Fail", delta_color=color)
st.divider()
# Detailed per-user sections
for user_id in selected_users:
if user_id not in layer2_data:
continue
user_data = layer2_data[user_id]
overall_score = user_data.get('overall_score', 0)
# Check threshold violations
violations = []
dlr = user_data.get('data_loss_ratio', {}).get('overall_dlr', 0)
mdr = user_data.get('missing_data_ratio', {}).get('overall_mdr', 0)
scr = user_data.get('sensor_channel_ratio', {}).get('overall_scr', 1.0)
wear_time = user_data.get('wear_time', {}).get('wear_time_percentage', 100)
plausibility = user_data.get('plausibility', {}).get('average_plausible_percentage', 100)
if dlr > thresholds['layer2']['dlr_max']:
violations.append(f"DLR ({dlr:.0%} > {thresholds['layer2']['dlr_max']:.0%})")
if mdr > thresholds['layer2']['mdr_max']:
violations.append(f"MDR ({mdr:.0%} > {thresholds['layer2']['mdr_max']:.0%})")
if scr < thresholds['layer2']['scr_min']:
violations.append(f"SCR ({scr:.0%} < {thresholds['layer2']['scr_min']:.0%})")
if wear_time < thresholds['layer2']['wear_time_pct_min']:
violations.append(f"Wear ({wear_time:.0f}% < {thresholds['layer2']['wear_time_pct_min']:.0f}%)")
if plausibility < thresholds['layer2']['plausibility_pct_min']:
violations.append(f"Plaus ({plausibility:.0f}% < {thresholds['layer2']['plausibility_pct_min']:.0f}%)")
status_icon = "βœ…" if not violations else "❌"
status_text = "All Pass" if not violations else f"{len(violations)} violations"
with st.expander(f"{status_icon} User: {user_id} - Score: {overall_score:.3f} - {status_text}", expanded=expand_all):
if violations:
st.error(f"**Threshold Violations:** {', '.join(violations)}")
# Metrics in columns
col1, col2, col3 = st.columns(3)
with col1:
st.metric(
"Data Loss Ratio",
f"{dlr:.2%}",
delta=f"Threshold: {thresholds['layer2']['dlr_max']:.0%}",
delta_color="normal" if dlr <= thresholds['layer2']['dlr_max'] else "inverse"
)
st.metric(
"Missing Data Ratio",
f"{mdr:.2%}",
delta=f"Threshold: {thresholds['layer2']['mdr_max']:.0%}",
delta_color="normal" if mdr <= thresholds['layer2']['mdr_max'] else "inverse"
)
with col2:
st.metric(
"Sensor Channel Ratio",
f"{scr:.2f}",
delta=f"Min: {thresholds['layer2']['scr_min']:.2f}",
delta_color="normal" if scr >= thresholds['layer2']['scr_min'] else "inverse"
)
st.metric(
"Wear Time",
f"{wear_time:.1f}%",
delta=f"Min: {thresholds['layer2']['wear_time_pct_min']:.0f}%",
delta_color="normal" if wear_time >= thresholds['layer2']['wear_time_pct_min'] else "inverse"
)
with col3:
st.metric(
"Data Plausibility",
f"{plausibility:.1f}%",
delta=f"Min: {thresholds['layer2']['plausibility_pct_min']:.0f}%",
delta_color="normal" if plausibility >= thresholds['layer2']['plausibility_pct_min'] else "inverse"
)
st.metric(
"Overall Layer 2",
f"{overall_score:.3f}",
delta="Pass" if not violations else f"{len(violations)} issues"
)
def render_layer3_with_thresholds(results: Dict, selected_users: List[str], thresholds: Dict, expand_all: bool):
"""Layer 3 with threshold indicators and FIXED extraction"""
st.header("πŸ“‘ Layer 3: Signal Quality")
# Show current thresholds
st.info(f"""🎚️ **Current Layer 3 Thresholds:**
β€’ PPG: {thresholds['layer3']['ppg_quality_min']:.2f} | ACC: {thresholds['layer3']['acc_quality_min']:.2f}
β€’ EDA Wrist: {thresholds['layer3']['eda_wrist_quality_min']:.2f} | EDA Chest: {thresholds['layer3']['eda_chest_quality_min']:.2f}
β€’ Temp Wrist: {thresholds['layer3']['temp_wrist_quality_min']:.2f} | Temp Chest: {thresholds['layer3']['temp_chest_quality_min']:.2f}""")
if 'layer3' not in results or 'users' not in results['layer3']:
st.warning("No Layer 3 data available")
return
layer3_users = results['layer3']['users']
# Overall statistics
col1, col2, col3 = st.columns(3)
valid_scores = []
for user_id in selected_users:
if user_id in layer3_users:
score = layer3_users[user_id].get('overall_score', None)
if score:
valid_scores.append(score)
with col1:
if valid_scores:
avg_score = sum(valid_scores) / len(valid_scores)
st.metric("Average Layer 3 Score", f"{avg_score:.3f}")
with col2:
# Count users above minimum threshold (using PPG as reference)
above_threshold = sum(1 for s in valid_scores if s > thresholds['layer3']['ppg_quality_min'])
st.metric("Above Threshold", f"{above_threshold}/{len(valid_scores)}")
with col3:
st.metric("Modalities Analyzed", "6")
st.divider()
# Detailed per-user sections
for user_id in selected_users:
if user_id not in layer3_users:
continue
user_data = layer3_users[user_id]
overall_score = user_data.get('overall_score', 0)
# Get signal quality data - USING CORRECT STRUCTURE
signal_quality = user_data.get('signal_quality', {})
# Check threshold violations for each modality
violations = []
scores = []
labels = []
details = []
# PPG
if 'ppg' in signal_quality and 'summary' in signal_quality['ppg']:
summary = signal_quality['ppg']['summary']
score = summary.get('overall_quality_score', None)
if score is not None:
scores.append(score)
labels.append('PPG')
if score < thresholds['layer3']['ppg_quality_min']:
violations.append(f"PPG ({score:.2f} < {thresholds['layer3']['ppg_quality_min']:.2f})")
details.append({
'Windows': summary.get('total_windows_analyzed', 0),
'SNR': summary.get('mean_snr', 0),
'Threshold': thresholds['layer3']['ppg_quality_min']
})
# ACC - uses 'quality_score' not 'overall_quality_score'!
if 'acc' in signal_quality and 'summary' in signal_quality['acc']:
summary = signal_quality['acc']['summary']
score = summary.get('quality_score', None) # Different field!
if score is not None:
scores.append(score)
labels.append('ACC')
if score < thresholds['layer3']['acc_quality_min']:
violations.append(f"ACC ({score:.2f} < {thresholds['layer3']['acc_quality_min']:.2f})")
details.append({
'Issues': summary.get('total_issues_detected', 0),
'MAI': summary.get('mai_assessment', 'N/A'),
'Threshold': thresholds['layer3']['acc_quality_min']
})
# EDA - split into wrist and chest
if 'eda' in signal_quality:
if 'wrist' in signal_quality['eda'] and 'summary' in signal_quality['eda']['wrist']:
summary = signal_quality['eda']['wrist']['summary']
score = summary.get('overall_quality_score', None)
if score is not None:
scores.append(score)
labels.append('EDA WRIST')
if score < thresholds['layer3']['eda_wrist_quality_min']:
violations.append(f"EDA Wrist ({score:.2f} < {thresholds['layer3']['eda_wrist_quality_min']:.2f})")
details.append({
'SCR Peaks': summary.get('total_scr_peaks', 0),
'Threshold': thresholds['layer3']['eda_wrist_quality_min']
})
if 'chest' in signal_quality['eda'] and 'summary' in signal_quality['eda']['chest']:
summary = signal_quality['eda']['chest']['summary']
score = summary.get('overall_quality_score', None)
if score is not None:
scores.append(score)
labels.append('EDA CHEST')
if score < thresholds['layer3']['eda_chest_quality_min']:
violations.append(f"EDA Chest ({score:.2f} < {thresholds['layer3']['eda_chest_quality_min']:.2f})")
details.append({
'SCR Peaks': summary.get('total_scr_peaks', 0),
'Threshold': thresholds['layer3']['eda_chest_quality_min']
})
# Temperature - split into wrist and chest
if 'temperature' in signal_quality:
if 'wrist' in signal_quality['temperature'] and 'summary' in signal_quality['temperature']['wrist']:
summary = signal_quality['temperature']['wrist']['summary']
score = summary.get('overall_quality_score', None)
if score is not None:
scores.append(score)
labels.append('TEMP WRIST')
if score < thresholds['layer3']['temp_wrist_quality_min']:
violations.append(f"Temp Wrist ({score:.2f} < {thresholds['layer3']['temp_wrist_quality_min']:.2f})")
details.append({
'Mean Temp': f"{summary.get('mean_temperature', 0):.1f}Β°C",
'Threshold': thresholds['layer3']['temp_wrist_quality_min']
})
if 'chest' in signal_quality['temperature'] and 'summary' in signal_quality['temperature']['chest']:
summary = signal_quality['temperature']['chest']['summary']
score = summary.get('overall_quality_score', None)
if score is not None:
scores.append(score)
labels.append('TEMP CHEST')
if score < thresholds['layer3']['temp_chest_quality_min']:
violations.append(f"Temp Chest ({score:.2f} < {thresholds['layer3']['temp_chest_quality_min']:.2f})")
mean_temp = summary.get('mean_temperature', 'N/A')
if isinstance(mean_temp, str):
temp_str = mean_temp
else:
temp_str = f"{float(mean_temp):.1f}Β°C"
details.append({
'Mean Temp': temp_str,
'Threshold': thresholds['layer3']['temp_chest_quality_min']
})
status_icon = "βœ…" if not violations else "❌"
status_text = "All Pass" if not violations else f"{len(violations)} below threshold"
with st.expander(f"{status_icon} User: {user_id} - Score: {overall_score:.3f} - {status_text}", expanded=expand_all):
st.metric("Overall Signal Quality", f"{overall_score:.3f}")
if violations:
st.error(f"**Threshold Violations:** {', '.join(violations[:3])}")
if len(violations) > 3:
st.error(f"... and {len(violations)-3} more")
st.divider()
if scores:
# Create bar chart with threshold lines
fig = go.Figure()
# Add bars
colors = []
for i, (label, score) in enumerate(zip(labels, scores)):
# Get the appropriate threshold for this modality
if 'PPG' in label:
thresh = thresholds['layer3']['ppg_quality_min']
elif 'ACC' in label:
thresh = thresholds['layer3']['acc_quality_min']
elif 'EDA WRIST' in label:
thresh = thresholds['layer3']['eda_wrist_quality_min']
elif 'EDA CHEST' in label:
thresh = thresholds['layer3']['eda_chest_quality_min']
elif 'TEMP WRIST' in label:
thresh = thresholds['layer3']['temp_wrist_quality_min']
elif 'TEMP CHEST' in label:
thresh = thresholds['layer3']['temp_chest_quality_min']
else:
thresh = 0.8
color = 'green' if score >= thresh else 'red'
colors.append(color)
fig.add_trace(go.Bar(
x=labels,
y=scores,
marker_color=colors,
text=[f"{s:.3f}" for s in scores],
textposition='outside'
))
# Add individual threshold lines for each modality
for i, (label, detail) in enumerate(zip(labels, details)):
if 'Threshold' in detail:
fig.add_shape(
type="line",
x0=i-0.4, x1=i+0.4,
y0=detail['Threshold'], y1=detail['Threshold'],
line=dict(color="gray", width=2, dash="dash")
)
fig.update_layout(
title=f"Signal Quality vs Thresholds - {user_id}",
yaxis=dict(range=[0, 1.1], title="Quality Score"),
xaxis=dict(title="Modality"),
showlegend=False,
height=350
)
st.plotly_chart(fig, use_container_width=True, key=f"layer3_bar_thresh_{user_id}")
# Detailed metrics
st.subheader("πŸ“Š Detailed Metrics")
cols = st.columns(3)
for idx, (label, score, detail) in enumerate(zip(labels, scores, details)):
col_idx = idx % 3
with cols[col_idx]:
thresh = detail.get('Threshold', 0.8)
status = "βœ…" if score >= thresh else "❌"
st.write(f"**{status} {label}**")
st.write(f"Score: {score:.3f}")
st.write(f"Threshold: {thresh:.2f}")
st.write(f"Margin: {score - thresh:+.3f}")
for key, value in detail.items():
if key != 'Threshold':
st.write(f"β€’ {key}: {value}")
else:
st.info("No signal quality data available for this user")
def render_alerts_with_thresholds(summaries: List[Dict], results: Dict, selected_users: List[str], thresholds: Dict):
"""Alerts tab showing violations based on current thresholds"""
st.header("⚠️ Quality Alerts - Dynamic Thresholds")
# Show current threshold summary
with st.expander("πŸ“‹ Current Threshold Settings", expanded=True):
col1, col2, col3 = st.columns(3)
with col1:
st.write("**Global:**")
st.write(f"β€’ Overall Min: {thresholds['global']['overall_score_min']:.2f}")
st.write("**Layer 1:**")
st.write(f"β€’ Validator Min: {thresholds['layer1']['any_validator_score_min']:.2f}")
with col2:
st.write("**Layer 2:**")
st.write(f"β€’ DLR Max: {thresholds['layer2']['dlr_max']:.0%}")
st.write(f"β€’ MDR Max: {thresholds['layer2']['mdr_max']:.0%}")
st.write(f"β€’ Wear Time Min: {thresholds['layer2']['wear_time_pct_min']:.0f}%")
with col3:
st.write("**Layer 3:**")
st.write(f"β€’ PPG Min: {thresholds['layer3']['ppg_quality_min']:.2f}")
st.write(f"β€’ ACC Min: {thresholds['layer3']['acc_quality_min']:.2f}")
st.write(f"β€’ EDA Min: {thresholds['layer3']['eda_wrist_quality_min']:.2f}")
st.divider()
# Evaluate alerts with current thresholds
all_alerts = []
for summary in summaries:
if summary['user_id'] not in selected_users:
continue
user_alerts = evaluate_alerts_with_custom_thresholds(summary, results, thresholds)
all_alerts.extend(user_alerts)
# Display summary metrics
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric("Total Alerts", len(all_alerts))
with col2:
high_alerts = sum(1 for a in all_alerts if a['severity'] == 'HIGH')
st.metric("High Severity", high_alerts, delta_color="inverse" if high_alerts > 0 else "off")
with col3:
medium_alerts = sum(1 for a in all_alerts if a['severity'] == 'MEDIUM')
st.metric("Medium Severity", medium_alerts)
with col4:
users_with_alerts = len(set(a['user_id'] for a in all_alerts))
st.metric("Users Affected", f"{users_with_alerts}/{len(selected_users)}")
st.divider()
if all_alerts:
st.subheader("🚨 Active Alerts")
# Create DataFrame for better display
alerts_df = pd.DataFrame(all_alerts)
# Display by severity
for severity in ['HIGH', 'MEDIUM']:
severity_alerts = alerts_df[alerts_df['severity'] == severity]
if not severity_alerts.empty:
if severity == 'HIGH':
st.error(f"**High Severity Alerts ({len(severity_alerts)})**")
else:
st.warning(f"**Medium Severity Alerts ({len(severity_alerts)})**")
for _, alert in severity_alerts.iterrows():
col1, col2, col3 = st.columns([2, 3, 2])
with col1:
st.write(f"**{alert['user_id']}** - {alert['layer']}")
with col2:
st.write(alert['message'])
with col3:
st.write(f"Value: {alert['value']:.3f}")
# Summary statistics
st.divider()
st.subheader("πŸ“Š Alert Statistics")
col1, col2 = st.columns(2)
with col1:
# Alerts by layer
layer_counts = alerts_df['layer'].value_counts()
fig = px.pie(values=layer_counts.values, names=layer_counts.index, title="Alerts by Layer")
st.plotly_chart(fig, use_container_width=True, key="alerts_by_layer")
with col2:
# Alerts by metric
metric_counts = alerts_df['metric'].value_counts().head(5)
fig = px.bar(x=metric_counts.values, y=metric_counts.index, orientation='h', title="Top 5 Metrics with Alerts")
st.plotly_chart(fig, use_container_width=True, key="alerts_by_metric")
else:
st.success("βœ… No alerts triggered with current thresholds!")
st.balloons()
# Show what would trigger alerts
st.info("πŸ’‘ Adjust thresholds in the sidebar to explore different quality criteria")
def main():
"""Main dashboard application with adjustable thresholds"""
# Initialize session state
initialize_threshold_state()
st.title("🎚️ WESAD Quality Metrics Dashboard - Adjustable Thresholds")
st.markdown("**Interactive threshold adjustment for dynamic quality assessment**")
# Load data
with st.spinner("Loading quality assessment data..."):
results, summaries, metadata = load_dashboard_data()
if not results:
st.error("❌ No quality assessment report found!")
st.info("Please run: `python3 run_full.py --save` to generate a report")
return
# Enhanced sidebar with threshold controls
selected_users, thresholds, show_raw_data, expand_all, show_threshold_lines = render_enhanced_sidebar(metadata, summaries)
# Create tabs
tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
"πŸ“ˆ Overview",
"πŸ” Layer 1: Integrity",
"πŸ“Š Layer 2: Completeness",
"πŸ“‘ Layer 3: Signal Quality",
"⚠️ Dynamic Alerts",
"πŸ“Š Threshold Analysis"
])
with tab1:
render_overview_with_thresholds(summaries, selected_users, thresholds, show_threshold_lines)
with tab2:
render_layer1_with_thresholds(results, selected_users, thresholds, expand_all)
with tab3:
render_layer2_with_thresholds(results, selected_users, thresholds, expand_all)
with tab4:
render_layer3_with_thresholds(results, selected_users, thresholds, expand_all)
with tab5:
render_alerts_with_thresholds(summaries, results, selected_users, thresholds)
with tab6:
st.header("πŸ“Š Threshold Sensitivity Analysis")
# Threshold impact analysis
st.subheader("Impact of Current Thresholds")
# Calculate pass rates for different thresholds
threshold_ranges = {
'Strict (0.90)': 0.90,
'High (0.85)': 0.85,
'Current': thresholds['global']['overall_score_min'],
'Standard (0.80)': 0.80,
'Moderate (0.70)': 0.70,
'Lenient (0.60)': 0.60
}
pass_rates = []
for name, thresh in threshold_ranges.items():
passing = sum(1 for s in summaries if s['user_id'] in selected_users and s.get('overall_mean_score', 0) >= thresh)
total = len([s for s in summaries if s['user_id'] in selected_users])
pass_rates.append({
'Threshold': f"{name} ({thresh:.2f})",
'Pass Rate': (passing / total * 100) if total > 0 else 0,
'Passing': passing,
'Failing': total - passing
})
df_pass = pd.DataFrame(pass_rates)
col1, col2 = st.columns(2)
with col1:
fig = px.bar(df_pass, x='Threshold', y='Pass Rate', title="Pass Rate by Threshold Level")
fig.add_hline(y=80, line_dash="dash", annotation_text="80% target")
st.plotly_chart(fig, use_container_width=True, key="pass_rate_analysis")
with col2:
fig = px.bar(df_pass, x='Threshold', y=['Passing', 'Failing'],
title="Users Passing vs Failing", barmode='stack')
st.plotly_chart(fig, use_container_width=True, key="pass_fail_stack")
# Detailed threshold impact table
st.subheader("Detailed Threshold Impact")
st.dataframe(df_pass, use_container_width=True, hide_index=True)
# Footer
st.divider()
st.caption(f"Adjustable Thresholds Dashboard | Data: {metadata['filename']} | Updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if __name__ == "__main__":
main()