CoolFace
Apppublic

ProfRick/EC-Coupling

sourceHugging Faceupdated 11mo agoView on Hugging Face
0likes
app.py573 linesDownload Raw Back to root
1import gradio as gr2import matplotlib.pyplot as plt3import numpy as np4from io import BytesIO5from PIL import Image6from matplotlib.patches import Rectangle, Circle, FancyArrowPatch, PathPatch7from matplotlib.path import Path8 9# ================== Content (transcript language) ==================10STEPS = [11    dict(12        title="Step 1: Motor neuron → NMJ",13        where=("A motor neuron goes directly from the spinal cord to the skeletal muscle. "14               "The action potential travels down the axon to the axon terminal, calcium channels open, "15               "and acetylcholine is released into the synapse."),16        question="When the motor neuron releases acetylcholine, what happens next?",17        options=[18            "The muscle relaxes immediately.",19            "Acetylcholine moves across the synapse and binds to receptors on the muscle cell membrane.",20            "Calcium leaves the muscle fiber."21        ],22        correct=1,23        visual="neuron"24    ),25    dict(26        title="Step 2: Motor end plate (ligand-gated channels)",27        where=("Acetylcholine moves to the motor end plate and binds nicotinic acetylcholine receptors. "28               "These are ligand-gated channels that open when acetylcholine binds."),29        question="When acetylcholine binds to its receptor at the motor end plate, which ion moves into the muscle cell?",30        options=[31            "Sodium moves into the cell.",32            "Potassium moves into the cell.",33            "Calcium leaves the sarcoplasmic reticulum."34        ],35        correct=0,36        visual="nmj"37    ),38    dict(39        title="Step 3: Threshold → action potential on sarcolemma",40        where=("Sodium entry changes the voltage until threshold potential is reached. "41               "Voltage-gated channels open and an action potential travels along the sarcolemma."),42        question="What allows the electrical signal to travel quickly across the muscle cell membrane?",43        options=[44            "Voltage-gated sodium channels opening along the sarcolemma.",45            "Continuous acetylcholine release.",46            "ATP from mitochondria."47        ],48        correct=0,49        visual="sarcolemma"50    ),51    dict(52        title="Step 4: T-tubule voltage sensing (DHP)",53        where=("The sarcolemma dives into the cell as the T-tubule. "54               "When the action potential reaches this area, the DHP receptor senses the voltage change."),55        question="When the DHP receptor senses the voltage change, what does it do?",56        options=[57            "It moves the ryanidine receptor so calcium can leave the sarcoplasmic reticulum.",58            "It brings more sodium into the cell.",59            "It breaks down ATP."60        ],61        correct=0,62        visual="t_tubule"63    ),64    dict(65        title="Step 5: Calcium leaves the SR",66        where=("The ryanidine receptor opens. Calcium moves out of the sarcoplasmic reticulum into the cytoplasm "67               "following a concentration gradient (high in SR → lower in cytoplasm)."),68        question="Why does calcium move out of the sarcoplasmic reticulum?",69        options=[70            "There is a high concentration of calcium inside the SR and a lower concentration in the cytoplasm.",71            "Calcium is pushed out by sodium.",72            "It is actively pumped out using ATP."73        ],74        correct=0,75        visual="sr_release"76    ),77    dict(78        title="Step 6: Troponin → tropomyosin moves",79        where=("Once calcium is in the sarcoplasm, it binds to troponin, which causes tropomyosin to move away "80               "from the binding sites on actin."),81        question="What is exposed when tropomyosin moves?",82        options=[83            "The myosin binding sites on actin.",84            "The ATP-binding sites on myosin.",85            "The calcium pumps on the sarcoplasmic reticulum."86        ],87        correct=0,88        visual="thin_filament"89    ),90    dict(91        title="Step 7: Cross-bridge cycling (ATP’s role)",92        where=("Myosin binds to actin. ATP causes detachment; ATP hydrolysis re-cocks the myosin head. "93               "Without ATP, myosin stays attached and the muscle is stiff."),94        question="If there is no ATP available, what happens?",95        options=[96            "The myosin remains attached to actin, causing stiffness.",97            "The muscle continues to contract rapidly.",98            "The SR releases more calcium."99        ],100        correct=0,101        visual="crossbridge"102    ),103    dict(104        title="Step 8: Relaxation",105        where=("When the excitatory signal stops, acetylcholine esterase breaks down acetylcholine. "106               "Calcium is pumped back into the sarcoplasmic reticulum, and tropomyosin moves back over the binding sites."),107        question="What two actions cause relaxation?",108        options=[109            "Acetylcholine breakdown and calcium re-uptake into the sarcoplasmic reticulum.",110            "More acetylcholine release and ATP depletion.",111            "Sodium leaving the muscle fiber."112        ],113        correct=0,114        visual="relax"115    ),116]117 118NODES = [119    "ACh released at NMJ",120    "ACh binds nicotinic receptor",121    "Na⁺ entry → threshold → sarcolemma AP",122    "T-tubule DHP senses voltage",123    "RYR opens; Ca²⁺ leaves SR",124    "Ca²⁺ binds troponin; tropomyosin moves",125    "Cross-bridge cycling (ATP present)",126    "Relaxation: AChE + SERCA"127]128 129EDGES = {130    "ACh released at NMJ": ["ACh binds nicotinic receptor"],131    "ACh binds nicotinic receptor": ["Na⁺ entry → threshold → sarcolemma AP"],132    "Na⁺ entry → threshold → sarcolemma AP": ["T-tubule DHP senses voltage"],133    "T-tubule DHP senses voltage": ["RYR opens; Ca²⁺ leaves SR"],134    "RYR opens; Ca²⁺ leaves SR": ["Ca²⁺ binds troponin; tropomyosin moves"],135    "Ca²⁺ binds troponin; tropomyosin moves": ["Cross-bridge cycling (ATP present)"],136    "Cross-bridge cycling (ATP present)": ["Relaxation: AChE + SERCA"],137    "Relaxation: AChE + SERCA": []138}139 140# ================== Visual style helpers (HD schematics) ==================141PALETTE = {142    "membrane": "#222831",143    "t_tubule": "#3e8ed0",144    "sr": "#f59e0b",145    "channel": "#6b7280",146    "receptor": "#7c3aed",147    "vesicle": "#22c55e",148    "ach": "#16a34a",149    "na": "#2563eb",150    "ca": "#ef4444",151    "text": "#111827",152}153 154def _ion(ax, x, y, label, color, r=0.035):155    ax.add_patch(Circle((x, y), r, facecolor=color, edgecolor="white", linewidth=1.2))156    ax.text(x, y, label, ha="center", va="center", color="white", fontsize=9, fontweight="bold")157 158def _membrane(ax, x0=0.05, x1=0.95, y=0.5, thickness=0.03):159    ax.add_patch(Rectangle((x0, y - thickness/2), x1-x0, thickness,160                           facecolor=PALETTE["membrane"], alpha=0.15, edgecolor=PALETTE["membrane"]))161 162def _t_tubule(ax, x=0.5, y0=0.12, y1=0.88, w=0.06):163    ax.add_patch(Rectangle((x-w/2, y0), w, y1-y0, facecolor=PALETTE["t_tubule"], alpha=0.12, edgecolor=PALETTE["t_tubule"]))164    ax.text(x, y1+0.05, "T-tubule", ha="center", va="bottom", fontsize=11, color=PALETTE["t_tubule"])165 166def _sr(ax, x0=0.3, x1=0.7, y=0.18, h=0.06, label=True):167    ax.add_patch(Rectangle((x0, y), x1-x0, h, facecolor=PALETTE["sr"], alpha=0.10, edgecolor=PALETTE["sr"]))168    if label:169        ax.text((x0+x1)/2, y-0.03, "SR", ha="center", va="top", fontsize=11, color=PALETTE["sr"])170 171def _nicotinic_receptor(ax, x=0.8, y=0.5, w=0.06, h=0.04):172    # dimer-like shapes173    ax.add_patch(Rectangle((x-w, y-h/2), w, h, facecolor=PALETTE["receptor"], alpha=0.25, edgecolor=PALETTE["receptor"]))174    ax.add_patch(Rectangle((x, y-h/2), w, h, facecolor=PALETTE["receptor"], alpha=0.25, edgecolor=PALETTE["receptor"]))175    ax.text(x, y-0.07, "nicotinic AChR", ha="center", va="top", fontsize=9, color=PALETTE["receptor"])176 177def _channel(ax, x, y, open_state=True, label=None):178    h = 0.06179    ax.add_patch(Rectangle((x-0.01, y-h/2), 0.02, h,180                           facecolor=PALETTE["channel"], alpha=0.20, edgecolor=PALETTE["channel"]))181    if open_state:182        ax.add_line(plt.Line2D([x-0.007, x+0.007], [y-h/2+0.01, y+h/2-0.01], color=PALETTE["channel"], linewidth=2))183    else:184        ax.add_line(plt.Line2D([x-0.01, x+0.01], [y+0.03, y-0.03], color=PALETTE["channel"], linewidth=2))185    if label:186        ax.text(x, y+0.055, label, ha="center", va="bottom", fontsize=9, color=PALETTE["channel"])187 188def _arrow(ax, x0, y0, x1, y1, color="#111", width=2.4, curve=0.0, label=None, label_pos=0.5):189    if curve == 0:190        arrow = FancyArrowPatch((x0, y0), (x1, y1),191                                arrowstyle="-|>", mutation_scale=12, linewidth=width, color=color)192    else:193        verts = [(x0, y0), ((x0+x1)/2, (y0+y1)/2 + curve), (x1, y1)]194        codes = [Path.MOVETO, Path.CURVE3, Path.CURVE3]195        path = Path(verts, codes)196        arrow = FancyArrowPatch(path=path, arrowstyle="-|>", mutation_scale=12, linewidth=width, color=color)197    ax.add_patch(arrow)198    if label:199        mx = x0 + (x1-x0)*label_pos200        my = y0 + (y1-y0)*label_pos + (curve if curve else 0)201        ax.text(mx, my, label, fontsize=10, color=color, fontweight="bold",202                ha="center", va="bottom")203 204def _vesicle(ax, x, y, r=0.035, n_ach=3):205    ax.add_patch(Circle((x, y), r, facecolor=PALETTE["vesicle"], edgecolor="#136f45", linewidth=1))206    for k in range(n_ach):207        _ion(ax, x + (k-1)*(r*0.45), y, "ACh", PALETTE["ach"], r=0.017)208 209def _legend(ax, items):210    # items: list of (color, label)211    x, y = 0.05, 0.05212    for i, (c, t) in enumerate(items):213        ax.add_patch(Rectangle((x, y+i*0.04), 0.02, 0.02, facecolor=c, edgecolor=c))214        ax.text(x+0.025, y+i*0.04+0.01, t, va="center", ha="left", fontsize=9, color=PALETTE["text"])215 216def draw_visual(kind: str, detail: str = "HD") -> np.ndarray:217    """218    detail: 'Basic' or 'HD'219    """220    # Larger canvas for HD; use antialiasing and facecolors221    figsize = (7.2, 4.0) if detail == "HD" else (6, 3)222    fig, ax = plt.subplots(figsize=figsize)223    ax.set_xlim(0, 1)224    ax.set_ylim(0, 1)225    ax.axis("off")226 227    # Common elements for several views228    if kind in {"neuron", "nmj", "sarcolemma"}:229        _membrane(ax, y=0.5, thickness=0.035)230 231    if kind == "neuron":232        ax.text(0.06, 0.86, "Motor neuron → axon terminal (NMJ)", color=PALETTE["text"], fontsize=12, fontweight="bold")233        # Axon234        ax.add_line(plt.Line2D([0.06, 0.38], [0.5, 0.5], color=PALETTE["membrane"], linewidth=5))235        # Vesicles at terminal236        for dx in [0.44, 0.50, 0.56]:237            _vesicle(ax, dx, 0.62, r=0.035 if detail=="HD" else 0.03)238        # ACh diffusion to membrane239        for dx in [0.48, 0.54]:240            _arrow(ax, dx, 0.60, dx+0.10, 0.52, color=PALETTE["ach"], width=2, curve=-0.05)241        ax.text(0.72, 0.44, "ACh in synapse", color=PALETTE["ach"], fontsize=10)242        _legend(ax, [(PALETTE["vesicle"], "Vesicle"), (PALETTE["ach"], "Acetylcholine")])243 244    elif kind == "nmj":245        ax.text(0.06, 0.9, "Motor end plate (nicotinic receptors open with ACh)", fontsize=12, fontweight="bold", color=PALETTE["text"])246        _nicotinic_receptor(ax, x=0.78, y=0.5)247        # ACh arrows from cleft to receptor248        for dy in [-0.03, 0.0, 0.03]:249            _arrow(ax, 0.62, 0.55+dy, 0.75, 0.50+dy, color=PALETTE["ach"], width=2, curve=-0.02)250        # Na+ entry (if open)251        _channel(ax, 0.78, 0.5, open_state=True, label="Na⁺ channel")252        for k in range(4):253            _ion(ax, 0.80 + 0.03*k, 0.58 + 0.02*np.sin(k), "Na⁺", PALETTE["na"])254            _arrow(ax, 0.80 + 0.03*k, 0.58 + 0.02*np.sin(k), 0.78, 0.52, color=PALETTE["na"], width=1.8, curve=-0.01, label=None)255 256    elif kind == "sarcolemma":257        ax.text(0.5, 0.9, "Sarcolemma AP via voltage-gated Na⁺", ha="center", fontsize=12, fontweight="bold", color=PALETTE["text"])258        # A wave of open channels (dots) and Na influx259        for xi in np.linspace(0.15, 0.85, 7):260            _channel(ax, xi, 0.5, open_state=True)261            _ion(ax, xi+0.03, 0.62, "Na⁺", PALETTE["na"])262            _arrow(ax, xi+0.03, 0.62, xi, 0.52, color=PALETTE["na"], width=1.6, curve=-0.015)263 264    elif kind == "t_tubule":265        _t_tubule(ax, x=0.5, y0=0.12, y1=0.88)266        _sr(ax, x0=0.30, x1=0.70, y=0.12, h=0.08, label=True)267        ax.text(0.5, 0.94, "DHP senses voltage → moves RYR", ha="center", fontsize=12, fontweight="bold", color=PALETTE["text"])268        # DHP (on T-tubule wall)269        ax.add_patch(Rectangle((0.48, 0.50-0.04), 0.04, 0.08, facecolor=PALETTE["receptor"], alpha=0.25, edgecolor=PALETTE["receptor"]))270        ax.text(0.50, 0.46, "DHP", ha="center", va="top", fontsize=9, color=PALETTE["receptor"])271        # RYR (on SR)272        ax.add_patch(Rectangle((0.42, 0.12), 0.16, 0.06, facecolor=PALETTE["sr"], alpha=0.18, edgecolor=PALETTE["sr"]))273        ax.text(0.50, 0.19, "RYR", ha="center", va="center", fontsize=10, color=PALETTE["sr"])274        # Link arrow275        _arrow(ax, 0.50, 0.50, 0.50, 0.18, color="#444", width=2, label="Coupling", label_pos=0.55)276 277    elif kind == "sr_release":278        _t_tubule(ax, x=0.5, y0=0.60, y1=0.95)279        _sr(ax, x0=0.20, x1=0.80, y=0.18, h=0.10, label=True)280        ax.text(0.5, 0.56, "RYR opens; Ca²⁺ leaves SR (high → lower)", ha="center", fontsize=12, fontweight="bold", color=PALETTE["text"])281        # Ca arrows SR -> cytosol282        for x in np.linspace(0.28, 0.72, 5):283            _ion(ax, x, 0.25, "Ca²⁺", PALETTE["ca"])284            _arrow(ax, x, 0.25, x, 0.45, color=PALETTE["ca"], width=2, curve=0.0)285 286    elif kind == "thin_filament":287        ax.text(0.5, 0.9, "Ca²⁺ binds troponin → Tropomyosin moves", ha="center", fontsize=12, fontweight="bold", color=PALETTE["text"])288        # Actin cable289        ax.add_patch(Rectangle((0.15, 0.45), 0.70, 0.05, facecolor="#9ca3af", edgecolor="#6b7280"))290        # Binding sites reveal (dots)291        for x in np.linspace(0.18, 0.80, 7):292            ax.add_patch(Circle((x, 0.475), 0.01, facecolor="#374151"))293        # Ca icons near troponin294        for x in [0.30, 0.50, 0.70]:295            _ion(ax, x, 0.58, "Ca²⁺", PALETTE["ca"])296            _arrow(ax, x, 0.58, x, 0.48, color=PALETTE["ca"], width=2)297 298    elif kind == "crossbridge":299        ax.text(0.5, 0.90, "Cross-bridge cycling (ATP detaches; hydrolysis re-cocks)", ha="center",300                fontsize=12, fontweight="bold", color=PALETTE["text"])301        # Actin (top) and myosin (bottom)302        ax.add_patch(Rectangle((0.12, 0.62), 0.76, 0.04, facecolor="#9ca3af", edgecolor="#6b7280"))303        ax.add_patch(Rectangle((0.12, 0.36), 0.76, 0.04, facecolor="#6b7280", edgecolor="#374151"))304        # Heads and binding305        for x in np.linspace(0.18, 0.82, 5):306            _arrow(ax, x, 0.40, x, 0.60, color="#374151", width=2)307        ax.text(0.50, 0.50, "ATP binds → detachment\nATP hydrolysis → re-cock", ha="center", va="center",308                fontsize=10, color=PALETTE["text"])309 310    elif kind == "relax":311        ax.text(0.5, 0.90, "Relaxation: ACh broken down; Ca²⁺ pumped back to SR", ha="center", fontsize=12, fontweight="bold", color=PALETTE["text"])312        _sr(ax, x0=0.20, x1=0.80, y=0.70, h=0.08, label=True)313        # Ca back to SR314        for x in np.linspace(0.28, 0.72, 5):315            _ion(ax, x, 0.42, "Ca²⁺", PALETTE["ca"])316            _arrow(ax, x, 0.45, x, 0.74, color=PALETTE["ca"], width=2)317        ax.text(0.20, 0.30, "AChE breaks down ACh", fontsize=10, color=PALETTE["ach"])318        _legend(ax, [(PALETTE["ca"], "Calcium"), (PALETTE["ach"], "Acetylcholine")])319 320    # Render to array321    buf = BytesIO()322    fig.tight_layout()323    fig.savefig(buf, format="png", dpi=(180 if detail == "HD" else 110), bbox_inches="tight")324    plt.close(fig)325    buf.seek(0)326    img = Image.open(buf).convert("RGB")327    return np.array(img)328 329# ================== Step Trainer logic (now passes detail level) ==================330def render_step(i:int, detail:str):331    i = int(i)332    s = STEPS[i]333    img = draw_visual(s["visual"], detail=detail)334    return (f"### {s['title']}",335            s["where"],336            img,337            f"**{s['question']}**",338            gr.update(choices=s["options"], value=s["options"][0]),339            "",  # feedback340            i,   # state341            i)342 343def submit_step(i:int, picked:str, detail:str):344    i = int(i)345    s = STEPS[i]346    idx = s["options"].index(picked) if picked in s["options"] else -1347    if idx == s["correct"]:348        if i < len(STEPS)-1:349            i += 1350            fb = "✅ **Correct. Advancing…**"351        else:352            fb = "✅ **Done. Relaxation complete.**"353    else:354        i = 0355        fb = "❌ **Incorrect. Returning to Step 1.**"356    title, where, img, q, choices, _, _, _ = render_step(i, detail)357    return title, where, img, q, choices, fb, i, i358 359def restart_step(_i:int, detail:str):360    title, where, img, q, choices, _, i, p = render_step(0, detail)361    return title, where, img, q, choices, "Restarted.", i, p362 363# ================== Failure-Point ==================364def failure_check(fails, guess):365    failed_idx = sorted([NODES.index(f) for f in (fails or [])]) if fails else []366    lines = []367    if failed_idx:368        stop = failed_idx[0]369        for j, lab in enumerate(NODES):370            ok = j < stop371            lines.append(("✅ " if ok else "⛔ ") + lab)372            if not ok:373                break374        fb = "✅ **Correct: first failed step located.**" if (guess and NODES.index(guess)==stop) else "❌ **Not quite—identify the FIRST failed step.**"375    else:376        lines = ["✅ " + l for l in NODES]377        lines.append("(No failures set — full propagation to relaxation.)")378        fb = "No failures toggled."379    return "```\n" + "\n".join(lines) + "\n```", fb380 381# ================== Sandbox ==================382def sandbox_metrics(Na_out, Na_in, Ca_sr, Ca_cyto, ATP):383    na_drive = max(0.0, (Na_out - Na_in) / max(1.0, Na_out))384    ap_prob  = min(1.0, na_drive * 1.5)385    dhp_ok   = ap_prob386    ca_drive = max(0.0, (Ca_sr - Ca_cyto) / max(1.0, Ca_sr))387    ca_rel   = dhp_ok * ca_drive388    xbridges = min(1.0, ca_rel * (0.5 + 0.5*min(1.0, ATP)))389    relax_ok = min(1.0, ATP) * 0.7 + (1.0 - ca_rel) * 0.3390    return dict(ap_prob=ap_prob, ca_release=ca_rel, crossbridge=xbridges, relax_ok=relax_ok)391 392def sandbox_plot(Na_out, Na_in, Ca_sr, Ca_cyto, ATP):393    vals = sandbox_metrics(Na_out, Na_in, Ca_sr, Ca_cyto, ATP)394    fig, ax = plt.subplots(figsize=(6,3))395    keys = ["ap_prob","ca_release","crossbridge","relax_ok"]396    ax.bar(keys, [vals[k] for k in keys], color=[PALETTE["na"], PALETTE["ca"], "#374151", "#10b981"])397    ax.set_ylim(0,1); ax.set_title("Predicted behaviors (0–1)")398    for i, k in enumerate(keys):399        ax.text(i, vals[k]+0.03, f"{vals[k]:.2f}", ha="center", va="bottom", fontsize=9)400    buf = BytesIO(); fig.tight_layout(); fig.savefig(buf, format="png", bbox_inches="tight", dpi=150); plt.close(fig)401    buf.seek(0); img = Image.open(buf).convert("RGB")402    return np.array(img)403 404# ================== Causality Builder ==================405def chain_add(chain, pick):406    import json407    chain = json.loads(chain)408    remaining = [n for n in NODES if n not in chain]409    if not remaining:410        return (gr.update(value=chain, interactive=False),411                "Chain complete.",412                gr.update(choices=[], interactive=False),413                " → ".join(chain))414    if pick is None:415        return (gr.update(value=chain),416                "Pick a next event.",417                gr.update(),418                " → ".join(chain))419    ok = pick in EDGES.get(chain[-1], [])420    if ok:421        chain.append(pick)422        remaining = [n for n in NODES if n not in chain]423        msg = "✅ **Link accepted.**"424    else:425        msg = "❌ **That event doesn’t logically follow. Try a different next step.**"426    return (gr.update(value=chain),427            msg,428            gr.update(choices=remaining, value=(remaining[0] if remaining else None), interactive=bool(remaining)),429            " → ".join(chain))430 431def chain_reset():432    import json433    chain = [NODES[0]]434    remaining = [n for n in NODES if n not in chain]435    return (gr.update(value=chain),436            "Reset.",437            gr.update(choices=remaining, value=remaining[0] if remaining else None, interactive=bool(remaining)),438            " → ".join(chain))439 440# ================== Fatigue Lab ==================441def simulate_fatigue(tmax=60, dt=0.5, atp_init=1.0, aerobic=0.3, anaerobic=0.2, load=0.5, serca_load=0.3):442    n=int(tmax/dt)+1; t=np.linspace(0,tmax,n); atp=np.zeros(n); atp[0]=atp_init; rigor=np.zeros(n,dtype=bool)443    for i in range(1,n):444        cons = load*0.4 + serca_load*0.25445        prod = aerobic*0.2 + anaerobic*0.15446        atp[i] = np.clip(atp[i-1] + dt*(prod - cons), 0, 1.2)447        rigor[i] = atp[i] < 0.1448    fig, ax = plt.subplots(1,2, figsize=(8,3))449    ax[0].plot(t, atp, color="#111827"); ax[0].set_xlabel("time (s)"); ax[0].set_ylabel("ATP (a.u.)"); ax[0].set_title("ATP dynamics")450    ax[1].bar(["rigor fraction"], [rigor.mean()], color="#ef4444"); ax[1].set_ylim(0,1); ax[1].set_title("Rigor")451    buf = BytesIO(); fig.tight_layout(); fig.savefig(buf, format="png", bbox_inches="tight", dpi=140); plt.close(fig)452    buf.seek(0); img = Image.open(buf).convert("RGB")453    msg = "Low ATP periods → stiffness (myosin remains attached)." if rigor.mean()>0 else "No stiffness expected (ATP maintained)."454    return np.array(img), f"Estimated rigor fraction: {rigor.mean():.2f}\n{msg}"455 456# ================== Passport Analyzer ==================457CORE_CONCEPTS = {458  "Flow down gradients": ["gradient","concentration","moves from high to low","diffuse","diffusion"],459  "Cell-to-cell communication": ["neurotransmitter","acetylcholine","receptor","synapse","binds"],460  "Structure–function": ["structure","function","troponin","tropomyosin","binding site","receptor opens"],461  "Energy flow": ["ATP","ADP","Pi","hydrolysis","energy"],462  "Interdependence": ["depends","linked","together","if/then","cascade","pathway"]463}464def passport_analyze(text):465    t = (text or "").lower()466    counts = {k: sum(t.count(w) for w in ws) for k,ws in CORE_CONCEPTS.items()}467    keys = list(counts.keys()); vals = [counts[k] for k in keys]468    fig, ax = plt.subplots(figsize=(6,3)); ax.bar(keys, vals, color="#3e8ed0"); ax.set_title("Core Concept mentions"); ax.tick_params(axis='x', rotation=30)469    for i,k in enumerate(keys):470        ax.text(i, vals[i]+0.05, str(vals[i]), ha="center")471    buf = BytesIO(); fig.tight_layout(); fig.savefig(buf, format="png", bbox_inches="tight", dpi=140); plt.close(fig)472    buf.seek(0); img = Image.open(buf).convert("RGB")473    weak = [k for k in keys if counts[k]==0]474    note = ("Consider adding explicit references to: " + ", ".join(weak)) if weak else "Balanced coverage detected."475    import json476    return np.array(img), json.dumps(counts, indent=2) + "\n\n" + note477 478# ================== UI ==================479with gr.Blocks(title="EC Coupling Suite") as demo:480    gr.Markdown("# EC Coupling Learning Suite (Transcript Language)")481 482    with gr.Row():483        detail_picker = gr.Radio(choices=["Basic", "HD"], value="HD", label="Visual detail")484 485    with gr.Tabs():486        # Step Trainer487        with gr.Tab("Step Trainer"):488            step_state = gr.State(0)489            title = gr.Markdown()490            where = gr.Markdown()491            img = gr.Image(label="Where are we?")492            q = gr.Markdown()493            choice = gr.Radio(choices=[], label="Predict what happens next")494            submit = gr.Button("Submit", variant="primary")495            restart = gr.Button("Restart (Step 1)")496            fb = gr.Markdown()497            prog = gr.Slider(0, len(STEPS)-1, value=0, step=1, interactive=False, label="Progress")498 499            demo.load(lambda d: render_step(0, d), inputs=[detail_picker],500                      outputs=[title, where, img, q, choice, fb, step_state, prog])501 502            submit.click(submit_step, [step_state, choice, detail_picker],503                         [title, where, img, q, choice, fb, step_state, prog])504            restart.click(restart_step, [step_state, detail_picker],505                          [title, where, img, q, choice, fb, step_state, prog])506 507        # Failure-Point508        with gr.Tab("Failure-Point"):509            gr.Markdown("Toggle failures and diagnose the **first** failed step.")510            fails = gr.CheckboxGroup(choices=NODES, label="Failures")511            guess = gr.Dropdown(choices=NODES, label="Your diagnosis")512            check = gr.Button("Test & Check", variant="primary")513            log = gr.Markdown()514            verdict = gr.Markdown()515            check.click(failure_check, [fails, guess], [log, verdict])516 517        # Sandbox518        with gr.Tab("Sandbox"):519            gr.Markdown("Adjust gradients and ATP; observe predicted behaviors (heuristic).")520            Na_out = gr.Slider(10, 160, value=140, step=1, label='[Na⁺] outside')521            Na_in  = gr.Slider(0, 50, value=15, step=1, label='[Na⁺] inside')522            Ca_sr  = gr.Slider(0.1, 10.0, value=3.0, step=0.1, label='[Ca²⁺] SR')523            Ca_c   = gr.Slider(0.0, 1.0, value=0.1, step=0.01, label='[Ca²⁺] cytoplasm')524            ATP    = gr.Slider(0.0, 1.0, value=0.8, step=0.01, label='ATP (0–1)')525            sb_img = gr.Image(label="Predicted bars")526            for w in [Na_out, Na_in, Ca_sr, Ca_c, ATP]:527                w.change(sandbox_plot, [Na_out, Na_in, Ca_sr, Ca_c, ATP], [sb_img])528            sb_img.value = sandbox_plot(Na_out.value, Na_in.value, Ca_sr.value, Ca_c.value, ATP.value)529 530        # Causality531        with gr.Tab("Causality"):532            gr.Markdown("Build a valid chain; logic is checked at each link.")533            import json534            chain_state = gr.State(json.dumps([NODES[0]]))535            chain_text = gr.Markdown(" → ".join([NODES[0]]))536            next_pick = gr.Dropdown(choices=[n for n in NODES if n != NODES[0]], label="Next event")537            add = gr.Button("Add link", variant="primary")538            reset = gr.Button("Reset")539            fb2 = gr.Markdown()540            add.click(chain_add, [chain_state, next_pick], [chain_state, fb2, next_pick, chain_text])541            reset.click(chain_reset, [], [chain_state, fb2, next_pick, chain_text])542 543        # Fatigue544        with gr.Tab("Fatigue"):545            gr.Markdown("Adjust ATP supply/demand; see ATP curve and rigor fraction.")546            aerobic = gr.Slider(0,1,value=0.4,step=0.01,label="Aerobic supply")547            anaer   = gr.Slider(0,1,value=0.3,step=0.01,label="Anaerobic supply")548            work    = gr.Slider(0,1,value=0.6,step=0.01,label="Mechanical load")549            serca   = gr.Slider(0,1,value=0.4,step=0.01,label="SERCA load")550            dur     = gr.Slider(10,180,value=90,step=1,label="Duration (s)")551            ftg_img = gr.Image(label="ATP & Rigor")552            ftg_txt = gr.Markdown()553 554            def ftg_update(dur_val, aer, anr, load, sl):555                return simulate_fatigue(dur_val, 0.5, 1.0, aer, anr, load, sl)556 557            for w in [aerobic, anaer, work, serca, dur]:558                w.change(ftg_update, [dur, aerobic, anaer, work, serca], [ftg_img, ftg_txt])559 560            img0, txt0 = simulate_fatigue(90, 0.5, 1.0, 0.4, 0.3, 0.6, 0.4)561            ftg_img.value, ftg_txt.value = img0, txt0562 563        # Passport564        with gr.Tab("Passport"):565            gr.Markdown("Paste your notes; see Core Concept emphasis.")566            ta = gr.Textbox(lines=8, label="Notes / reflection")567            pass_img = gr.Image(label="Concept counts")568            pass_txt = gr.Markdown()569            run = gr.Button("Analyze", variant="primary")570            run.click(passport_analyze, [ta], [pass_img, pass_txt])571 572demo.launch()573