CoolFace
Apppublic

ask4rakesh/RLTestSimulation

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
app.py626 linesDownload Raw Back to root
1"""2GridWorld Navigator — Q-Learning RL Edition3  • Manual play tab  (keyboard / buttons)4  • RL Training tab  (watch the agent learn live)5"""6 7import gymnasium as gym8from gymnasium import spaces9import numpy as np10from collections import deque11import random, threading, time, json12import gradio as gr13 14# ═══════════════════════════════════════════════════════════════════════════════15# GridWorld Environment16# ═══════════════════════════════════════════════════════════════════════════════17 18GRID_SIZE        = 1019MAX_STEPS        = 15020OBSTACLE_DENSITY = 0.2021 22REWARD_GOAL    =  10.023REWARD_CLOSER  =   0.524REWARD_FARTHER =  -0.325REWARD_NEW     =   0.126REWARD_WALL    =  -0.527REWARD_STEP    =  -0.0128REWARD_TIMEOUT =  -2.029 30 31def manhattan(a, b):32    return abs(a[0]-b[0]) + abs(a[1]-b[1])33 34 35def is_solvable(grid, start, goal, size):36    visited, q = {start}, deque([start])37    while q:38        r, c = q.popleft()39        if (r,c) == goal: return True40        for dr, dc in [(-1,0),(0,1),(1,0),(0,-1)]:41            nr, nc = r+dr, c+dc42            if 0<=nr<size and 0<=nc<size and (nr,nc) not in visited and grid[nr][nc]!='X':43                visited.add((nr,nc)); q.append((nr,nc))44    return False45 46 47def generate_maze(size=GRID_SIZE, density=OBSTACLE_DENSITY):48    for _ in range(2000):49        grid  = [['.' for _ in range(size)] for _ in range(size)]50        cells = [(r,c) for r in range(size) for c in range(size)]51        random.shuffle(cells)52        start, goal = cells[0], cells[1]53        for r,c in cells[2:]:54            if random.random() < density:55                grid[r][c] = 'X'56        grid[start[0]][start[1]] = 'S'57        grid[goal[0]][goal[1]]   = 'G'58        if is_solvable(grid, start, goal, size):59            return grid, start, goal60    grid = [['.' for _ in range(size)] for _ in range(size)]61    grid[0][0]='S'; grid[size-1][size-1]='G'62    return grid, (0,0), (size-1,size-1)63 64 65class GridWorldEnv(gym.Env):66    def __init__(self, size=GRID_SIZE, fixed_maze=None):67        super().__init__()68        self.size = size69        self.fixed_maze = fixed_maze          # if set, never regenerate70        self.observation_space = spaces.Box(0.,1.,shape=(4,),dtype=np.float32)71        self.action_space = spaces.Discrete(4)72        self._reset_internals()73 74    def _reset_internals(self):75        if self.fixed_maze:76            import copy77            self.grid, self.start, self.goal = copy.deepcopy(self.fixed_maze)78        else:79            self.grid, self.start, self.goal = generate_maze(self.size)80        self.agent_pos  = list(self.start)81        self.steps      = 082        self.total_score= 0.83        self.last_reward= 0.84        self.visited    = {tuple(self.agent_pos)}85        self.done       = False86 87    def _obs(self):88        ax,ay = self.agent_pos; gx,gy = self.goal; s=self.size-189        return np.array([ax/s,ay/s,gx/s,gy/s],dtype=np.float32)90 91    def reset(self,*,seed=None,options=None):92        super().reset(seed=seed); self._reset_internals()93        return self._obs(),{}94 95    def step(self,action):96        if self.done: return self._obs(),0.,True,False,{}97        dr,dc=[(-1,0),(0,1),(1,0),(0,-1)][action]98        nr,nc = self.agent_pos[0]+dr, self.agent_pos[1]+dc99        prev_dist = manhattan(self.agent_pos,self.goal)100 101        if not(0<=nr<self.size and 0<=nc<self.size) or self.grid[nr][nc]=='X':102            rew = REWARD_WALL+REWARD_STEP103            self.last_reward=rew; self.total_score+=rew; self.steps+=1104            trunc = self.steps>=MAX_STEPS105            if trunc: rew+=REWARD_TIMEOUT; self.total_score+=REWARD_TIMEOUT; self.done=True106            return self._obs(),rew,False,trunc,{}107 108        self.agent_pos=[nr,nc]; new_dist=manhattan(self.agent_pos,self.goal); self.steps+=1109        rew = REWARD_STEP110        if tuple(self.agent_pos) not in self.visited:111            rew+=REWARD_NEW; self.visited.add(tuple(self.agent_pos))112        rew += REWARD_CLOSER if new_dist<prev_dist else (REWARD_FARTHER if new_dist>prev_dist else 0)113 114        if (nr,nc)==self.goal:115            rew+=REWARD_GOAL; self.total_score+=rew; self.last_reward=rew116            self.done=True; return self._obs(),rew,True,False,{}117 118        trunc = self.steps>=MAX_STEPS119        if trunc: rew+=REWARD_TIMEOUT; self.done=True120        self.total_score+=rew; self.last_reward=rew121        return self._obs(),rew,False,trunc,{}122 123 124# ═══════════════════════════════════════════════════════════════════════════════125# Q-Learning Agent  (tabular, state = (agent_row, agent_col))126# ═══════════════════════════════════════════════════════════════════════════════127 128class QAgent:129    def __init__(self, size=GRID_SIZE, lr=0.2, gamma=0.95, eps=1.0,130                 eps_min=0.05, eps_decay=0.997):131        self.size=size; self.lr=lr; self.gamma=gamma132        self.eps=eps; self.eps_min=eps_min; self.eps_decay=eps_decay133        self.Q = np.zeros((size,size,4), dtype=np.float32)134 135    def act(self, pos, explore=True):136        if explore and random.random()<self.eps:137            return random.randint(0,3)138        return int(np.argmax(self.Q[pos[0],pos[1]]))139 140    def learn(self, pos, action, reward, npos, done):141        q_curr  = self.Q[pos[0],pos[1],action]142        q_next  = 0. if done else float(np.max(self.Q[npos[0],npos[1]]))143        self.Q[pos[0],pos[1],action] += self.lr*(reward+self.gamma*q_next-q_curr)144 145    def decay_eps(self):146        self.eps = max(self.eps_min, self.eps*self.eps_decay)147 148    def reset(self):149        self.Q[:] = 0.150        self.eps  = 1.0151 152 153# ═══════════════════════════════════════════════════════════════════════════════154# Shared RL state  (written by training thread, read by UI)155# ═══════════════════════════════════════════════════════════════════════════════156 157rl_lock = threading.Lock()158 159rl_state = dict(160    running   = False,161    episode   = 0,162    eps       = 1.0,163    ep_reward = 0.,164    steps     = 0,165    agent_pos = [0,0],166    goal      = (0,0),167    grid      = None,168    visited_trail = set(),     # cells visited this episode169    success_history = [],      # list of bool per episode170    reward_history  = [],      # cumulative reward per episode171    Q_values        = None,    # snapshot for heatmap172    status    = "idle",        # idle / training / paused / done173    msg       = "Press ▶ Start Training to begin.",174    speed     = 0.06,          # seconds between steps175    total_episodes = 300,176)177 178rl_agent = QAgent()179rl_env   = GridWorldEnv()180 181_stop_event  = threading.Event()182_pause_event = threading.Event()   # set = paused183 184 185def rl_train_thread(total_episodes, speed_getter):186    global rl_state, rl_agent, rl_env187 188    # Fix the maze for the whole training run so the agent learns one map189    grid, start, goal = generate_maze()190    import copy191    fixed = (copy.deepcopy(grid), start, goal)192    rl_env = GridWorldEnv(fixed_maze=fixed)193    rl_agent.reset()194 195    for ep in range(1, total_episodes+1):196        if _stop_event.is_set(): break197 198        obs, _ = rl_env.reset()199        ep_rew  = 0.200        visited = {tuple(rl_env.agent_pos)}201 202        with rl_lock:203            rl_state['episode']   = ep204            rl_state['eps']       = rl_agent.eps205            rl_state['grid']      = copy.deepcopy(rl_env.grid)206            rl_state['goal']      = rl_env.goal207            rl_state['agent_pos'] = list(rl_env.agent_pos)208            rl_state['visited_trail'] = set(visited)209            rl_state['status']    = 'training'210            rl_state['msg']       = f"Episode {ep}/{total_episodes}  |  ε={rl_agent.eps:.3f}"211 212        while not rl_env.done:213            if _stop_event.is_set(): return214            while _pause_event.is_set():215                time.sleep(0.05)216                if _stop_event.is_set(): return217 218            pos    = list(rl_env.agent_pos)219            action = rl_agent.act(pos)220            obs2, rew, term, trunc, _ = rl_env.step(action)221            npos   = list(rl_env.agent_pos)222            rl_agent.learn(pos, action, rew, npos, term or trunc)223            ep_rew += rew224            visited.add(tuple(npos))225 226            with rl_lock:227                rl_state['agent_pos']     = list(npos)228                rl_state['ep_reward']     = ep_rew229                rl_state['steps']         = rl_env.steps230                rl_state['visited_trail'] = set(visited)231                rl_state['eps']           = rl_agent.eps232                rl_state['msg'] = (233                    f"Episode {ep}/{total_episodes}  |  "234                    f"step {rl_env.steps}  |  ε={rl_agent.eps:.3f}  |  "235                    f"reward {ep_rew:+.1f}"236                )237 238            spd = speed_getter()239            time.sleep(max(0.001, spd))240 241        success = (tuple(rl_env.agent_pos) == rl_env.goal)242        with rl_lock:243            rl_state['success_history'].append(success)244            rl_state['reward_history'].append(ep_rew)245            rl_state['Q_values'] = rl_agent.Q.copy()246            if ep >= total_episodes:247                rl_state['status'] = 'done'248                rl_state['msg']    = f"Training complete! {total_episodes} episodes."249 250        rl_agent.decay_eps()251 252    with rl_lock:253        rl_state['status'] = 'done'254        rl_state['running'] = False255 256 257# ═══════════════════════════════════════════════════════════════════════════════258# HTML renderers259# ═══════════════════════════════════════════════════════════════════════════════260 261CELL_PX = 44262 263 264def _cell_bg(r, c, ap, goal, grid, trail, Q=None):265    pos = (r,c)266    if pos == tuple(ap):267        return 'agent'268    cell = grid[r][c]269    if cell == 'X': return 'obs'270    if cell == 'S': return 'start'271    if pos == goal: return 'goal'272    if pos in trail: return 'trail'273    return 'empty'274 275 276def render_grid_html(grid, ap, goal, trail=None, Q=None, show_q=False):277    if grid is None: return "<p style='color:#8899bb;font-family:sans-serif;padding:20px'>Press ▶ Start to begin training…</p>"278    trail = trail or set()279    size  = len(grid)280 281    styles = dict(282        agent  = "background:radial-gradient(circle at 38% 35%,#ff6b6b,#e84393);",283        obs    = "background:#8899aa;",284        start  = "background:#4a90d9;color:#fff;font-weight:700;",285        goal   = "background:#27ae60;color:#fff;font-weight:700;",286        trail  = "background:#c8ecd4;",287        empty  = "background:#eef2ff;",288    )289 290    out = f"""<style>291.gw{{display:inline-block;border:3px solid #c0c8e0;border-radius:8px;overflow:hidden;box-shadow:0 4px 24px #a0b0c044}}292.gw table{{border-collapse:collapse;table-layout:fixed}}293.gw td{{width:{CELL_PX}px;height:{CELL_PX}px;text-align:center;vertical-align:middle;294        font-family:monospace;font-size:13px;border:1px solid #dde4f0;position:relative}}295.gw-a{{display:inline-block;width:30px;height:30px;296       background:radial-gradient(circle at 38% 35%,#ff6b6b,#e84393);297       border-radius:50%;box-shadow:0 0 14px #e84393aa;border:2px solid #ffb3d9}}298.gw-q{{font-size:9px;color:#6655aa;line-height:1.1}}299</style><div class="gw"><table>"""300 301    for r in range(size):302        out += "<tr>"303        for c in range(size):304            kind = _cell_bg(r,c,ap,goal,grid,trail,Q)305            st   = styles[kind]306 307            # Q-value overlay on non-special cells308            if show_q and Q is not None and kind in ('empty','trail'):309                best_a = int(np.argmax(Q[r,c]))310                arrows = ['↑','→','↓','←']311                qv     = Q[r,c,best_a]312                inner  = f'<div class="gw-q">{arrows[best_a]}<br>{qv:.1f}</div>'313                # colour by best Q314                mn,mx = Q.min(), Q.max()315                t = (qv-mn)/(mx-mn+1e-9)316                r_hex = int(200+t*20); g_hex=int(220+t*35); b_hex=int(255-t*80)317                st = f"background:rgb({r_hex},{g_hex},{b_hex});"318            elif kind=='agent':319                inner = '<div class="gw-a"></div>'320            elif kind=='goal':321                inner = 'G'322            elif kind=='start':323                inner = 'S'324            else:325                inner = ''326 327            out += f'<td style="{st}">{inner}</td>'328        out += "</tr>"329    out += "</table></div>"330    return out331 332 333def render_scoreboard(ep, total_ep, eps, ep_rew, steps, agent_pos, goal,334                      success_hist, reward_hist, status):335    dist = manhattan(agent_pos, goal)336    recent = success_hist[-20:] if success_hist else []337    win_rate = sum(recent)/len(recent)*100 if recent else 0.338 339    bar_len = 14340    filled  = int(bar_len * (ep / max(total_ep,1)))341    prog_bar= '█'*filled + '░'*(bar_len-filled)342 343    # sparkline for last 30 rewards344    rh = reward_hist[-30:] if reward_hist else []345    if rh:346        mn,mx = min(rh),max(rh)347        rng = mx-mn or 1348        SPARKS = '▁▂▃▄▅▆▇█'349        spark = ''.join(SPARKS[min(7,int((v-mn)/rng*7))] for v in rh)350    else:351        spark = '—'352 353    status_color = {'idle':'#6677aa','training':'#7bcde0','paused':'#f0a500',354                    'done':'#22dd6a'}.get(status,'#aaa')355 356    rows = [357        ("Episode",   f"{ep} / {total_ep}"),358        ("Progress",  f'<span style="letter-spacing:1px;color:#5566cc">{prog_bar}</span>'),359        ("ε (explore)",f"{eps:.4f}"),360        ("Ep Reward", f"{ep_rew:+.2f}"),361        ("Steps",     str(steps)),362        ("Agent",     f"({agent_pos[0]},{agent_pos[1]})"),363        ("Goal",      f"({goal[0]},{goal[1]})"),364        ("Distance",  str(dist)),365        ("Win rate",  f"{win_rate:.0f}% (last 20)"),366        ("Rewards ↗", f'<span style="font-size:10px;letter-spacing:0">{spark}</span>'),367        ("Status",    f'<span style="color:{status_color}">{status.upper()}</span>'),368    ]369 370    html = """<style>371.sb{font-family:monospace;background:#ffffff;border-radius:10px;372    padding:16px 20px;border:2px solid #c8d4f0;box-shadow:0 4px 20px #a0b0c030}373.sb h2{color:#5566cc;margin:0 0 12px;font-size:14px;letter-spacing:2px}374.sb-row{display:flex;justify-content:space-between;gap:12px;375         padding:4px 0;border-bottom:1px solid #e0e8f8}376.sb-row:last-child{border-bottom:none}377.sb-l{color:#778;font-size:12px}.sb-v{color:#223;font-size:12px;font-weight:700}378</style><div class="sb"><h2>📊 RL Scoreboard</h2>"""379    for lbl,val in rows:380        html += f'<div class="sb-row"><span class="sb-l">{lbl}</span><span class="sb-v">{val}</span></div>\n'381    html += "</div>"382    return html383 384 385# ═══════════════════════════════════════════════════════════════════════════════386# Manual-play singletons387# ═══════════════════════════════════════════════════════════════════════════════388 389manual_env = GridWorldEnv()390manual_env.reset()391 392 393def manual_reset():394    manual_env.reset()395    g = render_grid_html(manual_env.grid, manual_env.agent_pos, manual_env.goal)396    return g, _manual_score(), "New maze! Navigate to the green G cell."397 398 399def _manual_score():400    e = manual_env401    ax,ay=e.agent_pos; gx,gy=e.goal402    reached = e.done and (ax,ay)==e.goal403    tout    = e.done and not reached404    sc = '#22863a' if reached else ('#e74c3c' if tout else '#aaa')405    st = '✅ GOAL!' if reached else ('⏰ TIMEOUT' if tout else '▶ Playing')406    rows=[("Score",f"{e.total_score:+.2f}"),("Last Reward",f"{e.last_reward:+.2f}"),407          ("Steps",f"{e.steps}/{MAX_STEPS}"),("Agent",f"({ax},{ay})"),408          ("Goal",f"({gx},{gy})"),("Distance",str(manhattan(e.agent_pos,e.goal))),409          ("Status",f'<span style="color:{sc}">{st}</span>')]410    h='<style>.msb{font-family:monospace;background:#ffffff;border-radius:10px;padding:14px 18px;border:2px solid #c8d4f0;box-shadow:0 4px 20px #a0b0c030}.msb h2{color:#5566cc;margin:0 0 10px;font-size:13px;letter-spacing:2px}.mr{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid #e0e8f8}.mr:last-child{border-bottom:none}.ml{color:#778;font-size:12px}.mv{color:#223;font-size:12px;font-weight:700}</style><div class="msb"><h2>📊 Score</h2>'411    for l,v in rows:412        h+=f'<div class="mr"><span class="ml">{l}</span><span class="mv">{v}</span></div>'413    return h+'</div>'414 415 416def manual_step(action):417    if manual_env.done:418        return render_grid_html(manual_env.grid,manual_env.agent_pos,manual_env.goal), _manual_score(), "Game over — press Reset."419    manual_env.step(action)420    names=["⬆ UP","➡ RIGHT","⬇ DOWN","⬅ LEFT"]421    msg=f"Moved {names[action]}  |  reward {manual_env.last_reward:+.2f}"422    if manual_env.done:423        msg="🎉 Goal reached!" if tuple(manual_env.agent_pos)==manual_env.goal else "⏰ Timed out."424    return render_grid_html(manual_env.grid,manual_env.agent_pos,manual_env.goal), _manual_score(), msg425 426 427# ═══════════════════════════════════════════════════════════════════════════════428# RL tab callbacks429# ═══════════════════════════════════════════════════════════════════════════════430 431_training_thread = None432 433def _speed_getter():434    with rl_lock: return rl_state['speed']435 436 437def rl_start(total_ep, speed_val, show_q):438    global _training_thread, rl_state439    _stop_event.clear(); _pause_event.clear()440    with rl_lock:441        rl_state['running'] = True442        rl_state['total_episodes'] = int(total_ep)443        rl_state['success_history'] = []444        rl_state['reward_history']  = []445        rl_state['episode'] = 0446        rl_state['speed']   = float(speed_val)447        rl_state['status']  = 'training'448        rl_state['msg']     = 'Starting…'449    _training_thread = threading.Thread(450        target=rl_train_thread, args=(int(total_ep), _speed_getter), daemon=True)451    _training_thread.start()452    return gr.update(interactive=False), gr.update(interactive=True), gr.update(interactive=True)453 454 455def rl_pause():456    if _pause_event.is_set():457        _pause_event.clear()458        with rl_lock: rl_state['status']='training'459        return gr.update(value="⏸ Pause")460    else:461        _pause_event.set()462        with rl_lock: rl_state['status']='paused'463        return gr.update(value="▶ Resume")464 465 466def rl_stop():467    _stop_event.set(); _pause_event.clear()468    with rl_lock:469        rl_state['running']=False; rl_state['status']='idle'470        rl_state['msg']='Stopped.'471    return gr.update(interactive=True), gr.update(interactive=False), gr.update(interactive=False, value="⏸ Pause")472 473 474def rl_update(show_q):475    with rl_lock:476        s = dict(rl_state)477 478    grid = s['grid']479    ap   = s['agent_pos']480    goal = s['goal']481    trail= s['visited_trail']482    Q    = s['Q_values']483 484    grid_h  = render_grid_html(grid, ap, goal, trail, Q, show_q)485    score_h = render_scoreboard(486        s['episode'], s['total_episodes'], s['eps'], s['ep_reward'],487        s['steps'], ap, goal if goal else (0,0),488        s['success_history'], s['reward_history'], s['status']489    )490    return grid_h, score_h, s['msg']491 492 493def set_speed(v):494    with rl_lock: rl_state['speed'] = float(v)495 496 497# ═══════════════════════════════════════════════════════════════════════════════498# Gradio UI499# ═══════════════════════════════════════════════════════════════════════════════500 501CUSTOM_CSS = """502body,.gradio-container{background:#f4f7ff!important}503#title-md h1{font-family:'Poppins','Segoe UI',sans-serif;color:#4455cc;letter-spacing:3px;text-align:center;margin-bottom:2px}504#title-md p{color:#8899bb;text-align:center;font-family:sans-serif;font-size:12px}505.tab-nav button{font-family:sans-serif!important;letter-spacing:1px!important}506.ctrl-btn button{background:#ffffff!important;color:#4455cc!important;border:2px solid #c0cce8!important;507  border-radius:10px!important;font-size:18px!important;font-weight:700!important;508  min-width:50px!important;min-height:44px!important;box-shadow:0 2px 8px #a0b0d020!important}509.ctrl-btn button:hover{background:#eef2ff!important;border-color:#4455cc!important;box-shadow:0 4px 12px #4455cc22!important}510#start-btn button{background:linear-gradient(135deg,#4a90d9,#27ae60)!important;color:#fff!important;511  border:none!important;border-radius:10px!important;font-family:sans-serif!important;font-weight:700!important;512  box-shadow:0 4px 14px #27ae6044!important}513#stop-btn button{background:#fff0f0!important;color:#e74c3c!important;border:2px solid #f0c0c0!important;514  border-radius:10px!important;font-family:sans-serif!important;font-weight:700!important}515#pause-btn button{background:#fffbea!important;color:#d08000!important;border:2px solid #f0d880!important;516  border-radius:10px!important;font-family:sans-serif!important;font-weight:700!important}517#reset-btn-m button{background:linear-gradient(135deg,#4a90d9,#27ae60)!important;color:#fff!important;518  border:none!important;border-radius:10px!important;font-family:sans-serif!important;font-weight:700!important;519  width:100%!important;box-shadow:0 4px 14px #27ae6044!important}520.msg-box textarea{background:#ffffff!important;color:#334!important;font-family:monospace!important;521  border:1px solid #c8d4f0!important;border-radius:6px!important;font-size:12px!important}522"""523 524KEY_JS = """() => {525  if(window._gwKeys) return; window._gwKeys=true;526  document.addEventListener('keydown',(e)=>{527    const m={'ArrowUp':'btn-mu','ArrowRight':'btn-mr','ArrowDown':'btn-md','ArrowLeft':'btn-ml'};528    if(m[e.key]){e.preventDefault();document.getElementById(m[e.key])?.querySelector('button')?.click()}529  });530}"""531 532with gr.Blocks(title="GridWorld RL Navigator") as demo:533 534    gr.Markdown("# 🤖 GridWorld RL Navigator\n*Q-Learning · Gymnasium · Live Training Visualization*",535                elem_id="title-md")536 537    with gr.Tabs():538 539        # ── TAB 1 : RL Training ───────────────────────────────────────────────540        with gr.Tab("🧠 RL Training"):541            with gr.Row():542                with gr.Column(scale=3):543                    rl_grid = gr.HTML(544                        "<div style='color:#8899bb;font-family:sans-serif;padding:30px;text-align:center;background:#f8faff;border-radius:8px;border:2px dashed #c8d4f0'>🤖 Press ▶ Start Training to begin…</div>",545                        label="")546                    rl_msg  = gr.Textbox(value="Configure settings and press Start Training.",547                                         label="", interactive=False, lines=1, elem_classes="msg-box")548 549                with gr.Column(scale=1):550                    rl_score = gr.HTML("")551 552            with gr.Row():553                with gr.Column():554                    total_ep_sl = gr.Slider(50, 1000, value=300, step=50,555                                             label="Total Episodes", interactive=True)556                    speed_sl    = gr.Slider(0.001, 0.3, value=0.06, step=0.005,557                                             label="Step delay (s) — lower = faster", interactive=True)558                    show_q_cb   = gr.Checkbox(label="Show Q-value arrows on grid", value=False)559 560                with gr.Column():561                    start_btn = gr.Button("▶  Start Training", elem_id="start-btn", variant="primary")562                    pause_btn = gr.Button("⏸ Pause", elem_id="pause-btn", interactive=False)563                    stop_btn  = gr.Button("⏹ Stop",  elem_id="stop-btn",  interactive=False)564 565            gr.HTML("""<div style="font-family:sans-serif;font-size:12px;color:#6677aa;text-align:center;margin-top:8px;padding:6px;background:#fff;border-radius:8px;border:1px solid #dde4f0">566              <span style="color:#4a90d9;font-weight:bold">■ S</span> Start &nbsp;567              <span style="color:#27ae60;font-weight:bold">■ G</span> Goal &nbsp;568              <span style="color:#a0c8a0">■</span> Trail &nbsp;569              <span style="color:#8899aa;font-weight:bold">■ X</span> Obstacle &nbsp;570              <span style="color:#e84393;font-weight:bold">● A</span> Agent571            </div>""")572 573            # Live polling timer574            rl_timer = gr.Timer(value=0.15)575            rl_timer.tick(fn=rl_update, inputs=[show_q_cb], outputs=[rl_grid, rl_score, rl_msg])576 577            speed_sl.change(fn=set_speed, inputs=[speed_sl])578 579            start_btn.click(fn=rl_start,580                            inputs=[total_ep_sl, speed_sl, show_q_cb],581                            outputs=[start_btn, pause_btn, stop_btn])582            pause_btn.click(fn=rl_pause, outputs=[pause_btn])583            stop_btn.click(fn=rl_stop,  outputs=[start_btn, pause_btn, stop_btn])584 585        # ── TAB 2 : Manual Play ───────────────────────────────────────────────586        with gr.Tab("🕹️ Manual Play"):587            with gr.Row():588                with gr.Column(scale=3, elem_id="grid-col"):589                    m_grid = gr.HTML(590                        render_grid_html(manual_env.grid, manual_env.agent_pos, manual_env.goal))591 592                    with gr.Row():593                        gr.HTML("")594                        with gr.Column(scale=1, min_width=54, elem_id="btn-mu", elem_classes="ctrl-btn"):595                            mu = gr.Button("⬆")596                        gr.HTML("")597                    with gr.Row():598                        with gr.Column(scale=1, min_width=54, elem_id="btn-ml", elem_classes="ctrl-btn"):599                            ml = gr.Button("⬅")600                        with gr.Column(scale=1, min_width=54, elem_id="btn-md", elem_classes="ctrl-btn"):601                            md = gr.Button("⬇")602                        with gr.Column(scale=1, min_width=54, elem_id="btn-mr", elem_classes="ctrl-btn"):603                            mr = gr.Button("➡")604 605                    with gr.Row():606                        with gr.Column(elem_id="reset-btn-m"):607                            mr2 = gr.Button("🔄 New Maze", variant="primary")608 609                    m_msg = gr.Textbox(value="Navigate the red agent to the green goal!",610                                       label="", interactive=False, lines=1, elem_classes="msg-box")611 612                with gr.Column(scale=1):613                    m_score = gr.HTML(_manual_score())614 615            m_outs = [m_grid, m_score, m_msg]616            mu.click(fn=lambda: manual_step(0), outputs=m_outs)617            mr.click(fn=lambda: manual_step(1), outputs=m_outs)618            md.click(fn=lambda: manual_step(2), outputs=m_outs)619            ml.click(fn=lambda: manual_step(3), outputs=m_outs)620            mr2.click(fn=manual_reset, outputs=m_outs)621 622    demo.load(fn=None, js=KEY_JS)623 624if __name__ == "__main__":625    demo.launch(css=CUSTOM_CSS)626