wenjun99 commited on
Commit
16d2bff
·
verified ·
1 Parent(s): aea4424

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +284 -142
app.py CHANGED
@@ -174,30 +174,139 @@ with tab2:
174
  # --------------------------------------------------
175
 
176
  with tab3:
 
 
 
 
 
177
  st.header("🧪 Pipetting Command Generator")
178
  st.markdown("""
179
  Upload your sample file (Excel, CSV, or TXT) containing binary mutation data.
180
  The app will:
181
  - Auto-detect or create `Sample`, `Position#`, `Total edited`, and `Volume per "1"` columns
182
- - Generate pipetting commands dynamically for any number of positions
183
- - Use one source well per source index and reroute to the next source if a well is full
184
- - Display and allow CSV download for both commands and source volume summaries
185
  """)
186
 
187
  uploaded = st.file_uploader("📤 Upload data file", type=["xlsx", "csv", "txt"])
188
- max_per_well_ul = st.number_input("Maximum volume per source well (µL)", min_value=10.0, max_value=1000.0, value=160.0, step=10.0)
189
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  if uploaded is not None:
191
  try:
192
  # --- Load file ---
193
  if uploaded.name.endswith(".xlsx"):
194
- sheet_names = pd.ExcelFile(uploaded).sheet_names
195
- sheet_choice = st.selectbox("Select sheet:", sheet_names)
196
- df = pd.read_excel(uploaded, sheet_name=sheet_choice)
197
  elif uploaded.name.endswith(".csv"):
198
  df = pd.read_csv(uploaded)
199
- else: # TXT
200
- df = pd.read_csv(uploaded, sep="\t")
 
 
 
201
 
202
  st.success(f"✅ Loaded file with {len(df)} rows and {len(df.columns)} columns")
203
 
@@ -220,159 +329,192 @@ with tab3:
220
  position_cols = candidate_cols
221
  st.info(f"Position columns inferred automatically: {len(position_cols)} detected.")
222
 
 
 
 
223
  # --- Ensure Total edited ---
224
  if "Total edited" not in df.columns:
225
- df["Total edited"] = df[position_cols].apply(pd.to_numeric, errors="coerce").fillna(0).sum(axis=1).astype(int)
226
  st.info("`Total edited` column missing — calculated automatically as sum of 1s per row.")
227
 
228
  # --- Ensure Volume per "1" ---
229
  vol_candidates = [c for c in df.columns if "volume per" in c.lower()]
230
  if not vol_candidates:
231
  df['Volume per "1"'] = 64 / df["Total edited"].replace(0, np.nan)
232
- df['Volume per "1"'] = df['Volume per "1"'].fillna(0)
233
  st.info('`Volume per "1"` column missing — calculated automatically as 64 / Total edited.')
234
  volume_col = 'Volume per "1"'
235
  else:
236
  volume_col = vol_candidates[0]
237
 
238
- # --- Constants ---
239
- ROWS_96 = ["A", "B", "C", "D", "E", "F", "G", "H"]
240
- COLS_96 = list(range(1, 13))
241
- num_positions = len(position_cols)
242
-
243
- def well_name(row_letter, col_number):
244
- return f"{row_letter}{col_number}"
245
-
246
- def sample_index_to_plate_and_well(sample_idx):
247
- plate_num = ((sample_idx - 1) // 96) + 1
248
- within_plate = (sample_idx - 1) % 96
249
- row_idx = within_plate // 12
250
- col_idx = within_plate % 12
251
- return plate_num, well_name(ROWS_96[row_idx], COLS_96[col_idx])
252
-
253
- # one well per source index
254
- def source_index_to_well(source_idx):
255
- plate_num = ((source_idx - 1) // 96) + 1
256
- within_plate = (source_idx - 1) % 96
257
- row_idx = within_plate // 12
258
- col_idx = within_plate % 12
259
- return plate_num, well_name(ROWS_96[row_idx], COLS_96[col_idx])
260
-
261
- def pick_tool(volume_ul):
262
- return "TS_10" if volume_ul <= 10.0 else "TS_50"
263
-
264
- # --- Core logic ---
265
- commands = []
266
- source_volume_totals = {}
267
- per_source_vol = {i: 0.0 for i in range(1, num_positions + 1)}
268
-
269
- for _, row in df.iterrows():
270
- sample_id = int(row["Sample"])
271
- vol_per_one = float(row[volume_col])
272
- if vol_per_one > max_per_well_ul:
273
- st.warning(f"Volume per '1' ({vol_per_one} µL) exceeds per-well cap of {max_per_well_ul} µL for sample {sample_id}.")
274
- dest_plate, dest_well = sample_index_to_plate_and_well(sample_id)
275
- tool = pick_tool(vol_per_one)
276
-
277
- for pos_idx, col in enumerate(position_cols, 1):
278
- val = row[col]
279
- try:
280
- is_one = (float(val) == 1.0)
281
- except:
282
- is_one = str(val).strip() == "1"
283
- if not is_one:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  continue
 
 
 
 
 
 
 
 
 
285
 
286
- src_plate, src_well = source_index_to_well(pos_idx)
287
- current_vol = per_source_vol[pos_idx]
288
-
289
- # reroute if source full
290
- if current_vol + vol_per_one > max_per_well_ul:
291
- rerouted = False
292
- for next_src in range(pos_idx + 1, num_positions + 1):
293
- next_plate, next_well = source_index_to_well(next_src)
294
- if per_source_vol[next_src] + vol_per_one <= max_per_well_ul:
295
- st.warning(f"⚠️ Source {pos_idx} full → rerouted {vol_per_one:.2f} µL to Source {next_src}.")
296
- per_source_vol[next_src] += vol_per_one
297
- source_volume_totals[(next_plate, next_well)] = source_volume_totals.get((next_plate, next_well), 0.0) + vol_per_one
298
- commands.append({
299
- "SourceIdx": f"{pos_idx}→{next_src}",
300
- "Source plate": next_plate,
301
- "Source well": next_well,
302
- "Destination plate": dest_plate,
303
- "Destination well": dest_well,
304
- "Volume": round(vol_per_one, 2),
305
- "Tool": tool,
306
- "Note": "Rerouted due to full source"
307
- })
308
- rerouted = True
309
  break
310
- if not rerouted:
311
- st.error(f"❌ All sources from {pos_idx} onward full. Cannot add {vol_per_one} µL for Sample {sample_id}.")
 
 
 
 
 
312
  st.stop()
313
- continue # skip normal addition
314
-
315
- # normal case
316
- per_source_vol[pos_idx] += vol_per_one
317
- source_volume_totals[(src_plate, src_well)] = source_volume_totals.get((src_plate, src_well), 0.0) + vol_per_one
318
- commands.append({
319
- "SourceIdx": pos_idx,
320
- "Source plate": src_plate,
321
- "Source well": src_well,
322
- "Destination plate": dest_plate,
323
- "Destination well": dest_well,
324
- "Volume": round(vol_per_one, 2),
325
- "Tool": tool
326
- })
327
-
328
- # --- Compile results ---
329
- commands_df = pd.DataFrame(commands)
330
- commands_df = commands_df.sort_values(
331
- by=["Source plate", "Source well", "Destination plate", "Destination well"],
332
- kind="stable"
333
- )
334
 
335
- if "Note" in commands_df.columns:
336
- commands_df = commands_df[["SourceIdx", "Source plate", "Source well",
337
- "Destination plate", "Destination well", "Volume", "Tool", "Note"]]
338
- else:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  commands_df = commands_df[["SourceIdx", "Source plate", "Source well",
340
- "Destination plate", "Destination well", "Volume", "Tool"]]
341
-
342
- # --- Source summary ---
343
- summary_rows = []
344
- for src_idx in range(1, num_positions + 1):
345
- src_plate, src_well = source_index_to_well(src_idx)
346
- total = source_volume_totals.get((src_plate, src_well), 0.0)
347
- summary_rows.append({
348
- "Source": src_idx,
349
- "Source plate": src_plate,
350
- "Source well": src_well,
351
- "Total volume taken (µL)": round(total, 2)
352
- })
353
- summary_df = pd.DataFrame(summary_rows)
354
-
355
- st.success(f"✅ Generated {len(commands_df)} commands across {num_positions} positions.")
356
-
357
- st.markdown("### 💧 Pipetting Commands")
358
- st.dataframe(commands_df, use_container_width=True, height=400)
359
- st.download_button(
360
- "⬇️ Download Commands CSV",
361
- commands_df.to_csv(index=False),
362
- "pipetting_commands.csv",
363
- mime="text/csv"
364
- )
 
 
365
 
366
- st.markdown("### 📊 Source Volume Summary")
367
- st.dataframe(summary_df, use_container_width=True, height=400)
368
- st.download_button(
369
- "⬇️ Download Source Summary CSV",
370
- summary_df.to_csv(index=False),
371
- "source_volume_summary.csv",
372
- mime="text/csv"
373
- )
374
 
375
  except Exception as e:
376
  st.error(f"❌ Error processing file: {e}")
377
  else:
378
- st.info("👆 Upload an Excel/CSV/TXT file to start generating pipetting commands.")
 
 
174
  # --------------------------------------------------
175
 
176
  with tab3:
177
+ import numpy as np
178
+ import pandas as pd
179
+ import re
180
+ from math import ceil
181
+
182
  st.header("🧪 Pipetting Command Generator")
183
  st.markdown("""
184
  Upload your sample file (Excel, CSV, or TXT) containing binary mutation data.
185
  The app will:
186
  - Auto-detect or create `Sample`, `Position#`, `Total edited`, and `Volume per "1"` columns
187
+ - Calculate total demand per input and suggest a **uniform layout width** (consecutive wells per input)
188
+ - **Preview** the layout on a plate map (with tooltips)
189
+ - After confirmation, generate pipetting commands and a source volume summary
190
  """)
191
 
192
  uploaded = st.file_uploader("📤 Upload data file", type=["xlsx", "csv", "txt"])
193
+ max_per_well_ul = st.number_input(
194
+ "Maximum volume per source well (µL)",
195
+ min_value=10.0, max_value=2000.0, value=160.0, step=10.0
196
+ )
197
+
198
+ # ---------- Helpers (plate geometry & viz) ----------
199
+ ROWS_96 = ["A", "B", "C", "D", "E", "F", "G", "H"]
200
+ COLS_96 = list(range(1, 13))
201
+
202
+ def well_name(row_letter, col_number):
203
+ return f"{row_letter}{col_number}"
204
+
205
+ def enumerate_plate_wells():
206
+ """Yield wells A1..A12, B1..B12, ..., H12 for a single plate."""
207
+ for r in ROWS_96:
208
+ for c in COLS_96:
209
+ yield f"{r}{c}"
210
+
211
+ def sample_index_to_plate_and_well(sample_idx: int):
212
+ """Destination mapping: 96-well plates in reading order, extends to multiple plates."""
213
+ plate_num = ((sample_idx - 1) // 96) + 1
214
+ within_plate = (sample_idx - 1) % 96
215
+ row_idx = within_plate // 12
216
+ col_idx = within_plate % 12
217
+ return plate_num, well_name(ROWS_96[row_idx], COLS_96[col_idx])
218
+
219
+ def build_global_wells_list(n_plates: int):
220
+ out = []
221
+ for p in range(1, n_plates + 1):
222
+ for w in enumerate_plate_wells():
223
+ out.append((p, w))
224
+ return out
225
+
226
+ def pick_tool(volume_ul: float) -> str:
227
+ return "TS_10" if volume_ul <= 10.0 else "TS_50"
228
+
229
+ # Color palette (cycled if many inputs)
230
+ PALETTE = [
231
+ "#4F46E5", "#22C55E", "#F59E0B", "#EF4444", "#06B6D4", "#A855F7", "#84CC16", "#F97316",
232
+ "#0EA5E9", "#E11D48", "#10B981", "#7C3AED", "#15803D", "#EA580C", "#2563EB", "#DC2626"
233
+ ]
234
+
235
+ def render_plate_map_html(plates_used, well_to_input, max_wells_per_source, inputs_count):
236
+ """
237
+ Render HTML plates. well_to_input: dict[(plate, well)] = (input_idx, index_within_input_block)
238
+ """
239
+ # Legend HTML
240
+ legend_spans = []
241
+ for i in range(1, inputs_count + 1):
242
+ color = PALETTE[(i-1) % len(PALETTE)]
243
+ legend_spans.append(
244
+ f"<span style='display:inline-block;margin-right:12px'>"
245
+ f"<span style='display:inline-block;width:12px;height:12px;background:{color};border:1px solid #333;margin-right:6px;vertical-align:middle'></span>"
246
+ f"Input {i}</span>"
247
+ )
248
+ legend_html = "<div style='margin:8px 0 16px 0'>" + "".join(legend_spans) + "</div>"
249
+
250
+ # CSS for grid + tooltip (title attribute works too; we use both)
251
+ css = """
252
+ <style>
253
+ .plate { margin: 10px 0 24px 0; }
254
+ .plate-title { font-weight: 600; margin: 4px 0 8px 0; }
255
+ .grid { display: grid; grid-template-columns: 32px repeat(12, 38px); grid-auto-rows: 32px; gap: 4px; }
256
+ .cell { width: 38px; height: 32px; border: 1px solid #DDD; display:flex; align-items:center; justify-content:center; font-size:12px; background:#FAFAFA; position:relative; }
257
+ .head { font-weight:600; background:#F3F4F6; }
258
+ .cell[data-color] { color:#111; }
259
+ .cell .tip { visibility:hidden; opacity:0; transition:opacity 0.15s ease; position:absolute; bottom:100%; transform:translateY(-6px); left:50%; transform:translate(-50%, -6px); background:#111; color:#fff; padding:4px 6px; font-size:11px; border-radius:4px; white-space:nowrap; pointer-events:none; }
260
+ .cell:hover .tip { visibility:visible; opacity:0.95; }
261
+ </style>
262
+ """
263
+
264
+ body = [css, legend_html]
265
+ # Build each plate
266
+ for p in range(1, plates_used + 1):
267
+ body.append(f"<div class='plate'><div class='plate-title'>Plate {p}</div>")
268
+ # header row
269
+ body.append("<div class='grid'>")
270
+ body.append("<div class='cell head'></div>")
271
+ for c in COLS_96:
272
+ body.append(f"<div class='cell head'>{c}</div>")
273
+ # rows
274
+ for r in ROWS_96:
275
+ body.append(f"<div class='cell head'>{r}</div>")
276
+ for c in COLS_96:
277
+ well = f"{r}{c}"
278
+ key = (p, well)
279
+ if key in well_to_input:
280
+ input_idx, within_idx = well_to_input[key]
281
+ color = PALETTE[(input_idx-1) % len(PALETTE)]
282
+ tip = f"Input {input_idx} • P{p}:{well} • Block well {within_idx}/{max_wells_per_source}"
283
+ cell_html = (
284
+ f"<div class='cell' data-color style='background:{color};border-color:#555' title='{tip}'>"
285
+ f"<span class='tip'>{tip}</span>"
286
+ "</div>"
287
+ )
288
+ else:
289
+ cell_html = "<div class='cell'></div>"
290
+ body.append(cell_html)
291
+ body.append("</div></div>") # grid + plate
292
+
293
+ return "".join(body)
294
+
295
+ # ---------- Main flow ----------
296
  if uploaded is not None:
297
  try:
298
  # --- Load file ---
299
  if uploaded.name.endswith(".xlsx"):
300
+ xls = pd.ExcelFile(uploaded)
301
+ sheet_choice = st.selectbox("Select sheet:", xls.sheet_names)
302
+ df = pd.read_excel(xls, sheet_name=sheet_choice)
303
  elif uploaded.name.endswith(".csv"):
304
  df = pd.read_csv(uploaded)
305
+ else: # TXT (tab-delimited fallback)
306
+ try:
307
+ df = pd.read_csv(uploaded, sep="\t")
308
+ except Exception:
309
+ df = pd.read_csv(uploaded)
310
 
311
  st.success(f"✅ Loaded file with {len(df)} rows and {len(df.columns)} columns")
312
 
 
329
  position_cols = candidate_cols
330
  st.info(f"Position columns inferred automatically: {len(position_cols)} detected.")
331
 
332
+ # Normalize Position columns to numeric {0,1}
333
+ df[position_cols] = df[position_cols].apply(pd.to_numeric, errors="coerce").fillna(0).astype(int)
334
+
335
  # --- Ensure Total edited ---
336
  if "Total edited" not in df.columns:
337
+ df["Total edited"] = df[position_cols].sum(axis=1).astype(int)
338
  st.info("`Total edited` column missing — calculated automatically as sum of 1s per row.")
339
 
340
  # --- Ensure Volume per "1" ---
341
  vol_candidates = [c for c in df.columns if "volume per" in c.lower()]
342
  if not vol_candidates:
343
  df['Volume per "1"'] = 64 / df["Total edited"].replace(0, np.nan)
344
+ df['Volume per "1"'] = df['Volume per "1"'].fillna(0) # rows with 0 edits → 0 µL
345
  st.info('`Volume per "1"` column missing — calculated automatically as 64 / Total edited.')
346
  volume_col = 'Volume per "1"'
347
  else:
348
  volume_col = vol_candidates[0]
349
 
350
+ # Safety: per-transfer must not exceed per-well cap
351
+ if df[volume_col].max() > max_per_well_ul:
352
+ st.error(
353
+ f"❌ At least one row has `Volume per \"1\"` greater than the per-well cap ({max_per_well_ul} µL). "
354
+ "Increase the cap or reduce per-transfer volume."
355
+ )
356
+ st.stop()
357
+
358
+ # --- Compute total demand per input ---
359
+ vol_per_one_series = pd.to_numeric(df[volume_col], errors="coerce").fillna(0.0)
360
+ total_volume_per_input = []
361
+ for pos in position_cols:
362
+ mask = df[pos] == 1
363
+ total_vol = float(vol_per_one_series[mask].sum())
364
+ total_volume_per_input.append(total_vol)
365
+
366
+ wells_needed_per_input = [
367
+ int(ceil(tv / max_per_well_ul)) if tv > 0 else 0
368
+ for tv in total_volume_per_input
369
+ ]
370
+ num_inputs = len(position_cols)
371
+ max_wells_per_source = max(wells_needed_per_input) if wells_needed_per_input else 0
372
+
373
+ st.markdown("### 👀 Preview: Suggested Uniform Layout")
374
+ if max_wells_per_source == 0:
375
+ st.info("No edits detected (all inputs require 0 µL). Nothing to allocate.")
376
+ st.stop()
377
+
378
+ st.write(
379
+ f"💡 Suggested layout: **{max_wells_per_source} consecutive wells per input** "
380
+ f"(cap {max_per_well_ul:.0f} µL/well)."
381
+ )
382
+
383
+ # Total wells and plates needed
384
+ total_wells_needed_uniform = num_inputs * max_wells_per_source
385
+ plates_needed = int(ceil(total_wells_needed_uniform / 96)) if total_wells_needed_uniform > 0 else 1
386
+
387
+ # Global wells list long enough to cover allocation
388
+ global_wells = build_global_wells_list(plates_needed) # [(p, 'A1'), ...]
389
+ global_wells = global_wells[:total_wells_needed_uniform] # exact length
390
+
391
+ # Assign blocks of size max_wells_per_source per input in order
392
+ assigned_wells_map = {} # input_idx (1-based) -> list[(plate, well)]
393
+ well_to_input = {} # (plate, well) -> (input_idx, within_block_index 1..max_wells_per_source)
394
+ preview_rows = []
395
+ for i in range(1, num_inputs + 1):
396
+ start = (i - 1) * max_wells_per_source
397
+ end = start + max_wells_per_source
398
+ block = global_wells[start:end]
399
+ assigned_wells_map[i] = block
400
+ for j, (p, w) in enumerate(block, start=1):
401
+ well_to_input[(p, w)] = (i, j)
402
+ # Make a readable block string
403
+ block_str = ", ".join([f"P{p}:{w}" for (p, w) in block])
404
+ preview_rows.append({
405
+ "Input (Position #)": i,
406
+ "Total demand (µL)": round(total_volume_per_input[i-1], 2),
407
+ "Wells needed (actual)": wells_needed_per_input[i-1],
408
+ "Allocated (uniform)": max_wells_per_source,
409
+ "Assigned wells": block_str
410
+ })
411
+
412
+ preview_df = pd.DataFrame(preview_rows)
413
+ st.dataframe(preview_df, use_container_width=True, height=300)
414
+
415
+ # Fancy Plate Map with tooltips
416
+ st.markdown("#### Plate Map (hover cells for details)")
417
+ plate_html = render_plate_map_html(plates_needed, well_to_input, max_wells_per_source, num_inputs)
418
+ st.markdown(plate_html, unsafe_allow_html=True)
419
+
420
+ # --- Generate Commands ---
421
+ st.markdown("### ✅ Generate Pipetting Commands")
422
+ generate = st.button("Generate using this layout")
423
+
424
+ if generate:
425
+ # Track per-input per-well usage (µL)
426
+ per_input_well_cum = {i: [0.0] * max_wells_per_source for i in range(1, num_inputs + 1)}
427
+ commands = []
428
+ source_volume_totals = {} # (plate, well) -> total µL drawn
429
+
430
+ for _, row in df.iterrows():
431
+ sample_id = int(row["Sample"])
432
+ vol_per_one = float(row[volume_col])
433
+ if vol_per_one <= 0:
434
  continue
435
+ dest_plate, dest_well = sample_index_to_plate_and_well(sample_id)
436
+ tool = pick_tool(vol_per_one)
437
+
438
+ for pos_idx, col in enumerate(position_cols, start=1):
439
+ if int(row[col]) != 1:
440
+ continue
441
+
442
+ wells_for_input = assigned_wells_map[pos_idx]
443
+ cum_list = per_input_well_cum[pos_idx]
444
 
445
+ chosen = None
446
+ for j, ((src_plate, src_well), current_vol) in enumerate(zip(wells_for_input, cum_list)):
447
+ if current_vol + vol_per_one <= max_per_well_ul:
448
+ chosen = (j, src_plate, src_well)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
  break
450
+
451
+ if chosen is None:
452
+ # With uniform pre-allocation this shouldn't happen unless extreme rounding / cap too small
453
+ st.error(
454
+ f"Allocation exhausted for Input {pos_idx} while creating commands. "
455
+ "Increase the max volume per well or review per-transfer volume."
456
+ )
457
  st.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
+ j, src_plate, src_well = chosen
460
+ cum_list[j] += vol_per_one
461
+ per_input_well_cum[pos_idx] = cum_list
462
+ source_volume_totals[(src_plate, src_well)] = source_volume_totals.get((src_plate, src_well), 0.0) + vol_per_one
463
+
464
+ commands.append({
465
+ "SourceIdx": pos_idx,
466
+ "Source plate": src_plate,
467
+ "Source well": src_well,
468
+ "Destination plate": dest_plate,
469
+ "Destination well": dest_well,
470
+ "Volume": round(vol_per_one, 2),
471
+ "Tool": tool
472
+ })
473
+
474
+ # Compile results
475
+ commands_df = pd.DataFrame(commands).sort_values(
476
+ by=["Source plate", "Source well", "Destination plate", "Destination well"], kind="stable"
477
+ )
478
  commands_df = commands_df[["SourceIdx", "Source plate", "Source well",
479
+ "Destination plate", "Destination well", "Volume", "Tool"]]
480
+
481
+ # Source summary (include allocated capacity per well)
482
+ summary_rows = []
483
+ for i in range(1, num_inputs + 1):
484
+ for (p, w), used in zip(assigned_wells_map[i], per_input_well_cum[i]):
485
+ total = source_volume_totals.get((p, w), 0.0)
486
+ summary_rows.append({
487
+ "Source": i,
488
+ "Source plate": p,
489
+ "Source well": w,
490
+ "Total volume taken (µL)": round(total, 2),
491
+ "Allocated capacity (µL)": round(max_per_well_ul, 2)
492
+ })
493
+ summary_df = pd.DataFrame(summary_rows)
494
+
495
+ used_plates = max([p for wells in assigned_wells_map.values() for (p, _) in wells]) if assigned_wells_map else 1
496
+ st.success(f" Generated {len(commands_df)} commands across {num_inputs} inputs using {used_plates} plate(s).")
497
+
498
+ st.markdown("### 💧 Pipetting Commands")
499
+ st.dataframe(commands_df, use_container_width=True, height=400)
500
+ st.download_button(
501
+ "⬇️ Download Commands CSV",
502
+ commands_df.to_csv(index=False),
503
+ "pipetting_commands.csv",
504
+ mime="text/csv"
505
+ )
506
 
507
+ st.markdown("### 📊 Source Volume Summary")
508
+ st.dataframe(summary_df, use_container_width=True, height=400)
509
+ st.download_button(
510
+ "⬇️ Download Source Summary CSV",
511
+ summary_df.to_csv(index=False),
512
+ "source_volume_summary.csv",
513
+ mime="text/csv"
514
+ )
515
 
516
  except Exception as e:
517
  st.error(f"❌ Error processing file: {e}")
518
  else:
519
+ st.info("👆 Upload an Excel/CSV/TXT file to start.")
520
+