Skip to content

Class ParticipationModel

Bases: Model

The ParticipationModel class provides a base environment for multi-agent simulations within a grid-based world (split into territories) that reacts dynamically to frequently held collective decision-making processes ("elections"). It incorporates voting agents with personalities, color cells (grid fields), and areas (election territories). This model is designed to analyze different voting rules and their impact.

This class provides mechanisms for creating and managing cells, agents, and areas, along with data collection for analysis. Colors in the model mutate depending on a predefined mutation rate and are influenced by elections. Agents interact based on their personalities, knowledge, and experiences.

Attributes:

Name Type Description
grid SingleGrid

Grid representing the environment with a single occupancy per cell (the color).

height int

The height of the grid.

width int

The width of the grid.

colors ndarray

Array containing the unique color identifiers.

voting_rule Callable

A function defining the social welfare function to aggregate agent preferences. This callable typically takes agent rankings as input and returns a single aggregate result.

distance_func Callable

A function used to calculate a distance metric when comparing rankings. It takes two rankings and returns a numeric distance score.

mu float

Mutation rate; the probability of each color cell to mutate after an elections.

color_probs ndarray

Probabilities used to determine individual color mutation outcomes.

options ndarray

Matrix (array of arrays) where each subarray represents an option (color-ranking) available to agents.

option_vec ndarray

Array holding the indices of the available options for computational efficiency.

color_cells list[ColorCell]

List of all color cells. Initialized during the model setup.

voting_agents list[VoteAgent]

List of all voting agents. Initialized during the model setup.

personalities list

List of unique personalities available for agents.

personality_distribution ndarray

The (global) probability distribution of personalities among all agents.

areas list[Area]

List of areas (regions or territories within the grid) in which elections take place. Initialized during model setup.

global_area Area

The area encompassing the entire grid.

av_area_height int

Average height of areas in the simulation.

av_area_width int

Average width of areas created in the simulation.

area_size_variance float

Variance in area sizes to introduce non-uniformity among election territories.

common_assets int

Total resources to be distributed among all agents.

av_area_color_dst ndarray

Current (area)-average color distribution.

election_costs float

Cost associated with participating in elections.

max_reward float

Maximum reward possible for an agent each election.

known_cells int

Number of cells each agent knows the color of.

datacollector DataCollector

A tool for collecting data (metrics and statistics) at each simulation step.

scheduler CustomScheduler

The scheduler responsible for executing the step function.

draw_borders bool

Only for visualization (no effect on simulation).

_preset_color_dst ndarray

A predefined global color distribution (set randomly) that affects cell initialization globally.

Source code in democracy_sim/participation_model.py
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
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
class ParticipationModel(mesa.Model):
    """
    The ParticipationModel class provides a base environment for
    multi-agent simulations within a grid-based world (split into territories)
    that reacts dynamically to frequently held collective decision-making
    processes ("elections"). It incorporates voting agents with personalities,
    color cells (grid fields), and areas (election territories). This model is
    designed to analyze different voting rules and their impact.

    This class provides mechanisms for creating and managing cells, agents,
    and areas, along with data collection for analysis. Colors in the model
    mutate depending on a predefined mutation rate and are influenced by
    elections. Agents interact based on their personalities, knowledge, and
    experiences.

    Attributes:
        grid (mesa.space.SingleGrid): Grid representing the environment
            with a single occupancy per cell (the color).
        height (int): The height of the grid.
        width (int): The width of the grid.
        colors (ndarray): Array containing the unique color identifiers.
        voting_rule (Callable): A function defining the social welfare
            function to aggregate agent preferences. This callable typically
            takes agent rankings as input and returns a single aggregate result.
        distance_func (Callable): A function used to calculate a
            distance metric when comparing rankings. It takes two rankings
            and returns a numeric distance score.
        mu (float): Mutation rate; the probability of each color cell to mutate
            after an elections.
        color_probs (ndarray):
            Probabilities used to determine individual color mutation outcomes.
        options (ndarray): Matrix (array of arrays) where each subarray
            represents an option (color-ranking) available to agents.
        option_vec (ndarray): Array holding the indices of the available options
            for computational efficiency.
        color_cells (list[ColorCell]): List of all color cells.
            Initialized during the model setup.
        voting_agents (list[VoteAgent]): List of all voting agents.
            Initialized during the model setup.
        personalities (list): List of unique personalities available for agents.
        personality_distribution (ndarray): The (global) probability
            distribution of personalities among all agents.
        areas (list[Area]): List of areas (regions or territories within the
            grid) in which elections take place. Initialized during model setup.
        global_area (Area): The area encompassing the entire grid.
        av_area_height (int): Average height of areas in the simulation.
        av_area_width (int): Average width of areas created in the simulation.
        area_size_variance (float): Variance in area sizes to introduce
            non-uniformity among election territories.
        common_assets (int): Total resources to be distributed among all agents.
        av_area_color_dst (ndarray): Current (area)-average color distribution.
        election_costs (float): Cost associated with participating in elections.
        max_reward (float): Maximum reward possible for an agent each election.
        known_cells (int): Number of cells each agent knows the color of.
        datacollector (mesa.DataCollector): A tool for collecting data
            (metrics and statistics) at each simulation step.
        scheduler (CustomScheduler): The scheduler responsible for executing the
            step function.
        draw_borders (bool): Only for visualization (no effect on simulation).
        _preset_color_dst (ndarray): A predefined global color distribution
            (set randomly) that affects cell initialization globally.
        """

    def __init__(self, height, width, num_agents, num_colors, num_personalities,
                 mu, election_impact_on_mutation, common_assets, known_cells,
                 num_areas, av_area_height, av_area_width, area_size_variance,
                 patch_power, color_patches_steps, draw_borders, heterogeneity,
                 rule_idx, distance_idx, election_costs, max_reward,
                 show_area_stats):
        super().__init__()
        # TODO clean up class (public/private variables)
        self.height = height
        self.width = width
        self.colors = np.arange(num_colors)
        # Create a scheduler that goes through areas first then color cells
        self.scheduler = CustomScheduler(self)
        # The grid
        # SingleGrid enforces at most one agent per cell;
        # MultiGrid allows multiple agents to be in the same cell.
        self.grid = mesa.space.SingleGrid(height=height, width=width, torus=True)
        # Random bias factors that affect the initial color distribution
        self._vertical_bias = self.random.uniform(0, 1)
        self._horizontal_bias = self.random.uniform(0, 1)
        self.draw_borders = draw_borders
        # Color distribution (global)
        self._preset_color_dst = self.create_color_distribution(heterogeneity)
        self._av_area_color_dst = self._preset_color_dst
        # Elections
        self.election_costs = election_costs
        self.max_reward = max_reward
        self.known_cells = known_cells  # Integer
        self.voting_rule = social_welfare_functions[rule_idx]
        self.distance_func = distance_functions[distance_idx]
        self.options = self.create_all_options(num_colors)
        # Simulation variables
        self.mu = mu  # Mutation rate for the color cells (0.1 = 10 % mutate)
        self.common_assets = common_assets
        # Election impact factor on color mutation through a probability array
        self.color_probs = self.init_color_probs(election_impact_on_mutation)
        # Create search pairs once for faster iterations when comparing rankings
        self.search_pairs = list(combinations(range(0, self.options.size), 2))  # TODO check if correct!
        self.option_vec = np.arange(self.options.size)  # Also to speed up
        self.color_search_pairs = list(combinations(range(0, num_colors), 2))
        # Create color cells
        self.color_cells: List[Optional[ColorCell]] = [None] * (height * width)
        self._initialize_color_cells()
        # Create agents
        # TODO: Where do the agents get there known cells from and how!?
        self.voting_agents: List[Optional[VoteAgent]] = [None] * num_agents
        self.personalities = self.create_personalities(num_personalities)
        self.personality_distribution = self.pers_dist(num_personalities)
        self.initialize_voting_agents()
        # Area variables
        self.global_area = self.initialize_global_area()  # TODO create bool variable to make this optional
        self.areas: List[Optional[Area]] = [None] * num_areas
        self.av_area_height = av_area_height
        self.av_area_width = av_area_width
        self.area_size_variance = area_size_variance
        # Adjust the color pattern to make it less random (see color patches)
        self.adjust_color_pattern(color_patches_steps, patch_power)
        # Create areas
        self.initialize_all_areas()
        # Data collector
        self.datacollector = self.initialize_datacollector()
        # Collect initial data
        self.datacollector.collect(self)
        # Statistics
        self.show_area_stats = show_area_stats

    @property
    def num_colors(self):
        return len(self.colors)

    @property
    def av_area_color_dst(self):
        return self._av_area_color_dst

    @av_area_color_dst.setter
    def av_area_color_dst(self, value):
        self._av_area_color_dst = value

    @property
    def num_agents(self):
        return len(self.voting_agents)

    @property
    def num_areas(self):
        return len(self.areas)

    @property
    def preset_color_dst(self):
        return len(self._preset_color_dst)

    def _initialize_color_cells(self):
        """
        This method initializes a color cells for each cell in the model's grid.
        """
        # Create a color cell for each cell in the grid
        for unique_id, (_, (row, col)) in enumerate(self.grid.coord_iter()):
            # The colors are chosen by a predefined color distribution
            color = self.color_by_dst(self._preset_color_dst)
            # Create the cell
            cell = ColorCell(unique_id, self, (row, col), color)
            # Add it to the grid
            self.grid.place_agent(cell, (row, col))
            # Add the color cell to the scheduler
            #self.scheduler.add(cell) # TODO: check speed diffs using this..
            # And to the 'model.color_cells' list (for faster access)
            self.color_cells[unique_id] = cell  # TODO: check if its not better to simply use the grid when finally changing the grid type to SingleGrid

    def initialize_voting_agents(self):
        """
        This method initializes as many voting agents as set in the model with
        a randomly chosen personality. It places them randomly on the grid.
        It also ensures that each agent is assigned to the color cell it is
        standing on.
        """
        dist = self.personality_distribution
        rng = np.random.default_rng()
        assets = self.common_assets // self.num_agents
        for a_id in range(self.num_agents):
            # Get a random position
            x = self.random.randrange(self.width)
            y = self.random.randrange(self.height)
            personality = rng.choice(self.personalities, p=dist)
            # Create agent without appending (add to the pre-defined list)
            agent = VoteAgent(a_id, self, (x, y), personality,
                              assets=assets, add=False)  # TODO: initial assets?!
            self.voting_agents[a_id] = agent  # Add using the index (faster)
            # Add the agent to the grid by placing it on a cell
            cell = self.grid.get_cell_list_contents([(x, y)])[0]
            if TYPE_CHECKING:
                cell = cast(ColorCell, cell)
            cell.add_agent(agent)

    def init_color_probs(self, election_impact):
        """
        This method initializes a probability array for the mutation of colors.
        The probabilities reflect the election outcome with some impact factor.

        Args:
            election_impact (float): The impact the election has on the mutation.
        """
        p = (np.arange(self.num_colors, 0, -1)) ** election_impact
        # Normalize
        p = p / sum(p)
        return p

    def initialize_area(self, a_id: int, x_coord, y_coord):
        """
        This method initializes one area in the models' grid.
        """
        area = Area(a_id, self, self.av_area_height, self.av_area_width,
                    self.area_size_variance)
        # Place the area in the grid using its indexing field
        # this adds the corresponding color cells and voting agents to the area
        area.idx_field = (x_coord, y_coord)
        # Save in the models' areas-list
        self.areas[a_id] = area

    def initialize_all_areas(self) -> None:
        """
        Initializes all areas on the grid in the model.

        This method divides the grid into approximately evenly distributed areas,
        ensuring that the areas are spaced as uniformly as possible based
        on the grid dimensions and the average area size specified by
        `av_area_width` and `av_area_height`.

        The grid may contain more or fewer areas than an exact square
        grid arrangement due to `num_areas` not always being a perfect square.
        If the number of areas is not a perfect square, the remaining areas
        are placed randomly on the grid to ensure that `num_areas`
        areas are initialized.

        Args:
            None.

        Returns:
            None. initializes `num_areas` and places them directly on the grid.

        Raises:
            None, but if `self.num_areas == 0`, the method exits early.

        Example:
            - Given `num_areas = 4` and `grid.width = grid.height = 10`,
              this method might initialize areas with approximate distances
              to maximize uniform distribution (like a 2x2 grid).
            - For `num_areas = 5`, four areas will be initialized evenly, and
              the fifth will be placed randomly due to the uneven distribution.
        """
        if self.num_areas == 0:
            return
        # Calculate the number of areas in each direction
        roo_apx = round(sqrt(self.num_areas))
        nr_areas_x = self.grid.width // self.av_area_width
        nr_areas_y = self.grid.width // self.av_area_height
        # Calculate the distance between the areas
        area_x_dist = self.grid.width // roo_apx
        area_y_dist = self.grid.height // roo_apx
        print(f"roo_apx: {roo_apx}, nr_areas_x: {nr_areas_x}, "
              f"nr_areas_y: {nr_areas_y}, area_x_dist: {area_x_dist}, "
              f"area_y_dist: {area_y_dist}")  # TODO rm print
        x_coords = range(0, self.grid.width, area_x_dist)
        y_coords = range(0, self.grid.height, area_y_dist)
        # Add additional areas if necessary (num_areas not a square number)
        additional_x, additional_y = [], []
        missing = self.num_areas - len(x_coords) * len(y_coords)
        for _ in range(missing):
            additional_x.append(self.random.randrange(self.grid.width))
            additional_y.append(self.random.randrange(self.grid.height))
        # Create the area's ids
        a_ids = iter(range(self.num_areas))
        # Initialize all areas
        for x_coord in x_coords:
            for y_coord in y_coords:
                a_id = next(a_ids, -1)
                if a_id == -1:
                    break
                self.initialize_area(a_id, x_coord, y_coord)
        for x_coord, y_coord in zip(additional_x, additional_y):
            self.initialize_area(next(a_ids), x_coord, y_coord)


    def initialize_global_area(self):
        """
        This method initializes the global area spanning the whole grid.

        Returns:
            Area: The global area (with unique_id set to -1 and idx to (0, 0)).
        """
        global_area = Area(-1, self, self.height, self.width, 0)
        # Place the area in the grid using its indexing field
        # this adds the corresponding color cells and voting agents to the area
        global_area.idx_field = (0, 0)
        return global_area


    def create_personalities(self, n: int):
        """
        Creates n unique "personalities," where a "personality" is a specific
        permutation of self.num_colors color indices.

        Args:
            n (int): Number of unique personalities to generate.

        Returns:
            np.ndarray: Array of shape `(n, num_colors)`.

        Raises:
            ValueError: If `n` exceeds the possible unique permutations.

        Example:
            for n=2 and self.num_colors=3, the function could return:

            [[1, 0, 2],
            [2, 1, 0]]
        """
        # p_colors = range(1, self.num_colors)  # Personalities exclude white
        max_permutations = np.math.factorial(self.num_colors)
        if n > max_permutations or n < 1:
            raise ValueError(f"Cannot generate {n} unique personalities: "
                             f"only {max_permutations} unique ones exist.")
        selected_permutations = set()
        while len(selected_permutations) < n:
            # Sample a permutation lazily and add it to the set
            perm = tuple(self.random.sample(range(self.num_colors),
                                            self.num_colors))
            selected_permutations.add(perm)

        return np.array(list(selected_permutations))


    def initialize_datacollector(self):
        color_data = {f"Color {i}": get_color_distribution_function(i) for i in
                      range(self.num_colors)}
        return mesa.DataCollector(
            model_reporters={
                "Collective assets": compute_collective_assets,
                "Gini Index (0-100)": compute_gini_index,
                "Voter turnout globally (in percent)": get_voter_turnout,
                **color_data
            },
            agent_reporters={
                # "Voter Turnout": lambda a: a.voter_turnout if isinstance(a, Area) else None,
                # "Color Distribution": lambda a: a.color_distribution if isinstance(a, Area) else None,
                #
                #"VoterTurnout": lambda a: a.voter_turnout if isinstance(a, Area) else None,
                "VoterTurnout": get_area_voter_turnout,
                "DistToReality": get_area_dist_to_reality,
                "ColorDistribution": get_area_color_distribution,
                "ElectionResults": get_election_results,
                # "Personality-Based Reward": get_area_personality_based_reward,
                # "Gini Index": get_area_gini_index
            },
            # tables={
            #    "AreaData": ["Step", "AreaID", "ColorDistribution",
            #                 "VoterTurnout"]
            # }
        )


    def step(self):
        """
        Advance the model by one step.
        """

        # Conduct elections in the areas
        # and then mutate the color cells according to election outcomes
        self.scheduler.step()
        # Update the global color distribution
        self.update_av_area_color_dst()
        # Collect data for monitoring and data analysis
        self.datacollector.collect(self)


    def adjust_color_pattern(self, color_patches_steps: int, patch_power: float):
        """Adjusting the color pattern to make it less predictable.

        Args:
            color_patches_steps: How often to run the color-patches step.
            patch_power: The power of the patching (like a radius of impact).
        """
        cells = self.color_cells
        for _ in range(color_patches_steps):
            print(f"Color adjustment step {_}")
            self.random.shuffle(cells)
            for cell in cells:
                most_common_color = self.color_patches(cell, patch_power)
                cell.color = most_common_color


    def create_color_distribution(self, heterogeneity: float):
        """
        This method is used to create a color distribution that has a bias
        according to the given heterogeneity factor.

        Args:
            heterogeneity (float): Factor used as sigma in 'random.gauss'.
        """
        colors = range(self.num_colors)
        values = [abs(self.random.gauss(1, heterogeneity)) for _ in colors]
        # Normalize (with float division)
        total = sum(values)
        dst_array = [value / total for value in values]
        return dst_array


    def color_patches(self, cell: ColorCell, patch_power: float):
        """
        This method is used to create a less random initial color distribution
        using a similar logic to the color patches model.
        It uses a (normalized) bias coordinate to center the impact of the
        color patches structures impact around.

        Args:
            cell: The cell that may change its color accordingly
            patch_power: Like a radius of impact around the bias point.

        Returns:
            int: The consensus color or the cell's own color if no consensus.
        """
        # Calculate the normalized position of the cell
        normalized_x = cell.row / self.height
        normalized_y = cell.col / self.width
        # Calculate the distance of the cell to the bias point
        bias_factor = (abs(normalized_x - self._horizontal_bias)
                       + abs(normalized_y - self._vertical_bias))
        # The closer the cell to the bias-point, the less often it is
        # to be replaced by a color chosen from the initial distribution:
        if abs(self.random.gauss(0, patch_power)) < bias_factor:
            return self.color_by_dst(self._preset_color_dst)
        # Otherwise, apply the color patches logic
        neighbor_cells = self.grid.get_neighbors((cell.row, cell.col),
                                                 moore=True,
                                                 include_center=False)
        color_counts = {}  # Count neighbors' colors
        for neighbor in neighbor_cells:
            if isinstance(neighbor, ColorCell):
                color = neighbor.color
                color_counts[color] = color_counts.get(color, 0) + 1
        if color_counts:
            max_count = max(color_counts.values())
            most_common_colors = [color for color, count in color_counts.items()
                                  if count == max_count]
            return self.random.choice(most_common_colors)
        return cell.color  # Return the cell's own color if no consensus


    def update_av_area_color_dst(self):
        """
        This method updates the av_area_color_dst attribute of the model.
        Beware: On overlapping areas, cells are counted several times.
        """
        sums = np.zeros(self.num_colors)
        for area in self.areas:
            sums += area.color_distribution
        # Return the average color distributions
        self.av_area_color_dst = sums / self.num_areas


    @staticmethod
    def pers_dist(size):
        """
        This method creates a normalized normal distribution array for picking
        and depicting the distribution of personalities in the model.

        Args:
            size: The mean value of the normal distribution.

        Returns:
            np.array: Normalized (sum is one) array mimicking a gaussian curve.
        """
        # Generate a normal distribution
        rng = np.random.default_rng()
        dist = rng.normal(0, 1, size)
        dist.sort()  # To create a gaussian curve like array
        dist = np.abs(dist)  # Flip negative values "up"
        # Normalize the distribution to sum to one
        dist /= dist.sum()
        return dist


    @staticmethod
    def create_all_options(n: int, include_ties=False):
        """
        Creates a matrix (an array of all possible ranking vectors),
        if specified including ties.
        Rank values start from 0.

        Args:
            n (int): The number of items to rank (number of colors in our case)
            include_ties (bool): If True, rankings include ties.

        Returns:
            np.array: A matrix containing all possible ranking vectors.
        """
        if include_ties:
            # Create all possible combinations and sort out invalid rankings
            # i.e. [1, 1, 1] or [1, 2, 2] aren't valid as no option is ranked first.
            r = np.array([np.array(comb) for comb in product(range(n), repeat=n)
                          if set(range(max(comb))).issubset(comb)])
        else:
            r = np.array([np.array(p) for p in permutations(range(n))])
        return r

    @staticmethod
    def color_by_dst(color_distribution: np.array) -> int:
        """
        Selects a color (int) from range(len(color_distribution))
        based on the given color_distribution array, where each entry represents
        the probability of selecting that index.

        Args:
            color_distribution: Array determining the selection probabilities.

        Returns:
            int: The selected index based on the given probabilities.

        Example:
            color_distribution = [0.2, 0.3, 0.5]
            Color 1 will be selected with a probability of 0.3.
        """
        if abs(sum(color_distribution) -1) > 1e-8:
            raise ValueError("The color_distribution array must sum to 1.")
        r = np.random.random()  # Random float between 0 and 1
        cumulative_sum = 0.0
        for color_idx, prob in enumerate(color_distribution):
            if prob < 0:
                raise ValueError("color_distribution contains negative value.")
            cumulative_sum += prob
            if r < cumulative_sum:  # Compare r against the cumulative probability
                return color_idx

        # This point should never be reached.
        raise ValueError("Unexpected error in color_distribution.")

adjust_color_pattern(color_patches_steps, patch_power)

Adjusting the color pattern to make it less predictable.

Parameters:

Name Type Description Default
color_patches_steps int

How often to run the color-patches step.

required
patch_power float

The power of the patching (like a radius of impact).

required
Source code in democracy_sim/participation_model.py
839
840
841
842
843
844
845
846
847
848
849
850
851
852
def adjust_color_pattern(self, color_patches_steps: int, patch_power: float):
    """Adjusting the color pattern to make it less predictable.

    Args:
        color_patches_steps: How often to run the color-patches step.
        patch_power: The power of the patching (like a radius of impact).
    """
    cells = self.color_cells
    for _ in range(color_patches_steps):
        print(f"Color adjustment step {_}")
        self.random.shuffle(cells)
        for cell in cells:
            most_common_color = self.color_patches(cell, patch_power)
            cell.color = most_common_color

color_by_dst(color_distribution) staticmethod

Selects a color (int) from range(len(color_distribution)) based on the given color_distribution array, where each entry represents the probability of selecting that index.

Parameters:

Name Type Description Default
color_distribution array

Array determining the selection probabilities.

required

Returns:

Name Type Description
int int

The selected index based on the given probabilities.

Example

color_distribution = [0.2, 0.3, 0.5] Color 1 will be selected with a probability of 0.3.

Source code in democracy_sim/participation_model.py
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
@staticmethod
def color_by_dst(color_distribution: np.array) -> int:
    """
    Selects a color (int) from range(len(color_distribution))
    based on the given color_distribution array, where each entry represents
    the probability of selecting that index.

    Args:
        color_distribution: Array determining the selection probabilities.

    Returns:
        int: The selected index based on the given probabilities.

    Example:
        color_distribution = [0.2, 0.3, 0.5]
        Color 1 will be selected with a probability of 0.3.
    """
    if abs(sum(color_distribution) -1) > 1e-8:
        raise ValueError("The color_distribution array must sum to 1.")
    r = np.random.random()  # Random float between 0 and 1
    cumulative_sum = 0.0
    for color_idx, prob in enumerate(color_distribution):
        if prob < 0:
            raise ValueError("color_distribution contains negative value.")
        cumulative_sum += prob
        if r < cumulative_sum:  # Compare r against the cumulative probability
            return color_idx

    # This point should never be reached.
    raise ValueError("Unexpected error in color_distribution.")

color_patches(cell, patch_power)

This method is used to create a less random initial color distribution using a similar logic to the color patches model. It uses a (normalized) bias coordinate to center the impact of the color patches structures impact around.

Parameters:

Name Type Description Default
cell ColorCell

The cell that may change its color accordingly

required
patch_power float

Like a radius of impact around the bias point.

required

Returns:

Name Type Description
int

The consensus color or the cell's own color if no consensus.

Source code in democracy_sim/participation_model.py
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
def color_patches(self, cell: ColorCell, patch_power: float):
    """
    This method is used to create a less random initial color distribution
    using a similar logic to the color patches model.
    It uses a (normalized) bias coordinate to center the impact of the
    color patches structures impact around.

    Args:
        cell: The cell that may change its color accordingly
        patch_power: Like a radius of impact around the bias point.

    Returns:
        int: The consensus color or the cell's own color if no consensus.
    """
    # Calculate the normalized position of the cell
    normalized_x = cell.row / self.height
    normalized_y = cell.col / self.width
    # Calculate the distance of the cell to the bias point
    bias_factor = (abs(normalized_x - self._horizontal_bias)
                   + abs(normalized_y - self._vertical_bias))
    # The closer the cell to the bias-point, the less often it is
    # to be replaced by a color chosen from the initial distribution:
    if abs(self.random.gauss(0, patch_power)) < bias_factor:
        return self.color_by_dst(self._preset_color_dst)
    # Otherwise, apply the color patches logic
    neighbor_cells = self.grid.get_neighbors((cell.row, cell.col),
                                             moore=True,
                                             include_center=False)
    color_counts = {}  # Count neighbors' colors
    for neighbor in neighbor_cells:
        if isinstance(neighbor, ColorCell):
            color = neighbor.color
            color_counts[color] = color_counts.get(color, 0) + 1
    if color_counts:
        max_count = max(color_counts.values())
        most_common_colors = [color for color, count in color_counts.items()
                              if count == max_count]
        return self.random.choice(most_common_colors)
    return cell.color  # Return the cell's own color if no consensus

create_all_options(n, include_ties=False) staticmethod

Creates a matrix (an array of all possible ranking vectors), if specified including ties. Rank values start from 0.

Parameters:

Name Type Description Default
n int

The number of items to rank (number of colors in our case)

required
include_ties bool

If True, rankings include ties.

False

Returns:

Type Description

np.array: A matrix containing all possible ranking vectors.

Source code in democracy_sim/participation_model.py
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
@staticmethod
def create_all_options(n: int, include_ties=False):
    """
    Creates a matrix (an array of all possible ranking vectors),
    if specified including ties.
    Rank values start from 0.

    Args:
        n (int): The number of items to rank (number of colors in our case)
        include_ties (bool): If True, rankings include ties.

    Returns:
        np.array: A matrix containing all possible ranking vectors.
    """
    if include_ties:
        # Create all possible combinations and sort out invalid rankings
        # i.e. [1, 1, 1] or [1, 2, 2] aren't valid as no option is ranked first.
        r = np.array([np.array(comb) for comb in product(range(n), repeat=n)
                      if set(range(max(comb))).issubset(comb)])
    else:
        r = np.array([np.array(p) for p in permutations(range(n))])
    return r

create_color_distribution(heterogeneity)

This method is used to create a color distribution that has a bias according to the given heterogeneity factor.

Parameters:

Name Type Description Default
heterogeneity float

Factor used as sigma in 'random.gauss'.

required
Source code in democracy_sim/participation_model.py
855
856
857
858
859
860
861
862
863
864
865
866
867
868
def create_color_distribution(self, heterogeneity: float):
    """
    This method is used to create a color distribution that has a bias
    according to the given heterogeneity factor.

    Args:
        heterogeneity (float): Factor used as sigma in 'random.gauss'.
    """
    colors = range(self.num_colors)
    values = [abs(self.random.gauss(1, heterogeneity)) for _ in colors]
    # Normalize (with float division)
    total = sum(values)
    dst_array = [value / total for value in values]
    return dst_array

create_personalities(n)

Creates n unique "personalities," where a "personality" is a specific permutation of self.num_colors color indices.

Parameters:

Name Type Description Default
n int

Number of unique personalities to generate.

required

Returns:

Type Description

np.ndarray: Array of shape (n, num_colors).

Raises:

Type Description
ValueError

If n exceeds the possible unique permutations.

Example

for n=2 and self.num_colors=3, the function could return:

[[1, 0, 2], [2, 1, 0]]

Source code in democracy_sim/participation_model.py
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
def create_personalities(self, n: int):
    """
    Creates n unique "personalities," where a "personality" is a specific
    permutation of self.num_colors color indices.

    Args:
        n (int): Number of unique personalities to generate.

    Returns:
        np.ndarray: Array of shape `(n, num_colors)`.

    Raises:
        ValueError: If `n` exceeds the possible unique permutations.

    Example:
        for n=2 and self.num_colors=3, the function could return:

        [[1, 0, 2],
        [2, 1, 0]]
    """
    # p_colors = range(1, self.num_colors)  # Personalities exclude white
    max_permutations = np.math.factorial(self.num_colors)
    if n > max_permutations or n < 1:
        raise ValueError(f"Cannot generate {n} unique personalities: "
                         f"only {max_permutations} unique ones exist.")
    selected_permutations = set()
    while len(selected_permutations) < n:
        # Sample a permutation lazily and add it to the set
        perm = tuple(self.random.sample(range(self.num_colors),
                                        self.num_colors))
        selected_permutations.add(perm)

    return np.array(list(selected_permutations))

init_color_probs(election_impact)

This method initializes a probability array for the mutation of colors. The probabilities reflect the election outcome with some impact factor.

Parameters:

Name Type Description Default
election_impact float

The impact the election has on the mutation.

required
Source code in democracy_sim/participation_model.py
658
659
660
661
662
663
664
665
666
667
668
669
def init_color_probs(self, election_impact):
    """
    This method initializes a probability array for the mutation of colors.
    The probabilities reflect the election outcome with some impact factor.

    Args:
        election_impact (float): The impact the election has on the mutation.
    """
    p = (np.arange(self.num_colors, 0, -1)) ** election_impact
    # Normalize
    p = p / sum(p)
    return p

initialize_all_areas()

Initializes all areas on the grid in the model.

This method divides the grid into approximately evenly distributed areas, ensuring that the areas are spaced as uniformly as possible based on the grid dimensions and the average area size specified by av_area_width and av_area_height.

The grid may contain more or fewer areas than an exact square grid arrangement due to num_areas not always being a perfect square. If the number of areas is not a perfect square, the remaining areas are placed randomly on the grid to ensure that num_areas areas are initialized.

Returns:

Type Description
None

None. initializes num_areas and places them directly on the grid.

Example
  • Given num_areas = 4 and grid.width = grid.height = 10, this method might initialize areas with approximate distances to maximize uniform distribution (like a 2x2 grid).
  • For num_areas = 5, four areas will be initialized evenly, and the fifth will be placed randomly due to the uneven distribution.
Source code in democracy_sim/participation_model.py
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
def initialize_all_areas(self) -> None:
    """
    Initializes all areas on the grid in the model.

    This method divides the grid into approximately evenly distributed areas,
    ensuring that the areas are spaced as uniformly as possible based
    on the grid dimensions and the average area size specified by
    `av_area_width` and `av_area_height`.

    The grid may contain more or fewer areas than an exact square
    grid arrangement due to `num_areas` not always being a perfect square.
    If the number of areas is not a perfect square, the remaining areas
    are placed randomly on the grid to ensure that `num_areas`
    areas are initialized.

    Args:
        None.

    Returns:
        None. initializes `num_areas` and places them directly on the grid.

    Raises:
        None, but if `self.num_areas == 0`, the method exits early.

    Example:
        - Given `num_areas = 4` and `grid.width = grid.height = 10`,
          this method might initialize areas with approximate distances
          to maximize uniform distribution (like a 2x2 grid).
        - For `num_areas = 5`, four areas will be initialized evenly, and
          the fifth will be placed randomly due to the uneven distribution.
    """
    if self.num_areas == 0:
        return
    # Calculate the number of areas in each direction
    roo_apx = round(sqrt(self.num_areas))
    nr_areas_x = self.grid.width // self.av_area_width
    nr_areas_y = self.grid.width // self.av_area_height
    # Calculate the distance between the areas
    area_x_dist = self.grid.width // roo_apx
    area_y_dist = self.grid.height // roo_apx
    print(f"roo_apx: {roo_apx}, nr_areas_x: {nr_areas_x}, "
          f"nr_areas_y: {nr_areas_y}, area_x_dist: {area_x_dist}, "
          f"area_y_dist: {area_y_dist}")  # TODO rm print
    x_coords = range(0, self.grid.width, area_x_dist)
    y_coords = range(0, self.grid.height, area_y_dist)
    # Add additional areas if necessary (num_areas not a square number)
    additional_x, additional_y = [], []
    missing = self.num_areas - len(x_coords) * len(y_coords)
    for _ in range(missing):
        additional_x.append(self.random.randrange(self.grid.width))
        additional_y.append(self.random.randrange(self.grid.height))
    # Create the area's ids
    a_ids = iter(range(self.num_areas))
    # Initialize all areas
    for x_coord in x_coords:
        for y_coord in y_coords:
            a_id = next(a_ids, -1)
            if a_id == -1:
                break
            self.initialize_area(a_id, x_coord, y_coord)
    for x_coord, y_coord in zip(additional_x, additional_y):
        self.initialize_area(next(a_ids), x_coord, y_coord)

initialize_area(a_id, x_coord, y_coord)

This method initializes one area in the models' grid.

Source code in democracy_sim/participation_model.py
671
672
673
674
675
676
677
678
679
680
681
def initialize_area(self, a_id: int, x_coord, y_coord):
    """
    This method initializes one area in the models' grid.
    """
    area = Area(a_id, self, self.av_area_height, self.av_area_width,
                self.area_size_variance)
    # Place the area in the grid using its indexing field
    # this adds the corresponding color cells and voting agents to the area
    area.idx_field = (x_coord, y_coord)
    # Save in the models' areas-list
    self.areas[a_id] = area

initialize_global_area()

This method initializes the global area spanning the whole grid.

Returns:

Name Type Description
Area

The global area (with unique_id set to -1 and idx to (0, 0)).

Source code in democracy_sim/participation_model.py
747
748
749
750
751
752
753
754
755
756
757
758
def initialize_global_area(self):
    """
    This method initializes the global area spanning the whole grid.

    Returns:
        Area: The global area (with unique_id set to -1 and idx to (0, 0)).
    """
    global_area = Area(-1, self, self.height, self.width, 0)
    # Place the area in the grid using its indexing field
    # this adds the corresponding color cells and voting agents to the area
    global_area.idx_field = (0, 0)
    return global_area

initialize_voting_agents()

This method initializes as many voting agents as set in the model with a randomly chosen personality. It places them randomly on the grid. It also ensures that each agent is assigned to the color cell it is standing on.

Source code in democracy_sim/participation_model.py
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
def initialize_voting_agents(self):
    """
    This method initializes as many voting agents as set in the model with
    a randomly chosen personality. It places them randomly on the grid.
    It also ensures that each agent is assigned to the color cell it is
    standing on.
    """
    dist = self.personality_distribution
    rng = np.random.default_rng()
    assets = self.common_assets // self.num_agents
    for a_id in range(self.num_agents):
        # Get a random position
        x = self.random.randrange(self.width)
        y = self.random.randrange(self.height)
        personality = rng.choice(self.personalities, p=dist)
        # Create agent without appending (add to the pre-defined list)
        agent = VoteAgent(a_id, self, (x, y), personality,
                          assets=assets, add=False)  # TODO: initial assets?!
        self.voting_agents[a_id] = agent  # Add using the index (faster)
        # Add the agent to the grid by placing it on a cell
        cell = self.grid.get_cell_list_contents([(x, y)])[0]
        if TYPE_CHECKING:
            cell = cast(ColorCell, cell)
        cell.add_agent(agent)

pers_dist(size) staticmethod

This method creates a normalized normal distribution array for picking and depicting the distribution of personalities in the model.

Parameters:

Name Type Description Default
size

The mean value of the normal distribution.

required

Returns:

Type Description

np.array: Normalized (sum is one) array mimicking a gaussian curve.

Source code in democracy_sim/participation_model.py
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
@staticmethod
def pers_dist(size):
    """
    This method creates a normalized normal distribution array for picking
    and depicting the distribution of personalities in the model.

    Args:
        size: The mean value of the normal distribution.

    Returns:
        np.array: Normalized (sum is one) array mimicking a gaussian curve.
    """
    # Generate a normal distribution
    rng = np.random.default_rng()
    dist = rng.normal(0, 1, size)
    dist.sort()  # To create a gaussian curve like array
    dist = np.abs(dist)  # Flip negative values "up"
    # Normalize the distribution to sum to one
    dist /= dist.sum()
    return dist

step()

Advance the model by one step.

Source code in democracy_sim/participation_model.py
825
826
827
828
829
830
831
832
833
834
835
836
def step(self):
    """
    Advance the model by one step.
    """

    # Conduct elections in the areas
    # and then mutate the color cells according to election outcomes
    self.scheduler.step()
    # Update the global color distribution
    self.update_av_area_color_dst()
    # Collect data for monitoring and data analysis
    self.datacollector.collect(self)

update_av_area_color_dst()

This method updates the av_area_color_dst attribute of the model. Beware: On overlapping areas, cells are counted several times.

Source code in democracy_sim/participation_model.py
912
913
914
915
916
917
918
919
920
921
def update_av_area_color_dst(self):
    """
    This method updates the av_area_color_dst attribute of the model.
    Beware: On overlapping areas, cells are counted several times.
    """
    sums = np.zeros(self.num_colors)
    for area in self.areas:
        sums += area.color_distribution
    # Return the average color distributions
    self.av_area_color_dst = sums / self.num_areas