arka2696 commited on
Commit
477bdf9
·
verified ·
1 Parent(s): 9ce1198

Create plate_map_app.py

Browse files
Files changed (1) hide show
  1. plate_map_app.py +489 -0
plate_map_app.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # plate_map_app.py
2
+ # Advanced Plate Map Designer — fixed-width concentration bars + per-well colors + compact legends
3
+ import io
4
+ from typing import List, Optional, Dict
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import streamlit as st
9
+ import matplotlib.pyplot as plt
10
+ from matplotlib.patches import Circle, Rectangle
11
+
12
+ ROW_LETTERS = "ABCDEFGHIJKLMNOP"
13
+
14
+ PLATE_SPECS = {
15
+ "96-well (8x12)": {"rows": 8, "cols": 12},
16
+ "24-well (4x6)": {"rows": 4, "cols": 6},
17
+ }
18
+
19
+ DEFAULT_COLORMAPS = {"concentration": "magma", "seeding_density": "viridis"}
20
+
21
+ # ----------------------------- Helpers -----------------------------
22
+
23
+ def well_id(r: int, c: int) -> str:
24
+ return f"{ROW_LETTERS[r-1]}{c}"
25
+
26
+ def parse_well_token(token: str) -> List[str]:
27
+ token = token.strip().upper()
28
+ if token in ("ALL", "*"):
29
+ return ["ALL"]
30
+ token = token.replace("-", ":")
31
+ if ":" in token:
32
+ s, e = token.split(":")
33
+ srow, scol = s[0], int(s[1:])
34
+ erow, ecol = e[0], int(e[1:])
35
+ r0, r1 = ROW_LETTERS.index(srow)+1, ROW_LETTERS.index(erow)+1
36
+ out = []
37
+ for r in range(min(r0, r1), max(r0, r1)+1):
38
+ for c in range(min(scol, ecol), max(scol, ecol)+1):
39
+ out.append(f"{ROW_LETTERS[r-1]}{c}")
40
+ return out
41
+ return [token]
42
+
43
+ def expand_well_ranges(rng: str) -> List[str]:
44
+ if not rng: return []
45
+ out, seen = [], set()
46
+ for t in [t for t in rng.replace(";", ",").split(",") if t.strip()]:
47
+ for w in parse_well_token(t):
48
+ if w not in seen:
49
+ out.append(w); seen.add(w)
50
+ return out
51
+
52
+ def empty_plate_df(rows: int, cols: int) -> pd.DataFrame:
53
+ rows_list = []
54
+ for r in range(1, rows+1):
55
+ for c in range(1, cols+1):
56
+ rows_list.append({
57
+ "well": well_id(r, c), "row": ROW_LETTERS[r-1], "col": c,
58
+ "cell_line": "", "cell_color": "", "seeding_density": np.nan,
59
+ "compound": "", "compound_color": "",
60
+ "concentration": np.nan, "conc_units": "",
61
+ "conc_bar_color": "", # per-well concentration bar color
62
+ "vehicle": "", "control_type": "",
63
+ "timepoint": "", "replicate": "", "group_id": "", "notes": ""
64
+ })
65
+ return pd.DataFrame(rows_list)
66
+
67
+ def serial_values(start: float, factor: float, n: int, direction="decreasing"):
68
+ vals = [start]
69
+ for _ in range(n-1):
70
+ vals.append(vals[-1]/factor if direction=="decreasing" else vals[-1]*factor)
71
+ return vals
72
+
73
+ def load_uploaded_csv(uploaded) -> Optional[pd.DataFrame]:
74
+ try: return pd.read_csv(uploaded)
75
+ except Exception:
76
+ uploaded.seek(0)
77
+ try: return pd.read_excel(uploaded)
78
+ except Exception: return None
79
+
80
+ def build_compound_color_map(df: pd.DataFrame) -> Dict[str, str]:
81
+ comps = [s for s in (str(x).strip() for x in df["compound"].dropna()) if s]
82
+ comps = list(dict.fromkeys(comps))
83
+ palette = plt.get_cmap("tab20").colors
84
+ mapping = {}
85
+ for _, r in df.iterrows():
86
+ comp = str(r["compound"]).strip()
87
+ c = str(r.get("compound_color", "")).strip()
88
+ if comp and c: mapping[comp] = c
89
+ import matplotlib as mpl
90
+ k = 0
91
+ for comp in comps:
92
+ if comp not in mapping:
93
+ mapping[comp] = mpl.colors.to_hex(palette[k % len(palette)]); k += 1
94
+ return mapping
95
+
96
+ def build_celltype_color_map(df: pd.DataFrame) -> Dict[str, str]:
97
+ lines = [s for s in (str(x).strip() for x in df["cell_line"].dropna()) if s]
98
+ lines = list(dict.fromkeys(lines))
99
+ palette = plt.get_cmap("tab10").colors
100
+ mapping = {}
101
+ for _, r in df.iterrows():
102
+ line = str(r["cell_line"]).strip()
103
+ c = str(r.get("cell_color", "")).strip()
104
+ if line and c and line not in mapping: mapping[line] = c
105
+ import matplotlib as mpl
106
+ k = 0
107
+ for line in lines:
108
+ if line not in mapping:
109
+ mapping[line] = mpl.colors.to_hex(palette[k % len(palette)]); k += 1
110
+ return mapping
111
+
112
+ # ----------------------------- Drawing -----------------------------
113
+
114
+ def draw_plate_figure(
115
+ df: pd.DataFrame,
116
+ rows: int,
117
+ cols: int,
118
+ color_by: str,
119
+ second_label: str = "concentration",
120
+ show_legend: bool = True,
121
+ edge_highlight: bool = True,
122
+ figsize_scale: float = 1.0,
123
+ dpi: int = 300,
124
+ text_fontsize: int = 8,
125
+ conc_mode: str = "text", # "text" (above wells) | "bar" | "none"
126
+ conc_bar_color_global: str = "#4d4d4d", # fallback for per-well bar color
127
+ ):
128
+ cmap_name = DEFAULT_COLORMAPS.get(color_by, "viridis")
129
+ numeric_fields = {"concentration", "seeding_density"}
130
+
131
+ fig_w = max(6.0, cols * 0.55) * figsize_scale
132
+ fig_h = max(5.5, rows * 0.7) * figsize_scale
133
+ fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=dpi)
134
+ ax.set_aspect("equal"); ax.axis("off")
135
+
136
+ panel = Rectangle((0, 0), cols + 2, rows + 2, linewidth=0.6, edgecolor="#444", facecolor="#FAFAFA")
137
+ ax.add_patch(panel)
138
+
139
+ x0, y0 = 1, 1
140
+ radius = 0.35
141
+
142
+ compound_map = build_compound_color_map(df)
143
+ celltype_map = build_celltype_color_map(df)
144
+
145
+ face_colors, scalar_norm, categorical_palette = {}, None, {}
146
+
147
+ series = df[color_by] if color_by in df.columns else pd.Series([""] * len(df))
148
+ if color_by in numeric_fields:
149
+ vals = pd.to_numeric(series, errors="coerce")
150
+ finite = vals.replace([np.inf, -np.inf], np.nan).dropna()
151
+ vmin = finite.min() if len(finite) else 0.0
152
+ vmax = finite.max() if len(finite) else 1.0
153
+ vmin, vmax = (vmin, vmax) if np.isfinite([vmin, vmax]).all() and vmin != vmax else (0.0, 1.0)
154
+ import matplotlib as mpl
155
+ cmap = plt.get_cmap(cmap_name)
156
+ scalar_norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
157
+ for i, w in df["well"].items():
158
+ val = pd.to_numeric(df.loc[i, color_by], errors="coerce")
159
+ face_colors[w] = "#FFFFFF" if pd.isna(val) else mpl.colors.to_hex(cmap(scalar_norm(val)))
160
+ else:
161
+ if color_by == "compound":
162
+ for _, r in df.iterrows():
163
+ comp = str(r["compound"]).strip(); w = r["well"]
164
+ face_colors[w] = compound_map.get(comp, "#FFFFFF") if comp else "#FFFFFF"
165
+ categorical_palette = compound_map.copy()
166
+ else:
167
+ categories = sorted([c for c in series.astype(str).unique() if c])
168
+ if categories:
169
+ palette = plt.get_cmap("tab20").colors
170
+ for i, cat in enumerate(categories):
171
+ import matplotlib as mpl
172
+ categorical_palette[cat] = mpl.colors.to_hex(palette[i % len(palette)])
173
+ for i, w in df["well"].items():
174
+ key = str(df.loc[i, color_by]) if color_by in df.columns else ""
175
+ face_colors[w] = categorical_palette.get(key, "#FFFFFF") if key else "#FFFFFF"
176
+
177
+ # Units for concentration labels
178
+ units = df["conc_units"].dropna()
179
+ conc_unit = str(units.iloc[0]) if len(units) else ""
180
+
181
+ # ---- Draw wells
182
+ for _, row in df.iterrows():
183
+ r_idx = ROW_LETTERS.index(row["row"]) + 1
184
+ c_idx = int(row["col"])
185
+ cx = x0 + c_idx - 0.5
186
+ cy = y0 + (rows - r_idx + 1) - 0.5
187
+ fill = face_colors.get(row["well"], "#FFFFFF")
188
+
189
+ edge_color = "#333333"; lw = 1.0
190
+ if isinstance(row.get("control_type", ""), str) and row["control_type"]:
191
+ edge_color = "#000000"; lw = 2.0
192
+
193
+ if edge_highlight and (r_idx in (1, rows) or c_idx in (1, cols)):
194
+ ax.add_patch(Circle((cx, cy), radius*1.15, linewidth=0, facecolor="#EFEFEF", alpha=0.6, zorder=0))
195
+
196
+ ax.add_patch(Circle((cx, cy), radius=radius, linewidth=lw, edgecolor=edge_color, facecolor=fill, zorder=3))
197
+
198
+ # cell type inner dot
199
+ cell_col = str(row.get("cell_color", "")).strip()
200
+ if cell_col:
201
+ ax.add_patch(Circle((cx, cy), radius=radius*0.28, linewidth=0.6, edgecolor="#333333",
202
+ facecolor=cell_col, zorder=4))
203
+
204
+ # concentration: text (above) OR fixed-width bar (below; no text)
205
+ if conc_mode == "text":
206
+ val = row.get("concentration", np.nan)
207
+ if pd.notna(val):
208
+ try: txt = f"{float(val):.3g}{conc_unit if conc_unit else ''}"
209
+ except Exception: txt = str(val)
210
+ ax.text(cx, cy + radius + 0.14, txt, ha="center", va="bottom",
211
+ fontsize=text_fontsize, zorder=6)
212
+ elif conc_mode == "bar":
213
+ val = pd.to_numeric(row.get("concentration", np.nan), errors="coerce")
214
+ if not pd.isna(val):
215
+ # fixed width bar (same for all wells)
216
+ bar_w = 0.9
217
+ bar_h = 0.09
218
+ x_left = cx - bar_w/2
219
+ y_bottom = cy - radius - 0.18
220
+ bw_color = str(row.get("conc_bar_color", "")).strip() or conc_bar_color_global
221
+ ax.add_patch(Rectangle((x_left, y_bottom), bar_w, bar_h, facecolor=bw_color,
222
+ edgecolor="#333333", linewidth=0.6, zorder=6))
223
+
224
+ # other overlay in center if not using concentration text
225
+ if conc_mode != "text" and second_label and second_label not in ("", "concentration"):
226
+ val = row.get(second_label, "")
227
+ if pd.notna(val) and str(val):
228
+ ax.text(cx, cy, str(val), ha="center", va="center", fontsize=text_fontsize, zorder=5)
229
+
230
+ # row/col labels
231
+ for c in range(1, cols+1):
232
+ ax.text(x0 + c - 0.5, y0 + rows + 0.22, str(c), ha="center", va="bottom", fontsize=10, color="#333333")
233
+ for r in range(1, rows+1):
234
+ ax.text(x0 - 0.2, y0 + (rows - r + 1) - 0.5, ROW_LETTERS[r-1], ha="right", va="center", fontsize=10, color="#333333")
235
+
236
+ # ---- Legends (right)
237
+ if show_legend:
238
+ # Cell type
239
+ if celltype_map:
240
+ ax_c = fig.add_axes([0.86, 0.72, 0.12, 0.22]); ax_c.axis("off")
241
+ ax_c.text(0.0, 1.05, "Cell type", fontsize=11, fontweight="bold", transform=ax_c.transAxes)
242
+ y = 0.92; dy = 0.10
243
+ for line, col in list(celltype_map.items())[:25]:
244
+ ax_c.add_patch(Circle((0.08, y-0.03), radius=0.03, transform=ax_c.transAxes,
245
+ facecolor=col, edgecolor="#333", linewidth=0.6, clip_on=False))
246
+ ax_c.text(0.18, y-0.03, line, fontsize=9, va="center", transform=ax_c.transAxes)
247
+ y -= dy
248
+
249
+ # Treatment
250
+ if compound_map:
251
+ ax_t = fig.add_axes([0.86, 0.44, 0.12, 0.22]); ax_t.axis("off")
252
+ ax_t.text(0.0, 1.05, "Treatment", fontsize=11, fontweight="bold", transform=ax_t.transAxes)
253
+ y = 0.92; dy = 0.10
254
+ for comp, col in list(compound_map.items())[:20]:
255
+ ax_t.add_patch(Rectangle((0.0, y-0.05), 0.25, 0.04, transform=ax_t.transAxes,
256
+ facecolor=col, edgecolor="#333", linewidth=0.6, clip_on=False))
257
+ ax_t.text(0.30, y-0.035, comp, fontsize=9, va="center", transform=ax_t.transAxes)
258
+ y -= dy
259
+
260
+ # Concentration legend — equal-length bars, distinct colors, label to the right
261
+ if conc_mode == "bar":
262
+ conc_vals = pd.to_numeric(df["concentration"], errors="coerce").replace([np.inf, -np.inf], np.nan).dropna()
263
+ if len(conc_vals):
264
+ uniq_vals = sorted({float(v) for v in conc_vals.tolist()}, reverse=True)[:12]
265
+ pal = plt.get_cmap("tab20").colors
266
+ legend_colors: Dict[float, str] = {}
267
+ idx = 0
268
+ for v in uniq_vals:
269
+ # try to use any per-well bar color used for that exact value
270
+ rows_v = df[pd.to_numeric(df["concentration"], errors="coerce")==v]
271
+ col = ""
272
+ for _, rr in rows_v.iterrows():
273
+ cbc = str(rr.get("conc_bar_color","")).strip()
274
+ if cbc: col = cbc; break
275
+ if not col:
276
+ import matplotlib as mpl
277
+ col = mpl.colors.to_hex(pal[idx % len(pal)]); idx += 1
278
+ legend_colors[v] = col
279
+
280
+ ax_b = fig.add_axes([0.86, 0.18, 0.12, 0.22]); ax_b.axis("off")
281
+ ax_b.text(0.0, 1.05, "Concentration", fontsize=11, fontweight="bold", transform=ax_b.transAxes)
282
+ y = 0.88; dy = 0.11
283
+ for v in uniq_vals:
284
+ color = legend_colors[v]
285
+ bar_w = 0.70 # fixed legend bar width
286
+ bar_h = 0.10
287
+ ax_b.add_patch(Rectangle((0.0, y-bar_h/2), bar_w, bar_h, transform=ax_b.transAxes,
288
+ facecolor=color, edgecolor="#333", linewidth=0.6))
289
+ label = f" – {v:.3g}{conc_unit if conc_unit else ''}"
290
+ ax_b.text(0.74, y, label, fontsize=9, va="center", ha="left", transform=ax_b.transAxes)
291
+ y -= dy
292
+
293
+ ax.set_xlim(0, cols + 2); ax.set_ylim(0, rows + 2)
294
+ ax.set_title("Plate Map", fontsize=22, pad=14)
295
+ fig.tight_layout(rect=[0.05, 0.05, 0.84, 0.95]) # extra right margin
296
+ return fig, ax
297
+
298
+ # ----------------------------- UI -----------------------------
299
+ st.set_page_config(page_title="Advanced Plate Map Designer", layout="wide")
300
+ st.title("Advanced Plate Map Designer")
301
+ st.caption("Design detailed 24- and 96-well plate maps, encode multiple experimental factors, and export high-quality figures and tables.")
302
+
303
+ with st.sidebar:
304
+ st.header("Plate Settings")
305
+ plate_choice = st.selectbox("Plate type", list(PLATE_SPECS.keys()), index=0)
306
+ rows = PLATE_SPECS[plate_choice]["rows"]; cols = PLATE_SPECS[plate_choice]["cols"]
307
+ if "plate_df" not in st.session_state or st.session_state.get("shape", (0,0)) != (rows, cols):
308
+ st.session_state["plate_df"] = empty_plate_df(rows, cols)
309
+ st.session_state["shape"] = (rows, cols)
310
+
311
+ st.divider(); st.subheader("Global Encodings")
312
+ color_by = st.selectbox("Color wells by", ["compound","cell_line","control_type","concentration","seeding_density"], 0)
313
+ second_label = st.selectbox("Text overlay", ["concentration","seeding_density","compound","cell_line","replicate","group_id","notes","none"], 0)
314
+ if second_label == "none": second_label = ""
315
+ text_fontsize = st.slider("Text overlay font size", 5, 18, 8)
316
+ conc_mode = st.selectbox("Concentration display", ["text","bar","none"], 1)
317
+ conc_bar_color_global = "#4d4d4d"
318
+ if conc_mode == "bar":
319
+ conc_bar_color_global = st.color_picker("Default bar color (fallback)", value="#4d4d4d")
320
+ show_legend = st.checkbox("Show legends", True)
321
+ edge_highlight = st.checkbox("Highlight edge wells", True)
322
+
323
+ st.divider(); st.subheader("Export")
324
+ export_dpi = st.slider("Figure DPI", 150, 1200, 600, step=50)
325
+ fig_scale = st.slider("Figure size scale", 0.6, 2.5, 1.0, step=0.1)
326
+
327
+ col_left, col_right = st.columns([0.55, 0.45])
328
+
329
+ with col_left:
330
+ st.subheader("Plate Map")
331
+ fig, ax = draw_plate_figure(
332
+ st.session_state["plate_df"], rows, cols,
333
+ color_by=color_by, second_label=second_label,
334
+ show_legend=show_legend, edge_highlight=edge_highlight,
335
+ figsize_scale=fig_scale, dpi=export_dpi,
336
+ text_fontsize=text_fontsize,
337
+ conc_mode=conc_mode,
338
+ conc_bar_color_global=conc_bar_color_global
339
+ )
340
+ buf = io.BytesIO(); fig.savefig(buf, format="png", dpi=export_dpi, bbox_inches="tight")
341
+ st.image(buf.getvalue(), caption="Rendered plate")
342
+ st.download_button("⬇️ Download PNG", data=buf.getvalue(), file_name="plate_map.png", mime="image/png")
343
+ buf_svg = io.BytesIO(); fig.savefig(buf_svg, format="svg", bbox_inches="tight")
344
+ st.download_button("⬇️ Download SVG", data=buf_svg.getvalue(), file_name="plate_map.svg", mime="image/svg+xml")
345
+
346
+ with col_right:
347
+ st.subheader("Assignments & Tools")
348
+
349
+ # ---------- Quick Assign ----------
350
+ with st.expander("Quick Assign by Well Range", expanded=True):
351
+ rng = st.text_input("Well range(s) (e.g., A1:A6, B1:B3, C5)", value="")
352
+ c1, c2 = st.columns(2)
353
+ with c1:
354
+ cell_line = st.text_input("Cell line / type", value="")
355
+ cell_color = st.color_picker("Cell color (inner dot)", value="#FFFFFF")
356
+ seeding = st.number_input("Seeding density (cells/well)", min_value=0, value=0, step=100)
357
+ replicate = st.text_input("Replicate ID", value="")
358
+ with c2:
359
+ compound = st.text_input("Compound", value="")
360
+ comp_color = st.color_picker("Compound color (used when 'color by' = compound)", value="#1f77b4")
361
+ concentration = st.number_input("Concentration", min_value=0.0, value=0.0, step=0.1, format="%.4f")
362
+ conc_units = st.text_input("Conc Units (e.g., nM, μM, mg/mL)", value="")
363
+ c3, c4 = st.columns(2)
364
+ with c3:
365
+ control_type = st.selectbox("Control type", ["","Negative","Positive","Vehicle","Blank"], 0)
366
+ timepoint = st.text_input("Timepoint (e.g., 24h)", value="")
367
+ with c4:
368
+ group_id = st.text_input("Group / Condition ID", value="")
369
+ vehicle = st.text_input("Vehicle (optional, e.g., DMSO)", value="")
370
+ conc_bar_color_assign = st.color_picker("Concentration bar color (for these wells)", value="#4d4d4d")
371
+ notes = st.text_area("Notes", value="", height=80)
372
+
373
+ if st.button("Apply to selected wells"):
374
+ wells = expand_well_ranges(rng)
375
+ df = st.session_state["plate_df"]
376
+ if "ALL" in wells: wells = df["well"].tolist()
377
+ updates = {
378
+ "cell_line": cell_line,
379
+ "cell_color": cell_color if cell_color != "#FFFFFF" else "",
380
+ "seeding_density": np.nan if seeding == 0 else seeding,
381
+ "compound": compound,
382
+ "compound_color": comp_color if comp_color else "",
383
+ "concentration": np.nan if concentration == 0 else concentration,
384
+ "conc_units": conc_units,
385
+ "conc_bar_color": conc_bar_color_assign if conc_mode=="bar" else "",
386
+ "control_type": control_type,
387
+ "timepoint": timepoint,
388
+ "replicate": replicate,
389
+ "group_id": group_id,
390
+ "vehicle": vehicle,
391
+ "notes": notes,
392
+ }
393
+ for w in wells:
394
+ st.session_state["plate_df"].loc[
395
+ st.session_state["plate_df"]["well"] == w, list(updates.keys())
396
+ ] = list(updates.values())
397
+ st.success(f"Updated {len(wells)} well(s).")
398
+
399
+ # ---------- Serial Dilution ----------
400
+ with st.expander("Serial Dilution Wizard", expanded=False):
401
+ start_well = st.text_input("Start well (e.g., B2)", value="")
402
+ steps = st.number_input("Number of steps", 2, 24, 6)
403
+ start_conc = st.number_input("Starting concentration", 0.0, value=10.0, step=0.1, format="%.4f")
404
+ factor = st.number_input("Dilution factor", 1.0, value=2.0, step=0.1, format="%.3f")
405
+ direction = st.selectbox("Direction", ["across row →", "down column ↓"], 0)
406
+ units = st.text_input("Units (e.g., μM, mg/mL)", value="μM")
407
+ compound_name = st.text_input("Compound (optional)", value="")
408
+ comp_color2 = st.color_picker("Compound color", value="#FF7F0E")
409
+ conc_bar_color2 = st.color_picker("Concentration bar color (for these steps)", value="#4d4d4d")
410
+ replicate2 = st.text_input("Replicate ID (optional)", value="")
411
+ if st.button("Fill serial dilution"):
412
+ try:
413
+ sr = ROW_LETTERS.index(start_well[0].upper()) + 1
414
+ sc = int(start_well[1:])
415
+ wells = []
416
+ for k in range(steps):
417
+ r = sr if "row" in direction else sr + k if "↓" in direction or "down" in direction else sr
418
+ c = sc + k if "→" in direction or "across" in direction else sc
419
+ if 1 <= r <= rows and 1 <= c <= cols: wells.append(f"{ROW_LETTERS[r-1]}{c}")
420
+ vals = serial_values(start_conc, factor, len(wells), "decreasing")
421
+ for w, val in zip(wells, vals):
422
+ st.session_state["plate_df"].loc[
423
+ st.session_state["plate_df"]["well"] == w,
424
+ ["compound","compound_color","concentration","conc_units","replicate","conc_bar_color"]
425
+ ] = [compound_name, comp_color2, val, units, replicate2, conc_bar_color2]
426
+ st.success(f"Applied serial dilution to {len(wells)} well(s).")
427
+ except Exception as e:
428
+ st.error(f"Could not parse start well. Error: {e}")
429
+
430
+ # ---------- Seeding Gradient ----------
431
+ with st.expander("Cell Seeding Gradient", expanded=False):
432
+ start_well2 = st.text_input("Start well (e.g., C3)", key="seed_start")
433
+ steps2 = st.number_input("Steps", 2, 24, 8, key="seed_steps")
434
+ start_cells = st.number_input("Starting cells/well", min_value=0, value=20000, step=500, key="seed_num")
435
+ factor2 = st.number_input("Multiplicative factor", 0.01, value=0.5, step=0.01, format="%.2f", key="seed_factor")
436
+ direction2 = st.selectbox("Direction", ["across row →", "down column ↓"], 0, key="seed_dir")
437
+ cell_line2 = st.text_input("Cell line", value="HepG2", key="seed_cellline")
438
+ cell_color2 = st.color_picker("Cell color", value="#2ca02c", key="seed_color")
439
+ if st.button("Fill seeding gradient"):
440
+ try:
441
+ sr = ROW_LETTERS.index(start_well2[0].upper()) + 1; sc = int(start_well2[1:])
442
+ wells = []
443
+ for k in range(steps2):
444
+ r = sr if "row" in direction2 else sr + k if "↓" in direction2 or "down" in direction2 else sr
445
+ c = sc + k if "→" in direction2 or "across" in direction2 else sc
446
+ if 1 <= r <= rows and 1 <= c <= cols: wells.append(f"{ROW_LETTERS[r-1]}{c}")
447
+ vals = [int(round(start_cells * (factor2 ** i))) for i in range(len(wells))]
448
+ for w, cells in zip(wells, vals):
449
+ st.session_state["plate_df"].loc[
450
+ st.session_state["plate_df"]["well"] == w,
451
+ ["cell_line","cell_color","seeding_density"]
452
+ ] = [cell_line2, cell_color2, cells]
453
+ st.success(f"Applied seeding gradient to {len(wells)} well(s).")
454
+ except Exception as e:
455
+ st.error(f"Could not parse start well. Error: {e}")
456
+
457
+ # ---------- Utilities / I/O ----------
458
+ with st.expander("🧰 Utilities", expanded=False):
459
+ edge_type = st.selectbox("Mark edge wells as", ["","Buffer","Blank","Vehicle"], 0)
460
+ if st.button("Apply to edge wells"):
461
+ if edge_type:
462
+ df = st.session_state["plate_df"]
463
+ mask = (df["row"].isin([ROW_LETTERS[0], ROW_LETTERS[rows-1]])) | (df["col"].isin([1, cols]))
464
+ st.session_state["plate_df"].loc[mask, "control_type"] = "Blank" if edge_type in ("Blank","Buffer") else "Vehicle"
465
+ st.success("Edge wells updated.")
466
+ if st.button("Clear all assignments"):
467
+ st.session_state["plate_df"] = empty_plate_df(rows, cols); st.success("Cleared.")
468
+
469
+ with st.expander("Import / Export Table", expanded=False):
470
+ uploaded = st.file_uploader("Upload CSV/Excel (must include a 'well' column)", type=["csv","xlsx"])
471
+ if uploaded is not None:
472
+ df_up = load_uploaded_csv(uploaded)
473
+ if df_up is None or "well" not in df_up.columns:
474
+ st.error("Failed to load file or missing 'well' column.")
475
+ else:
476
+ df = st.session_state["plate_df"].set_index("well")
477
+ for col in df_up.columns:
478
+ if col not in df.columns: df[col] = np.nan
479
+ # align columns present in the upload only
480
+ df.loc[df_up["well"], df_up.columns] = df_up.set_index("well")
481
+ st.session_state["plate_df"] = df.reset_index(); st.success(f"Imported {len(df_up)} rows.")
482
+
483
+ out_csv = st.session_state["plate_df"].to_csv(index=False).encode("utf-8")
484
+ st.download_button("⬇️ Download CSV", data=out_csv, file_name="plate_map.csv", mime="text/csv")
485
+ toexcel = io.BytesIO()
486
+ with pd.ExcelWriter(toexcel, engine="openpyxl") as writer:
487
+ st.session_state["plate_df"].to_excel(writer, index=False, sheet_name="plate")
488
+ st.download_button("⬇️ Download Excel", data=toexcel.getvalue(), file_name="plate_map.xlsx",
489
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")