Spaces:
Sleeping
Sleeping
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
|