# 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}
Pos: ({cell.x}, {cell.y})" resistance = None if isinstance(cell, CancerCell): resistance = round(cell.resistance, 2) info_str += f"
Resistance: {resistance}" elif isinstance(cell, ImmuneCell): info_str += f"
Steps Alive: {cell.steps_alive}" if cell.is_activated: info_str += "
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 ... """)