wenjun99's picture
Update app.py
3c883fe verified
import streamlit as st
import pandas as pd
import math
# === App Title ===
st.set_page_config(page_title="Robot Script Generator", layout="wide")
st.title("🧪 Robot Script Generator")
# === Voyager ASCII 6-bit conversion table ===
voyager_table = {
i: ch for i, ch in enumerate([
' ', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I',
'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2',
'3', '4', '5', '6', '7', '8', '9', '.', ',', '(',
')', '+', '-', '*', '/', '=', '$', '!', ':', '%',
'"', '#', '@', "'", '?', '&'
])
}
reverse_voyager_table = {v: k for k, v in voyager_table.items()}
# === Binary → String conversion ===
def binary_labels_to_string(bits: list[int]) -> str:
chars = []
for i in range(0, len(bits), 6):
chunk = bits[i:i+6]
if len(chunk) < 6:
chunk += [0] * (6 - len(chunk))
val = sum(b << (5 - j) for j, b in enumerate(chunk))
chars.append(voyager_table.get(val, '?'))
return ''.join(chars)
# === Well mapping ===
def get_well_position(sample_index):
"""Convert sample index (1-based) into A1–H12 pattern within its plate"""
row_letter = chr(65 + ((sample_index - 1) % 96) // 12) # 8 rows (A–H)
col_number = ((sample_index - 1) % 12) + 1 # 12 columns
return f"{row_letter}{col_number}"
def get_plate_id(sample_index):
"""Return Plate number based on 96 samples per plate"""
plate_number = math.ceil(sample_index / 96)
return f"Plate {plate_number}"
# === Track and replace source if volume exceeded ===
def track_and_replace_source(source_list, robot_script, volume_limit=150):
source_volumes = {}
adjusted_sources = []
for entry in robot_script:
src = entry['Source']
vol = entry['Volume']
source_volumes[src] = source_volumes.get(src, 0) + vol
if source_volumes[src] > volume_limit:
row_letter = src[0]
col_number = src[1:]
new_row_letter = chr(ord(row_letter) + 4)
new_src = f"{new_row_letter}{col_number}"
entry['Source'] = new_src
source_volumes[new_src] = source_volumes.get(new_src, 0) + vol
source_volumes[src] -= vol
adjusted_sources.append(entry)
return adjusted_sources, source_volumes
# === Fixed D-source transfers ===
def generate_fixed_d_source_instructions_to_all_samples(n_samples, fixed_volume=16, volume_limit=170):
d_source_volumes = {}
d_source_script = []
current_d_index = 1
for i in range(n_samples):
dest = get_well_position(i + 1)
plate = get_plate_id(i + 1)
current_d_well = f"D{current_d_index}"
d_source_volumes.setdefault(current_d_well, 0)
if d_source_volumes[current_d_well] + fixed_volume > volume_limit:
current_d_index += 1
current_d_well = f"D{current_d_index}"
d_source_volumes[current_d_well] = 0
d_source_volumes[current_d_well] += fixed_volume
tool = 'TS_50' if fixed_volume > 10 else 'TS_10'
d_source_script.append({
'Plate': plate,
'Source': current_d_well,
'Destination': dest,
'Volume': fixed_volume,
'Tool': tool
})
return d_source_script, d_source_volumes
def generate_source_wells(n):
wells, rows = [], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
for i in range(n):
row, col = rows[i // 12], (i % 12) + 1
wells.append(f"{row}{col}")
return wells
# === Main UI ===
st.header("Upload Binary Data (0/1)")
binary_file = st.file_uploader("Upload Binary CSV", type=["csv"])
st.divider()
st.subheader("Optional Metadata")
barcode_id_input = st.text_input("Barcode ID (optional)", value="")
labware_source_input = st.text_input("Labware Source ID", value="1")
labware_dest_input = st.text_input("Labware Destination ID", value="1")
name_input = st.text_input("Name field (optional)", value="")
volume_limit_input = st.number_input("Maximum Volume per Source Well (µL)", value=150, min_value=10, step=10)
# === Load Data ===
if binary_file:
df_binary = pd.read_csv(binary_file, header=None)
df_binary.columns = [str(i+1) for i in range(df_binary.shape[1])]
else:
st.info("No file uploaded — manually enter or paste your binary data below.")
st.caption("💡 Tip: You can copy and paste entire datasets (Ctrl+V) directly here — rows and columns will adjust automatically.")
# Ask the user how many columns (if they want to define manually)
n_cols = st.number_input(
"Number of columns (adjust if pasting a dataset)",
min_value=1, value=8, step=1
)
# Create an empty DataFrame with that many columns
initial_df = pd.DataFrame(columns=[str(i) for i in range(1, n_cols + 1)])
# Dynamic editor — allows copy-paste of arbitrary rows
df_binary = st.data_editor(
initial_df,
num_rows="dynamic",
key="manual_input",
use_container_width=True,
)
# If user pastes a larger dataset (more columns than n_cols)
# detect and rename automatically
if not df_binary.empty:
n_detected = df_binary.shape[1]
df_binary.columns = [str(i+1) for i in range(n_detected)]
if not df_binary.empty:
st.subheader("Binary Matrix")
st.dataframe(df_binary.style.applymap(lambda v: "background-color: lightgreen" if v == 1 else "background-color: lightcoral"))
st.download_button("⬇️ Download Binary CSV", df_binary.to_csv(index=False), "binary_matrix.csv")
# Decode to string
decoded = binary_labels_to_string(df_binary.values.flatten().astype(int).tolist())
st.subheader("Decoded String Output")
st.code(decoded)
st.download_button("⬇️ Download Decoded String", decoded, "decoded_string.txt")
# === Generate Robot Script ===
st.divider()
st.subheader("Generated Robot Script")
df_robot = df_binary.copy()
df_robot.insert(0, 'Sample', range(1, len(df_robot) + 1))
df_robot['# donors'] = df_robot.iloc[:, 1:].astype(int).sum(axis=1)
df_robot['volume donors (µL)'] = df_robot['# donors'].apply(
lambda x: 64 / x if x > 0 else 0
)
robot_script = []
source_wells = generate_source_wells(df_robot.shape[1] - 1)
for i, col in enumerate(df_robot.columns[1:]):
for row_idx, sample in df_robot.iterrows():
if sample['# donors'] == 0:
continue # skip samples with no donors
if int(sample[col]) == 1:
sample_id = int(sample['Sample'])
sample_index = row_idx + 1
plate = get_plate_id(sample_index)
source = source_wells[i]
dest = get_well_position(sample_index)
vol = round(sample['volume donors (µL)'], 2)
tool = 'TS_50' if vol > 10 else 'TS_10'
robot_script.append({
'Plate': plate, # ✅ New Column
'Source': source,
'Destination': dest,
'Volume': vol,
'Tool': tool
})
robot_script, source_volumes = track_and_replace_source(source_wells, robot_script, volume_limit=volume_limit_input)
d_script, d_volumes = generate_fixed_d_source_instructions_to_all_samples(
len(df_robot), fixed_volume=16, volume_limit=volume_limit_input
)
full_script = robot_script + d_script
robot_script_df = pd.DataFrame(full_script)
robot_script_df.insert(0, 'Barcode ID', barcode_id_input)
robot_script_df.insert(1, 'Labware_Source', labware_source_input)
robot_script_df.insert(3, 'Labware_Destination', labware_dest_input)
robot_script_df['Name'] = name_input
robot_script_df = robot_script_df[['Barcode ID', 'Labware_Source', 'Plate',
'Source', 'Labware_Destination', 'Destination',
'Volume', 'Tool', 'Name']]
st.dataframe(robot_script_df)
st.download_button("⬇️ Download Robot Script", robot_script_df.to_csv(index=False), "robot_script.csv")
# === Source Volume Summary ===
st.divider()
st.subheader("Total Volume Used Per Source")
combined_volumes = {**source_volumes, **d_volumes}
volume_df = pd.DataFrame(list(combined_volumes.items()), columns=['Source', 'Total Volume (µL)'])
st.dataframe(volume_df)
st.download_button("⬇️ Download Volume Summary", volume_df.to_csv(index=False), "source_volumes.csv")