wenjun99 commited on
Commit
7cd4b12
·
verified ·
1 Parent(s): dd417a3

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +642 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,644 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
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.")