Spaces:
Build error
Build error
Create plate_map_app.py
Browse files- 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")
|