Spaces:
Build error
Build error
Dror Hilman
commited on
Commit
·
c89d7cd
1
Parent(s):
01db3bc
simulation
Browse files- app.py +757 -2
- requirements.txt +135 -0
app.py
CHANGED
|
@@ -1,4 +1,759 @@
|
|
|
|
|
| 1 |
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
x = st.slider('Select a value')
|
| 4 |
-
st.write(x, 'squared is', x * x)
|
|
|
|
| 1 |
+
# app.py
|
| 2 |
import streamlit as st
|
| 3 |
+
import numpy as np
|
| 4 |
+
import random
|
| 5 |
+
import copy
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.express as px
|
| 8 |
+
import plotly.graph_objects as go # Using graph_objects for more control over the grid
|
| 9 |
+
# import matplotlib.pyplot as plt # No longer needed for grid
|
| 10 |
+
# import matplotlib.colors # No longer needed for grid
|
| 11 |
+
import time # For the delay in continuous run
|
| 12 |
+
|
| 13 |
+
# --- Simulation Core Classes ---
|
| 14 |
+
|
| 15 |
+
class Cell:
|
| 16 |
+
"""Base class for all cells."""
|
| 17 |
+
def __init__(self, x, y):
|
| 18 |
+
self.x = x
|
| 19 |
+
self.y = y # Represents ROW index in grid (origin top-left)
|
| 20 |
+
|
| 21 |
+
class CancerCell(Cell):
|
| 22 |
+
"""Represents a cancer cell."""
|
| 23 |
+
CELL_TYPE = 1 # Grid representation
|
| 24 |
+
LABEL = 'Cancer'
|
| 25 |
+
COLOR = 'red'
|
| 26 |
+
def __init__(self, x, y, growth_prob, metastasis_prob, resistance, mutation_rate):
|
| 27 |
+
super().__init__(x, y)
|
| 28 |
+
self.growth_prob = growth_prob
|
| 29 |
+
self.metastasis_prob = metastasis_prob
|
| 30 |
+
self.resistance = resistance # 0.0 (susceptible) to 1.0 (fully resistant)
|
| 31 |
+
self.mutation_rate = mutation_rate
|
| 32 |
+
self.is_alive = True
|
| 33 |
+
|
| 34 |
+
def attempt_division(self, sim):
|
| 35 |
+
"""Attempt to divide into an adjacent empty cell."""
|
| 36 |
+
if random.random() < self.growth_prob:
|
| 37 |
+
neighbors = sim.get_neighbors(self.x, self.y)
|
| 38 |
+
empty_neighbors = [(nx, ny) for nx, ny, cell_type in neighbors if cell_type == 0]
|
| 39 |
+
if empty_neighbors:
|
| 40 |
+
nx, ny = random.choice(empty_neighbors)
|
| 41 |
+
# Create a new cell with possibly mutated properties
|
| 42 |
+
new_cell = copy.deepcopy(self)
|
| 43 |
+
new_cell.x, new_cell.y = nx, ny
|
| 44 |
+
new_cell.mutate() # Mutate the offspring
|
| 45 |
+
return new_cell
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
def attempt_metastasis(self, sim):
|
| 49 |
+
"""Attempt to move to a random empty cell on the grid."""
|
| 50 |
+
if random.random() < self.metastasis_prob:
|
| 51 |
+
empty_cells = np.argwhere(sim.grid == 0)
|
| 52 |
+
if len(empty_cells) > 0:
|
| 53 |
+
new_y, new_x = random.choice(empty_cells) # Note: numpy argwhere returns (row, col) -> (y, x)
|
| 54 |
+
# Return the new position for the simulation to handle the move
|
| 55 |
+
return (int(new_x), int(new_y)) # Convert numpy types to int
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
def mutate(self):
|
| 59 |
+
"""Potentially mutate resistance and growth probability."""
|
| 60 |
+
if random.random() < self.mutation_rate:
|
| 61 |
+
# Mutate resistance slightly
|
| 62 |
+
self.resistance += random.uniform(-0.1, 0.1)
|
| 63 |
+
self.resistance = max(0.0, min(1.0, self.resistance)) # Keep within [0, 1]
|
| 64 |
+
if random.random() < self.mutation_rate:
|
| 65 |
+
# Mutate growth prob slightly
|
| 66 |
+
self.growth_prob += random.uniform(-0.05, 0.05)
|
| 67 |
+
self.growth_prob = max(0.0, min(1.0, self.growth_prob)) # Keep within [0, 1]
|
| 68 |
+
|
| 69 |
+
def check_drug_effect(self, drug_effect_base, drug_resistance_interaction):
|
| 70 |
+
"""Check if the drug kills this cell."""
|
| 71 |
+
effective_drug = drug_effect_base * max(0, (1.0 - self.resistance * drug_resistance_interaction))
|
| 72 |
+
if random.random() < effective_drug:
|
| 73 |
+
self.is_alive = False
|
| 74 |
+
return True # Killed by drug
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class ImmuneCell(Cell):
|
| 79 |
+
"""Represents an immune cell."""
|
| 80 |
+
CELL_TYPE = 2 # Grid representation
|
| 81 |
+
LABEL = 'Immune'
|
| 82 |
+
COLOR = 'blue'
|
| 83 |
+
def __init__(self, x, y, base_kill_prob, movement_prob, lifespan, activation_boost):
|
| 84 |
+
super().__init__(x, y)
|
| 85 |
+
self.base_kill_prob = base_kill_prob
|
| 86 |
+
self.movement_prob = movement_prob
|
| 87 |
+
self.lifespan = lifespan
|
| 88 |
+
self.activation_boost = activation_boost # Added boost when activated by drug
|
| 89 |
+
self.steps_alive = 0
|
| 90 |
+
self.is_activated = False # Can be temporarily boosted by drug
|
| 91 |
+
self.is_alive = True
|
| 92 |
+
|
| 93 |
+
def attempt_move(self, sim):
|
| 94 |
+
"""Attempt to move to a random adjacent cell (can be empty or occupied)."""
|
| 95 |
+
if random.random() < self.movement_prob:
|
| 96 |
+
neighbors = sim.get_neighbors(self.x, self.y, radius=1, include_self=False)
|
| 97 |
+
potential_moves = [(nx, ny) for nx, ny, _ in neighbors]
|
| 98 |
+
if potential_moves:
|
| 99 |
+
# Prioritize moving towards cancer cells slightly
|
| 100 |
+
cancer_neighbors = [(nx, ny) for nx, ny, cell_type in neighbors if cell_type == CancerCell.CELL_TYPE]
|
| 101 |
+
if cancer_neighbors and random.random() < 0.5: # 50% chance to prioritize cancer
|
| 102 |
+
nx, ny = random.choice(cancer_neighbors)
|
| 103 |
+
else:
|
| 104 |
+
nx, ny = random.choice(potential_moves)
|
| 105 |
+
# Return the new position for the simulation to handle the move
|
| 106 |
+
return (nx, ny)
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
def attempt_kill(self, sim):
|
| 110 |
+
"""Attempt to kill adjacent cancer cells."""
|
| 111 |
+
killed_coords = []
|
| 112 |
+
neighbors = sim.get_neighbors(self.x, self.y)
|
| 113 |
+
cancer_neighbors = [(nx, ny) for nx, ny, cell_type in neighbors if cell_type == CancerCell.CELL_TYPE]
|
| 114 |
+
|
| 115 |
+
current_kill_prob = self.base_kill_prob
|
| 116 |
+
if self.is_activated:
|
| 117 |
+
current_kill_prob += self.activation_boost
|
| 118 |
+
current_kill_prob = min(1.0, current_kill_prob) # Cap at 1.0
|
| 119 |
+
|
| 120 |
+
for nx, ny in cancer_neighbors:
|
| 121 |
+
if random.random() < current_kill_prob:
|
| 122 |
+
target_cell = sim.get_cell_at(nx, ny)
|
| 123 |
+
if target_cell and isinstance(target_cell, CancerCell) and target_cell.is_alive:
|
| 124 |
+
target_cell.is_alive = False
|
| 125 |
+
killed_coords.append((nx, ny))
|
| 126 |
+
return killed_coords # Return coords of killed cells
|
| 127 |
+
|
| 128 |
+
def step(self):
|
| 129 |
+
"""Increment age and check lifespan."""
|
| 130 |
+
self.steps_alive += 1
|
| 131 |
+
if self.steps_alive >= self.lifespan:
|
| 132 |
+
self.is_alive = False
|
| 133 |
+
self.is_activated = False # Reset activation each step unless reactivated
|
| 134 |
+
|
| 135 |
+
def activate_by_drug(self, drug_immune_boost_prob):
|
| 136 |
+
"""Potentially activate based on drug presence."""
|
| 137 |
+
if random.random() < drug_immune_boost_prob:
|
| 138 |
+
self.is_activated = True
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class Simulation:
|
| 142 |
+
"""Manages the simulation grid and cells."""
|
| 143 |
+
def __init__(self, params):
|
| 144 |
+
self.params = params
|
| 145 |
+
self.grid_size = params['grid_size']
|
| 146 |
+
self.grid = np.zeros((self.grid_size, self.grid_size), dtype=int)
|
| 147 |
+
self.cells = {} # Using dict {(x, y): cell_obj} for faster lookup
|
| 148 |
+
self.history = [] # To store cell counts over time
|
| 149 |
+
self.current_step = 0
|
| 150 |
+
self._initialize_cells()
|
| 151 |
+
self._record_history() # Record initial state
|
| 152 |
+
|
| 153 |
+
def _initialize_cells(self):
|
| 154 |
+
"""Place initial cells on the grid."""
|
| 155 |
+
center_x, center_y = self.grid_size // 2, self.grid_size // 2
|
| 156 |
+
|
| 157 |
+
# Initial Cancer Cells (cluster in the center)
|
| 158 |
+
radius = max(1, int(np.sqrt(self.params['initial_cancer_cells']) / 2))
|
| 159 |
+
count = 0
|
| 160 |
+
placed_coords = set() # Keep track of where we placed cells initially
|
| 161 |
+
for r in range(radius + 2): # Search slightly larger radius if needed
|
| 162 |
+
for x in range(center_x - r, center_x + r + 1):
|
| 163 |
+
for y in range(center_y - r, center_y + r + 1):
|
| 164 |
+
if count >= self.params['initial_cancer_cells']: break
|
| 165 |
+
if 0 <= x < self.grid_size and 0 <= y < self.grid_size:
|
| 166 |
+
coords = (x,y)
|
| 167 |
+
if self.grid[y, x] == 0 and coords not in placed_coords: # Ensure cell is placed in empty spot
|
| 168 |
+
cell = CancerCell(x, y,
|
| 169 |
+
self.params['cancer_growth_prob'],
|
| 170 |
+
self.params['cancer_metastasis_prob'],
|
| 171 |
+
self.params['cancer_initial_resistance'],
|
| 172 |
+
self.params['cancer_mutation_rate'])
|
| 173 |
+
self.grid[y, x] = CancerCell.CELL_TYPE
|
| 174 |
+
self.cells[coords] = cell
|
| 175 |
+
placed_coords.add(coords)
|
| 176 |
+
count += 1
|
| 177 |
+
if count >= self.params['initial_cancer_cells']: break
|
| 178 |
+
if count >= self.params['initial_cancer_cells']: break
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# Initial Immune Cells (randomly distributed)
|
| 182 |
+
immune_count = 0
|
| 183 |
+
attempts = 0 # Prevent infinite loop if grid is too full
|
| 184 |
+
max_attempts = self.grid_size * self.grid_size * 2
|
| 185 |
+
while immune_count < self.params['initial_immune_cells'] and attempts < max_attempts:
|
| 186 |
+
x, y = random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1)
|
| 187 |
+
coords = (x,y)
|
| 188 |
+
if self.grid[y, x] == 0 and coords not in placed_coords: # Place only in empty spots
|
| 189 |
+
cell = ImmuneCell(x, y,
|
| 190 |
+
self.params['immune_base_kill_prob'],
|
| 191 |
+
self.params['immune_movement_prob'],
|
| 192 |
+
self.params['immune_lifespan'],
|
| 193 |
+
self.params['drug_immune_activation_boost'])
|
| 194 |
+
self.grid[y, x] = ImmuneCell.CELL_TYPE
|
| 195 |
+
self.cells[coords] = cell
|
| 196 |
+
placed_coords.add(coords)
|
| 197 |
+
immune_count += 1
|
| 198 |
+
attempts += 1
|
| 199 |
+
if attempts >= max_attempts and immune_count < self.params['initial_immune_cells']:
|
| 200 |
+
st.warning(f"Could only place {immune_count}/{self.params['initial_immune_cells']} immune cells due to space constraints.")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def get_neighbors(self, x, y, radius=1, include_self=False):
|
| 204 |
+
"""Get neighbors within a radius, handling grid boundaries."""
|
| 205 |
+
neighbors = []
|
| 206 |
+
for dx in range(-radius, radius + 1):
|
| 207 |
+
for dy in range(-radius, radius + 1):
|
| 208 |
+
if not include_self and dx == 0 and dy == 0:
|
| 209 |
+
continue
|
| 210 |
+
nx, ny = x + dx, y + dy
|
| 211 |
+
# Check boundaries (no wrap-around)
|
| 212 |
+
if 0 <= nx < self.grid_size and 0 <= ny < self.grid_size:
|
| 213 |
+
neighbors.append((nx, ny, self.grid[ny, nx]))
|
| 214 |
+
return neighbors
|
| 215 |
+
|
| 216 |
+
def get_cell_at(self, x, y):
|
| 217 |
+
"""Retrieve cell object at given coordinates."""
|
| 218 |
+
return self.cells.get((x, y), None)
|
| 219 |
+
|
| 220 |
+
def _apply_drug_effects(self):
|
| 221 |
+
"""Apply drug effects: killing cancer cells and activating immune cells."""
|
| 222 |
+
killed_by_drug = []
|
| 223 |
+
immune_cells_to_activate = []
|
| 224 |
+
|
| 225 |
+
# Iterate through a copy of keys because dict size might change
|
| 226 |
+
for coords, cell in list(self.cells.items()):
|
| 227 |
+
if isinstance(cell, CancerCell) and cell.is_alive:
|
| 228 |
+
if cell.check_drug_effect(self.params['drug_effect_base'], self.params['drug_resistance_interaction']):
|
| 229 |
+
killed_by_drug.append(coords)
|
| 230 |
+
# Check for nearby immune cells to activate
|
| 231 |
+
neighbors = self.get_neighbors(cell.x, cell.y, radius=self.params['drug_immune_activation_radius'])
|
| 232 |
+
for nx, ny, cell_type in neighbors:
|
| 233 |
+
if cell_type == ImmuneCell.CELL_TYPE:
|
| 234 |
+
immune_cell = self.get_cell_at(nx, ny)
|
| 235 |
+
if immune_cell and immune_cell.is_alive:
|
| 236 |
+
immune_cells_to_activate.append(immune_cell)
|
| 237 |
+
|
| 238 |
+
# Apply activation (using a set to avoid duplicate activations if multiple cancer cells are nearby)
|
| 239 |
+
for immune_cell in set(immune_cells_to_activate):
|
| 240 |
+
immune_cell.activate_by_drug(self.params['drug_immune_boost_prob']) # Pass prob here
|
| 241 |
+
|
| 242 |
+
return killed_by_drug
|
| 243 |
+
|
| 244 |
+
def _immune_cell_actions(self):
|
| 245 |
+
"""Handle immune cell movement and killing actions."""
|
| 246 |
+
immune_moves = {} # {old_coords: new_coords}
|
| 247 |
+
killed_by_immune = []
|
| 248 |
+
|
| 249 |
+
# Iterate through a copy of keys
|
| 250 |
+
immune_cells_list = [cell for cell in self.cells.values() if isinstance(cell, ImmuneCell) and cell.is_alive]
|
| 251 |
+
random.shuffle(immune_cells_list) # Randomize order of action
|
| 252 |
+
|
| 253 |
+
for cell in immune_cells_list:
|
| 254 |
+
if not cell.is_alive: continue
|
| 255 |
+
|
| 256 |
+
# 1. Aging
|
| 257 |
+
cell.step()
|
| 258 |
+
if not cell.is_alive: continue # Died of old age
|
| 259 |
+
|
| 260 |
+
# 2. Attempt Kill
|
| 261 |
+
killed_coords = cell.attempt_kill(self)
|
| 262 |
+
killed_by_immune.extend(killed_coords)
|
| 263 |
+
|
| 264 |
+
# 3. Attempt Move (only if it didn't die)
|
| 265 |
+
new_pos = cell.attempt_move(self)
|
| 266 |
+
if new_pos:
|
| 267 |
+
nx, ny = new_pos
|
| 268 |
+
# Check if target is empty OR occupied by a cancer cell immune cell can kill/displace
|
| 269 |
+
# Prevent moving onto another immune cell's intended spot in this step
|
| 270 |
+
# Simplification: allow overlap for now, let update handle conflict
|
| 271 |
+
if new_pos not in immune_moves.values(): # Avoid multiple immune cells targetting same spot
|
| 272 |
+
immune_moves[(cell.x, cell.y)] = new_pos
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
return immune_moves, killed_by_immune
|
| 276 |
+
|
| 277 |
+
def _cancer_cell_actions(self):
|
| 278 |
+
"""Handle cancer cell division, metastasis, and mutation."""
|
| 279 |
+
new_cancer_cells = []
|
| 280 |
+
cancer_moves = {} # {old_coords: new_coords} for metastasis
|
| 281 |
+
|
| 282 |
+
# Iterate through a copy of keys
|
| 283 |
+
cancer_cells_list = [cell for cell in self.cells.values() if isinstance(cell, CancerCell) and cell.is_alive]
|
| 284 |
+
random.shuffle(cancer_cells_list) # Randomize order
|
| 285 |
+
|
| 286 |
+
for cell in cancer_cells_list:
|
| 287 |
+
if not cell.is_alive: continue # Could have been killed earlier in the step
|
| 288 |
+
|
| 289 |
+
# 1. Mutation (apply first, affects division/metastasis below)
|
| 290 |
+
# Moved mutation inside attempt_division/metastasis for offspring only in prev ver, let's keep it there.
|
| 291 |
+
# Parent mutation should also occur
|
| 292 |
+
cell.mutate() # Parent mutates regardless of division/metastasis
|
| 293 |
+
|
| 294 |
+
# 2. Attempt Division
|
| 295 |
+
offspring = cell.attempt_division(self) # Offspring inherits parent's (possibly mutated) state and then mutates itself
|
| 296 |
+
if offspring:
|
| 297 |
+
# Check if the target spot is still empty (could have been taken by metastasis/immune move)
|
| 298 |
+
# This check will happen more definitively in _update_grid_and_cells
|
| 299 |
+
new_cancer_cells.append(offspring)
|
| 300 |
+
|
| 301 |
+
# 3. Attempt Metastasis (only if division didn't occur?) Let's allow both for now.
|
| 302 |
+
new_pos = cell.attempt_metastasis(self)
|
| 303 |
+
if new_pos:
|
| 304 |
+
# Check if the target spot is still empty AND not targeted by another metastasis
|
| 305 |
+
# Defer final check to _update_grid_and_cells
|
| 306 |
+
if new_pos not in cancer_moves.values(): # Avoid multiple metastases targeting same spot
|
| 307 |
+
cancer_moves[(cell.x, cell.y)] = new_pos
|
| 308 |
+
|
| 309 |
+
return new_cancer_cells, cancer_moves
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _update_grid_and_cells(self, killed_coords_list, immune_moves, cancer_moves, new_cancer_cells):
|
| 313 |
+
"""Update the grid and cell dictionary based on actions."""
|
| 314 |
+
|
| 315 |
+
# 1. Process deaths first
|
| 316 |
+
all_killed_coords = set()
|
| 317 |
+
for coords_list in killed_coords_list:
|
| 318 |
+
all_killed_coords.update(coords_list)
|
| 319 |
+
|
| 320 |
+
# Also add immune cells that died of old age
|
| 321 |
+
for coords, cell in list(self.cells.items()):
|
| 322 |
+
if isinstance(cell, ImmuneCell) and not cell.is_alive:
|
| 323 |
+
all_killed_coords.add(coords)
|
| 324 |
+
|
| 325 |
+
for x, y in all_killed_coords:
|
| 326 |
+
if (x, y) in self.cells:
|
| 327 |
+
self.grid[y, x] = 0
|
| 328 |
+
if (x, y) in self.cells: # Check again, might be double-killed?
|
| 329 |
+
del self.cells[(x, y)]
|
| 330 |
+
|
| 331 |
+
# --- Resolve Move Conflicts & Update ---
|
| 332 |
+
# Priority: Immune > Cancer Metastasis. If conflict, immune wins, cancer move fails.
|
| 333 |
+
# If immune A wants to move to B, and immune C wants to move to B, one fails randomly.
|
| 334 |
+
# If cancer A wants to move to B, and cancer C wants to move to B, one fails randomly.
|
| 335 |
+
# If immune A wants to move to B, and cancer C wants to move to B, immune wins.
|
| 336 |
+
|
| 337 |
+
occupied_targets = set()
|
| 338 |
+
resolved_immune_moves = {} # {old_coords: new_coords}
|
| 339 |
+
resolved_cancer_moves = {} # {old_coords: new_coords}
|
| 340 |
+
|
| 341 |
+
# Shuffle move order for fairness in conflicts
|
| 342 |
+
immune_move_items = list(immune_moves.items())
|
| 343 |
+
random.shuffle(immune_move_items)
|
| 344 |
+
cancer_move_items = list(cancer_moves.items())
|
| 345 |
+
random.shuffle(cancer_move_items)
|
| 346 |
+
|
| 347 |
+
# Process immune moves first
|
| 348 |
+
for old_coords, new_coords in immune_move_items:
|
| 349 |
+
if old_coords not in self.cells: continue # Cell died before moving
|
| 350 |
+
if new_coords not in occupied_targets:
|
| 351 |
+
target_cell = self.get_cell_at(new_coords[0], new_coords[1])
|
| 352 |
+
# Allow move if target is empty, or is a cancer cell (implicit displacement/kill)
|
| 353 |
+
if target_cell is None or isinstance(target_cell, CancerCell):
|
| 354 |
+
resolved_immune_moves[old_coords] = new_coords
|
| 355 |
+
occupied_targets.add(new_coords)
|
| 356 |
+
# else: blocked by another immune cell already there or moving there
|
| 357 |
+
|
| 358 |
+
# Process cancer metastasis moves
|
| 359 |
+
for old_coords, new_coords in cancer_move_items:
|
| 360 |
+
if old_coords not in self.cells: continue # Cell died before moving
|
| 361 |
+
if new_coords not in occupied_targets:
|
| 362 |
+
target_cell = self.get_cell_at(new_coords[0], new_coords[1])
|
| 363 |
+
# Allow move ONLY if target is empty
|
| 364 |
+
if target_cell is None:
|
| 365 |
+
resolved_cancer_moves[old_coords] = new_coords
|
| 366 |
+
occupied_targets.add(new_coords)
|
| 367 |
+
# else: blocked by existing cell or an immune cell moving there
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# Apply moves: remove old, add new
|
| 371 |
+
moved_cells_buffer = {} # Store {new_coords: cell} before adding back
|
| 372 |
+
|
| 373 |
+
for old_coords, new_coords in resolved_immune_moves.items():
|
| 374 |
+
if old_coords in self.cells: # Check if cell still exists
|
| 375 |
+
cell = self.cells.pop(old_coords)
|
| 376 |
+
self.grid[old_coords[1], old_coords[0]] = 0
|
| 377 |
+
cell.x, cell.y = new_coords
|
| 378 |
+
moved_cells_buffer[new_coords] = cell
|
| 379 |
+
|
| 380 |
+
for old_coords, new_coords in resolved_cancer_moves.items():
|
| 381 |
+
if old_coords in self.cells: # Check if cell still exists
|
| 382 |
+
cell = self.cells.pop(old_coords)
|
| 383 |
+
self.grid[old_coords[1], old_coords[0]] = 0
|
| 384 |
+
cell.x, cell.y = new_coords
|
| 385 |
+
moved_cells_buffer[new_coords] = cell
|
| 386 |
+
|
| 387 |
+
# Add moved cells back, handling displacement
|
| 388 |
+
for new_coords, cell in moved_cells_buffer.items():
|
| 389 |
+
# If an immune cell lands on a cancer cell's spot, the cancer cell should be gone.
|
| 390 |
+
if isinstance(cell, ImmuneCell) and new_coords in self.cells and isinstance(self.cells[new_coords], CancerCell):
|
| 391 |
+
del self.cells[new_coords] # Remove the displaced cancer cell
|
| 392 |
+
|
| 393 |
+
# Place the moved cell
|
| 394 |
+
self.cells[new_coords] = cell
|
| 395 |
+
self.grid[new_coords[1], new_coords[0]] = cell.CELL_TYPE
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
# 3. Process births (add new cancer cells)
|
| 399 |
+
# Shuffle order for fairness if multiple births target same location (unlikely but possible)
|
| 400 |
+
random.shuffle(new_cancer_cells)
|
| 401 |
+
added_cells_count = 0
|
| 402 |
+
for cell in new_cancer_cells:
|
| 403 |
+
coords = (cell.x, cell.y)
|
| 404 |
+
# Final check if the spot is truly empty *after* deaths and moves
|
| 405 |
+
if self.grid[cell.y, cell.x] == 0 and coords not in self.cells:
|
| 406 |
+
self.grid[cell.y, cell.x] = CancerCell.CELL_TYPE
|
| 407 |
+
self.cells[coords] = cell
|
| 408 |
+
added_cells_count += 1
|
| 409 |
+
# else: Birth failed due to space conflict
|
| 410 |
+
|
| 411 |
+
def step(self):
|
| 412 |
+
"""Perform one step of the simulation."""
|
| 413 |
+
# Check if simulation should stop before proceeding
|
| 414 |
+
cancer_count = sum(1 for cell in self.cells.values() if isinstance(cell, CancerCell))
|
| 415 |
+
if cancer_count == 0 and self.current_step > 0: # Check after at least one step
|
| 416 |
+
st.session_state.final_message = "Cancer eliminated!"
|
| 417 |
+
return False # Stop simulation
|
| 418 |
+
if self.current_step >= self.params['max_steps']:
|
| 419 |
+
st.session_state.final_message = "Maximum steps reached."
|
| 420 |
+
return False # Stop simulation
|
| 421 |
+
if not self.cells: # Stop if absolutely no cells left for some reason
|
| 422 |
+
st.session_state.final_message = "No cells remaining."
|
| 423 |
+
return False
|
| 424 |
+
|
| 425 |
+
# --- Action Phase ---
|
| 426 |
+
# 1. Drug effects (kill cancer, activate immune)
|
| 427 |
+
killed_by_drug = self._apply_drug_effects()
|
| 428 |
+
|
| 429 |
+
# 2. Immune cell actions (move, kill, age)
|
| 430 |
+
immune_moves, killed_by_immune = self._immune_cell_actions()
|
| 431 |
+
|
| 432 |
+
# 3. Cancer cell actions (divide, metastasize) - Mutation happens within these
|
| 433 |
+
new_cancer_cells, cancer_moves = self._cancer_cell_actions()
|
| 434 |
+
|
| 435 |
+
# --- Update Phase ---
|
| 436 |
+
# Consolidate killed cells list
|
| 437 |
+
all_killed_this_step = [killed_by_drug, killed_by_immune]
|
| 438 |
+
|
| 439 |
+
# Apply all changes to grid and cell list
|
| 440 |
+
self._update_grid_and_cells(all_killed_this_step, immune_moves, cancer_moves, new_cancer_cells)
|
| 441 |
+
|
| 442 |
+
# --- End Step ---
|
| 443 |
+
self.current_step += 1
|
| 444 |
+
self._record_history()
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
return True # Continue simulation
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def _record_history(self):
|
| 451 |
+
"""Record the number of each cell type."""
|
| 452 |
+
cancer_count = sum(1 for cell in self.cells.values() if isinstance(cell, CancerCell))
|
| 453 |
+
immune_count = sum(1 for cell in self.cells.values() if isinstance(cell, ImmuneCell))
|
| 454 |
+
avg_resistance = 0
|
| 455 |
+
if cancer_count > 0:
|
| 456 |
+
avg_resistance = sum(cell.resistance for cell in self.cells.values() if isinstance(cell, CancerCell)) / cancer_count
|
| 457 |
+
|
| 458 |
+
self.history.append({
|
| 459 |
+
'Step': self.current_step,
|
| 460 |
+
'Cancer Cells': cancer_count,
|
| 461 |
+
'Immune Cells': immune_count,
|
| 462 |
+
'Average Resistance': avg_resistance
|
| 463 |
+
})
|
| 464 |
+
|
| 465 |
+
def get_history_df(self):
|
| 466 |
+
"""Return the recorded history as a Pandas DataFrame."""
|
| 467 |
+
return pd.DataFrame(self.history)
|
| 468 |
+
|
| 469 |
+
def get_plotly_grid_data(self):
|
| 470 |
+
"""Prepare data for Plotly scatter plot grid visualization."""
|
| 471 |
+
if not self.cells:
|
| 472 |
+
return pd.DataFrame(columns=['x', 'y_plotly', 'Type', 'Color', 'Resistance', 'Info'])
|
| 473 |
+
|
| 474 |
+
cell_data = []
|
| 475 |
+
for coords, cell in self.cells.items():
|
| 476 |
+
# Plotly scatter typically has origin at bottom-left.
|
| 477 |
+
# Our grid (y, x) has origin top-left. Transform y for plotting.
|
| 478 |
+
plotly_y = self.grid_size - 1 - cell.y
|
| 479 |
+
info_str = f"Type: {cell.LABEL}<br>Pos: ({cell.x}, {cell.y})"
|
| 480 |
+
resistance = None
|
| 481 |
+
if isinstance(cell, CancerCell):
|
| 482 |
+
resistance = round(cell.resistance, 2)
|
| 483 |
+
info_str += f"<br>Resistance: {resistance}"
|
| 484 |
+
elif isinstance(cell, ImmuneCell):
|
| 485 |
+
info_str += f"<br>Steps Alive: {cell.steps_alive}"
|
| 486 |
+
if cell.is_activated:
|
| 487 |
+
info_str += "<br>Status: Activated"
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
cell_data.append({
|
| 491 |
+
'x': cell.x,
|
| 492 |
+
'y_plotly': plotly_y,
|
| 493 |
+
'Type': cell.LABEL,
|
| 494 |
+
'Color': cell.COLOR,
|
| 495 |
+
'Resistance': resistance, # Store for potential coloring/hover later
|
| 496 |
+
'Info': info_str
|
| 497 |
+
})
|
| 498 |
+
return pd.DataFrame(cell_data)
|
| 499 |
+
|
| 500 |
+
# --- Streamlit App ---
|
| 501 |
+
|
| 502 |
+
st.set_page_config(layout="wide")
|
| 503 |
+
st.title("Cancer Simulation: Tumor Growth, Immune Response & Drug Treatment")
|
| 504 |
+
|
| 505 |
+
# --- Instructions ---
|
| 506 |
+
st.markdown("""
|
| 507 |
+
Welcome to the Cancer Simulation!
|
| 508 |
+
* Use the **sidebar** on the left to set the initial parameters for the simulation.
|
| 509 |
+
* Click **Start / Restart Simulation** to initialize the grid with the chosen parameters.
|
| 510 |
+
* **Run N Step(s):** Executes a fixed number of simulation steps.
|
| 511 |
+
* **Run Continuously:** Automatically runs the simulation step-by-step with a short delay (approx. 100ms) between steps. The grid and plots will update dynamically.
|
| 512 |
+
* **Stop:** Pauses the simulation (either manual steps or continuous run).
|
| 513 |
+
* The **Simulation Grid** visualizes the cells (Red=Cancer, Blue=Immune). Hover over cells for details.
|
| 514 |
+
* The **Plots** below the grid show population dynamics and average cancer cell resistance over time.
|
| 515 |
+
""")
|
| 516 |
+
st.divider()
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
# --- Parameters Sidebar ---
|
| 520 |
+
with st.sidebar:
|
| 521 |
+
st.header("Simulation Parameters")
|
| 522 |
+
|
| 523 |
+
st.subheader("Grid & General")
|
| 524 |
+
grid_size = st.slider("Grid Size (N x N)", 20, 100, 50, key="grid_size_slider")
|
| 525 |
+
max_steps = st.number_input("Max Simulation Steps", 50, 1000, 200, key="max_steps_input")
|
| 526 |
+
|
| 527 |
+
st.subheader("Initial Cells")
|
| 528 |
+
initial_cancer_cells = st.slider("Initial Cancer Cells", 1, max(1,grid_size*grid_size//4), 10, key="init_cancer_slider") # Limit initial cells
|
| 529 |
+
initial_immune_cells = st.slider("Initial Immune Cells", 0, max(1,grid_size*grid_size//2), 50, key="init_immune_slider")
|
| 530 |
+
|
| 531 |
+
st.subheader("Cancer Cell Properties")
|
| 532 |
+
cancer_growth_prob = st.slider("Growth Probability", 0.0, 1.0, 0.2, 0.01, key="cancer_growth_slider")
|
| 533 |
+
cancer_metastasis_prob = st.slider("Metastasis Probability", 0.0, 0.1, 0.005, 0.001, format="%.3f", key="cancer_meta_slider")
|
| 534 |
+
cancer_initial_resistance = st.slider("Initial Drug Resistance", 0.0, 1.0, 0.1, 0.01, key="cancer_res_slider")
|
| 535 |
+
cancer_mutation_rate = st.slider("Mutation Rate", 0.0, 0.1, 0.01, 0.001, format="%.3f", key="cancer_mut_slider")
|
| 536 |
+
|
| 537 |
+
st.subheader("Immune Cell Properties")
|
| 538 |
+
immune_base_kill_prob = st.slider("Base Kill Probability", 0.0, 1.0, 0.3, 0.01, key="immune_kill_slider")
|
| 539 |
+
immune_movement_prob = st.slider("Movement Probability", 0.0, 1.0, 0.8, 0.01, key="immune_move_slider")
|
| 540 |
+
immune_lifespan = st.number_input("Lifespan (steps)", 10, 500, 100, key="immune_life_input")
|
| 541 |
+
|
| 542 |
+
st.subheader("Drug Properties")
|
| 543 |
+
drug_effect_base = st.slider("Base Drug Effect (Kill Prob)", 0.0, 1.0, 0.4, 0.01, key="drug_effect_slider")
|
| 544 |
+
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")
|
| 545 |
+
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")
|
| 546 |
+
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")
|
| 547 |
+
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")
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
# Store parameters in a dictionary
|
| 551 |
+
simulation_params = {
|
| 552 |
+
'grid_size': grid_size,
|
| 553 |
+
'max_steps': max_steps,
|
| 554 |
+
'initial_cancer_cells': initial_cancer_cells,
|
| 555 |
+
'initial_immune_cells': initial_immune_cells,
|
| 556 |
+
'cancer_growth_prob': cancer_growth_prob,
|
| 557 |
+
'cancer_metastasis_prob': cancer_metastasis_prob,
|
| 558 |
+
'cancer_initial_resistance': cancer_initial_resistance,
|
| 559 |
+
'cancer_mutation_rate': cancer_mutation_rate,
|
| 560 |
+
'immune_base_kill_prob': immune_base_kill_prob,
|
| 561 |
+
'immune_movement_prob': immune_movement_prob,
|
| 562 |
+
'immune_lifespan': immune_lifespan,
|
| 563 |
+
'drug_effect_base': drug_effect_base,
|
| 564 |
+
'drug_resistance_interaction': drug_resistance_interaction,
|
| 565 |
+
'drug_immune_activation_boost': drug_immune_activation_boost,
|
| 566 |
+
'drug_immune_boost_prob': drug_immune_boost_prob,
|
| 567 |
+
'drug_immune_activation_radius': drug_immune_activation_radius,
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
# --- Simulation Control and State ---
|
| 571 |
+
|
| 572 |
+
# Initialize simulation state
|
| 573 |
+
if 'simulation' not in st.session_state:
|
| 574 |
+
st.session_state.simulation = None
|
| 575 |
+
st.session_state.running = False # Overall simulation active (not paused/stopped)
|
| 576 |
+
st.session_state.continuously_running = False # Auto-step mode active
|
| 577 |
+
st.session_state.history_df = pd.DataFrame()
|
| 578 |
+
st.session_state.final_message = "" # To display end condition
|
| 579 |
+
|
| 580 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 581 |
+
|
| 582 |
+
with col1:
|
| 583 |
+
if st.button("Start / Restart Simulation", key="start_button"):
|
| 584 |
+
st.session_state.simulation = Simulation(copy.deepcopy(simulation_params)) # Use deepcopy for params
|
| 585 |
+
st.session_state.running = True
|
| 586 |
+
st.session_state.continuously_running = False # Stop continuous if restarting
|
| 587 |
+
st.session_state.history_df = st.session_state.simulation.get_history_df()
|
| 588 |
+
st.session_state.final_message = ""
|
| 589 |
+
st.success("Simulation Initialized.")
|
| 590 |
+
st.rerun() # Rerun to update displays immediately
|
| 591 |
+
|
| 592 |
+
with col2:
|
| 593 |
+
steps_to_run = st.number_input("Run Steps", min_value=1, max_value=max_steps, value=10, key="steps_input_manual")
|
| 594 |
+
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")
|
| 595 |
+
|
| 596 |
+
if run_button:
|
| 597 |
+
sim = st.session_state.simulation
|
| 598 |
+
if sim:
|
| 599 |
+
progress_bar = st.progress(0)
|
| 600 |
+
steps_taken = 0
|
| 601 |
+
for i in range(steps_to_run):
|
| 602 |
+
if not st.session_state.running: break # Check if stopped externally
|
| 603 |
+
keep_running = sim.step()
|
| 604 |
+
steps_taken += 1
|
| 605 |
+
# Need to update history inside loop if we want live plot updates during manual steps, but simpler to update after
|
| 606 |
+
if not keep_running:
|
| 607 |
+
st.session_state.running = False # Simulation ended naturally
|
| 608 |
+
break
|
| 609 |
+
progress_bar.progress((i + 1) / steps_to_run)
|
| 610 |
+
|
| 611 |
+
progress_bar.empty()
|
| 612 |
+
st.session_state.history_df = sim.get_history_df() # Update history after batch run
|
| 613 |
+
st.info(f"Ran {steps_taken} steps. Current step: {sim.current_step}")
|
| 614 |
+
if not st.session_state.running and st.session_state.final_message:
|
| 615 |
+
st.success(st.session_state.final_message) # Show end reason
|
| 616 |
+
st.rerun() # Update displays
|
| 617 |
+
|
| 618 |
+
with col3:
|
| 619 |
+
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")
|
| 620 |
+
if run_cont_button:
|
| 621 |
+
st.session_state.continuously_running = True
|
| 622 |
+
st.info("Running continuously...")
|
| 623 |
+
st.rerun() # Start the continuous loop
|
| 624 |
+
|
| 625 |
+
with col4:
|
| 626 |
+
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
|
| 627 |
+
if stop_button:
|
| 628 |
+
st.session_state.running = False # Stop the simulation process
|
| 629 |
+
st.session_state.continuously_running = False # Turn off continuous mode
|
| 630 |
+
st.warning("Simulation stopped by user.")
|
| 631 |
+
st.rerun()
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
# --- Dynamic Update Logic for Continuous Run ---
|
| 635 |
+
if st.session_state.get('simulation') and st.session_state.get('continuously_running') and st.session_state.get('running'):
|
| 636 |
+
sim = st.session_state.simulation
|
| 637 |
+
keep_running = sim.step()
|
| 638 |
+
st.session_state.history_df = sim.get_history_df() # Update history
|
| 639 |
+
|
| 640 |
+
if not keep_running:
|
| 641 |
+
st.session_state.running = False # Simulation ended naturally
|
| 642 |
+
st.session_state.continuously_running = False # Stop continuous mode
|
| 643 |
+
if st.session_state.final_message:
|
| 644 |
+
st.success(st.session_state.final_message) # Show end reason
|
| 645 |
+
|
| 646 |
+
# Schedule the next rerun with a delay
|
| 647 |
+
time.sleep(0.1) # 100 ms delay
|
| 648 |
+
st.rerun()
|
| 649 |
+
|
| 650 |
+
# --- Visualization ---
|
| 651 |
+
# Use placeholders to potentially update plots faster
|
| 652 |
+
grid_placeholder = st.empty()
|
| 653 |
+
charts_placeholder = st.container() # Use a container for the two charts
|
| 654 |
+
|
| 655 |
+
if st.session_state.simulation:
|
| 656 |
+
sim = st.session_state.simulation
|
| 657 |
+
|
| 658 |
+
# --- Plotly Grid Visualization ---
|
| 659 |
+
with grid_placeholder.container(): # Draw in the placeholder
|
| 660 |
+
st.subheader(f"Simulation Grid (Step: {sim.current_step})")
|
| 661 |
+
df_grid = sim.get_plotly_grid_data()
|
| 662 |
+
|
| 663 |
+
fig_grid = go.Figure()
|
| 664 |
+
|
| 665 |
+
if not df_grid.empty:
|
| 666 |
+
# Add scatter trace for cells
|
| 667 |
+
fig_grid.add_trace(go.Scatter(
|
| 668 |
+
x=df_grid['x'],
|
| 669 |
+
y=df_grid['y_plotly'],
|
| 670 |
+
mode='markers',
|
| 671 |
+
marker=dict(
|
| 672 |
+
color=df_grid['Color'],
|
| 673 |
+
size=max(5, 400 / sim.grid_size), # Adjust marker size based on grid size
|
| 674 |
+
symbol='square'
|
| 675 |
+
),
|
| 676 |
+
text=df_grid['Info'], # Text appearing on hover
|
| 677 |
+
hoverinfo='text',
|
| 678 |
+
showlegend=False
|
| 679 |
+
))
|
| 680 |
+
|
| 681 |
+
# Configure layout
|
| 682 |
+
fig_grid.update_layout(
|
| 683 |
+
xaxis=dict(
|
| 684 |
+
range=[-0.5, sim.grid_size - 0.5],
|
| 685 |
+
showgrid=True,
|
| 686 |
+
gridcolor='lightgrey',
|
| 687 |
+
zeroline=False,
|
| 688 |
+
showticklabels=False,
|
| 689 |
+
fixedrange=True # Prevent zoom/pan
|
| 690 |
+
),
|
| 691 |
+
yaxis=dict(
|
| 692 |
+
range=[-0.5, sim.grid_size - 0.5],
|
| 693 |
+
showgrid=True,
|
| 694 |
+
gridcolor='lightgrey',
|
| 695 |
+
zeroline=False,
|
| 696 |
+
showticklabels=False,
|
| 697 |
+
scaleanchor="x", # Ensure square cells
|
| 698 |
+
scaleratio=1,
|
| 699 |
+
fixedrange=True # Prevent zoom/pan
|
| 700 |
+
),
|
| 701 |
+
width=min(600, 800), # Adjust size as needed
|
| 702 |
+
height=min(600, 800),
|
| 703 |
+
margin=dict(l=10, r=10, t=40, b=10),
|
| 704 |
+
paper_bgcolor='white',
|
| 705 |
+
plot_bgcolor='white',
|
| 706 |
+
# Add manual legend items if needed, or rely on text/color
|
| 707 |
+
legend=dict(
|
| 708 |
+
itemsizing='constant',
|
| 709 |
+
orientation="h",
|
| 710 |
+
yanchor="bottom",
|
| 711 |
+
y=1.02,
|
| 712 |
+
xanchor="right",
|
| 713 |
+
x=1
|
| 714 |
+
)
|
| 715 |
+
)
|
| 716 |
+
# Add dummy traces for legend (if desired)
|
| 717 |
+
fig_grid.add_trace(go.Scatter(x=[None], y=[None], mode='markers', marker=dict(color=CancerCell.COLOR, size=10, symbol='square'), name='Cancer Cell'))
|
| 718 |
+
fig_grid.add_trace(go.Scatter(x=[None], y=[None], mode='markers', marker=dict(color=ImmuneCell.COLOR, size=10, symbol='square'), name='Immune Cell'))
|
| 719 |
+
|
| 720 |
+
st.plotly_chart(fig_grid, use_container_width=True) # Make it responsive
|
| 721 |
+
|
| 722 |
+
|
| 723 |
+
# --- Time Series Plots ---
|
| 724 |
+
with charts_placeholder: # Draw in the placeholder
|
| 725 |
+
st.divider()
|
| 726 |
+
col_chart1, col_chart2 = st.columns(2)
|
| 727 |
+
|
| 728 |
+
if not st.session_state.history_df.empty:
|
| 729 |
+
df_history = st.session_state.history_df
|
| 730 |
+
|
| 731 |
+
with col_chart1:
|
| 732 |
+
st.subheader("Cell Counts Over Time")
|
| 733 |
+
df_melt = df_history.melt(id_vars=['Step'],
|
| 734 |
+
value_vars=['Cancer Cells', 'Immune Cells'],
|
| 735 |
+
var_name='Cell Type', value_name='Count')
|
| 736 |
+
|
| 737 |
+
fig_line = px.line(df_melt, x='Step', y='Count', color='Cell Type',
|
| 738 |
+
title="Population Dynamics", markers=False, # Use markers=False for potentially smoother continuous updates
|
| 739 |
+
color_discrete_map={'Cancer Cells': CancerCell.COLOR, 'Immune Cells': ImmuneCell.COLOR})
|
| 740 |
+
fig_line.update_layout(legend_title_text='Cell Type')
|
| 741 |
+
st.plotly_chart(fig_line, use_container_width=True)
|
| 742 |
+
|
| 743 |
+
with col_chart2:
|
| 744 |
+
st.subheader("Average Cancer Cell Drug Resistance")
|
| 745 |
+
fig_res = px.line(df_history, x='Step', y='Average Resistance',
|
| 746 |
+
title="Average Resistance", markers=False) # Use markers=False
|
| 747 |
+
fig_res.update_yaxes(range=[0, 1.05]) # Resistance is between 0 and 1, add buffer
|
| 748 |
+
st.plotly_chart(fig_res, use_container_width=True)
|
| 749 |
+
|
| 750 |
+
elif st.session_state.simulation: # If sim exists but no history yet (step 0)
|
| 751 |
+
st.info("Run the simulation to see the plots.")
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
else:
|
| 755 |
+
st.info("Click 'Start / Restart Simulation' to begin.")
|
| 756 |
+
|
| 757 |
+
# Add some explanations at the bottom as well if desired
|
| 758 |
+
# st.markdown(""" --- Explanation ... """)
|
| 759 |
|
|
|
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiofiles==22.1.0
|
| 2 |
+
aiosqlite==0.18.0
|
| 3 |
+
altair==5.5.0
|
| 4 |
+
anyio==3.6.2
|
| 5 |
+
argon2-cffi==21.3.0
|
| 6 |
+
argon2-cffi-bindings==21.2.0
|
| 7 |
+
arrow==1.2.3
|
| 8 |
+
asttokens==2.2.1
|
| 9 |
+
attrs==22.2.0
|
| 10 |
+
Babel==2.12.1
|
| 11 |
+
backcall==0.2.0
|
| 12 |
+
beautifulsoup4==4.11.2
|
| 13 |
+
bleach==6.0.0
|
| 14 |
+
blinker==1.9.0
|
| 15 |
+
brotlipy==0.7.0
|
| 16 |
+
cachetools==5.5.2
|
| 17 |
+
certifi==2021.10.8
|
| 18 |
+
cffi @ file:///opt/conda/conda-bld/cffi_1642701102775/work
|
| 19 |
+
charset-normalizer @ file:///tmp/build/80754af9/charset-normalizer_1630003229654/work
|
| 20 |
+
click==8.1.8
|
| 21 |
+
colorama @ file:///tmp/build/80754af9/colorama_1607707115595/work
|
| 22 |
+
comm==0.1.2
|
| 23 |
+
conda==4.12.0
|
| 24 |
+
conda-content-trust @ file:///tmp/build/80754af9/conda-content-trust_1617045594566/work
|
| 25 |
+
conda-package-handling @ file:///tmp/build/80754af9/conda-package-handling_1649105784853/work
|
| 26 |
+
contourpy==1.3.0
|
| 27 |
+
cryptography @ file:///tmp/build/80754af9/cryptography_1639414572950/work
|
| 28 |
+
cycler==0.12.1
|
| 29 |
+
debugpy==1.6.6
|
| 30 |
+
decorator==5.1.1
|
| 31 |
+
defusedxml==0.7.1
|
| 32 |
+
executing==1.2.0
|
| 33 |
+
fastjsonschema==2.16.3
|
| 34 |
+
fonttools==4.56.0
|
| 35 |
+
fqdn==1.5.1
|
| 36 |
+
gitdb==4.0.12
|
| 37 |
+
GitPython==3.1.44
|
| 38 |
+
idna @ file:///tmp/build/80754af9/idna_1637925883363/work
|
| 39 |
+
importlib-metadata==6.0.0
|
| 40 |
+
importlib_resources==6.5.2
|
| 41 |
+
ipykernel==6.21.3
|
| 42 |
+
ipython==8.11.0
|
| 43 |
+
ipython-genutils==0.2.0
|
| 44 |
+
isoduration==20.11.0
|
| 45 |
+
jedi==0.18.2
|
| 46 |
+
Jinja2==3.1.2
|
| 47 |
+
json5==0.9.11
|
| 48 |
+
jsonpointer==2.3
|
| 49 |
+
jsonschema==4.17.3
|
| 50 |
+
jupyter-events==0.6.3
|
| 51 |
+
jupyter-ydoc==0.2.2
|
| 52 |
+
jupyter_client==8.0.3
|
| 53 |
+
jupyter_core==5.2.0
|
| 54 |
+
jupyter_server==2.4.0
|
| 55 |
+
jupyter_server_fileid==0.8.0
|
| 56 |
+
jupyter_server_terminals==0.4.4
|
| 57 |
+
jupyter_server_ydoc==0.6.1
|
| 58 |
+
jupyterlab==3.6.1
|
| 59 |
+
jupyterlab-pygments==0.2.2
|
| 60 |
+
jupyterlab_server==2.20.0
|
| 61 |
+
kiwisolver==1.4.7
|
| 62 |
+
MarkupSafe==2.1.2
|
| 63 |
+
matplotlib==3.9.4
|
| 64 |
+
matplotlib-inline==0.1.6
|
| 65 |
+
mistune==2.0.5
|
| 66 |
+
narwhals==1.32.0
|
| 67 |
+
nbclassic==0.5.3
|
| 68 |
+
nbclient==0.7.2
|
| 69 |
+
nbconvert==7.2.9
|
| 70 |
+
nbformat==5.7.3
|
| 71 |
+
nest-asyncio==1.5.6
|
| 72 |
+
notebook==6.5.3
|
| 73 |
+
notebook_shim==0.2.2
|
| 74 |
+
numpy==2.0.2
|
| 75 |
+
packaging==23.0
|
| 76 |
+
pandas==2.2.3
|
| 77 |
+
pandocfilters==1.5.0
|
| 78 |
+
parso==0.8.3
|
| 79 |
+
pexpect==4.8.0
|
| 80 |
+
pickleshare==0.7.5
|
| 81 |
+
pillow==11.1.0
|
| 82 |
+
platformdirs==3.1.0
|
| 83 |
+
plotly==6.0.1
|
| 84 |
+
prometheus-client==0.16.0
|
| 85 |
+
prompt-toolkit==3.0.38
|
| 86 |
+
protobuf==5.29.4
|
| 87 |
+
psutil==5.9.4
|
| 88 |
+
ptyprocess==0.7.0
|
| 89 |
+
pure-eval==0.2.2
|
| 90 |
+
pyarrow==19.0.1
|
| 91 |
+
pycosat==0.6.3
|
| 92 |
+
pycparser @ file:///tmp/build/80754af9/pycparser_1636541352034/work
|
| 93 |
+
pydeck==0.9.1
|
| 94 |
+
Pygments==2.14.0
|
| 95 |
+
pyOpenSSL @ file:///opt/conda/conda-bld/pyopenssl_1643788558760/work
|
| 96 |
+
pyparsing==3.2.3
|
| 97 |
+
pyrsistent==0.19.3
|
| 98 |
+
PySocks @ file:///tmp/build/80754af9/pysocks_1605305812635/work
|
| 99 |
+
python-dateutil==2.8.2
|
| 100 |
+
python-json-logger==2.0.7
|
| 101 |
+
pytz==2025.2
|
| 102 |
+
PyYAML==6.0
|
| 103 |
+
pyzmq==25.0.0
|
| 104 |
+
requests==2.28.2
|
| 105 |
+
rfc3339-validator==0.1.4
|
| 106 |
+
rfc3986-validator==0.1.1
|
| 107 |
+
ruamel-yaml-conda @ file:///tmp/build/80754af9/ruamel_yaml_1616016711199/work
|
| 108 |
+
Send2Trash==1.8.0
|
| 109 |
+
six @ file:///tmp/build/80754af9/six_1644875935023/work
|
| 110 |
+
smmap==5.0.2
|
| 111 |
+
sniffio==1.3.0
|
| 112 |
+
soupsieve==2.4
|
| 113 |
+
stack-data==0.6.2
|
| 114 |
+
streamlit==1.44.0
|
| 115 |
+
tenacity==9.0.0
|
| 116 |
+
terminado==0.17.1
|
| 117 |
+
tinycss2==1.2.1
|
| 118 |
+
toml==0.10.2
|
| 119 |
+
tomli==2.0.1
|
| 120 |
+
tornado==6.2
|
| 121 |
+
tqdm @ file:///opt/conda/conda-bld/tqdm_1647339053476/work
|
| 122 |
+
traitlets==5.9.0
|
| 123 |
+
typing_extensions==4.13.0
|
| 124 |
+
tzdata==2025.2
|
| 125 |
+
uri-template==1.2.0
|
| 126 |
+
urllib3 @ file:///opt/conda/conda-bld/urllib3_1643638302206/work
|
| 127 |
+
uv==0.6.10
|
| 128 |
+
watchdog==6.0.0
|
| 129 |
+
wcwidth==0.2.6
|
| 130 |
+
webcolors==1.12
|
| 131 |
+
webencodings==0.5.1
|
| 132 |
+
websocket-client==1.5.1
|
| 133 |
+
y-py==0.5.9
|
| 134 |
+
ypy-websocket==0.8.2
|
| 135 |
+
zipp==3.15.0
|