File size: 36,316 Bytes
c89d7cd
01db3bc
c89d7cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01db3bc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
# app.py
import streamlit as st
import numpy as np
import random
import copy
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go # Using graph_objects for more control over the grid
# import matplotlib.pyplot as plt # No longer needed for grid
# import matplotlib.colors # No longer needed for grid
import time # For the delay in continuous run

# --- Simulation Core Classes ---

class Cell:
    """Base class for all cells."""
    def __init__(self, x, y):
        self.x = x
        self.y = y # Represents ROW index in grid (origin top-left)

class CancerCell(Cell):
    """Represents a cancer cell."""
    CELL_TYPE = 1 # Grid representation
    LABEL = 'Cancer'
    COLOR = 'red'
    def __init__(self, x, y, growth_prob, metastasis_prob, resistance, mutation_rate):
        super().__init__(x, y)
        self.growth_prob = growth_prob
        self.metastasis_prob = metastasis_prob
        self.resistance = resistance # 0.0 (susceptible) to 1.0 (fully resistant)
        self.mutation_rate = mutation_rate
        self.is_alive = True

    def attempt_division(self, sim):
        """Attempt to divide into an adjacent empty cell."""
        if random.random() < self.growth_prob:
            neighbors = sim.get_neighbors(self.x, self.y)
            empty_neighbors = [(nx, ny) for nx, ny, cell_type in neighbors if cell_type == 0]
            if empty_neighbors:
                nx, ny = random.choice(empty_neighbors)
                # Create a new cell with possibly mutated properties
                new_cell = copy.deepcopy(self)
                new_cell.x, new_cell.y = nx, ny
                new_cell.mutate() # Mutate the offspring
                return new_cell
        return None

    def attempt_metastasis(self, sim):
        """Attempt to move to a random empty cell on the grid."""
        if random.random() < self.metastasis_prob:
            empty_cells = np.argwhere(sim.grid == 0)
            if len(empty_cells) > 0:
                new_y, new_x = random.choice(empty_cells) # Note: numpy argwhere returns (row, col) -> (y, x)
                # Return the new position for the simulation to handle the move
                return (int(new_x), int(new_y)) # Convert numpy types to int
        return None

    def mutate(self):
        """Potentially mutate resistance and growth probability."""
        if random.random() < self.mutation_rate:
            # Mutate resistance slightly
            self.resistance += random.uniform(-0.1, 0.1)
            self.resistance = max(0.0, min(1.0, self.resistance)) # Keep within [0, 1]
        if random.random() < self.mutation_rate:
             # Mutate growth prob slightly
            self.growth_prob += random.uniform(-0.05, 0.05)
            self.growth_prob = max(0.0, min(1.0, self.growth_prob)) # Keep within [0, 1]

    def check_drug_effect(self, drug_effect_base, drug_resistance_interaction):
        """Check if the drug kills this cell."""
        effective_drug = drug_effect_base * max(0, (1.0 - self.resistance * drug_resistance_interaction))
        if random.random() < effective_drug:
            self.is_alive = False
            return True # Killed by drug
        return False


class ImmuneCell(Cell):
    """Represents an immune cell."""
    CELL_TYPE = 2 # Grid representation
    LABEL = 'Immune'
    COLOR = 'blue'
    def __init__(self, x, y, base_kill_prob, movement_prob, lifespan, activation_boost):
        super().__init__(x, y)
        self.base_kill_prob = base_kill_prob
        self.movement_prob = movement_prob
        self.lifespan = lifespan
        self.activation_boost = activation_boost # Added boost when activated by drug
        self.steps_alive = 0
        self.is_activated = False # Can be temporarily boosted by drug
        self.is_alive = True

    def attempt_move(self, sim):
        """Attempt to move to a random adjacent cell (can be empty or occupied)."""
        if random.random() < self.movement_prob:
            neighbors = sim.get_neighbors(self.x, self.y, radius=1, include_self=False)
            potential_moves = [(nx, ny) for nx, ny, _ in neighbors]
            if potential_moves:
                 # Prioritize moving towards cancer cells slightly
                cancer_neighbors = [(nx, ny) for nx, ny, cell_type in neighbors if cell_type == CancerCell.CELL_TYPE]
                if cancer_neighbors and random.random() < 0.5: # 50% chance to prioritize cancer
                     nx, ny = random.choice(cancer_neighbors)
                else:
                    nx, ny = random.choice(potential_moves)
                 # Return the new position for the simulation to handle the move
                return (nx, ny)
        return None

    def attempt_kill(self, sim):
        """Attempt to kill adjacent cancer cells."""
        killed_coords = []
        neighbors = sim.get_neighbors(self.x, self.y)
        cancer_neighbors = [(nx, ny) for nx, ny, cell_type in neighbors if cell_type == CancerCell.CELL_TYPE]

        current_kill_prob = self.base_kill_prob
        if self.is_activated:
            current_kill_prob += self.activation_boost
            current_kill_prob = min(1.0, current_kill_prob) # Cap at 1.0

        for nx, ny in cancer_neighbors:
            if random.random() < current_kill_prob:
                target_cell = sim.get_cell_at(nx, ny)
                if target_cell and isinstance(target_cell, CancerCell) and target_cell.is_alive:
                    target_cell.is_alive = False
                    killed_coords.append((nx, ny))
        return killed_coords # Return coords of killed cells

    def step(self):
        """Increment age and check lifespan."""
        self.steps_alive += 1
        if self.steps_alive >= self.lifespan:
            self.is_alive = False
        self.is_activated = False # Reset activation each step unless reactivated

    def activate_by_drug(self, drug_immune_boost_prob):
         """Potentially activate based on drug presence."""
         if random.random() < drug_immune_boost_prob:
             self.is_activated = True


class Simulation:
    """Manages the simulation grid and cells."""
    def __init__(self, params):
        self.params = params
        self.grid_size = params['grid_size']
        self.grid = np.zeros((self.grid_size, self.grid_size), dtype=int)
        self.cells = {} # Using dict {(x, y): cell_obj} for faster lookup
        self.history = [] # To store cell counts over time
        self.current_step = 0
        self._initialize_cells()
        self._record_history() # Record initial state

    def _initialize_cells(self):
        """Place initial cells on the grid."""
        center_x, center_y = self.grid_size // 2, self.grid_size // 2

        # Initial Cancer Cells (cluster in the center)
        radius = max(1, int(np.sqrt(self.params['initial_cancer_cells']) / 2))
        count = 0
        placed_coords = set() # Keep track of where we placed cells initially
        for r in range(radius + 2): # Search slightly larger radius if needed
             for x in range(center_x - r, center_x + r + 1):
                 for y in range(center_y - r, center_y + r + 1):
                     if count >= self.params['initial_cancer_cells']: break
                     if 0 <= x < self.grid_size and 0 <= y < self.grid_size:
                         coords = (x,y)
                         if self.grid[y, x] == 0 and coords not in placed_coords: # Ensure cell is placed in empty spot
                            cell = CancerCell(x, y,
                                              self.params['cancer_growth_prob'],
                                              self.params['cancer_metastasis_prob'],
                                              self.params['cancer_initial_resistance'],
                                              self.params['cancer_mutation_rate'])
                            self.grid[y, x] = CancerCell.CELL_TYPE
                            self.cells[coords] = cell
                            placed_coords.add(coords)
                            count += 1
                 if count >= self.params['initial_cancer_cells']: break
             if count >= self.params['initial_cancer_cells']: break


        # Initial Immune Cells (randomly distributed)
        immune_count = 0
        attempts = 0 # Prevent infinite loop if grid is too full
        max_attempts = self.grid_size * self.grid_size * 2
        while immune_count < self.params['initial_immune_cells'] and attempts < max_attempts:
            x, y = random.randint(0, self.grid_size - 1), random.randint(0, self.grid_size - 1)
            coords = (x,y)
            if self.grid[y, x] == 0 and coords not in placed_coords: # Place only in empty spots
                cell = ImmuneCell(x, y,
                                  self.params['immune_base_kill_prob'],
                                  self.params['immune_movement_prob'],
                                  self.params['immune_lifespan'],
                                  self.params['drug_immune_activation_boost'])
                self.grid[y, x] = ImmuneCell.CELL_TYPE
                self.cells[coords] = cell
                placed_coords.add(coords)
                immune_count += 1
            attempts += 1
        if attempts >= max_attempts and immune_count < self.params['initial_immune_cells']:
             st.warning(f"Could only place {immune_count}/{self.params['initial_immune_cells']} immune cells due to space constraints.")


    def get_neighbors(self, x, y, radius=1, include_self=False):
        """Get neighbors within a radius, handling grid boundaries."""
        neighbors = []
        for dx in range(-radius, radius + 1):
            for dy in range(-radius, radius + 1):
                if not include_self and dx == 0 and dy == 0:
                    continue
                nx, ny = x + dx, y + dy
                # Check boundaries (no wrap-around)
                if 0 <= nx < self.grid_size and 0 <= ny < self.grid_size:
                    neighbors.append((nx, ny, self.grid[ny, nx]))
        return neighbors

    def get_cell_at(self, x, y):
        """Retrieve cell object at given coordinates."""
        return self.cells.get((x, y), None)

    def _apply_drug_effects(self):
        """Apply drug effects: killing cancer cells and activating immune cells."""
        killed_by_drug = []
        immune_cells_to_activate = []

        # Iterate through a copy of keys because dict size might change
        for coords, cell in list(self.cells.items()):
            if isinstance(cell, CancerCell) and cell.is_alive:
                if cell.check_drug_effect(self.params['drug_effect_base'], self.params['drug_resistance_interaction']):
                     killed_by_drug.append(coords)
                     # Check for nearby immune cells to activate
                     neighbors = self.get_neighbors(cell.x, cell.y, radius=self.params['drug_immune_activation_radius'])
                     for nx, ny, cell_type in neighbors:
                         if cell_type == ImmuneCell.CELL_TYPE:
                             immune_cell = self.get_cell_at(nx, ny)
                             if immune_cell and immune_cell.is_alive:
                                 immune_cells_to_activate.append(immune_cell)

        # Apply activation (using a set to avoid duplicate activations if multiple cancer cells are nearby)
        for immune_cell in set(immune_cells_to_activate):
             immune_cell.activate_by_drug(self.params['drug_immune_boost_prob']) # Pass prob here

        return killed_by_drug

    def _immune_cell_actions(self):
        """Handle immune cell movement and killing actions."""
        immune_moves = {} # {old_coords: new_coords}
        killed_by_immune = []

        # Iterate through a copy of keys
        immune_cells_list = [cell for cell in self.cells.values() if isinstance(cell, ImmuneCell) and cell.is_alive]
        random.shuffle(immune_cells_list) # Randomize order of action

        for cell in immune_cells_list:
             if not cell.is_alive: continue

             # 1. Aging
             cell.step()
             if not cell.is_alive: continue # Died of old age

             # 2. Attempt Kill
             killed_coords = cell.attempt_kill(self)
             killed_by_immune.extend(killed_coords)

             # 3. Attempt Move (only if it didn't die)
             new_pos = cell.attempt_move(self)
             if new_pos:
                nx, ny = new_pos
                # Check if target is empty OR occupied by a cancer cell immune cell can kill/displace
                # Prevent moving onto another immune cell's intended spot in this step
                # Simplification: allow overlap for now, let update handle conflict
                if new_pos not in immune_moves.values(): # Avoid multiple immune cells targetting same spot
                   immune_moves[(cell.x, cell.y)] = new_pos


        return immune_moves, killed_by_immune

    def _cancer_cell_actions(self):
        """Handle cancer cell division, metastasis, and mutation."""
        new_cancer_cells = []
        cancer_moves = {} # {old_coords: new_coords} for metastasis

        # Iterate through a copy of keys
        cancer_cells_list = [cell for cell in self.cells.values() if isinstance(cell, CancerCell) and cell.is_alive]
        random.shuffle(cancer_cells_list) # Randomize order

        for cell in cancer_cells_list:
            if not cell.is_alive: continue # Could have been killed earlier in the step

            # 1. Mutation (apply first, affects division/metastasis below)
            # Moved mutation inside attempt_division/metastasis for offspring only in prev ver, let's keep it there.
            # Parent mutation should also occur
            cell.mutate() # Parent mutates regardless of division/metastasis

            # 2. Attempt Division
            offspring = cell.attempt_division(self) # Offspring inherits parent's (possibly mutated) state and then mutates itself
            if offspring:
                 # Check if the target spot is still empty (could have been taken by metastasis/immune move)
                 # This check will happen more definitively in _update_grid_and_cells
                 new_cancer_cells.append(offspring)

            # 3. Attempt Metastasis (only if division didn't occur?) Let's allow both for now.
            new_pos = cell.attempt_metastasis(self)
            if new_pos:
                # Check if the target spot is still empty AND not targeted by another metastasis
                # Defer final check to _update_grid_and_cells
                if new_pos not in cancer_moves.values(): # Avoid multiple metastases targeting same spot
                   cancer_moves[(cell.x, cell.y)] = new_pos

        return new_cancer_cells, cancer_moves


    def _update_grid_and_cells(self, killed_coords_list, immune_moves, cancer_moves, new_cancer_cells):
        """Update the grid and cell dictionary based on actions."""

        # 1. Process deaths first
        all_killed_coords = set()
        for coords_list in killed_coords_list:
            all_killed_coords.update(coords_list)

        # Also add immune cells that died of old age
        for coords, cell in list(self.cells.items()):
             if isinstance(cell, ImmuneCell) and not cell.is_alive:
                 all_killed_coords.add(coords)

        for x, y in all_killed_coords:
            if (x, y) in self.cells:
                self.grid[y, x] = 0
                if (x, y) in self.cells: # Check again, might be double-killed?
                    del self.cells[(x, y)]

        # --- Resolve Move Conflicts & Update ---
        # Priority: Immune > Cancer Metastasis. If conflict, immune wins, cancer move fails.
        # If immune A wants to move to B, and immune C wants to move to B, one fails randomly.
        # If cancer A wants to move to B, and cancer C wants to move to B, one fails randomly.
        # If immune A wants to move to B, and cancer C wants to move to B, immune wins.

        occupied_targets = set()
        resolved_immune_moves = {} # {old_coords: new_coords}
        resolved_cancer_moves = {} # {old_coords: new_coords}

        # Shuffle move order for fairness in conflicts
        immune_move_items = list(immune_moves.items())
        random.shuffle(immune_move_items)
        cancer_move_items = list(cancer_moves.items())
        random.shuffle(cancer_move_items)

        # Process immune moves first
        for old_coords, new_coords in immune_move_items:
            if old_coords not in self.cells: continue # Cell died before moving
            if new_coords not in occupied_targets:
                target_cell = self.get_cell_at(new_coords[0], new_coords[1])
                # Allow move if target is empty, or is a cancer cell (implicit displacement/kill)
                if target_cell is None or isinstance(target_cell, CancerCell):
                     resolved_immune_moves[old_coords] = new_coords
                     occupied_targets.add(new_coords)
                # else: blocked by another immune cell already there or moving there

        # Process cancer metastasis moves
        for old_coords, new_coords in cancer_move_items:
            if old_coords not in self.cells: continue # Cell died before moving
            if new_coords not in occupied_targets:
                 target_cell = self.get_cell_at(new_coords[0], new_coords[1])
                 # Allow move ONLY if target is empty
                 if target_cell is None:
                     resolved_cancer_moves[old_coords] = new_coords
                     occupied_targets.add(new_coords)
                 # else: blocked by existing cell or an immune cell moving there


        # Apply moves: remove old, add new
        moved_cells_buffer = {} # Store {new_coords: cell} before adding back

        for old_coords, new_coords in resolved_immune_moves.items():
            if old_coords in self.cells: # Check if cell still exists
                cell = self.cells.pop(old_coords)
                self.grid[old_coords[1], old_coords[0]] = 0
                cell.x, cell.y = new_coords
                moved_cells_buffer[new_coords] = cell

        for old_coords, new_coords in resolved_cancer_moves.items():
             if old_coords in self.cells: # Check if cell still exists
                cell = self.cells.pop(old_coords)
                self.grid[old_coords[1], old_coords[0]] = 0
                cell.x, cell.y = new_coords
                moved_cells_buffer[new_coords] = cell

        # Add moved cells back, handling displacement
        for new_coords, cell in moved_cells_buffer.items():
            # If an immune cell lands on a cancer cell's spot, the cancer cell should be gone.
            if isinstance(cell, ImmuneCell) and new_coords in self.cells and isinstance(self.cells[new_coords], CancerCell):
                 del self.cells[new_coords] # Remove the displaced cancer cell

            # Place the moved cell
            self.cells[new_coords] = cell
            self.grid[new_coords[1], new_coords[0]] = cell.CELL_TYPE


        # 3. Process births (add new cancer cells)
        # Shuffle order for fairness if multiple births target same location (unlikely but possible)
        random.shuffle(new_cancer_cells)
        added_cells_count = 0
        for cell in new_cancer_cells:
            coords = (cell.x, cell.y)
            # Final check if the spot is truly empty *after* deaths and moves
            if self.grid[cell.y, cell.x] == 0 and coords not in self.cells:
                self.grid[cell.y, cell.x] = CancerCell.CELL_TYPE
                self.cells[coords] = cell
                added_cells_count += 1
            # else: Birth failed due to space conflict

    def step(self):
        """Perform one step of the simulation."""
        # Check if simulation should stop before proceeding
        cancer_count = sum(1 for cell in self.cells.values() if isinstance(cell, CancerCell))
        if cancer_count == 0 and self.current_step > 0: # Check after at least one step
             st.session_state.final_message = "Cancer eliminated!"
             return False # Stop simulation
        if self.current_step >= self.params['max_steps']:
             st.session_state.final_message = "Maximum steps reached."
             return False # Stop simulation
        if not self.cells: # Stop if absolutely no cells left for some reason
             st.session_state.final_message = "No cells remaining."
             return False

        # --- Action Phase ---
        # 1. Drug effects (kill cancer, activate immune)
        killed_by_drug = self._apply_drug_effects()

        # 2. Immune cell actions (move, kill, age)
        immune_moves, killed_by_immune = self._immune_cell_actions()

        # 3. Cancer cell actions (divide, metastasize) - Mutation happens within these
        new_cancer_cells, cancer_moves = self._cancer_cell_actions()

        # --- Update Phase ---
        # Consolidate killed cells list
        all_killed_this_step = [killed_by_drug, killed_by_immune]

        # Apply all changes to grid and cell list
        self._update_grid_and_cells(all_killed_this_step, immune_moves, cancer_moves, new_cancer_cells)

        # --- End Step ---
        self.current_step += 1
        self._record_history()


        return True # Continue simulation


    def _record_history(self):
        """Record the number of each cell type."""
        cancer_count = sum(1 for cell in self.cells.values() if isinstance(cell, CancerCell))
        immune_count = sum(1 for cell in self.cells.values() if isinstance(cell, ImmuneCell))
        avg_resistance = 0
        if cancer_count > 0:
            avg_resistance = sum(cell.resistance for cell in self.cells.values() if isinstance(cell, CancerCell)) / cancer_count

        self.history.append({
            'Step': self.current_step,
            'Cancer Cells': cancer_count,
            'Immune Cells': immune_count,
            'Average Resistance': avg_resistance
        })

    def get_history_df(self):
        """Return the recorded history as a Pandas DataFrame."""
        return pd.DataFrame(self.history)

    def get_plotly_grid_data(self):
        """Prepare data for Plotly scatter plot grid visualization."""
        if not self.cells:
            return pd.DataFrame(columns=['x', 'y_plotly', 'Type', 'Color', 'Resistance', 'Info'])

        cell_data = []
        for coords, cell in self.cells.items():
            # Plotly scatter typically has origin at bottom-left.
            # Our grid (y, x) has origin top-left. Transform y for plotting.
            plotly_y = self.grid_size - 1 - cell.y
            info_str = f"Type: {cell.LABEL}<br>Pos: ({cell.x}, {cell.y})"
            resistance = None
            if isinstance(cell, CancerCell):
                resistance = round(cell.resistance, 2)
                info_str += f"<br>Resistance: {resistance}"
            elif isinstance(cell, ImmuneCell):
                 info_str += f"<br>Steps Alive: {cell.steps_alive}"
                 if cell.is_activated:
                      info_str += "<br>Status: Activated"


            cell_data.append({
                'x': cell.x,
                'y_plotly': plotly_y,
                'Type': cell.LABEL,
                'Color': cell.COLOR,
                'Resistance': resistance, # Store for potential coloring/hover later
                'Info': info_str
            })
        return pd.DataFrame(cell_data)

# --- Streamlit App ---

st.set_page_config(layout="wide")
st.title("Cancer Simulation: Tumor Growth, Immune Response & Drug Treatment")

# --- Instructions ---
st.markdown("""
Welcome to the Cancer Simulation!
*   Use the **sidebar** on the left to set the initial parameters for the simulation.
*   Click **Start / Restart Simulation** to initialize the grid with the chosen parameters.
*   **Run N Step(s):** Executes a fixed number of simulation steps.
*   **Run Continuously:** Automatically runs the simulation step-by-step with a short delay (approx. 100ms) between steps. The grid and plots will update dynamically.
*   **Stop:** Pauses the simulation (either manual steps or continuous run).
*   The **Simulation Grid** visualizes the cells (Red=Cancer, Blue=Immune). Hover over cells for details.
*   The **Plots** below the grid show population dynamics and average cancer cell resistance over time.
""")
st.divider()


# --- Parameters Sidebar ---
with st.sidebar:
    st.header("Simulation Parameters")

    st.subheader("Grid & General")
    grid_size = st.slider("Grid Size (N x N)", 20, 100, 50, key="grid_size_slider")
    max_steps = st.number_input("Max Simulation Steps", 50, 1000, 200, key="max_steps_input")

    st.subheader("Initial Cells")
    initial_cancer_cells = st.slider("Initial Cancer Cells", 1, max(1,grid_size*grid_size//4), 10, key="init_cancer_slider") # Limit initial cells
    initial_immune_cells = st.slider("Initial Immune Cells", 0, max(1,grid_size*grid_size//2), 50, key="init_immune_slider")

    st.subheader("Cancer Cell Properties")
    cancer_growth_prob = st.slider("Growth Probability", 0.0, 1.0, 0.2, 0.01, key="cancer_growth_slider")
    cancer_metastasis_prob = st.slider("Metastasis Probability", 0.0, 0.1, 0.005, 0.001, format="%.3f", key="cancer_meta_slider")
    cancer_initial_resistance = st.slider("Initial Drug Resistance", 0.0, 1.0, 0.1, 0.01, key="cancer_res_slider")
    cancer_mutation_rate = st.slider("Mutation Rate", 0.0, 0.1, 0.01, 0.001, format="%.3f", key="cancer_mut_slider")

    st.subheader("Immune Cell Properties")
    immune_base_kill_prob = st.slider("Base Kill Probability", 0.0, 1.0, 0.3, 0.01, key="immune_kill_slider")
    immune_movement_prob = st.slider("Movement Probability", 0.0, 1.0, 0.8, 0.01, key="immune_move_slider")
    immune_lifespan = st.number_input("Lifespan (steps)", 10, 500, 100, key="immune_life_input")

    st.subheader("Drug Properties")
    drug_effect_base = st.slider("Base Drug Effect (Kill Prob)", 0.0, 1.0, 0.4, 0.01, key="drug_effect_slider")
    drug_resistance_interaction = st.slider("Resistance Interaction Factor", 0.0, 2.0, 1.0, 0.05, help="How much resistance reduces drug effect (1.0=linear)", key="drug_resint_slider")
    drug_immune_activation_boost = st.slider("Immune Activation Boost", 0.0, 1.0, 0.3, 0.01, help="Added kill prob when activated", key="drug_immune_boost_slider")
    drug_immune_boost_prob = st.slider("Immune Activation Probability", 0.0, 1.0, 0.7, 0.01, help="Prob. an immune cell near dying cancer gets activated", key="drug_immune_prob_slider")
    drug_immune_activation_radius = st.slider("Immune Activation Radius", 0, 5, 1, help="Radius around dying cancer cell to activate immune cells", key="drug_immune_rad_slider")


# Store parameters in a dictionary
simulation_params = {
    'grid_size': grid_size,
    'max_steps': max_steps,
    'initial_cancer_cells': initial_cancer_cells,
    'initial_immune_cells': initial_immune_cells,
    'cancer_growth_prob': cancer_growth_prob,
    'cancer_metastasis_prob': cancer_metastasis_prob,
    'cancer_initial_resistance': cancer_initial_resistance,
    'cancer_mutation_rate': cancer_mutation_rate,
    'immune_base_kill_prob': immune_base_kill_prob,
    'immune_movement_prob': immune_movement_prob,
    'immune_lifespan': immune_lifespan,
    'drug_effect_base': drug_effect_base,
    'drug_resistance_interaction': drug_resistance_interaction,
    'drug_immune_activation_boost': drug_immune_activation_boost,
    'drug_immune_boost_prob': drug_immune_boost_prob,
    'drug_immune_activation_radius': drug_immune_activation_radius,
}

# --- Simulation Control and State ---

# Initialize simulation state
if 'simulation' not in st.session_state:
    st.session_state.simulation = None
    st.session_state.running = False # Overall simulation active (not paused/stopped)
    st.session_state.continuously_running = False # Auto-step mode active
    st.session_state.history_df = pd.DataFrame()
    st.session_state.final_message = "" # To display end condition

col1, col2, col3, col4 = st.columns(4)

with col1:
    if st.button("Start / Restart Simulation", key="start_button"):
        st.session_state.simulation = Simulation(copy.deepcopy(simulation_params)) # Use deepcopy for params
        st.session_state.running = True
        st.session_state.continuously_running = False # Stop continuous if restarting
        st.session_state.history_df = st.session_state.simulation.get_history_df()
        st.session_state.final_message = ""
        st.success("Simulation Initialized.")
        st.rerun() # Rerun to update displays immediately

with col2:
    steps_to_run = st.number_input("Run Steps", min_value=1, max_value=max_steps, value=10, key="steps_input_manual")
    run_button = st.button(f"Run {steps_to_run} Step(s)", disabled=(st.session_state.simulation is None or not st.session_state.running or st.session_state.continuously_running), key="run_steps_button")

    if run_button:
        sim = st.session_state.simulation
        if sim:
            progress_bar = st.progress(0)
            steps_taken = 0
            for i in range(steps_to_run):
                if not st.session_state.running: break # Check if stopped externally
                keep_running = sim.step()
                steps_taken += 1
                # Need to update history inside loop if we want live plot updates during manual steps, but simpler to update after
                if not keep_running:
                    st.session_state.running = False # Simulation ended naturally
                    break
                progress_bar.progress((i + 1) / steps_to_run)

            progress_bar.empty()
            st.session_state.history_df = sim.get_history_df() # Update history after batch run
            st.info(f"Ran {steps_taken} steps. Current step: {sim.current_step}")
            if not st.session_state.running and st.session_state.final_message:
                 st.success(st.session_state.final_message) # Show end reason
            st.rerun() # Update displays

with col3:
     run_cont_button = st.button("Run Continuously", disabled=(st.session_state.simulation is None or not st.session_state.running or st.session_state.continuously_running), key="run_cont_button")
     if run_cont_button:
         st.session_state.continuously_running = True
         st.info("Running continuously...")
         st.rerun() # Start the continuous loop

with col4:
     stop_button = st.button("Stop", disabled=(st.session_state.simulation is None or (not st.session_state.running) or (not st.session_state.continuously_running and not run_button) ), key="stop_button") # Enable if running or continuously running
     if stop_button:
         st.session_state.running = False # Stop the simulation process
         st.session_state.continuously_running = False # Turn off continuous mode
         st.warning("Simulation stopped by user.")
         st.rerun()


# --- Dynamic Update Logic for Continuous Run ---
if st.session_state.get('simulation') and st.session_state.get('continuously_running') and st.session_state.get('running'):
    sim = st.session_state.simulation
    keep_running = sim.step()
    st.session_state.history_df = sim.get_history_df() # Update history

    if not keep_running:
        st.session_state.running = False # Simulation ended naturally
        st.session_state.continuously_running = False # Stop continuous mode
        if st.session_state.final_message:
            st.success(st.session_state.final_message) # Show end reason

    # Schedule the next rerun with a delay
    time.sleep(0.1) # 100 ms delay
    st.rerun()

# --- Visualization ---
# Use placeholders to potentially update plots faster
grid_placeholder = st.empty()
charts_placeholder = st.container() # Use a container for the two charts

if st.session_state.simulation:
    sim = st.session_state.simulation

    # --- Plotly Grid Visualization ---
    with grid_placeholder.container(): # Draw in the placeholder
        st.subheader(f"Simulation Grid (Step: {sim.current_step})")
        df_grid = sim.get_plotly_grid_data()

        fig_grid = go.Figure()

        if not df_grid.empty:
             # Add scatter trace for cells
             fig_grid.add_trace(go.Scatter(
                 x=df_grid['x'],
                 y=df_grid['y_plotly'],
                 mode='markers',
                 marker=dict(
                     color=df_grid['Color'],
                     size=max(5, 400 / sim.grid_size), # Adjust marker size based on grid size
                     symbol='square'
                 ),
                 text=df_grid['Info'], # Text appearing on hover
                 hoverinfo='text',
                 showlegend=False
             ))

        # Configure layout
        fig_grid.update_layout(
            xaxis=dict(
                range=[-0.5, sim.grid_size - 0.5],
                showgrid=True,
                gridcolor='lightgrey',
                zeroline=False,
                showticklabels=False,
                fixedrange=True # Prevent zoom/pan
            ),
            yaxis=dict(
                range=[-0.5, sim.grid_size - 0.5],
                showgrid=True,
                gridcolor='lightgrey',
                zeroline=False,
                showticklabels=False,
                scaleanchor="x", # Ensure square cells
                scaleratio=1,
                fixedrange=True # Prevent zoom/pan
            ),
            width=min(600, 800), # Adjust size as needed
            height=min(600, 800),
            margin=dict(l=10, r=10, t=40, b=10),
            paper_bgcolor='white',
            plot_bgcolor='white',
            # Add manual legend items if needed, or rely on text/color
             legend=dict(
                itemsizing='constant',
                orientation="h",
                yanchor="bottom",
                y=1.02,
                xanchor="right",
                x=1
            )
        )
        # Add dummy traces for legend (if desired)
        fig_grid.add_trace(go.Scatter(x=[None], y=[None], mode='markers', marker=dict(color=CancerCell.COLOR, size=10, symbol='square'), name='Cancer Cell'))
        fig_grid.add_trace(go.Scatter(x=[None], y=[None], mode='markers', marker=dict(color=ImmuneCell.COLOR, size=10, symbol='square'), name='Immune Cell'))

        st.plotly_chart(fig_grid, use_container_width=True) # Make it responsive


    # --- Time Series Plots ---
    with charts_placeholder: # Draw in the placeholder
        st.divider()
        col_chart1, col_chart2 = st.columns(2)

        if not st.session_state.history_df.empty:
            df_history = st.session_state.history_df

            with col_chart1:
                st.subheader("Cell Counts Over Time")
                df_melt = df_history.melt(id_vars=['Step'],
                                          value_vars=['Cancer Cells', 'Immune Cells'],
                                          var_name='Cell Type', value_name='Count')

                fig_line = px.line(df_melt, x='Step', y='Count', color='Cell Type',
                                   title="Population Dynamics", markers=False, # Use markers=False for potentially smoother continuous updates
                                   color_discrete_map={'Cancer Cells': CancerCell.COLOR, 'Immune Cells': ImmuneCell.COLOR})
                fig_line.update_layout(legend_title_text='Cell Type')
                st.plotly_chart(fig_line, use_container_width=True)

            with col_chart2:
                st.subheader("Average Cancer Cell Drug Resistance")
                fig_res = px.line(df_history, x='Step', y='Average Resistance',
                                  title="Average Resistance", markers=False) # Use markers=False
                fig_res.update_yaxes(range=[0, 1.05]) # Resistance is between 0 and 1, add buffer
                st.plotly_chart(fig_res, use_container_width=True)

        elif st.session_state.simulation: # If sim exists but no history yet (step 0)
             st.info("Run the simulation to see the plots.")


else:
    st.info("Click 'Start / Restart Simulation' to begin.")

# Add some explanations at the bottom as well if desired
# st.markdown(""" --- Explanation ... """)