ProfRick commited on
Commit
0036231
·
verified ·
1 Parent(s): 2184d3e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +209 -84
app.py CHANGED
@@ -3,8 +3,10 @@ import matplotlib.pyplot as plt
3
  import numpy as np
4
  from io import BytesIO
5
  from PIL import Image
 
 
6
 
7
- # ---------- Transcript-authentic steps ----------
8
  STEPS = [
9
  dict(
10
  title="Step 1: Motor neuron → NMJ",
@@ -135,92 +137,210 @@ EDGES = {
135
  "Relaxation: AChE + SERCA": []
136
  }
137
 
138
- # ---------- visuals (return numpy arrays; robust on Spaces) ----------
139
- def draw_visual(kind: str) -> np.ndarray:
140
- fig, ax = plt.subplots(figsize=(6, 3))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  ax.axis("off")
142
 
143
- def rect(x, y, w, h, fill=False):
144
- ax.add_patch(plt.Rectangle((x,y), w, h, fill=fill))
145
- def arrow(x, y, dx, dy):
146
- ax.arrow(x, y, dx, dy, head_width=0.03, head_length=0.02, length_includes_head=True)
147
- def circle(cx, cy, r):
148
- ax.add_patch(plt.Circle((cx, cy), r, fill=False))
149
- def txt(x, y, t):
150
- ax.text(x, y, t, ha="center", va="center")
151
 
152
  if kind == "neuron":
153
- ax.text(0.05, 0.5, "Motor neuron → axon terminal (NMJ)\nACh released", ha="left", va="center")
154
- arrow(0.35, 0.5, 0.45, 0)
155
- circle(0.86, 0.5, 0.06)
156
- txt(0.86, 0.5, "Terminal")
 
 
 
 
 
 
 
 
157
  elif kind == "nmj":
158
- rect(0.1, 0.35, 0.25, 0.3, fill=False)
159
- txt(0.225, 0.66, "Presynaptic\nterminal")
160
- txt(0.46, 0.50, "Synaptic cleft")
161
- rect(0.62, 0.35, 0.28, 0.06, fill=True)
162
- txt(0.76, 0.47, "Motor end plate\n(nicotinic AChR)")
163
- arrow(0.35, 0.5, 0.22, 0)
164
- txt(0.46, 0.56, "ACh ")
 
 
 
 
165
  elif kind == "sarcolemma":
166
- rect(0.1, 0.2, 0.8, 0.12, fill=True)
167
- txt(0.5, 0.38, "Sarcolemma\n(voltage-gated Na⁺ open AP)")
 
 
 
 
 
168
  elif kind == "t_tubule":
169
- rect(0.45, 0.1, 0.1, 0.8, fill=False)
170
- txt(0.5, 0.93, "T-tubule")
171
- rect(0.35, 0.1, 0.3, 0.1, fill=False)
172
- txt(0.5, 0.06, "SR (RYR)")
173
- txt(0.5, 0.5, "DHP senses voltage")
 
 
 
 
 
 
 
174
  elif kind == "sr_release":
175
- rect(0.2, 0.65, 0.6, 0.15, fill=False)
176
- txt(0.5, 0.8, "SR: high [Ca²⁺]")
177
- rect(0.2, 0.2, 0.6, 0.15, fill=False)
178
- txt(0.5, 0.16, "Cytoplasm: lower [Ca²⁺]")
179
- for x in [0.3, 0.45, 0.6]:
180
- arrow(x, 0.65, 0, -0.25)
181
- txt(0.5, 0.48, "Ca²⁺ follows concentration gradient")
 
182
  elif kind == "thin_filament":
183
- rect(0.1, 0.45, 0.8, 0.1, fill=True)
184
- txt(0.5, 0.65, "Actin (thin filament)")
185
- txt(0.5, 0.3, "Ca²⁺ binds troponin →\nTropomyosin moves")
 
 
 
 
 
 
 
 
186
  elif kind == "crossbridge":
187
- rect(0.1, 0.55, 0.8, 0.05, fill=True)
188
- txt(0.5, 0.68, "Actin")
189
- rect(0.1, 0.35, 0.8, 0.05, fill=True)
190
- txt(0.5, 0.28, "Myosin")
191
- txt(0.5, 0.47, "ATP binds detachment\nATP hydrolysis → re-cock")
 
 
 
 
 
 
192
  elif kind == "relax":
193
- rect(0.2, 0.65, 0.6, 0.15, fill=False)
194
- txt(0.5, 0.8, "SR")
195
- rect(0.2, 0.2, 0.6, 0.15, fill=False)
196
- for x in [0.3, 0.45, 0.6]:
197
- arrow(x, 0.35, 0, 0.25)
198
- txt(0.5, 0.55, "Ca²⁺ pumped back (ATP-dependent)")
199
- txt(0.5, 0.12, "AChE breaks down acetylcholine")
 
200
 
 
201
  buf = BytesIO()
202
- plt.tight_layout()
203
- fig.savefig(buf, format="png", bbox_inches="tight")
204
  plt.close(fig)
205
  buf.seek(0)
206
  img = Image.open(buf).convert("RGB")
207
  return np.array(img)
208
 
209
- # ---------- Step Trainer logic ----------
210
- def render_step(i:int):
211
  i = int(i)
212
  s = STEPS[i]
213
- img = draw_visual(s["visual"])
214
  return (f"### {s['title']}",
215
  s["where"],
216
  img,
217
  f"**{s['question']}**",
218
  gr.update(choices=s["options"], value=s["options"][0]),
219
- "", # feedback clear
220
  i, # state
221
- i) # progress
222
 
223
- def submit_step(i:int, picked:str):
224
  i = int(i)
225
  s = STEPS[i]
226
  idx = s["options"].index(picked) if picked in s["options"] else -1
@@ -233,14 +353,14 @@ def submit_step(i:int, picked:str):
233
  else:
234
  i = 0
235
  fb = "❌ **Incorrect. Returning to Step 1.**"
236
- title, where, img, q, choices, _, _, _ = render_step(i)
237
  return title, where, img, q, choices, fb, i, i
238
 
239
- def restart_step(_i:int):
240
- title, where, img, q, choices, _, i, p = render_step(0)
241
  return title, where, img, q, choices, "Restarted.", i, p
242
 
243
- # ---------- Failure-Point ----------
244
  def failure_check(fails, guess):
245
  failed_idx = sorted([NODES.index(f) for f in (fails or [])]) if fails else []
246
  lines = []
@@ -258,7 +378,7 @@ def failure_check(fails, guess):
258
  fb = "No failures toggled."
259
  return "```\n" + "\n".join(lines) + "\n```", fb
260
 
261
- # ---------- Sandbox ----------
262
  def sandbox_metrics(Na_out, Na_in, Ca_sr, Ca_cyto, ATP):
263
  na_drive = max(0.0, (Na_out - Na_in) / max(1.0, Na_out))
264
  ap_prob = min(1.0, na_drive * 1.5)
@@ -273,14 +393,15 @@ def sandbox_plot(Na_out, Na_in, Ca_sr, Ca_cyto, ATP):
273
  vals = sandbox_metrics(Na_out, Na_in, Ca_sr, Ca_cyto, ATP)
274
  fig, ax = plt.subplots(figsize=(6,3))
275
  keys = ["ap_prob","ca_release","crossbridge","relax_ok"]
276
- ax.bar(keys, [vals[k] for k in keys])
277
- ax.set_ylim(0,1)
278
- ax.set_title("Predicted behaviors (0–1)")
279
- buf = BytesIO(); fig.tight_layout(); fig.savefig(buf, format="png", bbox_inches="tight"); plt.close(fig)
 
280
  buf.seek(0); img = Image.open(buf).convert("RGB")
281
  return np.array(img)
282
 
283
- # ---------- Causality Builder ----------
284
  def chain_add(chain, pick):
285
  import json
286
  chain = json.loads(chain)
@@ -316,7 +437,7 @@ def chain_reset():
316
  gr.update(choices=remaining, value=remaining[0] if remaining else None, interactive=bool(remaining)),
317
  " → ".join(chain))
318
 
319
- # ---------- Fatigue Lab ----------
320
  def simulate_fatigue(tmax=60, dt=0.5, atp_init=1.0, aerobic=0.3, anaerobic=0.2, load=0.5, serca_load=0.3):
321
  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)
322
  for i in range(1,n):
@@ -325,14 +446,14 @@ def simulate_fatigue(tmax=60, dt=0.5, atp_init=1.0, aerobic=0.3, anaerobic=0.2,
325
  atp[i] = np.clip(atp[i-1] + dt*(prod - cons), 0, 1.2)
326
  rigor[i] = atp[i] < 0.1
327
  fig, ax = plt.subplots(1,2, figsize=(8,3))
328
- ax[0].plot(t, atp); ax[0].set_xlabel("time (s)"); ax[0].set_ylabel("ATP (a.u.)"); ax[0].set_title("ATP dynamics")
329
- ax[1].bar(["rigor fraction"], [rigor.mean()]); ax[1].set_ylim(0,1); ax[1].set_title("Rigor")
330
- buf = BytesIO(); fig.tight_layout(); fig.savefig(buf, format="png", bbox_inches="tight"); plt.close(fig)
331
  buf.seek(0); img = Image.open(buf).convert("RGB")
332
  msg = "Low ATP periods → stiffness (myosin remains attached)." if rigor.mean()>0 else "No stiffness expected (ATP maintained)."
333
  return np.array(img), f"Estimated rigor fraction: {rigor.mean():.2f}\n{msg}"
334
 
335
- # ---------- Passport Analyzer ----------
336
  CORE_CONCEPTS = {
337
  "Flow down gradients": ["gradient","concentration","moves from high to low","diffuse","diffusion"],
338
  "Cell-to-cell communication": ["neurotransmitter","acetylcholine","receptor","synapse","binds"],
@@ -344,18 +465,23 @@ def passport_analyze(text):
344
  t = (text or "").lower()
345
  counts = {k: sum(t.count(w) for w in ws) for k,ws in CORE_CONCEPTS.items()}
346
  keys = list(counts.keys()); vals = [counts[k] for k in keys]
347
- fig, ax = plt.subplots(figsize=(6,3)); ax.bar(keys, vals); ax.set_title("Core Concept mentions"); ax.tick_params(axis='x', rotation=30)
348
- buf = BytesIO(); fig.tight_layout(); fig.savefig(buf, format="png", bbox_inches="tight"); plt.close(fig)
 
 
349
  buf.seek(0); img = Image.open(buf).convert("RGB")
350
  weak = [k for k in keys if counts[k]==0]
351
  note = ("Consider adding explicit references to: " + ", ".join(weak)) if weak else "Balanced coverage detected."
352
  import json
353
  return np.array(img), json.dumps(counts, indent=2) + "\n\n" + note
354
 
355
- # ---------- Build UI ----------
356
  with gr.Blocks(title="EC Coupling Suite") as demo:
357
  gr.Markdown("# EC Coupling Learning Suite (Transcript Language)")
358
 
 
 
 
359
  with gr.Tabs():
360
  # Step Trainer
361
  with gr.Tab("Step Trainer"):
@@ -370,13 +496,13 @@ with gr.Blocks(title="EC Coupling Suite") as demo:
370
  fb = gr.Markdown()
371
  prog = gr.Slider(0, len(STEPS)-1, value=0, step=1, interactive=False, label="Progress")
372
 
373
- # initialize on load (avoid setting .value directly)
374
- demo.load(fn=lambda: render_step(0),
375
- inputs=None,
376
  outputs=[title, where, img, q, choice, fb, step_state, prog])
377
 
378
- submit.click(submit_step, [step_state, choice], [title, where, img, q, choice, fb, step_state, prog])
379
- restart.click(restart_step, [step_state], [title, where, img, q, choice, fb, step_state, prog])
 
 
380
 
381
  # Failure-Point
382
  with gr.Tab("Failure-Point"):
@@ -397,7 +523,6 @@ with gr.Blocks(title="EC Coupling Suite") as demo:
397
  Ca_c = gr.Slider(0.0, 1.0, value=0.1, step=0.01, label='[Ca²⁺] cytoplasm')
398
  ATP = gr.Slider(0.0, 1.0, value=0.8, step=0.01, label='ATP (0–1)')
399
  sb_img = gr.Image(label="Predicted bars")
400
-
401
  for w in [Na_out, Na_in, Ca_sr, Ca_c, ATP]:
402
  w.change(sandbox_plot, [Na_out, Na_in, Ca_sr, Ca_c, ATP], [sb_img])
403
  sb_img.value = sandbox_plot(Na_out.value, Na_in.value, Ca_sr.value, Ca_c.value, ATP.value)
 
3
  import numpy as np
4
  from io import BytesIO
5
  from PIL import Image
6
+ from matplotlib.patches import Rectangle, Circle, FancyArrowPatch, PathPatch
7
+ from matplotlib.path import Path
8
 
9
+ # ================== Content (transcript language) ==================
10
  STEPS = [
11
  dict(
12
  title="Step 1: Motor neuron → NMJ",
 
137
  "Relaxation: AChE + SERCA": []
138
  }
139
 
140
+ # ================== Visual style helpers (HD schematics) ==================
141
+ PALETTE = {
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
+
154
+ def _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
+
158
+ def _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
+
162
+ def _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
+
166
+ def _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
+
171
+ def _nicotinic_receptor(ax, x=0.8, y=0.5, w=0.06, h=0.04):
172
+ # dimer-like shapes
173
+ 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
+
177
+ def _channel(ax, x, y, open_state=True, label=None):
178
+ h = 0.06
179
+ 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
+
188
+ def _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_pos
200
+ 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
+
204
+ def _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
+
209
+ def _legend(ax, items):
210
+ # items: list of (color, label)
211
+ x, y = 0.05, 0.05
212
+ 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
+
216
+ def draw_visual(kind: str, detail: str = "HD") -> np.ndarray:
217
+ """
218
+ detail: 'Basic' or 'HD'
219
+ """
220
+ # Larger canvas for HD; use antialiasing and facecolors
221
+ 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 views
228
+ 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
+ # Axon
234
+ ax.add_line(plt.Line2D([0.06, 0.38], [0.5, 0.5], color=PALETTE["membrane"], linewidth=5))
235
+ # Vesicles at terminal
236
+ 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 membrane
239
+ 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 receptor
248
+ 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 influx
259
+ 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 arrow
275
+ _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 -> cytosol
282
+ 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 cable
289
+ 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 troponin
294
+ 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 binding
305
+ 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 SR
314
+ 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 array
321
  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) ==================
330
+ def 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
+ "", # feedback
340
  i, # state
341
+ i)
342
 
343
+ def 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 -1
 
353
  else:
354
  i = 0
355
  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, i
358
 
359
+ def 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, p
362
 
363
+ # ================== Failure-Point ==================
364
  def failure_check(fails, guess):
365
  failed_idx = sorted([NODES.index(f) for f in (fails or [])]) if fails else []
366
  lines = []
 
378
  fb = "No failures toggled."
379
  return "```\n" + "\n".join(lines) + "\n```", fb
380
 
381
+ # ================== Sandbox ==================
382
  def 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)
 
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 ==================
405
  def chain_add(chain, pick):
406
  import json
407
  chain = json.loads(chain)
 
437
  gr.update(choices=remaining, value=remaining[0] if remaining else None, interactive=bool(remaining)),
438
  " → ".join(chain))
439
 
440
+ # ================== Fatigue Lab ==================
441
  def 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):
 
446
  atp[i] = np.clip(atp[i-1] + dt*(prod - cons), 0, 1.2)
447
  rigor[i] = atp[i] < 0.1
448
  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 ==================
457
  CORE_CONCEPTS = {
458
  "Flow down gradients": ["gradient","concentration","moves from high to low","diffuse","diffusion"],
459
  "Cell-to-cell communication": ["neurotransmitter","acetylcholine","receptor","synapse","binds"],
 
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 json
476
  return np.array(img), json.dumps(counts, indent=2) + "\n\n" + note
477
 
478
+ # ================== UI ==================
479
  with 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 Trainer
487
  with gr.Tab("Step Trainer"):
 
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-Point
508
  with gr.Tab("Failure-Point"):
 
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)