wenjun99 commited on
Commit
7a94627
·
verified ·
1 Parent(s): 3600019

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -644
app.py DELETED
@@ -1,644 +0,0 @@
1
- import streamlit as st
2
- import pandas as pd
3
- import io
4
- import re
5
- import numpy as np
6
- import openpyxl
7
- import base64
8
-
9
- # =========================
10
- # Streamlit App Setup
11
- # =========================
12
- st.set_page_config(page_title="DNA ↔ Binary Converter", layout="wide")
13
- st.title("DNA ↔ Binary Converter")
14
-
15
- # =========================
16
- # Encoding Schemes
17
- # =========================
18
- ENCODING_OPTIONS = ["Voyager 6-bit", "Base64 (6-bit)", "ASCII (7-bit)", "UTF-8 (8-bit)"]
19
-
20
- BITS_PER_UNIT = {
21
- "Voyager 6-bit": 6,
22
- "Base64 (6-bit)": 6,
23
- "ASCII (7-bit)": 7,
24
- "UTF-8 (8-bit)": 8,
25
- }
26
-
27
- # =========================
28
- # Voyager ASCII 6-bit Table
29
- # =========================
30
- voyager_table = {
31
- i: ch for i, ch in enumerate([
32
- ' ', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I',
33
- 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
34
- 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2',
35
- '3', '4', '5', '6', '7', '8', '9', '.', ',', '(',
36
- ')','+', '-', '*', '/', '=', '$', '!', ':', '%',
37
- '"', '#', '@', "'", '?', '&'
38
- ])
39
- }
40
- reverse_voyager_table = {v: k for k, v in voyager_table.items()}
41
-
42
- B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
43
-
44
- # =========================
45
- # Encoding Functions
46
- # =========================
47
- def encode_to_binary(text: str, scheme: str) -> tuple[list[int], list[str]]:
48
- """
49
- Returns (flat_bits, display_units).
50
- display_units is a list of labels for each chunk (character, byte, or Base64 symbol).
51
- """
52
- if scheme == "Voyager 6-bit":
53
- bits = []
54
- for char in text:
55
- val = reverse_voyager_table.get(char.upper(), 0)
56
- bits.extend([(val >> b) & 1 for b in range(5, -1, -1)])
57
- return bits, list(text.upper())
58
-
59
- elif scheme == "ASCII (7-bit)":
60
- bits = []
61
- for c in text:
62
- val = ord(c) & 0x7F
63
- bits.extend([(val >> b) & 1 for b in range(6, -1, -1)])
64
- return bits, list(text)
65
-
66
- elif scheme == "UTF-8 (8-bit)":
67
- raw = text.encode("utf-8")
68
- bits = []
69
- for byte in raw:
70
- bits.extend([(byte >> b) & 1 for b in range(7, -1, -1)])
71
- # For display: show hex byte value and the character it belongs to
72
- labels = [f"0x{b:02X}" for b in raw]
73
- return bits, labels
74
-
75
- elif scheme == "Base64 (6-bit)":
76
- b64_str = base64.b64encode(text.encode("utf-8")).decode("ascii")
77
- bits = []
78
- clean = b64_str.rstrip("=")
79
- for c in clean:
80
- val = B64_ALPHABET.index(c)
81
- bits.extend([(val >> b) & 1 for b in range(5, -1, -1)])
82
- return bits, list(clean)
83
-
84
- return [], []
85
-
86
-
87
- # =========================
88
- # Decoding Functions
89
- # =========================
90
- def decode_from_binary(bits: list[int], scheme: str) -> str:
91
- if scheme == "Voyager 6-bit":
92
- chars = []
93
- for i in range(0, len(bits), 6):
94
- chunk = bits[i:i + 6]
95
- if len(chunk) < 6:
96
- chunk += [0] * (6 - len(chunk))
97
- val = sum(b << (5 - j) for j, b in enumerate(chunk))
98
- chars.append(voyager_table.get(val, '?'))
99
- return ''.join(chars)
100
-
101
- elif scheme == "ASCII (7-bit)":
102
- chars = []
103
- for i in range(0, len(bits), 7):
104
- chunk = bits[i:i + 7]
105
- if len(chunk) < 7:
106
- chunk += [0] * (7 - len(chunk))
107
- val = sum(b << (6 - j) for j, b in enumerate(chunk))
108
- chars.append(chr(val) if 32 <= val < 127 else '?')
109
- return ''.join(chars)
110
-
111
- elif scheme == "UTF-8 (8-bit)":
112
- byte_list = []
113
- for i in range(0, len(bits), 8):
114
- chunk = bits[i:i + 8]
115
- if len(chunk) < 8:
116
- chunk += [0] * (8 - len(chunk))
117
- val = sum(b << (7 - j) for j, b in enumerate(chunk))
118
- byte_list.append(val)
119
- return bytes(byte_list).decode("utf-8", errors="replace")
120
-
121
- elif scheme == "Base64 (6-bit)":
122
- chars = []
123
- for i in range(0, len(bits), 6):
124
- chunk = bits[i:i + 6]
125
- if len(chunk) < 6:
126
- chunk += [0] * (6 - len(chunk))
127
- val = sum(b << (5 - j) for j, b in enumerate(chunk))
128
- chars.append(B64_ALPHABET[val])
129
- b64_str = ''.join(chars)
130
- # Add Base64 padding
131
- while len(b64_str) % 4 != 0:
132
- b64_str += '='
133
- try:
134
- return base64.b64decode(b64_str).decode("utf-8", errors="replace")
135
- except Exception:
136
- return "[Base64 decode error]"
137
-
138
- return ""
139
-
140
-
141
- # =========================
142
- # Tabs
143
- # =========================
144
- tab1, tab2, tab3 = st.tabs(["Encoding", "Decoding", "Writing"])
145
-
146
- # --------------------------------------------------
147
- # TAB 1: Text → Binary
148
- # --------------------------------------------------
149
- with tab1:
150
- st.markdown("""
151
- Convert any text into binary labels.
152
- Choose an encoding scheme and control how many positions (columns) are grouped per row.
153
- """)
154
-
155
- st.subheader("Step 1 – Choose Encoding & Input Text")
156
-
157
- encoding_scheme = st.selectbox(
158
- "Encoding scheme:",
159
- ENCODING_OPTIONS,
160
- index=0,
161
- key="enc_scheme",
162
- help=(
163
- "**Voyager 6-bit** – Custom 56-character table (A-Z, 0-9, punctuation). 6 bits/char.\n\n"
164
- "**Base64 (6-bit)** – Standard Base64 encoding of UTF-8 bytes. 6 bits/symbol.\n\n"
165
- "**ASCII (7-bit)** – Standard 7-bit ASCII. 7 bits/char.\n\n"
166
- "**UTF-8 (8-bit)** – Full UTF-8 byte encoding. 8 bits/byte. Supports all Unicode."
167
- )
168
- )
169
-
170
- bits_per = BITS_PER_UNIT[encoding_scheme]
171
-
172
- # Show a note for Voyager about supported characters
173
- if encoding_scheme == "Voyager 6-bit":
174
- supported = ''.join(voyager_table[i] for i in range(len(voyager_table)))
175
- st.caption(f"Supported characters ({len(voyager_table)}): `{supported}`")
176
-
177
- user_input = st.text_input("Enter your text:", value="DNA", key="input_text")
178
-
179
- col1, col2 = st.columns([2, 1])
180
- with col1:
181
- group_size = st.slider("Select number of target positions:", min_value=12, max_value=128, value=25)
182
- with col2:
183
- custom_cols = st.number_input("Or enter custom number:", min_value=1, max_value=512, value=group_size)
184
- if custom_cols != group_size:
185
- group_size = custom_cols
186
-
187
- if user_input:
188
- binary_labels, display_units = encode_to_binary(user_input, encoding_scheme)
189
- binary_concat = ''.join(map(str, binary_labels))
190
-
191
- # --- Output 1: Binary Labels per Unit ---
192
- unit_label = "Byte" if encoding_scheme == "UTF-8 (8-bit)" else "Character"
193
- st.markdown(f"### Output 1 – Binary Labels per {unit_label}")
194
- st.caption(f"Encoding: **{encoding_scheme}** — {bits_per} bits per {unit_label.lower()}")
195
-
196
- grouped_bits = [binary_labels[i:i + bits_per] for i in range(0, len(binary_labels), bits_per)]
197
- scroll_html = (
198
- "<div style='max-height:300px; overflow-y:auto; font-family:monospace; "
199
- "padding:6px; border:1px solid #ccc;'>"
200
- )
201
- for i, bits in enumerate(grouped_bits):
202
- label = display_units[i] if i < len(display_units) else "?"
203
- scroll_html += f"<div>'{label}' → {bits}</div>"
204
- scroll_html += "</div>"
205
- st.markdown(scroll_html, unsafe_allow_html=True)
206
-
207
- # Download per-character breakdown
208
- per_char_lines = []
209
- for i, bits in enumerate(grouped_bits):
210
- label = display_units[i] if i < len(display_units) else "?"
211
- per_char_lines.append(f"'{label}' → {''.join(map(str, bits))}")
212
- st.download_button(
213
- f"⬇️ Download Binary per {unit_label} (.txt)",
214
- data='\n'.join(per_char_lines),
215
- file_name="binary_per_unit.txt",
216
- mime="text/plain",
217
- key="download_per_unit"
218
- )
219
-
220
- # Download full concatenated binary text
221
- st.download_button(
222
- "⬇️ Download Concatenated Binary String",
223
- data=binary_concat,
224
- file_name="binary_full.txt",
225
- mime="text/plain",
226
- key="download_binary_txt"
227
- )
228
-
229
- # --- Output 2: Grouped Binary Matrix ---
230
- st.markdown("### Output 2 – Grouped Binary Matrix")
231
- groups = []
232
- for i in range(0, len(binary_labels), group_size):
233
- group = binary_labels[i:i + group_size]
234
- if len(group) < group_size:
235
- group += [0] * (group_size - len(group))
236
- groups.append(group)
237
-
238
- columns = [f"Position {i+1}" for i in range(group_size)]
239
- df = pd.DataFrame(groups, columns=columns)
240
- st.dataframe(df, use_container_width=True)
241
-
242
- st.download_button(
243
- "⬇️ Download as CSV",
244
- df.to_csv(index=False),
245
- file_name=f"binary_labels_{group_size}_positions.csv",
246
- mime="text/csv",
247
- key="download_binary_csv"
248
- )
249
- else:
250
- st.info("👆 Enter text above to see binary labels.")
251
-
252
- # --------------------------------------------------
253
- # TAB 2: Binary → Text
254
- # --------------------------------------------------
255
- with tab2:
256
- st.markdown("""
257
- Convert binary data back into readable text.
258
- Upload either:
259
- - `.csv` file with 0/1 values (any number of columns/rows)
260
- - `.xlsx` Excel file
261
- - `.txt` file containing a concatenated binary string (e.g. `010101...`)
262
- """)
263
-
264
- decode_scheme = st.selectbox(
265
- "Decoding scheme (must match the encoding used):",
266
- ENCODING_OPTIONS,
267
- index=0,
268
- key="dec_scheme",
269
- help="Select the same encoding scheme that was used to produce the binary data."
270
- )
271
-
272
- uploaded_decode = st.file_uploader(
273
- "Upload your file (.csv, .xlsx, or .txt):",
274
- type=["csv", "xlsx", "txt"],
275
- key="decode_uploader"
276
- )
277
-
278
- if uploaded_decode is not None:
279
- try:
280
- if uploaded_decode.name.endswith(".csv"):
281
- df = pd.read_csv(uploaded_decode)
282
- bits = df.values.flatten().astype(int).tolist()
283
- elif uploaded_decode.name.endswith(".xlsx"):
284
- df = pd.read_excel(uploaded_decode)
285
- bits = df.values.flatten().astype(int).tolist()
286
- elif uploaded_decode.name.endswith(".txt"):
287
- content = uploaded_decode.read().decode().strip()
288
- bits = [int(b) for b in content if b in ['0', '1']]
289
- else:
290
- bits = []
291
-
292
- if not bits:
293
- st.warning("No binary data detected.")
294
- else:
295
- recovered_text = decode_from_binary(bits, decode_scheme)
296
- st.success(f"✅ Conversion complete using **{decode_scheme}**!")
297
- st.markdown("**Recovered text:**")
298
- st.text_area("Output", recovered_text, height=150)
299
-
300
- st.download_button(
301
- "⬇️ Download Recovered Text (.txt)",
302
- data=recovered_text,
303
- file_name="recovered_text.txt",
304
- mime="text/plain",
305
- key="download_recovered"
306
- )
307
- except Exception as e:
308
- st.error(f"Error reading or converting file: {e}")
309
- else:
310
- st.info("👆 Upload a file to start the reverse conversion.")
311
-
312
- # --------------------------------------------------
313
- # TAB 3: Pipetting Command Generator
314
- # --------------------------------------------------
315
- with tab3:
316
- from math import ceil
317
-
318
- st.header("🧪 Pipetting Command Generator for Eppendorf epMotion liquid handler")
319
- st.markdown("""
320
- Upload your sample file (Excel, CSV, or TXT) containing binary mutation data.
321
- The app will:
322
- - Auto-detect or create `Sample`, `Position#`, `Total edited`, and `Volume per "1"` columns
323
- - Let you set the **Desired total volume per sample (µL)** used to compute `Volume per "1"`
324
- - Calculate total demand per input and suggest a **uniform layout** (same # consecutive wells per input)
325
- - **Preview** the layout on a plate map (with tooltips)
326
- - After confirmation, generate pipetting commands and a source volume summary
327
- """)
328
-
329
- uploaded_writing = st.file_uploader(
330
- "📤 Upload data file",
331
- type=["xlsx", "csv", "txt"],
332
- key="writing_uploader"
333
- )
334
- max_per_well_ul = st.number_input(
335
- "Maximum volume per source well (µL)",
336
- min_value=10.0, max_value=2000.0, value=160.0, step=10.0
337
- )
338
-
339
- # ---------- Helpers (plate geometry, parsing, viz) ----------
340
- ROWS_96 = ["A", "B", "C", "D", "E", "F", "G", "H"]
341
- COLS_96 = list(range(1, 13))
342
-
343
- def well_name(row_letter, col_number):
344
- return f"{row_letter}{col_number}"
345
-
346
- def enumerate_plate_wells():
347
- for r in ROWS_96:
348
- for c in COLS_96:
349
- yield f"{r}{c}"
350
-
351
- def parse_well_name(well: str):
352
- m = re.match(r"([A-Ha-h])\s*([0-9]+)", str(well).strip())
353
- if not m:
354
- return ("A", 0)
355
- return (m.group(1).upper(), int(m.group(2)))
356
-
357
- def sample_index_to_plate_and_well(sample_idx: int):
358
- plate_num = ((sample_idx - 1) // 96) + 1
359
- within_plate = (sample_idx - 1) % 96
360
- row_idx = within_plate // 12
361
- col_idx = within_plate % 12
362
- return plate_num, well_name(ROWS_96[row_idx], COLS_96[col_idx])
363
-
364
- def build_global_wells_list(n_plates: int):
365
- out = []
366
- for p in range(1, n_plates + 1):
367
- for w in enumerate_plate_wells():
368
- out.append((p, w))
369
- return out
370
-
371
- def pick_tool(volume_ul: float) -> str:
372
- return "TS_10" if volume_ul <= 10.0 else "TS_50"
373
-
374
- PALETTE = [
375
- "#4F46E5", "#22C55E", "#F59E0B", "#EF4444", "#06B6D4", "#A855F7", "#84CC16", "#F97316",
376
- "#0EA5E9", "#E11D48", "#10B981", "#7C3AED", "#15803D", "#EA580C", "#2563EB", "#DC2626"
377
- ]
378
-
379
- def render_plate_map_html(plates_used, well_to_input, max_wells_per_source, inputs_count):
380
- legend_spans = []
381
- for i in range(1, inputs_count + 1):
382
- color = PALETTE[(i-1) % len(PALETTE)]
383
- legend_spans.append(
384
- f"<span style='display:inline-block;margin-right:12px'>"
385
- f"<span style='display:inline-block;width:12px;height:12px;background:{color};border:1px solid #333;margin-right:6px;vertical-align:middle'></span>"
386
- f"Input {i}</span>"
387
- )
388
- legend_html = "<div style='margin:8px 0 16px 0'>" + "".join(legend_spans) + "</div>"
389
-
390
- css = """
391
- <style>
392
- .plate { margin: 10px 0 24px 0; }
393
- .plate-title { font-weight: 600; margin: 4px 0 8px 0; }
394
- .grid { display: grid; grid-template-columns: 32px repeat(12, 38px); grid-auto-rows: 32px; gap: 4px; }
395
- .cell { width: 38px; height: 32px; border: 1px solid #DDD; display:flex; align-items:center; justify-content:center; font-size:12px; background:#FAFAFA; position:relative; }
396
- .head { font-weight:600; background:#F3F4F6; }
397
- .cell[data-color] { color:#111; }
398
- .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; }
399
- .cell:hover .tip { visibility:visible; opacity:0.95; }
400
- </style>
401
- """
402
-
403
- body = [css, legend_html]
404
- for p in range(1, plates_used + 1):
405
- body.append(f"<div class='plate'><div class='plate-title'>Plate {p}</div>")
406
- body.append("<div class='grid'>")
407
- body.append("<div class='cell head'></div>")
408
- for c in COLS_96:
409
- body.append(f"<div class='cell head'>{c}</div>")
410
- for r in ROWS_96:
411
- body.append(f"<div class='cell head'>{r}</div>")
412
- for c in COLS_96:
413
- well = f"{r}{c}"
414
- key = (p, well)
415
- if key in well_to_input:
416
- input_idx, within_idx = well_to_input[key]
417
- color = PALETTE[(input_idx-1) % len(PALETTE)]
418
- tip = f"Input {input_idx} • P{p}:{well} • Block well {within_idx}/{max_wells_per_source}"
419
- cell_html = (
420
- f"<div class='cell' data-color style='background:{color};border-color:#555' title='{tip}'>"
421
- f"<span class='tip'>{tip}</span>"
422
- "</div>"
423
- )
424
- else:
425
- cell_html = "<div class='cell'></div>"
426
- body.append(cell_html)
427
- body.append("</div></div>")
428
- return "".join(body)
429
-
430
- # ---------- Main flow ----------
431
- if uploaded_writing is not None:
432
- try:
433
- if uploaded_writing.name.endswith(".xlsx"):
434
- df = pd.read_excel(uploaded_writing)
435
- elif uploaded_writing.name.endswith(".csv"):
436
- df = pd.read_csv(uploaded_writing)
437
- else:
438
- try:
439
- df = pd.read_csv(uploaded_writing, sep="\t")
440
- except Exception:
441
- df = pd.read_csv(uploaded_writing)
442
-
443
- st.success(f"✅ Loaded file with {len(df)} rows and {len(df.columns)} columns")
444
-
445
- df.columns = [str(c).strip() for c in df.columns]
446
-
447
- if not any(c.lower() == "sample" for c in df.columns):
448
- df.insert(0, "Sample", np.arange(1, len(df) + 1))
449
- st.info("`Sample` column missing — automatically generated 1..N.")
450
-
451
- position_cols = [c for c in df.columns if re.match(r"(?i)^position\s*\d+", c)]
452
- if not position_cols:
453
- non_pos_cols = {"sample", "total edited", 'volume per "1"', "volume per 1"}
454
- candidate_cols = [c for c in df.columns if c.lower() not in non_pos_cols]
455
- position_cols = candidate_cols
456
- st.info(f"Position columns inferred automatically: {len(position_cols)} detected.")
457
-
458
- def pos_key(col_name: str):
459
- m = re.search(r"(\d+)", col_name)
460
- return int(m.group(1)) if m else 10**9
461
- position_cols = sorted(position_cols, key=pos_key)
462
-
463
- df[position_cols] = df[position_cols].apply(pd.to_numeric, errors="coerce").fillna(0).astype(int)
464
-
465
- if "Total edited" not in df.columns:
466
- df["Total edited"] = df[position_cols].sum(axis=1).astype(int)
467
- st.info("`Total edited` column missing — calculated automatically as sum of 1s per row.")
468
-
469
- st.markdown("#### ⚙️ Volume Calculation Settings")
470
- default_total_vol = st.number_input(
471
- "Desired total volume per sample (µL)",
472
- min_value=1.0, max_value=10000.0, value=64.0, step=1.0,
473
- help="Used to compute Volume per '1' as (Desired total volume / Total edited) when not provided."
474
- )
475
-
476
- vol_candidates = [c for c in df.columns if "volume per" in c.lower()]
477
- if not vol_candidates:
478
- df['Volume per "1"'] = default_total_vol / df["Total edited"].replace(0, np.nan)
479
- df['Volume per "1"'] = df['Volume per "1"'].fillna(0)
480
- st.info(f'`Volume per "1"` column missing — calculated automatically as {default_total_vol:.0f} µL / Total edited.')
481
- volume_col = 'Volume per "1"'
482
- else:
483
- volume_col = vol_candidates[0]
484
-
485
- if df[volume_col].max() > max_per_well_ul:
486
- st.error(
487
- f"❌ At least one row has `Volume per \"1\"` greater than the per-well cap ({max_per_well_ul} µL). "
488
- "Increase the cap or reduce per-transfer volume."
489
- )
490
- st.stop()
491
-
492
- vol_per_one_series = pd.to_numeric(df[volume_col], errors="coerce").fillna(0.0)
493
- total_volume_per_input = [float(vol_per_one_series[df[pos] == 1].sum()) for pos in position_cols]
494
- wells_needed_per_input = [int(ceil(tv / max_per_well_ul)) if tv > 0 else 0 for tv in total_volume_per_input]
495
- num_inputs = len(position_cols)
496
- max_wells_per_source = max(wells_needed_per_input) if wells_needed_per_input else 0
497
-
498
- st.markdown("### 👀 Preview: Suggested Uniform Layout")
499
- if max_wells_per_source == 0:
500
- st.info("No edits detected — nothing to allocate.")
501
- st.stop()
502
-
503
- st.write(
504
- f"💡 Suggested layout: **{max_wells_per_source} consecutive wells per input** "
505
- f"(cap {max_per_well_ul:.0f} µL/well)."
506
- )
507
-
508
- total_wells_needed_uniform = num_inputs * max_wells_per_source
509
- plates_needed = int(ceil(total_wells_needed_uniform / 96)) or 1
510
-
511
- global_wells = sorted(
512
- build_global_wells_list(plates_needed),
513
- key=lambda x: (
514
- x[0],
515
- ROWS_96.index(parse_well_name(x[1])[0]),
516
- parse_well_name(x[1])[1]
517
- )
518
- )
519
- global_wells = global_wells[:total_wells_needed_uniform]
520
-
521
- assigned_wells_map, well_to_input, preview_rows = {}, {}, []
522
- for i in range(1, num_inputs + 1):
523
- start, end = (i - 1) * max_wells_per_source, i * max_wells_per_source
524
- block = global_wells[start:end]
525
- assigned_wells_map[i] = block
526
- for j, (p, w) in enumerate(block, start=1):
527
- well_to_input[(p, w)] = (i, j)
528
- block_str = ", ".join([f"P{p}:{w}" for (p, w) in block])
529
- preview_rows.append({
530
- "Input (Position #)": i,
531
- "Total demand (µL)": round(total_volume_per_input[i-1], 2),
532
- "Wells needed (actual)": wells_needed_per_input[i-1],
533
- "Allocated (uniform)": max_wells_per_source,
534
- "Assigned wells": block_str
535
- })
536
-
537
- preview_df = pd.DataFrame(preview_rows)
538
- st.dataframe(preview_df, use_container_width=True, height=300)
539
-
540
- st.markdown("#### Plate Map (hover cells for details)")
541
- plate_html = render_plate_map_html(plates_needed, well_to_input, max_wells_per_source, num_inputs)
542
- st.markdown(plate_html, unsafe_allow_html=True)
543
-
544
- st.markdown("### ✅ Generate Pipetting Commands")
545
- if st.button("Generate using this layout"):
546
- per_input_well_cum = {i: [0.0] * max_wells_per_source for i in range(1, num_inputs + 1)}
547
- commands, source_volume_totals = [], {}
548
-
549
- for _, row in df.iterrows():
550
- sample_id = int(row["Sample"])
551
- vol_per_one = float(row[volume_col])
552
- if vol_per_one <= 0:
553
- continue
554
- dest_plate, dest_well = sample_index_to_plate_and_well(sample_id)
555
- tool = pick_tool(vol_per_one)
556
-
557
- for pos_idx, col in enumerate(position_cols, start=1):
558
- if int(row[col]) != 1:
559
- continue
560
- wells_for_input = assigned_wells_map[pos_idx]
561
- cum_list = per_input_well_cum[pos_idx]
562
-
563
- chosen = None
564
- for j, ((src_plate, src_well), current_vol) in enumerate(zip(wells_for_input, cum_list)):
565
- if current_vol + vol_per_one <= max_per_well_ul:
566
- chosen = (j, src_plate, src_well)
567
- break
568
-
569
- if chosen is None:
570
- st.error(
571
- f"Allocation exhausted for Input {pos_idx} while creating commands. "
572
- "Increase the max volume per well or review per-transfer volume."
573
- )
574
- st.stop()
575
-
576
- j, src_plate, src_well = chosen
577
- cum_list[j] += vol_per_one
578
- per_input_well_cum[pos_idx] = cum_list
579
- source_volume_totals[(src_plate, src_well)] = source_volume_totals.get((src_plate, src_well), 0.0) + vol_per_one
580
-
581
- commands.append({
582
- "Input #": pos_idx,
583
- "Source plate": src_plate,
584
- "Source well": src_well,
585
- "Destination plate": dest_plate,
586
- "Destination well": dest_well,
587
- "Volume": round(vol_per_one, 2),
588
- "Tool": tool
589
- })
590
-
591
- commands_df = pd.DataFrame(commands)
592
-
593
- def row_idx_from_well(w): return ROWS_96.index(parse_well_name(w)[0])
594
- def col_num_from_well(w): return parse_well_name(w)[1]
595
-
596
- commands_df["Src_row_idx"] = commands_df["Source well"].apply(row_idx_from_well)
597
- commands_df["Src_col_num"] = commands_df["Source well"].apply(col_num_from_well)
598
- commands_df["Dst_row_idx"] = commands_df["Destination well"].apply(row_idx_from_well)
599
- commands_df["Dst_col_num"] = commands_df["Destination well"].apply(col_num_from_well)
600
-
601
- commands_df = commands_df.sort_values(
602
- by=["Input #", "Source plate", "Src_row_idx", "Src_col_num",
603
- "Destination plate", "Dst_row_idx", "Dst_col_num"],
604
- kind="stable"
605
- )
606
-
607
- commands_df = commands_df[[
608
- "Input #", "Source plate", "Source well",
609
- "Destination plate", "Destination well", "Volume", "Tool"
610
- ]]
611
-
612
- st.success(f"✅ Generated {len(commands_df)} commands across {num_inputs} inputs.")
613
-
614
- summary_rows = []
615
- for i in range(1, num_inputs + 1):
616
- for (p, w), used in zip(assigned_wells_map[i], per_input_well_cum[i]):
617
- total = source_volume_totals.get((p, w), 0.0)
618
- summary_rows.append({
619
- "Source": i, "Source plate": p, "Source well": w,
620
- "Total volume taken (µL)": round(total, 2),
621
- "Allocated capacity (µL)": round(max_per_well_ul, 2)
622
- })
623
- summary_df = pd.DataFrame(summary_rows)
624
- summary_df["Src_row_idx"] = summary_df["Source well"].apply(row_idx_from_well)
625
- summary_df["Src_col_num"] = summary_df["Source well"].apply(col_num_from_well)
626
- summary_df = summary_df.sort_values(
627
- by=["Source", "Source plate", "Src_row_idx", "Src_col_num"],
628
- kind="stable"
629
- )[
630
- ["Source", "Source plate", "Source well", "Total volume taken (µL)", "Allocated capacity (µL)"]
631
- ]
632
-
633
- st.markdown("### 💧 Pipetting Commands")
634
- st.dataframe(commands_df, use_container_width=True, height=400)
635
- st.download_button("⬇️ Download Commands CSV", commands_df.to_csv(index=False), "pipetting_commands.csv", mime="text/csv")
636
-
637
- st.markdown("### 📊 Source Volume Summary")
638
- st.dataframe(summary_df, use_container_width=True, height=400)
639
- st.download_button("⬇️ Download Source Summary CSV", summary_df.to_csv(index=False), "source_volume_summary.csv", mime="text/csv")
640
-
641
- except Exception as e:
642
- st.error(f"❌ Error processing file: {e}")
643
- else:
644
- st.info("👆 Upload an Excel/CSV/TXT file to start.")