Dror Hilman commited on
Commit
c89d7cd
·
1 Parent(s): 01db3bc

simulation

Browse files
Files changed (2) hide show
  1. app.py +757 -2
  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