""" app.py — SpectraQual Streamlit Dashboard (v3.0) Updated to use the new SpectraQualEnv class with OpenEnv interface. Features: - Real-time stacked reward component charts - Per-step accuracy / throughput display - Action confidence from reward components - Anomaly flag indicators - Explainability: "Why this decision?" """ import sys import os sys.path.insert(0, os.path.dirname(__file__)) import streamlit as st import matplotlib.pyplot as plt import time from env import SpectraQualEnv from models import PCBAction from config import ( COLOR_PRIMARY, COLOR_SUCCESS, COLOR_WARNING, COLOR_DANGER, COLOR_BG, COLOR_CARD, COLOR_MUTED, TASKS, ) # --------------------------- # PAGE CONFIG # --------------------------- st.set_page_config( page_title="SpectraQual", page_icon="⚔️", layout="wide", initial_sidebar_state="collapsed", ) # --------------------------- # GLOBAL STYLES # --------------------------- st.markdown(""" """, unsafe_allow_html=True) # --------------------------- # SESSION STATE # --------------------------- def _init_state(): if "env" not in st.session_state: st.session_state.env = None if "score" not in st.session_state: st.session_state.score = 0.0 if "history" not in st.session_state: st.session_state.history = [] # cumulative reward over time if "running" not in st.session_state: st.session_state.running = False if "log" not in st.session_state: st.session_state.log = [] # list of (pcb_obs, action, rc) if "task_id" not in st.session_state: st.session_state.task_id = "task_easy" if "last_result" not in st.session_state: st.session_state.last_result = None if "episode_done" not in st.session_state: st.session_state.episode_done = False _init_state() # --------------------------- # HELPERS # --------------------------- def defect_badge(d): m = { "none": ("b-none", "✓ NONE"), "missing_component": ("b-missing", "⚠ MISSING COMPONENT"), "solder_bridge": ("b-solder", "⚡ SOLDER BRIDGE"), "short_circuit": ("b-short", "✗ SHORT CIRCUIT"), } cls, label = m.get(d, ("b-none", d.upper())) return f'{label}' def reward_bar_html(label, score, color="#00e5ff"): pct = int(score * 100) return ( f'
' f' {label}' f'
' f'
' f'
' f' {score:.2f}' f'
' ) def get_env() -> SpectraQualEnv: if st.session_state.env is None: st.session_state.env = SpectraQualEnv(task_id=st.session_state.task_id) return st.session_state.env # --------------------------- # HEADER # --------------------------- st.title("⚔️ SPECTRAQUAL — SMART PCB DECISION SYSTEM") st.markdown( '

' 'REAL-TIME QUALITY INTELLIGENCE ENGINE · v3.0 · OpenEnv Compliant

', unsafe_allow_html=True, ) # --------------------------- # SIDEBAR TASK SELECTOR # --------------------------- with st.sidebar: st.markdown("### 🎯 Task Selection") task_choice = st.selectbox( "Select Task", options=list(TASKS.keys()), format_func=lambda t: f"{t} ({TASKS[t]['difficulty'].upper()})", index=list(TASKS.keys()).index(st.session_state.task_id), ) if task_choice != st.session_state.task_id: st.session_state.task_id = task_choice st.session_state.env = None st.session_state.score = 0.0 st.session_state.history = [] st.session_state.log = [] st.session_state.last_result = None st.session_state.episode_done = False cfg = TASKS[st.session_state.task_id] st.markdown(f""" **Boards:** {cfg['n_boards']} **Slots:** {cfg['n_slots']} **Seed:** {cfg['seed']} **Anomaly Rate:** {cfg['anomaly_rate']:.0%} **Difficulty:** {cfg['difficulty'].upper()} """) st.markdown("---") speed = st.slider("⚡ Speed (s/step)", 0.2, 2.0, 0.8, step=0.1) # --------------------------- # SPEED (fallback if sidebar collapsed) # --------------------------- if "speed" not in dir(): speed = 0.8 st.markdown("
", unsafe_allow_html=True) # --------------------------- # METRICS BAR # --------------------------- env_obj = get_env() state = env_obj.state() m1, m2, m3, m4, m5 = st.columns(5) m1.metric("💰 Cumul. Reward", f"{state['cumulative_reward']:.3f}") m2.metric("🎯 Accuracy", f"{state['rolling_accuracy']:.1%}") m3.metric("⚙️ Active Slots", sum(1 for s in state['slots'] if 0 < s < 9999)) m4.metric("🧠 Decisions", state['total_count']) m5.metric("⚠️ Bottlenecks", state['bottleneck_count']) last_r = round(st.session_state.log[-1][2].normalized, 3) if st.session_state.log else "N/A" status_color = "#00e5ff" if st.session_state.log else "#1e4a5a" st.markdown(f"""
🟢 TASK: {st.session_state.task_id.upper()}  ·  LAST REWARD: {last_r}  ·  STEPS: {state['step']}
""", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) # --------------------------- # CONTROL BUTTONS # --------------------------- c1, c2, c3, c4, c5 = st.columns(5) with c1: if st.button("▶ RUN STEP"): st.session_state.running = False st.session_state.run_once = True with c2: if st.button("⚡ AUTO RUN"): st.session_state.running = True with c3: if st.button("⛔ STOP"): st.session_state.running = False with c4: if st.button("🔄 RESET"): env_obj.reset() st.session_state.score = 0.0 st.session_state.history = [] st.session_state.log = [] st.session_state.last_result = None st.session_state.episode_done = False with c5: if st.button("🆕 NEW TASK"): st.session_state.env = None st.session_state.score = 0.0 st.session_state.history = [] st.session_state.log = [] st.session_state.last_result = None st.session_state.episode_done = False # --------------------------- # CORE STEP # --------------------------- def run_step(): env = get_env() # Initialize if needed if env._done or env._current_pcb is None: result = env.reset() if result.done: st.session_state.episode_done = True return None # Get current obs to determine action obs = env._build_observation(*__import__("reward").detect_anomaly(env._current_pcb)) # Use rule-based decision (greedy heuristic) from env import decide_action pcb_dict = { "defect_type": obs.defect_type, "component_cost": obs.component_cost, "criticality": obs.criticality, } action_str = decide_action(pcb_dict) result = env.step(PCBAction(action=action_str)) rc = result.reward_components st.session_state.score = env.state()["cumulative_reward"] st.session_state.history.append(st.session_state.score) st.session_state.log.append((result.observation, action_str, rc)) st.session_state.last_result = result if result.done: st.session_state.episode_done = True return result # --------------------------- # DISPLAY # --------------------------- def display(result): from collections import Counter obs = result.observation rc = result.reward_components col1, col2 = st.columns(2, gap="large") # ── LEFT ── with col1: st.subheader("PCB Info") anomaly_html = "" if obs.is_anomaly: anomaly_html = f'⚠️ ANOMALY {obs.anomaly_score:.2f}' st.markdown(f"""
Board ID       {obs.board_id}
Defect Type    {defect_badge(obs.defect_type)}
Component Cost ₹{obs.component_cost:.2f}
Criticality     {obs.criticality:.2f}
Anomaly        {anomaly_html if anomaly_html else 'Normal'}
""", unsafe_allow_html=True) st.subheader("Decision") action = st.session_state.log[-1][1] if st.session_state.log else "N/A" if action == "PASS": st.success(f"✅ {action}") elif "ROUTE" in action: st.warning(f"🛠️ {action}") elif action == "WAIT": st.warning("⏳ WAITING FOR SLOT AVAILABILITY") else: st.error(f"❌ {action}") if rc: st.subheader("🧠 Why this decision?") explanation_parts = rc.explanation.split(" | ") for part in explanation_parts[:3]: st.info(part) st.subheader("Step Reward") r = result.reward if r >= 0.6: st.markdown(f'▲ {r:.4f}', unsafe_allow_html=True) elif r >= 0.35: st.markdown(f'● {r:.4f}', unsafe_allow_html=True) else: st.markdown(f'▼ {r:.4f}', unsafe_allow_html=True) if rc: st.subheader("📊 Reward Component Breakdown") components = [ ("Defect Handling", rc.defect_reward, "#00e5ff"), ("Cost Efficiency", rc.cost_efficiency, "#00e676"), ("Queue Mgmt", rc.queue_penalty, "#ffb700"), ("Risk Factor", rc.criticality_factor, "#ff7800"), ("Anomaly Bonus", rc.anomaly_bonus, "#ff00c8"), ] bars_html = "" for label, val, color in components: bars_html += reward_bar_html(label, val, color) st.markdown(bars_html, unsafe_allow_html=True) st.subheader("Rolling Metrics") sub1, sub2 = st.columns(2) with sub1: st.metric("🎯 Accuracy", f"{obs.rolling_accuracy:.1%}") with sub2: st.metric("⚡ Throughput", f"{obs.throughput:.2f}") # ── RIGHT ── with col2: st.subheader("Factory Slots") slot_html = '
' for i, slot in enumerate(obs.slots_state): if slot == -1: slot_html += (f'
' f'SLOT {i:02d} · LOCKED
') elif slot > 0: slot_html += (f'
' f'SLOT {i:02d} · {slot}t
') else: slot_html += (f'
' f'SLOT {i:02d} · FREE
') slot_html += '
' st.markdown(slot_html, unsafe_allow_html=True) st.subheader("Cumulative Reward") score_color = "#00e676" if st.session_state.score >= 0.5 else "#ff5a5a" st.markdown( f'
' f'{st.session_state.score:.4f}
', unsafe_allow_html=True, ) st.subheader("📈 Reward Trend") fig, ax = plt.subplots(figsize=(5.5, 3)) fig.patch.set_facecolor("#080c12") ax.set_facecolor("#0a1420") history = st.session_state.history if history: ax.plot(history, color="#00e5ff", linewidth=1.8, marker='o', markersize=3.5, markerfacecolor="#00e5ff", markeredgewidth=0) ax.fill_between(range(len(history)), history, alpha=0.10, color="#00e5ff") ax.axhline(y=0.6, color="#00e676", linewidth=0.8, linestyle="--", alpha=0.5, label="Success threshold") ax.set_title("Cumulative Reward", color="#2e6a80", fontsize=9, pad=8) ax.set_xlabel("Steps", color="#2e6a80", fontsize=8) ax.set_ylabel("Score", color="#2e6a80", fontsize=8) ax.set_ylim(0, max(max(history, default=1.0) * 1.1, 1.0)) ax.tick_params(colors="#2e6a80", labelsize=7) ax.grid(color="#0d2535", linewidth=0.7, linestyle="--") for spine in ax.spines.values(): spine.set_edgecolor("#0d2535") fig.tight_layout(pad=1.2) st.pyplot(fig) plt.close(fig) # Stacked Reward Components Over Time if len(st.session_state.log) >= 2: st.subheader("📊 Component Breakdown Over Time") steps_data = st.session_state.log[-20:] # last 20 steps comp_labels = ["Defect", "Cost", "Queue", "Risk", "Anomaly"] comp_colors = ["#00e5ff", "#00e676", "#ffb700", "#ff7800", "#ff00c8"] comp_data = {l: [] for l in comp_labels} for _, _, rc_entry in steps_data: if rc_entry: comp_data["Defect"].append(rc_entry.defect_reward) comp_data["Cost"].append(rc_entry.cost_efficiency) comp_data["Queue"].append(rc_entry.queue_penalty) comp_data["Risk"].append(rc_entry.criticality_factor) comp_data["Anomaly"].append(rc_entry.anomaly_bonus) if any(comp_data.values()): fig2, ax2 = plt.subplots(figsize=(5.5, 2.8)) fig2.patch.set_facecolor("#080c12") ax2.set_facecolor("#0a1420") x = list(range(len(next(iter(comp_data.values()))))) bottom = [0.0] * len(x) for label, color in zip(comp_labels, comp_colors): vals = comp_data[label] if vals and len(vals) == len(x): # Normalize each component's contribution by weight ax2.fill_between(x, bottom, [b + v * 0.2 for b, v in zip(bottom, vals)], alpha=0.6, color=color, label=label) bottom = [b + v * 0.2 for b, v in zip(bottom, vals)] ax2.set_title("Reward Components (last 20 steps)", color="#2e6a80", fontsize=8, pad=6) ax2.set_xlabel("Steps", color="#2e6a80", fontsize=7) ax2.tick_params(colors="#2e6a80", labelsize=6) ax2.grid(color="#0d2535", linewidth=0.5, linestyle="--") for spine in ax2.spines.values(): spine.set_edgecolor("#0d2535") ax2.legend(loc="upper right", fontsize=6, facecolor="#080c12", edgecolor="#2e6a80", labelcolor="#c9d4e0") fig2.tight_layout(pad=1.0) st.pyplot(fig2) plt.close(fig2) # Decision Distribution if st.session_state.log: st.subheader("📊 Decision Distribution") decisions = [entry[1] for entry in st.session_state.log] from collections import Counter counts = dict(Counter(decisions)) st.bar_chart(counts) # Episode Done banner if st.session_state.episode_done: final = st.session_state.score if final >= 0.6: st.success(f"🏆 EPISODE COMPLETE — Score: {final:.4f} — SUCCESS!") else: st.warning(f"⚠️ EPISODE COMPLETE — Score: {final:.4f} — Below success threshold (0.60)") # --------------------------- # EXECUTION # --------------------------- if "run_once" in st.session_state and st.session_state.run_once: result = run_step() if result: display(result) st.session_state.run_once = False elif st.session_state.running: placeholder = st.empty() for _ in range(1000): if not st.session_state.running: break if st.session_state.episode_done: st.session_state.running = False break result = run_step() if result: with placeholder.container(): display(result) time.sleep(speed) elif st.session_state.last_result: display(st.session_state.last_result) else: st.markdown("""
[ SYSTEM IDLE ]

SELECT A TASK IN THE SIDEBAR   ·   PRESS   ▶ RUN STEP   OR   ⚡ AUTO RUN   TO BEGIN
""", unsafe_allow_html=True)