co_streamlit / app.py
Dror Hilman
simulation
c89d7cd
# app.py
import streamlit as st
import numpy as np
import random
import copy
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go # Using graph_objects for more control over the grid
# import matplotlib.pyplot as plt # No longer needed for grid
# import matplotlib.colors # No longer needed for grid
import time # For the delay in continuous run
# --- Simulation Core Classes ---
class Cell:
"""Base class for all cells."""
def __init__(self, x, y):
self.x = x
self.y = y # Represents ROW index in grid (origin top-left)
class CancerCell(Cell):
"""Represents a cancer cell."""
CELL_TYPE = 1 # Grid representation
LABEL = 'Cancer'
COLOR = 'red'
def __init__(self, x, y, growth_prob, metastasis_prob, resistance, mutation_rate):
super().__init__(x, y)
self.growth_prob = growth_prob
self.metastasis_prob = metastasis_prob
self.resistance = resistance # 0.0 (susceptible) to 1.0 (fully resistant)
self.mutation_rate = mutation_rate
self.is_alive = True
def attempt_division(self, sim):
"""Attempt to divide into an adjacent empty cell."""
if random.random() < self.growth_prob:
neighbors = sim.get_neighbors(self.x, self.y)
empty_neighbors = [(nx, ny) for nx, ny, cell_type in neighbors if cell_type == 0]
if empty_neighbors:
nx, ny = random.choice(empty_neighbors)
# Create a new cell with possibly mutated properties
new_cell = copy.deepcopy(self)
new_cell.x, new_cell.y = nx, ny
new_cell.mutate() # Mutate the offspring
return new_cell
return None
def attempt_metastasis(self, sim):
"""Attempt to move to a random empty cell on the grid."""
if random.random() < self.metastasis_prob:
empty_cells = np.argwhere(sim.grid == 0)
if len(empty_cells) > 0:
new_y, new_x = random.choice(empty_cells) # Note: numpy argwhere returns (row, col) -> (y, x)
# Return the new position for the simulation to handle the move
return (int(new_x), int(new_y)) # Convert numpy types to int
return None
def mutate(self):
"""Potentially mutate resistance and growth probability."""
if random.random() < self.mutation_rate:
# Mutate resistance slightly
self.resistance += random.uniform(-0.1, 0.1)
self.resistance = max(0.0, min(1.0, self.resistance)) # Keep within [0, 1]
if random.random() < self.mutation_rate:
# Mutate growth prob slightly
self.growth_prob += random.uniform(-0.05, 0.05)
self.growth_prob = max(0.0, min(1.0, self.growth_prob)) # Keep within [0, 1]
def check_drug_effect(self, drug_effect_base, drug_resistance_interaction):
"""Check if the drug kills this cell."""
effective_drug = drug_effect_base * max(0, (1.0 - self.resistance * drug_resistance_interaction))
if random.random() < effective_drug:
self.is_alive = False
return True # Killed by drug
return False
class ImmuneCell(Cell):
"""Represents an immune cell."""
CELL_TYPE = 2 # Grid representation
LABEL = 'Immune'
COLOR = 'blue'
def __init__(self, x, y, base_kill_prob, movement_prob, lifespan, activation_boost):
super().__init__(x, y)
self.base_kill_prob = base_kill_prob
self.movement_prob = movement_prob
self.lifespan = lifespan
self.activation_boost = activation_boost # Added boost when activated by drug
self.steps_alive = 0
self.is_activated = False # Can be temporarily boosted by drug
self.is_alive = True
def attempt_move(self, sim):
"""Attempt to move to a random adjacent cell (can be empty or occupied)."""
if random.random() < self.movement_prob:
neighbors = sim.get_neighbors(self.x, self.y, radius=1, include_self=False)
potential_moves = [(nx, ny) for nx, ny, _ in neighbors]
if potential_moves:
# Prioritize moving towards cancer cells slightly
cancer_neighbors = [(nx, ny) for nx, ny, cell_type in neighbors if cell_type == CancerCell.CELL_TYPE]
if cancer_neighbors and random.random() < 0.5: # 50% chance to prioritize cancer
nx, ny = random.choice(cancer_neighbors)
else:
nx, ny = random.choice(potential_moves)
# Return the new position for the simulation to handle the move
return (nx, ny)
return None
def attempt_kill(self, sim):
"""Attempt to kill adjacent cancer cells."""
killed_coords = []
neighbors = sim.get_neighbors(self.x, self.y)
cancer_neighbors = [(nx, ny) for nx, ny, cell_type in neighbors if cell_type == CancerCell.CELL_TYPE]
current_kill_prob = self.base_kill_prob
if self.is_activated:
current_kill_prob += self.activation_boost
current_kill_prob = min(1.0, current_kill_prob) # Cap at 1.0
for nx, ny in cancer_neighbors:
if random.random() < current_kill_prob:
target_cell = sim.get_cell_at(nx, ny)
if target_cell and isinstance(target_cell, CancerCell) and target_cell.is_alive:
target_cell.is_alive = False
killed_coords.append((nx, ny))
return killed_coords # Return coords of killed cells
def step(self):
"""Increment age and check lifespan."""
self.steps_alive += 1
if self.steps_alive >= self.lifespan:
self.is_alive = False
self.is_activated = False # Reset activation each step unless reactivated
def activate_by_drug(self, drug_immune_boost_prob):
"""Potentially activate based on drug presence."""
if random.random() < drug_immune_boost_prob:
self.is_activated = True
class Simulation:
"""Manages the simulation grid and cells."""
def __init__(self, params):
self.params = params
self.grid_size = params['grid_size']
self.grid = np.zeros((self.grid_size, self.grid_size), dtype=int)
self.cells = {} # Using dict {(x, y): cell_obj} for faster lookup
self.history = [] # To store cell counts over time
self.current_step = 0
self._initialize_cells()
self._record_history() # Record initial state
def _initialize_cells(self):
"""Place initial cells on the grid."""
center_x, center_y = self.grid_size // 2, self.grid_size // 2
# Initial Cancer Cells (cluster in the center)
radius = max(1, int(np.sqrt(self.params['initial_cancer_cells']) / 2))
count = 0
placed_coords = set() # Keep track of where we placed cells initially
for r in range(radius + 2): # Search slightly larger radius if needed
for x in range(center_x - r, center_x + r + 1):
for y in range(center_y - r, center_y + r + 1):
if count >= self.params['initial_cancer_cells']: break
if 0 <= x < self.grid_size and 0 <= y < self.grid_size:
coords = (x,y)
if self.grid[y, x] == 0 and coords not in placed_coords: # Ensure cell is placed in empty spot
cell = CancerCell(x, y,
self.params['cancer_growth_prob'],
self.params['cancer_metastasis_prob'],
self.params['cancer_initial_resistance'],
self.params['cancer_mutation_rate'])
self.grid[y, x] = CancerCell.CELL_TYPE
self.cells[coords] = cell
placed_coords.add(coords)
count += 1
if count >= self.params['initial_cancer_cells']: break
if count >= self.params['initial_cancer_cells']: break
# Initial Immune Cells (randomly distributed)
immune_count = 0
attempts = 0 # Prevent infinite loop if grid is too full
max_attempts = self.grid_size * self.grid_size * 2
while immune_count < self.params['initial_immune_cells'] and attempts < max_attempts:
x, y = random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1)
coords = (x,y)
if self.grid[y, x] == 0 and coords not in placed_coords: # Place only in empty spots
cell = ImmuneCell(x, y,
self.params['immune_base_kill_prob'],
self.params['immune_movement_prob'],
self.params['immune_lifespan'],
self.params['drug_immune_activation_boost'])
self.grid[y, x] = ImmuneCell.CELL_TYPE
self.cells[coords] = cell
placed_coords.add(coords)
immune_count += 1
attempts += 1
if attempts >= max_attempts and immune_count < self.params['initial_immune_cells']:
st.warning(f"Could only place {immune_count}/{self.params['initial_immune_cells']} immune cells due to space constraints.")
def get_neighbors(self, x, y, radius=1, include_self=False):
"""Get neighbors within a radius, handling grid boundaries."""
neighbors = []
for dx in range(-radius, radius + 1):
for dy in range(-radius, radius + 1):
if not include_self and dx == 0 and dy == 0:
continue
nx, ny = x + dx, y + dy
# Check boundaries (no wrap-around)
if 0 <= nx < self.grid_size and 0 <= ny < self.grid_size:
neighbors.append((nx, ny, self.grid[ny, nx]))
return neighbors
def get_cell_at(self, x, y):
"""Retrieve cell object at given coordinates."""
return self.cells.get((x, y), None)
def _apply_drug_effects(self):
"""Apply drug effects: killing cancer cells and activating immune cells."""
killed_by_drug = []
immune_cells_to_activate = []
# Iterate through a copy of keys because dict size might change
for coords, cell in list(self.cells.items()):
if isinstance(cell, CancerCell) and cell.is_alive:
if cell.check_drug_effect(self.params['drug_effect_base'], self.params['drug_resistance_interaction']):
killed_by_drug.append(coords)
# Check for nearby immune cells to activate
neighbors = self.get_neighbors(cell.x, cell.y, radius=self.params['drug_immune_activation_radius'])
for nx, ny, cell_type in neighbors:
if cell_type == ImmuneCell.CELL_TYPE:
immune_cell = self.get_cell_at(nx, ny)
if immune_cell and immune_cell.is_alive:
immune_cells_to_activate.append(immune_cell)
# Apply activation (using a set to avoid duplicate activations if multiple cancer cells are nearby)
for immune_cell in set(immune_cells_to_activate):
immune_cell.activate_by_drug(self.params['drug_immune_boost_prob']) # Pass prob here
return killed_by_drug
def _immune_cell_actions(self):
"""Handle immune cell movement and killing actions."""
immune_moves = {} # {old_coords: new_coords}
killed_by_immune = []
# Iterate through a copy of keys
immune_cells_list = [cell for cell in self.cells.values() if isinstance(cell, ImmuneCell) and cell.is_alive]
random.shuffle(immune_cells_list) # Randomize order of action
for cell in immune_cells_list:
if not cell.is_alive: continue
# 1. Aging
cell.step()
if not cell.is_alive: continue # Died of old age
# 2. Attempt Kill
killed_coords = cell.attempt_kill(self)
killed_by_immune.extend(killed_coords)
# 3. Attempt Move (only if it didn't die)
new_pos = cell.attempt_move(self)
if new_pos:
nx, ny = new_pos
# Check if target is empty OR occupied by a cancer cell immune cell can kill/displace
# Prevent moving onto another immune cell's intended spot in this step
# Simplification: allow overlap for now, let update handle conflict
if new_pos not in immune_moves.values(): # Avoid multiple immune cells targetting same spot
immune_moves[(cell.x, cell.y)] = new_pos
return immune_moves, killed_by_immune
def _cancer_cell_actions(self):
"""Handle cancer cell division, metastasis, and mutation."""
new_cancer_cells = []
cancer_moves = {} # {old_coords: new_coords} for metastasis
# Iterate through a copy of keys
cancer_cells_list = [cell for cell in self.cells.values() if isinstance(cell, CancerCell) and cell.is_alive]
random.shuffle(cancer_cells_list) # Randomize order
for cell in cancer_cells_list:
if not cell.is_alive: continue # Could have been killed earlier in the step
# 1. Mutation (apply first, affects division/metastasis below)
# Moved mutation inside attempt_division/metastasis for offspring only in prev ver, let's keep it there.
# Parent mutation should also occur
cell.mutate() # Parent mutates regardless of division/metastasis
# 2. Attempt Division
offspring = cell.attempt_division(self) # Offspring inherits parent's (possibly mutated) state and then mutates itself
if offspring:
# Check if the target spot is still empty (could have been taken by metastasis/immune move)
# This check will happen more definitively in _update_grid_and_cells
new_cancer_cells.append(offspring)
# 3. Attempt Metastasis (only if division didn't occur?) Let's allow both for now.
new_pos = cell.attempt_metastasis(self)
if new_pos:
# Check if the target spot is still empty AND not targeted by another metastasis
# Defer final check to _update_grid_and_cells
if new_pos not in cancer_moves.values(): # Avoid multiple metastases targeting same spot
cancer_moves[(cell.x, cell.y)] = new_pos
return new_cancer_cells, cancer_moves
def _update_grid_and_cells(self, killed_coords_list, immune_moves, cancer_moves, new_cancer_cells):
"""Update the grid and cell dictionary based on actions."""
# 1. Process deaths first
all_killed_coords = set()
for coords_list in killed_coords_list:
all_killed_coords.update(coords_list)
# Also add immune cells that died of old age
for coords, cell in list(self.cells.items()):
if isinstance(cell, ImmuneCell) and not cell.is_alive:
all_killed_coords.add(coords)
for x, y in all_killed_coords:
if (x, y) in self.cells:
self.grid[y, x] = 0
if (x, y) in self.cells: # Check again, might be double-killed?
del self.cells[(x, y)]
# --- Resolve Move Conflicts & Update ---
# Priority: Immune > Cancer Metastasis. If conflict, immune wins, cancer move fails.
# If immune A wants to move to B, and immune C wants to move to B, one fails randomly.
# If cancer A wants to move to B, and cancer C wants to move to B, one fails randomly.
# If immune A wants to move to B, and cancer C wants to move to B, immune wins.
occupied_targets = set()
resolved_immune_moves = {} # {old_coords: new_coords}
resolved_cancer_moves = {} # {old_coords: new_coords}
# Shuffle move order for fairness in conflicts
immune_move_items = list(immune_moves.items())
random.shuffle(immune_move_items)
cancer_move_items = list(cancer_moves.items())
random.shuffle(cancer_move_items)
# Process immune moves first
for old_coords, new_coords in immune_move_items:
if old_coords not in self.cells: continue # Cell died before moving
if new_coords not in occupied_targets:
target_cell = self.get_cell_at(new_coords[0], new_coords[1])
# Allow move if target is empty, or is a cancer cell (implicit displacement/kill)
if target_cell is None or isinstance(target_cell, CancerCell):
resolved_immune_moves[old_coords] = new_coords
occupied_targets.add(new_coords)
# else: blocked by another immune cell already there or moving there
# Process cancer metastasis moves
for old_coords, new_coords in cancer_move_items:
if old_coords not in self.cells: continue # Cell died before moving
if new_coords not in occupied_targets:
target_cell = self.get_cell_at(new_coords[0], new_coords[1])
# Allow move ONLY if target is empty
if target_cell is None:
resolved_cancer_moves[old_coords] = new_coords
occupied_targets.add(new_coords)
# else: blocked by existing cell or an immune cell moving there
# Apply moves: remove old, add new
moved_cells_buffer = {} # Store {new_coords: cell} before adding back
for old_coords, new_coords in resolved_immune_moves.items():
if old_coords in self.cells: # Check if cell still exists
cell = self.cells.pop(old_coords)
self.grid[old_coords[1], old_coords[0]] = 0
cell.x, cell.y = new_coords
moved_cells_buffer[new_coords] = cell
for old_coords, new_coords in resolved_cancer_moves.items():
if old_coords in self.cells: # Check if cell still exists
cell = self.cells.pop(old_coords)
self.grid[old_coords[1], old_coords[0]] = 0
cell.x, cell.y = new_coords
moved_cells_buffer[new_coords] = cell
# Add moved cells back, handling displacement
for new_coords, cell in moved_cells_buffer.items():
# If an immune cell lands on a cancer cell's spot, the cancer cell should be gone.
if isinstance(cell, ImmuneCell) and new_coords in self.cells and isinstance(self.cells[new_coords], CancerCell):
del self.cells[new_coords] # Remove the displaced cancer cell
# Place the moved cell
self.cells[new_coords] = cell
self.grid[new_coords[1], new_coords[0]] = cell.CELL_TYPE
# 3. Process births (add new cancer cells)
# Shuffle order for fairness if multiple births target same location (unlikely but possible)
random.shuffle(new_cancer_cells)
added_cells_count = 0
for cell in new_cancer_cells:
coords = (cell.x, cell.y)
# Final check if the spot is truly empty *after* deaths and moves
if self.grid[cell.y, cell.x] == 0 and coords not in self.cells:
self.grid[cell.y, cell.x] = CancerCell.CELL_TYPE
self.cells[coords] = cell
added_cells_count += 1
# else: Birth failed due to space conflict
def step(self):
"""Perform one step of the simulation."""
# Check if simulation should stop before proceeding
cancer_count = sum(1 for cell in self.cells.values() if isinstance(cell, CancerCell))
if cancer_count == 0 and self.current_step > 0: # Check after at least one step
st.session_state.final_message = "Cancer eliminated!"
return False # Stop simulation
if self.current_step >= self.params['max_steps']:
st.session_state.final_message = "Maximum steps reached."
return False # Stop simulation
if not self.cells: # Stop if absolutely no cells left for some reason
st.session_state.final_message = "No cells remaining."
return False
# --- Action Phase ---
# 1. Drug effects (kill cancer, activate immune)
killed_by_drug = self._apply_drug_effects()
# 2. Immune cell actions (move, kill, age)
immune_moves, killed_by_immune = self._immune_cell_actions()
# 3. Cancer cell actions (divide, metastasize) - Mutation happens within these
new_cancer_cells, cancer_moves = self._cancer_cell_actions()
# --- Update Phase ---
# Consolidate killed cells list
all_killed_this_step = [killed_by_drug, killed_by_immune]
# Apply all changes to grid and cell list
self._update_grid_and_cells(all_killed_this_step, immune_moves, cancer_moves, new_cancer_cells)
# --- End Step ---
self.current_step += 1
self._record_history()
return True # Continue simulation
def _record_history(self):
"""Record the number of each cell type."""
cancer_count = sum(1 for cell in self.cells.values() if isinstance(cell, CancerCell))
immune_count = sum(1 for cell in self.cells.values() if isinstance(cell, ImmuneCell))
avg_resistance = 0
if cancer_count > 0:
avg_resistance = sum(cell.resistance for cell in self.cells.values() if isinstance(cell, CancerCell)) / cancer_count
self.history.append({
'Step': self.current_step,
'Cancer Cells': cancer_count,
'Immune Cells': immune_count,
'Average Resistance': avg_resistance
})
def get_history_df(self):
"""Return the recorded history as a Pandas DataFrame."""
return pd.DataFrame(self.history)
def get_plotly_grid_data(self):
"""Prepare data for Plotly scatter plot grid visualization."""
if not self.cells:
return pd.DataFrame(columns=['x', 'y_plotly', 'Type', 'Color', 'Resistance', 'Info'])
cell_data = []
for coords, cell in self.cells.items():
# Plotly scatter typically has origin at bottom-left.
# Our grid (y, x) has origin top-left. Transform y for plotting.
plotly_y = self.grid_size - 1 - cell.y
info_str = f"Type: {cell.LABEL}<br>Pos: ({cell.x}, {cell.y})"
resistance = None
if isinstance(cell, CancerCell):
resistance = round(cell.resistance, 2)
info_str += f"<br>Resistance: {resistance}"
elif isinstance(cell, ImmuneCell):
info_str += f"<br>Steps Alive: {cell.steps_alive}"
if cell.is_activated:
info_str += "<br>Status: Activated"
cell_data.append({
'x': cell.x,
'y_plotly': plotly_y,
'Type': cell.LABEL,
'Color': cell.COLOR,
'Resistance': resistance, # Store for potential coloring/hover later
'Info': info_str
})
return pd.DataFrame(cell_data)
# --- Streamlit App ---
st.set_page_config(layout="wide")
st.title("Cancer Simulation: Tumor Growth, Immune Response & Drug Treatment")
# --- Instructions ---
st.markdown("""
Welcome to the Cancer Simulation!
* Use the **sidebar** on the left to set the initial parameters for the simulation.
* Click **Start / Restart Simulation** to initialize the grid with the chosen parameters.
* **Run N Step(s):** Executes a fixed number of simulation steps.
* **Run Continuously:** Automatically runs the simulation step-by-step with a short delay (approx. 100ms) between steps. The grid and plots will update dynamically.
* **Stop:** Pauses the simulation (either manual steps or continuous run).
* The **Simulation Grid** visualizes the cells (Red=Cancer, Blue=Immune). Hover over cells for details.
* The **Plots** below the grid show population dynamics and average cancer cell resistance over time.
""")
st.divider()
# --- Parameters Sidebar ---
with st.sidebar:
st.header("Simulation Parameters")
st.subheader("Grid & General")
grid_size = st.slider("Grid Size (N x N)", 20, 100, 50, key="grid_size_slider")
max_steps = st.number_input("Max Simulation Steps", 50, 1000, 200, key="max_steps_input")
st.subheader("Initial Cells")
initial_cancer_cells = st.slider("Initial Cancer Cells", 1, max(1,grid_size*grid_size//4), 10, key="init_cancer_slider") # Limit initial cells
initial_immune_cells = st.slider("Initial Immune Cells", 0, max(1,grid_size*grid_size//2), 50, key="init_immune_slider")
st.subheader("Cancer Cell Properties")
cancer_growth_prob = st.slider("Growth Probability", 0.0, 1.0, 0.2, 0.01, key="cancer_growth_slider")
cancer_metastasis_prob = st.slider("Metastasis Probability", 0.0, 0.1, 0.005, 0.001, format="%.3f", key="cancer_meta_slider")
cancer_initial_resistance = st.slider("Initial Drug Resistance", 0.0, 1.0, 0.1, 0.01, key="cancer_res_slider")
cancer_mutation_rate = st.slider("Mutation Rate", 0.0, 0.1, 0.01, 0.001, format="%.3f", key="cancer_mut_slider")
st.subheader("Immune Cell Properties")
immune_base_kill_prob = st.slider("Base Kill Probability", 0.0, 1.0, 0.3, 0.01, key="immune_kill_slider")
immune_movement_prob = st.slider("Movement Probability", 0.0, 1.0, 0.8, 0.01, key="immune_move_slider")
immune_lifespan = st.number_input("Lifespan (steps)", 10, 500, 100, key="immune_life_input")
st.subheader("Drug Properties")
drug_effect_base = st.slider("Base Drug Effect (Kill Prob)", 0.0, 1.0, 0.4, 0.01, key="drug_effect_slider")
drug_resistance_interaction = st.slider("Resistance Interaction Factor", 0.0, 2.0, 1.0, 0.05, help="How much resistance reduces drug effect (1.0=linear)", key="drug_resint_slider")
drug_immune_activation_boost = st.slider("Immune Activation Boost", 0.0, 1.0, 0.3, 0.01, help="Added kill prob when activated", key="drug_immune_boost_slider")
drug_immune_boost_prob = st.slider("Immune Activation Probability", 0.0, 1.0, 0.7, 0.01, help="Prob. an immune cell near dying cancer gets activated", key="drug_immune_prob_slider")
drug_immune_activation_radius = st.slider("Immune Activation Radius", 0, 5, 1, help="Radius around dying cancer cell to activate immune cells", key="drug_immune_rad_slider")
# Store parameters in a dictionary
simulation_params = {
'grid_size': grid_size,
'max_steps': max_steps,
'initial_cancer_cells': initial_cancer_cells,
'initial_immune_cells': initial_immune_cells,
'cancer_growth_prob': cancer_growth_prob,
'cancer_metastasis_prob': cancer_metastasis_prob,
'cancer_initial_resistance': cancer_initial_resistance,
'cancer_mutation_rate': cancer_mutation_rate,
'immune_base_kill_prob': immune_base_kill_prob,
'immune_movement_prob': immune_movement_prob,
'immune_lifespan': immune_lifespan,
'drug_effect_base': drug_effect_base,
'drug_resistance_interaction': drug_resistance_interaction,
'drug_immune_activation_boost': drug_immune_activation_boost,
'drug_immune_boost_prob': drug_immune_boost_prob,
'drug_immune_activation_radius': drug_immune_activation_radius,
}
# --- Simulation Control and State ---
# Initialize simulation state
if 'simulation' not in st.session_state:
st.session_state.simulation = None
st.session_state.running = False # Overall simulation active (not paused/stopped)
st.session_state.continuously_running = False # Auto-step mode active
st.session_state.history_df = pd.DataFrame()
st.session_state.final_message = "" # To display end condition
col1, col2, col3, col4 = st.columns(4)
with col1:
if st.button("Start / Restart Simulation", key="start_button"):
st.session_state.simulation = Simulation(copy.deepcopy(simulation_params)) # Use deepcopy for params
st.session_state.running = True
st.session_state.continuously_running = False # Stop continuous if restarting
st.session_state.history_df = st.session_state.simulation.get_history_df()
st.session_state.final_message = ""
st.success("Simulation Initialized.")
st.rerun() # Rerun to update displays immediately
with col2:
steps_to_run = st.number_input("Run Steps", min_value=1, max_value=max_steps, value=10, key="steps_input_manual")
run_button = st.button(f"Run {steps_to_run} Step(s)", disabled=(st.session_state.simulation is None or not st.session_state.running or st.session_state.continuously_running), key="run_steps_button")
if run_button:
sim = st.session_state.simulation
if sim:
progress_bar = st.progress(0)
steps_taken = 0
for i in range(steps_to_run):
if not st.session_state.running: break # Check if stopped externally
keep_running = sim.step()
steps_taken += 1
# Need to update history inside loop if we want live plot updates during manual steps, but simpler to update after
if not keep_running:
st.session_state.running = False # Simulation ended naturally
break
progress_bar.progress((i + 1) / steps_to_run)
progress_bar.empty()
st.session_state.history_df = sim.get_history_df() # Update history after batch run
st.info(f"Ran {steps_taken} steps. Current step: {sim.current_step}")
if not st.session_state.running and st.session_state.final_message:
st.success(st.session_state.final_message) # Show end reason
st.rerun() # Update displays
with col3:
run_cont_button = st.button("Run Continuously", disabled=(st.session_state.simulation is None or not st.session_state.running or st.session_state.continuously_running), key="run_cont_button")
if run_cont_button:
st.session_state.continuously_running = True
st.info("Running continuously...")
st.rerun() # Start the continuous loop
with col4:
stop_button = st.button("Stop", disabled=(st.session_state.simulation is None or (not st.session_state.running) or (not st.session_state.continuously_running and not run_button) ), key="stop_button") # Enable if running or continuously running
if stop_button:
st.session_state.running = False # Stop the simulation process
st.session_state.continuously_running = False # Turn off continuous mode
st.warning("Simulation stopped by user.")
st.rerun()
# --- Dynamic Update Logic for Continuous Run ---
if st.session_state.get('simulation') and st.session_state.get('continuously_running') and st.session_state.get('running'):
sim = st.session_state.simulation
keep_running = sim.step()
st.session_state.history_df = sim.get_history_df() # Update history
if not keep_running:
st.session_state.running = False # Simulation ended naturally
st.session_state.continuously_running = False # Stop continuous mode
if st.session_state.final_message:
st.success(st.session_state.final_message) # Show end reason
# Schedule the next rerun with a delay
time.sleep(0.1) # 100 ms delay
st.rerun()
# --- Visualization ---
# Use placeholders to potentially update plots faster
grid_placeholder = st.empty()
charts_placeholder = st.container() # Use a container for the two charts
if st.session_state.simulation:
sim = st.session_state.simulation
# --- Plotly Grid Visualization ---
with grid_placeholder.container(): # Draw in the placeholder
st.subheader(f"Simulation Grid (Step: {sim.current_step})")
df_grid = sim.get_plotly_grid_data()
fig_grid = go.Figure()
if not df_grid.empty:
# Add scatter trace for cells
fig_grid.add_trace(go.Scatter(
x=df_grid['x'],
y=df_grid['y_plotly'],
mode='markers',
marker=dict(
color=df_grid['Color'],
size=max(5, 400 / sim.grid_size), # Adjust marker size based on grid size
symbol='square'
),
text=df_grid['Info'], # Text appearing on hover
hoverinfo='text',
showlegend=False
))
# Configure layout
fig_grid.update_layout(
xaxis=dict(
range=[-0.5, sim.grid_size - 0.5],
showgrid=True,
gridcolor='lightgrey',
zeroline=False,
showticklabels=False,
fixedrange=True # Prevent zoom/pan
),
yaxis=dict(
range=[-0.5, sim.grid_size - 0.5],
showgrid=True,
gridcolor='lightgrey',
zeroline=False,
showticklabels=False,
scaleanchor="x", # Ensure square cells
scaleratio=1,
fixedrange=True # Prevent zoom/pan
),
width=min(600, 800), # Adjust size as needed
height=min(600, 800),
margin=dict(l=10, r=10, t=40, b=10),
paper_bgcolor='white',
plot_bgcolor='white',
# Add manual legend items if needed, or rely on text/color
legend=dict(
itemsizing='constant',
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1
)
)
# Add dummy traces for legend (if desired)
fig_grid.add_trace(go.Scatter(x=[None], y=[None], mode='markers', marker=dict(color=CancerCell.COLOR, size=10, symbol='square'), name='Cancer Cell'))
fig_grid.add_trace(go.Scatter(x=[None], y=[None], mode='markers', marker=dict(color=ImmuneCell.COLOR, size=10, symbol='square'), name='Immune Cell'))
st.plotly_chart(fig_grid, use_container_width=True) # Make it responsive
# --- Time Series Plots ---
with charts_placeholder: # Draw in the placeholder
st.divider()
col_chart1, col_chart2 = st.columns(2)
if not st.session_state.history_df.empty:
df_history = st.session_state.history_df
with col_chart1:
st.subheader("Cell Counts Over Time")
df_melt = df_history.melt(id_vars=['Step'],
value_vars=['Cancer Cells', 'Immune Cells'],
var_name='Cell Type', value_name='Count')
fig_line = px.line(df_melt, x='Step', y='Count', color='Cell Type',
title="Population Dynamics", markers=False, # Use markers=False for potentially smoother continuous updates
color_discrete_map={'Cancer Cells': CancerCell.COLOR, 'Immune Cells': ImmuneCell.COLOR})
fig_line.update_layout(legend_title_text='Cell Type')
st.plotly_chart(fig_line, use_container_width=True)
with col_chart2:
st.subheader("Average Cancer Cell Drug Resistance")
fig_res = px.line(df_history, x='Step', y='Average Resistance',
title="Average Resistance", markers=False) # Use markers=False
fig_res.update_yaxes(range=[0, 1.05]) # Resistance is between 0 and 1, add buffer
st.plotly_chart(fig_res, use_container_width=True)
elif st.session_state.simulation: # If sim exists but no history yet (step 0)
st.info("Run the simulation to see the plots.")
else:
st.info("Click 'Start / Restart Simulation' to begin.")
# Add some explanations at the bottom as well if desired
# st.markdown(""" --- Explanation ... """)