├── .gitignore ├── LICENSE.md ├── README.md ├── images ├── code_structure.png ├── gerrymandered_for_blue.png ├── gerrymandered_for_red.png ├── gerrymandered_for_tie.png ├── no_districts.png ├── square_districts.png ├── swap_diagram1.png └── swap_diagram2.png ├── main.py ├── misc.py ├── parameters.py ├── root.py ├── simulation ├── __init__.py ├── canvas.py ├── district.py ├── misc.py ├── person.py └── swap_manager.py ├── tests.py └── ui ├── __init__.py ├── control_panel ├── __init__.py └── buttons.py ├── info_panel ├── __init__.py ├── pie_charts.py └── swaps_done_info.py └── parameter_panel ├── __init__.py ├── adjuster_types.py ├── adjusters.py ├── misc.py └── parameter_adjuster_base.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .vscode 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 mazore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 13 | Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 16 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 17 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gerrymandering 2 | A program that draws district lines around a two-party grid of people (equal proportions for each party) in order to 3 | give an unfair advantage to one party. 4 | 5 | Try the browser version [here](https://mazore.github.io/gerrymandering-js) (repo [here](https://github.com/mazore/gerrymandering-js)) 6 | 7 | ## Explanation 8 | 9 | 10 | 11 | Above is a grid of people, each colored by which party they vote for. There are 72 blue people and 72 red people. 12 | 13 | 14 | 15 | Districts are groups of people (9 people in this case) enclosed in black lines, shaded by winner (which party has more 16 | people in the district). 17 | 18 | 19 | 20 | We can draw the district lines in such a way that gives blue 14 districts and red only 2. 21 | 22 | 23 | 24 | This is the **same people** but with lines drawn so that red has 14 districts and blue only has 2. 25 | 26 | ## Usage 27 | 28 | You should use the browser version [here](https://github.com/mazore/gerrymandering-js) 29 | 30 | For the desktop version, use this direct [download link](https://www.dropbox.com/s/n8uh1a8l9s8sxhx/gerrymandering.zip?dl=1), 31 | extract the zip file, and run the `gerrymandering.exe` file. 32 | 33 | To get the source code, just download the project off github and run `python main.py` in the directory to run the 34 | program. Only python3 standard library is required. 35 | 36 | ## How It Works 37 | 38 | ### Overview 39 | 40 | First, a grid of people is generated, with parties that are randomized while ensuring that there are an equal amount of 41 | people in each party. Districts are then drawn around those people. Districts are initially squares of size 42 | `district_size`, and we ensure that `grid_width` and `district_width` allow this to be possible. From there, we perform 43 | a series of swaps of people between districts. These swaps will over time give one party (specified by `help_party`) 44 | more and more districts, without changing the people grid. 45 | 46 | ### Swapping 47 | We pick 2 districts, `district1` and `district2` that are touching by 2 or more people. We also pick a person from each 48 | of those districts (`person1` and `person2`), using certain conditions to ensure that the swap not hinder the wrong 49 | party or cause disconnections in the districts. More information about these conditions can be found in `get_person1` 50 | and `get_person2` methods in `simulation/swap_manager.py`. 51 | 52 | ![Swap diagram 1](images/swap_diagram1.png) 53 | 54 | We can then make `person1` part of `district2`, and `person2` part of `district1`. 55 | 56 | ![Swap diagram 2](images/swap_diagram2.png) 57 | 58 | In this example, we have flipped one district from being tied to being blue. 59 | 60 | ### Code structure 61 | ![Structure diagram](images/code_structure.png) 62 | 63 | ## Roadmap & Contributing 64 | PR's, feedback, and general insight are much appreciated. 65 | 66 | ## Testing 67 | Run `python tests.py`. This runs two simulations, one that figures out how much time is spent doing swaps (see 68 | [Swapping](###swapping)) called avg_time, and another that takes the average score after a certain number of swaps 69 | called avg_score. It also prints the parameters used to run each of these simulations, set in file `tests.py`. These 70 | results can be compared with other versions, and the most recent results for the current version are at the bottom of 71 | this file. Keep in mind that the avg_time varies greatly different machines. 72 | 73 | ## Test results for this version 74 | ``` 75 | avg_time: 71.3078 ms 76 | avg_score: 29.09 77 | score parameters: Parameters(help_party=blue, favor_tie=False, district_size=16, grid_width=24, canvas_width=640, line_width=3, show_margins=False, sleep_between_draws=0, num_swaps_per_draw=2000, num_swaps=1000, simulation_time=None, hinder_party=red, num_simulations=10, start_running=True, num_districts=36.0) x 50 processes 78 | time parameters: Parameters(help_party=blue, favor_tie=False, district_size=16, grid_width=24, canvas_width=640, line_width=3, show_margins=False, sleep_between_draws=0, num_swaps_per_draw=2000, num_swaps=1000, simulation_time=None, hinder_party=red, num_simulations=150, start_running=True, num_districts=36.0) 79 | ``` 80 | -------------------------------------------------------------------------------- /images/code_structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazore/gerrymandering/c7b9986e95b2efbdd7947d324503d0c19e789d6d/images/code_structure.png -------------------------------------------------------------------------------- /images/gerrymandered_for_blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazore/gerrymandering/c7b9986e95b2efbdd7947d324503d0c19e789d6d/images/gerrymandered_for_blue.png -------------------------------------------------------------------------------- /images/gerrymandered_for_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazore/gerrymandering/c7b9986e95b2efbdd7947d324503d0c19e789d6d/images/gerrymandered_for_red.png -------------------------------------------------------------------------------- /images/gerrymandered_for_tie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazore/gerrymandering/c7b9986e95b2efbdd7947d324503d0c19e789d6d/images/gerrymandered_for_tie.png -------------------------------------------------------------------------------- /images/no_districts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazore/gerrymandering/c7b9986e95b2efbdd7947d324503d0c19e789d6d/images/no_districts.png -------------------------------------------------------------------------------- /images/square_districts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazore/gerrymandering/c7b9986e95b2efbdd7947d324503d0c19e789d6d/images/square_districts.png -------------------------------------------------------------------------------- /images/swap_diagram1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazore/gerrymandering/c7b9986e95b2efbdd7947d324503d0c19e789d6d/images/swap_diagram1.png -------------------------------------------------------------------------------- /images/swap_diagram2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mazore/gerrymandering/c7b9986e95b2efbdd7947d324503d0c19e789d6d/images/swap_diagram2.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from root import Root 2 | 3 | """ 4 | TODO: 5 | - experiment with trying to keep big districts more cohesive/clumped/less strung out 6 | - don't stall ui on sleep between draws 7 | - improve favor_tie (allow a not tied district to flip to tie if a tied district flips to not tied) 8 | - add 'precincts' or 'neighborhoods' that represent a certain number of people that vote different ways but are swapped 9 | as one person 10 | - stop at best possible 11 | - add incrementer parameter adjuster type 12 | - implement get_district2_weight in District class 13 | - better performance by different drawing method (not tkinter.Canvas), maybe website (flask) 14 | - district hover information 15 | - line smoothing (spline, make districts look more organic) 16 | - multiple parties? make red and blue into other non american colors? 17 | """ 18 | 19 | if __name__ == '__main__': 20 | Root() 21 | -------------------------------------------------------------------------------- /misc.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | from math import inf 3 | from random import random, uniform 4 | 5 | try: 6 | import line_profiler # Use `pip install line-profiler` 7 | 8 | profile = line_profiler.LineProfiler() # Use as decorator to save & print timings 9 | atexit.register(lambda: profile.print_stats() if profile.functions else None) # Print stats if decorator used 10 | except ImportError as e: 11 | line_profiler = None 12 | profile = None 13 | 14 | 15 | def constrain(val, min_val=-inf, max_val=inf): 16 | return min(max_val, max(min_val, val)) 17 | 18 | 19 | def fast_shuffled(x): 20 | """This shuffle is about 4x faster than random.shuffle""" 21 | return sorted(x, key=lambda _: random()) 22 | 23 | 24 | def hex_to_rgb(h): 25 | return tuple(int(h[i:i + 2], 16) for i in (1, 3, 5)) 26 | 27 | 28 | def rgb_to_hex(r, g, b): 29 | r = constrain(r, min_val=0, max_val=255) 30 | g = constrain(g, min_val=0, max_val=255) 31 | b = constrain(b, min_val=0, max_val=255) 32 | return '#%02x%02x%02x' % (r, g, b) 33 | 34 | 35 | def weighted_choice(choices): 36 | """Pass in a list of [(item, weight), ...], from https://stackoverflow.com/a/3679747/12977120""" 37 | total = sum(w for c, w in choices) 38 | r = uniform(0, total) 39 | n = 0 40 | for c, w in choices: 41 | n += w 42 | if n >= r: 43 | return c 44 | -------------------------------------------------------------------------------- /parameters.py: -------------------------------------------------------------------------------- 1 | from math import sqrt 2 | from simulation import BLUE, RED 3 | 4 | 5 | class ParameterDocs: 6 | help_party = 'Party to give help to in the gerrymandering process' 7 | favor_tie = 'Whether or not to try to make more tied districts' 8 | percentage_red = 'Percentage of the population that supports red party' 9 | district_size = 'Number of people contained in a district, must be perfect square' 10 | grid_width = 'Width (and height) of the grid of people, must be multiple of sqrt(district_size)' 11 | canvas_width = 'Width (and height) of the canvas in pixels' 12 | line_width = 'District line width in pixels' 13 | show_margins = 'Whether or not to change saturation of district colors based on how much it is won by' 14 | sleep_between_draws = 'Number of ms between drawing districts. Each draw, num_swaps_per_draw swaps are done' 15 | num_swaps_per_draw = 'Number of swaps done for every draw, which are done repeatedly while running. Increase to ' \ 16 | 'make faster but more chunky' 17 | num_swaps = 'Number of swaps to perform before restarting simulation, disabled for run infinitely' 18 | simulation_time = 'How long (seconds) to run before restarting simulation, disabled for run infinitely' 19 | # Hidden & convenience parameters 20 | hinder_party = 'Party to hinder in the gerrymandering process (calculated automatically)' 21 | num_simulations = 'Number of simulation repeats to run before quiting, per process, use None for keep restarting' 22 | start_running = 'Whether or not to start the simulation doing swaps' 23 | num_districts = 'Number of districts in total (calculated automatically)' 24 | 25 | 26 | class Parameters: 27 | def __init__(self, num_simulations=None, start_running=False, 28 | help_party=BLUE, favor_tie=False, 29 | percentage_red=50, 30 | district_size=16, grid_width=24, 31 | canvas_width=640, line_width=3, show_margins=False, 32 | sleep_between_draws=0, num_swaps_per_draw=1, 33 | num_swaps=None, simulation_time=None): 34 | assert help_party in (BLUE, RED) 35 | self.help_party = help_party 36 | self.favor_tie = favor_tie 37 | self.district_size = district_size 38 | self.grid_width = grid_width 39 | self.percentage_red = percentage_red 40 | self.canvas_width = canvas_width 41 | self.line_width = line_width 42 | self.show_margins = show_margins 43 | self.sleep_between_draws = sleep_between_draws 44 | self.num_swaps_per_draw = num_swaps_per_draw 45 | self.num_swaps = num_swaps 46 | self.simulation_time = simulation_time 47 | # Hidden & convenience parameters 48 | self.hinder_party = BLUE if help_party == RED else RED 49 | self.num_simulations = num_simulations 50 | self.start_running = start_running 51 | self.num_districts = (grid_width ** 2) / district_size 52 | 53 | if not sqrt(district_size).is_integer(): 54 | raise ValueError('districts start as squares, district_size must be a perfect square') 55 | if not sqrt(self.num_districts).is_integer(): 56 | raise ValueError('districts must be able to fit into the grid without remainders') 57 | 58 | def __repr__(self): 59 | inside = ', '.join(f'{k}={v}' for k, v in self.__dict__.items()) 60 | return f'Parameters({inside})' 61 | -------------------------------------------------------------------------------- /root.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import platform 3 | from parameters import Parameters 4 | import random 5 | from simulation import Canvas 6 | import tkinter as tk 7 | from ui import * 8 | 9 | if platform.system() == 'Windows': 10 | ctypes.windll.shcore.SetProcessDpiAwareness(1) 11 | 12 | 13 | class Root(tk.Tk): 14 | """Manages UI objects and Canvas, subclass of tkinter application root (represents a window)""" 15 | 16 | def __init__(self, parameters=Parameters(), seed=None): 17 | self.parameters = parameters 18 | super().__init__() 19 | self.font = 'Consolas 9' 20 | self.option_add('*font', self.font) 21 | 22 | if seed is not None: 23 | random.seed(seed) 24 | 25 | self.run_id = None 26 | self.simulation_datas = [] 27 | self.simulation_number = 1 28 | 29 | self.canvas = Canvas(self) # Main simulation widget 30 | self.canvas.pack(side='right') 31 | if parameters.start_running: 32 | self.run_id = self.after(1, self.canvas.run) 33 | self.ui_frame = tk.Frame() 34 | self.ui_frame.pack(side='right', anchor='n') 35 | 36 | self.info_panel = InfoPanel(self) 37 | self.control_panel = ControlPanel(self) 38 | self.parameter_panel = ParameterPanel(self) 39 | 40 | self.info_panel.pack(side='top') 41 | self.control_panel.pack(side='top', pady=3) 42 | self.parameter_panel.pack(side='top') 43 | 44 | self.geometry('+100+100') 45 | self.title('') 46 | self.protocol('WM_DELETE_WINDOW', self.on_close) 47 | 48 | self.mainloop() 49 | 50 | def on_close(self): 51 | self.canvas.running = False 52 | if self.run_id is not None: # Needed if not set in __init__ 53 | self.after_cancel(self.run_id) 54 | self.after_cancel(self.info_panel.after_id) 55 | self.destroy() 56 | 57 | def restart_simulation(self): 58 | """Makes a new simulation by creating a new Canvas, and saves the current simulation data""" 59 | self.simulation_datas.append(self.canvas.get_simulation_data()) 60 | 61 | was_running = self.canvas.running 62 | self.canvas.running = False 63 | if self.simulation_number == self.canvas.parameters.num_simulations: 64 | self.quit() 65 | return 66 | self.canvas.pack_forget() 67 | self.simulation_number += 1 68 | 69 | self.canvas = Canvas(self) 70 | self.canvas.pack(side='right') 71 | self.ui_frame.pack_forget() 72 | self.ui_frame.pack(side='right', anchor='n') 73 | if was_running: 74 | self.run_id = self.after(1, self.canvas.run) 75 | -------------------------------------------------------------------------------- /simulation/__init__.py: -------------------------------------------------------------------------------- 1 | from .canvas import Canvas 2 | from .misc import BLUE, RED 3 | -------------------------------------------------------------------------------- /simulation/canvas.py: -------------------------------------------------------------------------------- 1 | from .district import District 2 | from .misc import BLUE, RED, SimulationData 3 | from .person import Person 4 | from .swap_manager import SwapManager 5 | from math import ceil, sqrt 6 | from misc import fast_shuffled 7 | import tkinter as tk 8 | from time import sleep, time 9 | 10 | 11 | class Canvas(tk.Canvas): 12 | """Manages people, districts, and swapping, subclass of tkinter Canvas""" 13 | 14 | def __init__(self, root): 15 | self.root = root 16 | self.parameters = root.parameters 17 | super().__init__(width=root.parameters.canvas_width, height=root.parameters.canvas_width) 18 | 19 | self.running = False 20 | self.swap_manager = SwapManager(self) 21 | 22 | self.line_id_state_map = {} # {tkinter.Canvas id for the line: current state ('hidden' or 'normal')} 23 | self.people_grid = [] # 2d list of Person objects 24 | self.generate_people() 25 | 26 | self.show_districts = True 27 | self.districts = [] 28 | self.generate_districts() 29 | 30 | self.start_time = time() 31 | self.total_swap_time = 0 # Total time spent in the swap_manager.swap function in seconds 32 | 33 | self.bind('', self.left_click) 34 | self.bind('', self.middle_click) 35 | self.bind('', self.right_click) 36 | 37 | def run(self): 38 | """Start running or resume from being paused and update play_pause button""" 39 | self.running = True 40 | self.root.control_panel.play_pause_button.update_config() 41 | while True: 42 | if not self.running: 43 | break 44 | self.swap_manager.swap_dispatch() 45 | self.root.update() 46 | if self.parameters.sleep_between_draws != 0: 47 | sleep(self.parameters.sleep_between_draws / 1000) 48 | 49 | def pause(self): 50 | """Stop the simulation from doing swaps and update play_pause button""" 51 | self.running = False 52 | self.root.control_panel.play_pause_button.update_config() 53 | 54 | def left_click(self, _): 55 | self.root.control_panel.play_pause_button.play_pause() 56 | 57 | def middle_click(self, _): 58 | # self.toggle_districts_visible() 59 | self.root.control_panel.restarting_button.restart() 60 | 61 | def right_click(self, _): 62 | if not self.running: 63 | self.swap_manager.swap_dispatch() 64 | self.root.update() 65 | 66 | def toggle_districts_visible(self): 67 | self.pause() 68 | state = 'hidden' if self.show_districts else 'normal' 69 | for person in self.iter_people(): 70 | self.itemconfig(person.outer_id, state=state) 71 | self.show_districts = not self.show_districts 72 | self.redraw_districts() 73 | self.root.control_panel.toggle_districts_button.update_config() 74 | 75 | def get_simulation_data(self): 76 | return SimulationData( 77 | self.get_score()['tie' if self.parameters.favor_tie else self.parameters.help_party.name], 78 | self.swap_manager.swaps_done, 79 | time() - self.start_time, 80 | self.total_swap_time 81 | ) 82 | 83 | def get_score(self): 84 | """Return a dict of format {party_name: num_districts_won, ...}""" 85 | score = {'blue': 0, 'red': 0, 'tie': 0} 86 | for district in self.districts: 87 | score[district.get_winner().name] += 1 88 | return score 89 | 90 | def redraw_districts(self): 91 | [district.draw() for district in self.districts] 92 | 93 | def iter_people(self): 94 | for row in self.people_grid: 95 | for person in row: 96 | yield person 97 | 98 | def generate_people(self): 99 | """Create grid of people with randomized parties""" 100 | # Make sure peoples parties are random but percentage of red is as close to the specified number as possible 101 | people_count = self.parameters.grid_width ** 2 102 | red_count = round(self.parameters.percentage_red / 100 * people_count) 103 | parties = [RED] * red_count + [BLUE] * (people_count - red_count) 104 | parties = fast_shuffled(parties) 105 | 106 | square_width = self.parameters.canvas_width / self.parameters.grid_width 107 | for grid_y in range(self.parameters.grid_width): 108 | row = [] 109 | for grid_x in range(self.parameters.grid_width): 110 | p1 = (grid_x * square_width, grid_y * square_width) 111 | p2 = ((grid_x + 1) * square_width, (grid_y + 1) * square_width) 112 | party = parties[grid_x + grid_y * self.parameters.grid_width] 113 | row.append(Person(self, p1, p2, grid_x, grid_y, party=party)) 114 | self.people_grid.append(row) 115 | for person in self.iter_people(): 116 | person.secondary_init() 117 | 118 | def generate_districts(self): 119 | """Generate square districts of size district_size. We know this can fit because of assertions in Parameters 120 | initialization""" 121 | district_width = sqrt(self.parameters.district_size) 122 | for grid_x in range(int(sqrt(self.parameters.num_districts))): 123 | for grid_y in range(int(sqrt(self.parameters.num_districts))): 124 | grid_p1 = grid_x * district_width, grid_y * district_width 125 | grid_p2 = (grid_x + 1) * district_width, (grid_y + 1) * district_width 126 | self.districts.append(District(self, grid_p1, grid_p2)) 127 | -------------------------------------------------------------------------------- /simulation/district.py: -------------------------------------------------------------------------------- 1 | from .misc import TIE 2 | from collections import defaultdict 3 | from misc import hex_to_rgb, rgb_to_hex 4 | 5 | 6 | class District: 7 | """Represents a collection of people, with a line drawn around them. The winner is determined by which party has 8 | the most people contained in this district""" 9 | 10 | def __init__(self, canvas, p1, p2): 11 | self.canvas = canvas 12 | 13 | (self.x1, self.y1), (self.x2, self.y2) = p1, p2 # In grid coordinates 14 | self.net_advantage = 0 # help_party score - hinder_party score 15 | self.people = [] 16 | self.get_people() 17 | self.draw() 18 | 19 | def __repr__(self): 20 | return f'District that contains a person at {self.people[0].x, self.people[0].y} ' \ 21 | f'won by {self.get_winner()} with +{abs(self.net_advantage)} people margin' 22 | 23 | def ideal_give_away(self): 24 | """Which party this district prioritizes giving away, in the form of a person1 swapped into district2""" 25 | if self.canvas.parameters.favor_tie: 26 | if self.tied: 27 | return None 28 | return self.get_winner() 29 | 30 | if -4 <= self.net_advantage <= 2: # If not flippable or safe help_party, share our help_party people 31 | return self.canvas.parameters.hinder_party # If flippable/at risk, try to get more help_party people 32 | return self.canvas.parameters.help_party 33 | 34 | def get_people(self): 35 | """Used only on initialization, for filling self.people list, setting up people, and setting score""" 36 | for grid_y in range(int(self.y1), int(self.y2)): 37 | for grid_x in range(int(self.x1), int(self.x2)): 38 | person = self.canvas.people_grid[grid_y][grid_x] 39 | 40 | self.people.append(person) 41 | person.district = self 42 | self.canvas.itemconfig(person.outer_id, state='normal') 43 | self.net_advantage += 1 if person.party == self.canvas.parameters.help_party else -1 44 | 45 | @property 46 | def tied(self): 47 | return self.net_advantage == 0 48 | 49 | def get_winner(self): 50 | """Get whichever party has a majority of people, or a tie""" 51 | if self.tied: 52 | return TIE 53 | return self.canvas.parameters.help_party if self.net_advantage > 0 else self.canvas.parameters.hinder_party 54 | 55 | def get_district1_weight(self): 56 | """Returns the weight to use for this district when picking a randomized district1. Values were determined by a 57 | black box optimization method""" 58 | if 0 < self.net_advantage <= 2: # If at risk 59 | return 1 60 | if self.tied: 61 | return 11 62 | if -4 <= self.net_advantage <= 0: # If flippable 63 | return 4.35442295 64 | if self.net_advantage > 2: # If safe to help_party 65 | return 2.47490108 66 | return 2.06497273 # If safe not flippable/safe for hinder_party 67 | 68 | @staticmethod 69 | def get_district2_weight(_): 70 | """Returns the weight to use for this district when picking a randomized district2""" 71 | return 1 # To be implemented 72 | 73 | def draw(self): 74 | """Draw the outline and fill of the district""" 75 | # Outline 76 | line_ids = set() 77 | line_id_occurrence_map = defaultdict(int) 78 | for person in self.people: 79 | for line_id in person.edge_ids: 80 | line_ids.add(line_id) 81 | line_id_occurrence_map[line_id] += 1 82 | for line_id in line_ids: 83 | # Hide if not on edge of district (if repeated) 84 | state = 'hidden' if line_id_occurrence_map[line_id] > 1 else 'normal' 85 | if not self.canvas.show_districts: 86 | state = 'hidden' 87 | if self.canvas.line_id_state_map[line_id] != state: 88 | self.canvas.itemconfig(line_id, state=state) 89 | self.canvas.line_id_state_map[line_id] = state 90 | 91 | # Fill 92 | 93 | # Show margins in shading 94 | color = self.get_winner().color 95 | if self.canvas.parameters.show_margins: 96 | r, g, b = hex_to_rgb(color) 97 | factor = abs(self.net_advantage) / self.canvas.parameters.district_size * 3 98 | color = rgb_to_hex(int(r * factor), int(g * factor), int(b * factor)) 99 | for person in self.people: 100 | if person.outer_color != color: 101 | self.canvas.itemconfig(person.outer_id, fill=color) 102 | person.outer_color = color 103 | -------------------------------------------------------------------------------- /simulation/misc.py: -------------------------------------------------------------------------------- 1 | class SimulationData: 2 | def __init__(self, score, num_swaps, total_time, total_swap_time): 3 | self.score = score 4 | self.num_swaps = num_swaps 5 | self.total_time = total_time 6 | self.total_swap_time = total_swap_time 7 | 8 | 9 | class Party: 10 | def __init__(self, name, color): 11 | self.name = name 12 | self.color = color 13 | 14 | def __repr__(self): 15 | return self.name 16 | 17 | def __eq__(self, other): 18 | if not isinstance(other, Party): 19 | return False 20 | return self.name == other.name 21 | 22 | 23 | BLUE = Party('blue', '#5868aa') 24 | RED = Party('red', '#f95955') 25 | TIE = Party('tie', '#000000') 26 | -------------------------------------------------------------------------------- /simulation/person.py: -------------------------------------------------------------------------------- 1 | from itertools import groupby 2 | 3 | 4 | class Person: 5 | """This represents one person, who gets one vote for one party. District lines are drawn around these people""" 6 | 7 | def __init__(self, canvas, p1, p2, x, y, party): 8 | self.canvas = canvas 9 | 10 | self.party = party 11 | self.x, self.y = x, y 12 | self.p1, self.p2 = p1, p2 13 | self.district = None 14 | 15 | self.at_west, self.at_north = self.x == 0, self.y == 0 16 | self.at_east = self.x == self.canvas.parameters.grid_width - 1 17 | self.at_south = self.y == self.canvas.parameters.grid_width - 1 18 | """at_[direction] - Whether person is on far [direction] of grid""" 19 | self.person_north = self.person_south = self.person_west = self.person_east = None 20 | self.person_ne = self.person_se = self.person_sw = self.person_nw = None 21 | """person_[direction] - The person directly to the [direction] of this person""" 22 | 23 | self.inner_id = self.outer_id = self.outer_color = None 24 | """inner - smaller colored part, outer - bigger shaded part that's colored by district winner""" 25 | self.east_line_id = self.south_line_id = None 26 | self.adjacent_people, self.surrounding_people, self.edge_ids = [], [], [] 27 | 28 | self.setup_graphics() 29 | 30 | def setup_graphics(self): 31 | """Used only on initialization, sets up graphics drawing""" 32 | (x1, y1), (x2, y2) = self.p1, self.p2 33 | w = (x2 - x1) * 0.175 34 | offset = (x2 - x1) / 2 - w 35 | self.inner_id = self.canvas.create_rectangle( 36 | x1 + offset, y1 + offset, x2 - w * 2, y2 - w * 2, 37 | fill=self.party.color, width=0) 38 | self.outer_id = self.canvas.create_rectangle(x1, y1, x2, y2, fill='white', stipple='gray50', width=0) 39 | self.canvas.itemconfig(self.outer_id, state='hidden') 40 | self.canvas.tag_lower(self.outer_id) # Move below outlines 41 | self.outer_color = 'white' 42 | 43 | if self.canvas.parameters.line_width == 0: 44 | return # Hide lines 45 | self.east_line_id = self.south_line_id = None 46 | if not self.at_east: 47 | self.east_line_id = self.canvas.create_line(x2, y1, x2, y2, fill='black', 48 | width=self.canvas.parameters.line_width, state='hidden') 49 | self.canvas.line_id_state_map[self.east_line_id] = 'hidden' 50 | if not self.at_south: 51 | self.south_line_id = self.canvas.create_line(x1, y2, x2, y2, fill='black', 52 | width=self.canvas.parameters.line_width, state='hidden') 53 | self.canvas.line_id_state_map[self.south_line_id] = 'hidden' 54 | 55 | def secondary_init(self): 56 | """Called after all people are initialized, stores adjacent people""" 57 | if not self.at_north: 58 | self.person_north = self.canvas.people_grid[self.y - 1][self.x] 59 | if not self.at_east: 60 | self.person_east = self.canvas.people_grid[self.y][self.x + 1] 61 | if not self.at_south: 62 | self.person_south = self.canvas.people_grid[self.y + 1][self.x] 63 | if not self.at_west: 64 | self.person_west = self.canvas.people_grid[self.y][self.x - 1] 65 | 66 | if not self.at_north and not self.at_east: # Northeast 67 | self.person_ne = self.canvas.people_grid[self.y - 1][self.x + 1] 68 | if not self.at_south and not self.at_east: # Southeast 69 | self.person_se = self.canvas.people_grid[self.y + 1][self.x + 1] 70 | if not self.at_south and not self.at_west: # Southwest 71 | self.person_sw = self.canvas.people_grid[self.y + 1][self.x - 1] 72 | if not self.at_north and not self.at_west: # Northwest 73 | self.person_nw = self.canvas.people_grid[self.y - 1][self.x - 1] 74 | 75 | # `filter(None.__ne__, l)` removes all occurrences of None from a list 76 | self.adjacent_people = list(filter(None.__ne__, [ 77 | self.person_north, self.person_south, self.person_west, self.person_east 78 | ])) 79 | self.surrounding_people = [ 80 | self.person_north, self.person_ne, self.person_east, self.person_se, 81 | self.person_south, self.person_sw, self.person_west, self.person_nw 82 | ] 83 | self.edge_ids = list(filter(None.__ne__, [ 84 | getattr(self.person_west, 'east_line_id', None), self.east_line_id, 85 | getattr(self.person_north, 'south_line_id', None), self.south_line_id 86 | ])) 87 | """ 88 | adjacent_people - up to 4 people in direct cardinal directions 89 | surrounding_people - always length 8, includes all people in surrounding 8 squares, None if no person 90 | edge_ids - up to 4 tkinter Canvas ids that correspond to lines enclosing this person, hidden or not 91 | """ 92 | 93 | def __repr__(self): 94 | return f'{str(self.party).title()} person at {self.x, self.y}' 95 | 96 | def get_adjacent_districts(self): 97 | """Returns a list of districts neighboring this person, not including the district this is in""" 98 | return [person.district for person in self.adjacent_people if person.district is not self.district] 99 | 100 | def get_is_removable(self): 101 | """Returns whether the person can be removed from their district without disconnecting district 102 | 103 | Method: get a boolean list of whether each of the surrounding 8 people are in our district. If there are more 104 | than 2 'streaks' of True's (including carrying over between start and end of the list), then removing the 105 | square will cause a disconnected group because the surrounding squares are not connected to each other. This 106 | works on the assumption that there are no holes, which there aren't because all districts are the same size, 107 | and there are no people without a district. 108 | """ 109 | bool_list = [getattr(person, 'district', None) is self.district for person in self.surrounding_people] 110 | num_trues = bool_list.count(True) 111 | for k, v in groupby(bool_list * 2): 112 | if k and sum(1 for _ in v) >= num_trues: 113 | return True 114 | return False 115 | 116 | def change_districts(self, destination): 117 | """Change which district this person belongs to, does not change location or party""" 118 | self.district.people.remove(self) 119 | self.district.net_advantage -= 1 if self.party == self.canvas.parameters.help_party else -1 120 | destination.people.append(self) 121 | self.district = destination 122 | self.district.net_advantage += 1 if self.party == self.canvas.parameters.help_party else -1 123 | -------------------------------------------------------------------------------- /simulation/swap_manager.py: -------------------------------------------------------------------------------- 1 | from misc import fast_shuffled, weighted_choice 2 | from random import random 3 | from time import time 4 | 5 | 6 | class RestartGettingPeopleError(Exception): 7 | """Raised when we encounter certain conditions when getting people, and need to redo the getting people""" 8 | pass 9 | 10 | 11 | class SwapManager: 12 | """Manages the swapping of two people between districts. See readme for more information on how this works""" 13 | 14 | def __init__(self, canvas): 15 | self.canvas = canvas 16 | self.swaps_done = 0 17 | self.district1 = self.district2 = None 18 | self.person1 = self.person2 = None # person[n] is originally from district[n] 19 | 20 | def swap_dispatch(self): 21 | """Called repeatedly while running, calls swap multiple times if needed, draws once""" 22 | to_draw = set() 23 | for _ in range(self.canvas.parameters.num_swaps_per_draw): 24 | self.swap() 25 | to_draw.add(self.district1) 26 | to_draw.add(self.district2) 27 | simulation_time = self.canvas.parameters.simulation_time 28 | time_up = simulation_time is not None and time() - self.canvas.start_time >= simulation_time 29 | num_swaps = self.canvas.parameters.num_swaps 30 | if num_swaps is not None and self.swaps_done >= num_swaps or time_up: 31 | self.canvas.root.restart_simulation() 32 | return 33 | for district in to_draw: 34 | district.draw() 35 | 36 | def swap(self): 37 | """Do a swap of two people between their districts. See readme for more information on how this works""" 38 | time_before = time() 39 | 40 | while True: 41 | try: 42 | self.get_person1() 43 | self.get_person2() 44 | break 45 | except RestartGettingPeopleError: 46 | pass 47 | 48 | self.person1.change_districts(self.district2) 49 | self.person2.change_districts(self.district1) 50 | self.swaps_done += 1 51 | self.canvas.total_swap_time += time() - time_before 52 | 53 | def get_person1(self): 54 | """Gets district1 and person1, using with conditions to make sure no disconnections or harmful swaps occur""" 55 | for self.district1 in self.district1_generator(): 56 | ideal_party1 = self.district1.ideal_give_away() 57 | 58 | for self.person1 in fast_shuffled(self.district1.people): 59 | if ideal_party1 is not None and self.person1.party != ideal_party1: 60 | continue # If is not the ideal party to give away for this district 61 | if not self.person1.get_is_removable(): 62 | continue # If removing will cause disconnection in district1 63 | 64 | return 65 | 66 | def get_person2(self): 67 | """Gets district2 and person2. If no suitable district2 is found, we raise RestartGettingPeopleError""" 68 | for self.district2 in self.district2_generator(): 69 | party2_can_be_help_party = self.party2_can_be_help_party() 70 | favor_tie = self.canvas.parameters.favor_tie 71 | a_district_tied = self.district1.tied or self.district2.tied if favor_tie else None 72 | 73 | for self.person2 in sorted(self.district2.people, key=self.person2_key): 74 | if self.district1 not in self.person2.get_adjacent_districts(): 75 | continue # If not touching district1 76 | if self.person1 in self.person2.adjacent_people: 77 | continue # Swapping two adjacent people will likely cause disconnection, not always though 78 | if not self.person2.get_is_removable(): 79 | continue # If removing will cause disconnection in district2 80 | if favor_tie and a_district_tied and self.person1.party is not self.person2.party: 81 | continue # If swapping will cause a district to become not tied 82 | if not party2_can_be_help_party and self.person2.party == self.canvas.parameters.help_party: 83 | raise RestartGettingPeopleError # Better than `continue` 84 | 85 | return 86 | raise RestartGettingPeopleError 87 | 88 | def person2_key(self, person): 89 | """Used in get_person2, puts people of opposite parties to person1 first (lower number)""" 90 | return int(person.party == self.person1.party) + random() 91 | 92 | def party2_can_be_help_party(self): 93 | """Returns person2 can be help_party without having a decrease in help_party's total score""" 94 | if self.person1.party != self.canvas.parameters.hinder_party: 95 | return True # If net_advantages will stay the same or district2's will increase 96 | # Now we know that district2 net_advantage is decreasing by 2 and district1 net_advantage is increasing by 2 97 | if self.district2.net_advantage == 2: # If district2 will become tie from help_party 98 | if self.district1.tied: # district1 will become help_party from tie 99 | return True 100 | else: 101 | return False 102 | elif 0 <= self.district2.net_advantage <= 1: # If district2 will become hinder_party from help_party/tie 103 | if -2 <= self.district1.net_advantage <= -1: # If district1 will become help_party/tie from hinder_party 104 | return True 105 | else: 106 | return False 107 | return True 108 | 109 | def district1_generator(self): 110 | """Yields district1 options back weighted by districts district1_weight""" 111 | district_weight_map = {d: d.get_district1_weight() for d in self.canvas.districts} 112 | while True: 113 | choice = weighted_choice(district_weight_map.items()) 114 | yield choice 115 | del district_weight_map[choice] 116 | 117 | def district2_generator(self): 118 | """Yields district2 options back weighted by districts district2_weight""" 119 | possible_districts = self.person1.get_adjacent_districts() 120 | district_weight_map = {d: d.get_district2_weight(self.district1) for d in possible_districts} 121 | while True: 122 | if district_weight_map == {}: 123 | return 124 | choice = weighted_choice(district_weight_map.items()) 125 | yield choice 126 | del district_weight_map[choice] 127 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | from multiprocessing import Manager, Process 3 | from parameters import Parameters 4 | from root import Root 5 | 6 | 7 | def run_process(simulation_datas, parameters, seed): 8 | """Runs a process (window) and appends its simulation datas to the list""" 9 | root = Root(parameters=parameters, seed=seed) 10 | simulation_datas.extend(root.simulation_datas) 11 | 12 | 13 | def get_avg_time(print_params=False): 14 | """Runs simulations on one process and returns how long was spent on swapping per simulation""" 15 | parameters = Parameters(num_swaps_per_draw=2000, num_swaps=1000, num_simulations=150, start_running=True) 16 | if print_params: 17 | atexit.register(lambda: print(f'time parameters: {parameters}')) 18 | simulation_datas = [] 19 | run_process(simulation_datas, parameters, 1) 20 | times = [simulation_data.total_swap_time for simulation_data in simulation_datas] 21 | return sum(times) / len(times) 22 | 23 | 24 | def get_avg_score(parameters=None, num_processes=50, print_params=False): 25 | """Runs simulations on many processes and returns the average score of help_party per simulation""" 26 | if parameters is None: 27 | parameters = Parameters(num_swaps_per_draw=2000, num_swaps=1000, num_simulations=10, start_running=True) 28 | if print_params: 29 | atexit.register(lambda: print(f'score parameters: {parameters} x {num_processes} processes')) 30 | seeds = [i + 0 for i in range(num_processes)] # Change offset to check different seeds (shouldn't have affect) 31 | with Manager() as manager: 32 | simulation_datas = manager.list() 33 | processes = [] 34 | for i in range(num_processes): 35 | p = Process(target=run_process, args=(simulation_datas, parameters, seeds[i])) 36 | p.start() 37 | processes.append(p) 38 | for p in processes: 39 | p.join() 40 | scores = [simulation_data.score for simulation_data in simulation_datas] 41 | return sum(scores) / len(scores) 42 | 43 | 44 | def tests(): 45 | """Prints out statistics of the project, like the avg score and time per simulation. Used to test if changes made 46 | to the algorithm are beneficial""" 47 | # print(f'avg_time: {round(get_avg_time(print_params=True) * 1000, 4)} ms') 48 | print(f'avg_score: {get_avg_score(print_params=True)}') 49 | 50 | 51 | if __name__ == '__main__': 52 | tests() 53 | -------------------------------------------------------------------------------- /ui/__init__.py: -------------------------------------------------------------------------------- 1 | """The ui package contains high level widgets that are used by root.py to create the ui of the program (besides 2 | Canvas in the simulation package)""" 3 | from .control_panel import ControlPanel 4 | from .info_panel import InfoPanel 5 | from .parameter_panel import ParameterPanel 6 | -------------------------------------------------------------------------------- /ui/control_panel/__init__.py: -------------------------------------------------------------------------------- 1 | from .buttons import * 2 | import tkinter as tk 3 | 4 | 5 | class ControlPanel(tk.Frame): 6 | """A box that contains control buttons that play/pause, restart simulation, does 1 swap, and show/hide districts""" 7 | 8 | def __init__(self, root): 9 | self.root = root 10 | super().__init__(root.ui_frame, bd=1, relief='solid') 11 | 12 | self.play_pause_button = PlayPauseButton(self) 13 | self.restart_button = RestartButton(self) 14 | self.swap_button = SwapButton(self) 15 | self.toggle_districts_button = ToggleDistrictsButton(self) 16 | 17 | self.play_pause_button.pack(side='left') 18 | self.restart_button.pack(side='left') 19 | self.swap_button.pack(side='left') 20 | self.toggle_districts_button.pack(side='left') 21 | -------------------------------------------------------------------------------- /ui/control_panel/buttons.py: -------------------------------------------------------------------------------- 1 | from misc import rgb_to_hex 2 | from ui.parameter_panel.misc import InvalidParameter 3 | import tkinter as tk 4 | import tkinter.messagebox 5 | 6 | 7 | class ButtonBase(tk.Button): 8 | def __init__(self, control_panel, **kwargs): 9 | self.root = control_panel.root 10 | super().__init__(control_panel, **kwargs) 11 | 12 | self.blue, self.blue_increasing = 0, True 13 | self.flashing, self.flash_id = False, None 14 | self.normal_color = self.cget('bg') 15 | 16 | def update_color(self): 17 | factor = 1 if self.blue_increasing else -1 18 | self.blue += 50 * factor 19 | if not 0 < self.blue < 255: 20 | self.blue_increasing = not self.blue_increasing 21 | self.config(bg=rgb_to_hex(255, 255, self.blue)) 22 | self.flash_id = self.after(50, self.update_color) 23 | 24 | def start_flashing(self): 25 | if self.flashing: 26 | return 27 | self.flashing = True 28 | self.update_color() 29 | 30 | def stop_flashing(self): 31 | if not self.flashing: 32 | return 33 | self.flashing = False 34 | 35 | self.config(bg=self.normal_color) 36 | 37 | if self.flash_id is not None: 38 | self.after_cancel(self.flash_id) 39 | self.flash_id = None 40 | 41 | 42 | class PlayPauseButton(ButtonBase): 43 | def __init__(self, control_panel): 44 | self.root = control_panel.root 45 | super().__init__(control_panel, command=self.play_pause, width=5) 46 | self.update_config() 47 | 48 | self.start_flashing() 49 | 50 | def play_pause(self): 51 | """Toggle whether the simulation is playing or paused""" 52 | self.stop_flashing() 53 | if self.root.canvas.running: 54 | self.root.canvas.pause() # Calls self.update_config as well 55 | else: 56 | self.root.canvas.run() # Calls self.update_config as well 57 | 58 | def update_config(self): 59 | """Update the text of the button""" 60 | text = 'Pause' if self.root.canvas.running else 'Play' 61 | self.config(text=text) 62 | 63 | 64 | class RestartButton(ButtonBase): 65 | def __init__(self, control_panel): 66 | self.root = control_panel.root 67 | super().__init__(control_panel, command=self.restart, text='Restart') 68 | 69 | def restart(self): 70 | self.root.focus() # Remove focus from all widgets 71 | 72 | errors = [] 73 | for adjuster in self.root.parameter_panel.adjusters.values(): 74 | value = adjuster.get() 75 | if isinstance(value, InvalidParameter): 76 | errors.append(f'{adjuster.name} {value.message.lower()}') 77 | if errors: # If a parameter is invalid 78 | tk.messagebox.showerror('Can\'t restart', '\n'.join(errors)) 79 | return # If a parameter is invalid 80 | 81 | parameters = self.root.parameter_panel.get_parameters() 82 | self.root.parameters = parameters 83 | self.root.restart_simulation() 84 | self.root.info_panel.on_restart() 85 | self.root.parameter_panel.on_restart() 86 | 87 | 88 | class SwapButton(ButtonBase): 89 | def __init__(self, control_panel): 90 | self.root = control_panel.root 91 | super().__init__(control_panel, command=self.swap, text='1 Swap') 92 | 93 | def swap(self): 94 | self.root.canvas.swap_manager.swap_dispatch() 95 | self.root.update() 96 | 97 | 98 | class ToggleDistrictsButton(ButtonBase): 99 | def __init__(self, control_panel): 100 | self.root = control_panel.root 101 | # Use lambda to make sure function changes when root.canvas changes 102 | super().__init__(control_panel, command=lambda: self.root.canvas.toggle_districts_visible()) 103 | self.update_config() 104 | 105 | def update_config(self): 106 | """Update the text of the button""" 107 | show_hide = 'Hide' if self.root.canvas.show_districts else 'Show' 108 | self.config(text=f'{show_hide} Districts') 109 | -------------------------------------------------------------------------------- /ui/info_panel/__init__.py: -------------------------------------------------------------------------------- 1 | from .pie_charts import PieCharts 2 | from .swaps_done_info import SwapsDoneInfo 3 | import tkinter as tk 4 | 5 | 6 | class InfoPanel(tk.Frame): 7 | """A box that contains information like how many swaps done, how many districts per party, etc.""" 8 | 9 | def __init__(self, root): 10 | self.root = root 11 | super().__init__(root.ui_frame, bd=1, relief='solid') 12 | 13 | self.pie_charts = PieCharts(self) 14 | self.swaps_done_info = SwapsDoneInfo(self) 15 | 16 | self.pie_charts.pack(side='top') 17 | self.swaps_done_info.pack(side='top') 18 | 19 | self.after_id = self.root.after(10, self.update_info) 20 | 21 | def on_restart(self): 22 | self.pie_charts.on_restart() 23 | 24 | def update_info(self): 25 | self.pie_charts.update_info() 26 | self.swaps_done_info.update_info() 27 | self.after_id = self.root.after(100, self.update_info) 28 | -------------------------------------------------------------------------------- /ui/info_panel/pie_charts.py: -------------------------------------------------------------------------------- 1 | from math import cos, sin, radians 2 | from simulation import BLUE, RED 3 | import tkinter as tk 4 | 5 | 6 | class PieChart: 7 | def __init__(self, pie_charts, coords, name, get_score, get_quantity): 8 | self.pie_charts = pie_charts 9 | self.get_score, self.get_quantity = get_score, get_quantity 10 | self.score, self.quantity = self.get_score(), self.get_quantity() 11 | pie_charts.create_text((coords[0] + coords[2]) / 2, 0, text=name, font=pie_charts.root.font, anchor='n') 12 | pie_charts.create_oval(*coords, fill='gray', outline='white') # Tied part 13 | self.blue_id = pie_charts.create_arc(*coords, fill=BLUE.color, start=90, 14 | extent=self.score['blue'] / self.quantity * 360, outline='white') 15 | self.red_id = pie_charts.create_arc(*coords, fill=RED.color, start=90, 16 | extent=-self.score['red'] / self.quantity * 360, outline='white') 17 | self.blue_text_id = pie_charts.create_text(*self.arc_mid(self.blue_id), text=self.score['blue'], fill='white') 18 | self.red_text_id = pie_charts.create_text(*self.arc_mid(self.red_id), text=self.score['red'], fill='white') 19 | 20 | def arc_mid(self, arc_id): 21 | """Returns the midpoint of the line between the center and the midpoint of the arc""" 22 | start, extent = self.pie_charts.itemcget(arc_id, 'start'), self.pie_charts.itemcget(arc_id, 'extent') 23 | mid_angle = radians(float(start) + float(extent) / 2) 24 | x1, y1, x2, y2 = self.pie_charts.coords(arc_id) 25 | center_x, center_y, r = (x1 + x2) / 2, (y1 + y2) / 2, (x2 - x1) / 2 26 | return center_x + cos(mid_angle) * r / 2, center_y - sin(mid_angle) * r / 2 27 | 28 | def on_restart(self): 29 | self.quantity = self.get_quantity() # Recalculate quantity 30 | 31 | def update_info(self): 32 | current_score = self.get_score() 33 | if self.score == current_score: 34 | return # If no change 35 | self.score = current_score 36 | self.pie_charts.itemconfig(self.blue_id, extent=self.score['blue'] / self.quantity * 360) 37 | self.pie_charts.itemconfig(self.red_id, extent=-self.score['red'] / self.quantity * 360) 38 | self.pie_charts.itemconfig(self.blue_text_id, text=self.score['blue']) 39 | self.pie_charts.itemconfig(self.red_text_id, text=self.score['red']) 40 | self.pie_charts.coords(self.blue_text_id, *self.arc_mid(self.blue_id)) 41 | self.pie_charts.coords(self.red_text_id, *self.arc_mid(self.red_id)) 42 | 43 | 44 | class PieCharts(tk.Canvas): 45 | def __init__(self, info_panel): 46 | self.root = info_panel.root 47 | super().__init__(info_panel, width=200, height=100) 48 | 49 | self.people_chart = PieChart(self, (10, 20, 90, 100), 'population', 50 | self.get_score_people, self.get_quantity_people) 51 | self.district_chart = PieChart(self, (110, 20, 190, 100), 'districts', 52 | self.get_score_districts, self.get_quantity_districts) 53 | 54 | def on_restart(self): 55 | self.people_chart.on_restart() 56 | self.people_chart.update_info() 57 | self.district_chart.on_restart() 58 | 59 | def update_info(self): 60 | self.district_chart.update_info() 61 | 62 | def get_score_people(self): 63 | score = dict(blue=0, red=0) 64 | for person in self.root.canvas.iter_people(): 65 | score[person.party.name] += 1 66 | return score 67 | 68 | def get_score_districts(self): 69 | return self.root.canvas.get_score() 70 | 71 | def get_quantity_people(self): 72 | return self.root.parameters.grid_width ** 2 73 | 74 | def get_quantity_districts(self): 75 | return self.root.canvas.parameters.num_districts 76 | -------------------------------------------------------------------------------- /ui/info_panel/swaps_done_info.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | 4 | class SwapsDoneInfo(tk.Label): 5 | def __init__(self, info_panel): 6 | self.root = info_panel.root 7 | self.var = tk.StringVar() 8 | self.update_info() 9 | super().__init__(info_panel, textvariable=self.var) 10 | 11 | def update_info(self): 12 | self.var.set(f'swaps_done: {self.root.canvas.swap_manager.swaps_done}') 13 | -------------------------------------------------------------------------------- /ui/parameter_panel/__init__.py: -------------------------------------------------------------------------------- 1 | from .adjusters import all_adjusters 2 | from .misc import InvalidParameter, ButtonFrame, ToggleAdvancedButton 3 | from parameters import Parameters 4 | import tkinter as tk 5 | 6 | 7 | class ParameterPanel(tk.Frame): 8 | """A box that contains parameter adjusters that are used to adjust simulation parameters""" 9 | 10 | def __init__(self, root): 11 | self.root = root 12 | self.parameters = root.parameters 13 | super().__init__(root.ui_frame, bd=1, relief='solid') 14 | 15 | adjuster_instances = (adjuster_type(self) for adjuster_type in all_adjusters) 16 | self.adjusters = {adjuster.name: adjuster for adjuster in adjuster_instances} 17 | 18 | self.button_frame = ButtonFrame(self) 19 | self.toggle_advanced_button = ToggleAdvancedButton(self) 20 | 21 | def get_parameters(self): 22 | """Get a parameter object with all parameters set in this panel""" 23 | kwargs = {name: adjuster.get() for name, adjuster in self.adjusters.items()} 24 | return Parameters(**kwargs) 25 | 26 | def set_parameter(self, name, value): 27 | setattr(self.root.parameters, name, value) 28 | 29 | def on_restart(self): 30 | for adjuster in self.adjusters.values(): 31 | adjuster.label.config(font=self.root.font) 32 | self.root.control_panel.restart_button.stop_flashing() 33 | -------------------------------------------------------------------------------- /ui/parameter_panel/adjuster_types.py: -------------------------------------------------------------------------------- 1 | from .misc import InvalidParameter 2 | from .parameter_adjuster_base import ParameterAdjusterBase 3 | from math import inf 4 | import tkinter as tk 5 | 6 | 7 | class CheckboxAdjusterType(ParameterAdjusterBase): 8 | """Can be used to toggle a boolean on or off""" 9 | 10 | def __init__(self, parameter_panel, name, **kwargs): 11 | super().__init__(parameter_panel, name, pad_y=3, **kwargs) 12 | 13 | self.widget = tk.Checkbutton(self.frame, variable=self.var) 14 | self.widget.pack(side='left') 15 | 16 | def get_obj_from_str(self, s): 17 | if s in ('0', 'False'): 18 | return False 19 | if s in ('1', 'True'): 20 | return True 21 | raise ValueError 22 | 23 | 24 | class EntryAdjusterType(ParameterAdjusterBase): 25 | """Can be used to enter a value into a field""" 26 | 27 | def __init__(self, parameter_panel, name, type_, 28 | width=5, use_checkbutton=False, disabled_value=None, min_=None, max_=None, **kwargs): 29 | self.type = type_ 30 | self.min, self.max = -inf if min_ is None else min_, inf if max_ is None else max_ 31 | """min, max - both inclusive, range of values before becoming invalid""" 32 | self.disabled_value = disabled_value 33 | super().__init__(parameter_panel, name, pad_y=5, **kwargs) 34 | 35 | self.widget = tk.Entry(self.frame, textvariable=self.var, width=width, relief='solid') 36 | self.widget.bind('', lambda _: self.parameter_panel.root.focus()) 37 | 38 | self.use_checkbutton = use_checkbutton 39 | if use_checkbutton: 40 | self.checkbutton_var = tk.BooleanVar(value=self.default != 'None') 41 | self.checkbutton_var.trace('w', self.update_disabled) 42 | self.checkbutton = tk.Checkbutton(self.frame, variable=self.checkbutton_var) 43 | self.checkbutton.pack(side='left') 44 | if self.var.get() == 'None': 45 | self.var.set(disabled_value) 46 | self.update_disabled() 47 | 48 | self.widget.pack(side='left') 49 | 50 | def reset(self): 51 | if self.default == 'None': 52 | self.var.set(self.disabled_value) 53 | else: 54 | self.var.set(self.default) 55 | if self.use_checkbutton: 56 | self.checkbutton_var.set(self.default != 'None') 57 | 58 | def get_obj_from_str(self, s): 59 | if self.use_checkbutton and not self.checkbutton_var.get(): 60 | return None 61 | try: 62 | result = self.type(s) 63 | if self.min <= result <= self.max: 64 | return result 65 | return InvalidParameter(f'Out of range [{self.min}, {self.max}]') 66 | except ValueError: 67 | return InvalidParameter(f"Can't convert to number") 68 | 69 | def update_disabled(self, *_): 70 | self.parameter_panel.root.focus() # Remove focus from all widgets 71 | if self.checkbutton_var.get(): 72 | self.widget.config(relief='solid', state='normal') 73 | else: 74 | self.widget.config(relief='sunken', state='disabled') 75 | self.update_boldness() 76 | 77 | 78 | class PickerAdjusterType(ParameterAdjusterBase): 79 | """Can be used to choose an item from a list of (or function that returns) choices, shown as a dropdown list""" 80 | 81 | def __init__(self, parameter_panel, name, **kwargs): 82 | super().__init__(parameter_panel, name, **kwargs) 83 | 84 | self.widget = tk.OptionMenu(self.frame, self.var, None) 85 | self.widget.bind('', self.on_dropdown) 86 | self.widget.pack(side='left') 87 | 88 | def on_dropdown(self, _): 89 | """Refresh the choices in the list using the get_choices function""" 90 | self.widget['menu'].delete(0, 'end') 91 | for choice in self.get_choices(): 92 | self.widget['menu'].add_command(label=choice, command=lambda c=choice: self.choose(c)) 93 | 94 | def choose(self, choice): 95 | self.var.set(choice) 96 | 97 | def get_choices(self): 98 | """Overridden by subclasses, returns all choices valid""" 99 | return [] 100 | -------------------------------------------------------------------------------- /ui/parameter_panel/adjusters.py: -------------------------------------------------------------------------------- 1 | """Contains AdjusterType subclasses that are directly used by ParameterPanel""" 2 | from .adjuster_types import CheckboxAdjusterType, EntryAdjusterType, PickerAdjusterType 3 | from .misc import InvalidParameter 4 | from math import sqrt 5 | from simulation import BLUE, RED 6 | 7 | 8 | class HelpPartyAdjuster(PickerAdjusterType): 9 | def __init__(self, parameter_panel): 10 | super().__init__(parameter_panel, 'help_party', update_on_change=True) 11 | 12 | self.get_choices = lambda: [BLUE, RED] 13 | 14 | def after_choice(self, choice): 15 | """Set the hinder_party parameter to the opposite of help_party""" 16 | hinder_party = {'red': BLUE, 'blue': RED}[choice.name] 17 | if self.parameter_panel.root.parameters.hinder_party != hinder_party: # If different 18 | self.parameter_panel.set_parameter('hinder_party', hinder_party) 19 | for district in self.parameter_panel.root.canvas.districts: 20 | district.net_advantage *= -1 21 | 22 | def get_obj_from_str(self, s): 23 | return {'blue': BLUE, 'red': RED}[s] 24 | 25 | 26 | class FavorTieAdjuster(CheckboxAdjusterType): 27 | def __init__(self, parameter_panel): 28 | super().__init__(parameter_panel, 'favor_tie', update_on_change=True) 29 | 30 | 31 | class PercentageRedAdjuster(EntryAdjusterType): 32 | def __init__(self, parameter_panel): 33 | super().__init__(parameter_panel, 'percentage_red', float, width=6, min_=0, max_=100) 34 | 35 | 36 | class DistrictSizeAdjuster(PickerAdjusterType): 37 | def __init__(self, parameter_panel): 38 | self.get_obj_from_str = int 39 | super().__init__(parameter_panel, 'district_size') 40 | 41 | self.get_choices = lambda: [i * i for i in range(2, 10)] 42 | 43 | def after_choice(self, _): 44 | self.parameter_panel.adjusters['grid_width'].test_invalid() 45 | 46 | 47 | class GridWidthAdjuster(PickerAdjusterType): 48 | def __init__(self, parameter_panel): 49 | self.get_obj_from_str = int 50 | super().__init__(parameter_panel, 'grid_width') 51 | 52 | def get_choices(self): 53 | """Get choices for grid_width based on current set district_size""" 54 | district_width = int(sqrt(self.parameter_panel.adjusters['district_size'].get())) 55 | return [districts_per_row * district_width for districts_per_row in range(2, 15)] 56 | 57 | def test_invalid(self): 58 | choices = self.get_choices() 59 | current = self.get() 60 | if current not in choices: 61 | closest = min(choices, key=lambda val: abs(val - current)) 62 | self.var.set(closest) 63 | 64 | 65 | class CanvasWidthAdjuster(EntryAdjusterType): 66 | def __init__(self, parameter_panel): 67 | super().__init__(parameter_panel, 'canvas_width', int, min_=50, advanced=True) 68 | 69 | 70 | class LineWidthAdjuster(EntryAdjusterType): 71 | def __init__(self, parameter_panel): 72 | super().__init__(parameter_panel, 'line_width', int, min_=0, width=4, update_on_change=True, advanced=True) 73 | 74 | def after_choice(self, choice): 75 | if isinstance(choice, InvalidParameter): 76 | return 77 | canvas = self.parameter_panel.root.canvas 78 | for person in canvas.iter_people(): 79 | canvas.itemconfig(person.east_line_id, width=choice) 80 | canvas.itemconfig(person.south_line_id, width=choice) 81 | 82 | 83 | class ShowMarginsAdjuster(CheckboxAdjusterType): 84 | def __init__(self, parameter_panel): 85 | super().__init__(parameter_panel, 'show_margins', update_on_change=True, advanced=True) 86 | 87 | def after_choice(self, _): 88 | self.parameter_panel.root.canvas.redraw_districts() 89 | 90 | 91 | class SleepBetweenDrawsAdjuster(EntryAdjusterType): 92 | def __init__(self, parameter_panel): 93 | super().__init__(parameter_panel, 'sleep_between_draws', int, min_=0, update_on_change=True, advanced=True) 94 | 95 | 96 | class NumSwapsPerDrawAdjuster(EntryAdjusterType): 97 | def __init__(self, parameter_panel): 98 | super().__init__(parameter_panel, 'num_swaps_per_draw', int, min_=1, update_on_change=True, advanced=True) 99 | 100 | 101 | class NumSwapsAdjuster(EntryAdjusterType): 102 | def __init__(self, parameter_panel): 103 | super().__init__(parameter_panel, 'num_swaps', int, 104 | use_checkbutton=True, disabled_value=1000, min_=1, advanced=True) 105 | 106 | 107 | class SimulationTimeAdjuster(EntryAdjusterType): 108 | def __init__(self, parameter_panel): 109 | super().__init__(parameter_panel, 'simulation_time', float, 110 | use_checkbutton=True, disabled_value=2, width=4, min_=0.1, advanced=True) 111 | 112 | 113 | all_adjusters = [ 114 | HelpPartyAdjuster, 115 | FavorTieAdjuster, 116 | PercentageRedAdjuster, 117 | DistrictSizeAdjuster, 118 | GridWidthAdjuster, 119 | CanvasWidthAdjuster, 120 | LineWidthAdjuster, 121 | ShowMarginsAdjuster, 122 | SleepBetweenDrawsAdjuster, 123 | NumSwapsPerDrawAdjuster, 124 | NumSwapsAdjuster, 125 | SimulationTimeAdjuster, 126 | ] 127 | -------------------------------------------------------------------------------- /ui/parameter_panel/misc.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | 3 | 4 | class HoverInfo: 5 | """Shows text when mouse is hovered on given widget""" 6 | 7 | def __init__(self, widget, text): 8 | self.widget, self.text = widget, text 9 | self.widget.bind('', self.showtip) 10 | self.widget.bind('', self.hidetip) 11 | self.top_level = None 12 | 13 | def showtip(self, _): 14 | x, y, _, _ = self.widget.bbox("insert") 15 | x += self.widget.winfo_rootx() + 25 16 | y += self.widget.winfo_rooty() + 20 17 | self.top_level = tk.Toplevel(self.widget) 18 | self.top_level.wm_overrideredirect(True) # Leaves only the label and removes the app window 19 | self.top_level.wm_geometry(f'+{x}+{y}') 20 | label = tk.Label(self.top_level, text=self.text, justify='left', background="#ffffff", relief='solid', 21 | borderwidth=1, wraplength=180) 22 | label.pack(ipadx=1) 23 | 24 | def hidetip(self, _=None): 25 | if self.top_level: 26 | self.top_level.destroy() 27 | self.top_level = None 28 | 29 | def delete(self): 30 | self.hidetip() 31 | self.widget.unbind('') 32 | self.widget.unbind('') 33 | 34 | 35 | class InvalidParameter: 36 | def __init__(self, message): 37 | self.message = message 38 | 39 | 40 | class ButtonFrame(tk.Frame): 41 | """Stores DefaultParametersButton and DiscardChangesButton""" 42 | 43 | def __init__(self, parameter_panel): 44 | self.parameter_panel = parameter_panel 45 | super().__init__(parameter_panel) 46 | DefaultParametersButton(self).pack(side='left') 47 | DiscardChangesButton(self).pack(side='left') 48 | 49 | 50 | class DefaultParametersButton(tk.Button): 51 | """Reset all parameters to defaults set in Parameters __init__""" 52 | 53 | def __init__(self, button_frame): 54 | self.parameter_panel = button_frame.parameter_panel 55 | super().__init__(button_frame, command=self.reset, font='Consolas 8', text='Default') 56 | 57 | def reset(self): 58 | for adjuster in self.parameter_panel.adjusters.values(): 59 | adjuster.reset() 60 | 61 | 62 | class DiscardChangesButton(tk.Button): 63 | """Reverts parameter adjusters to the last time restart button was pressed, basically sets them to current 64 | parameters used by the simulation""" 65 | 66 | def __init__(self, button_frame): 67 | self.parameter_panel = button_frame.parameter_panel 68 | super().__init__(button_frame, command=self.reset, font='Consolas 8', text='Discard') 69 | 70 | def reset(self): 71 | for adjuster in self.parameter_panel.adjusters.values(): 72 | adjuster.revert() 73 | 74 | 75 | class ToggleAdvancedButton(tk.Button): 76 | def __init__(self, parameter_panel): 77 | self.parameter_panel = parameter_panel 78 | self.shown = False 79 | font = 'Consolas 8 underline' 80 | super().__init__(parameter_panel, command=self.toggle, font=font, text='Show advanced ▼', borderwidth=0) 81 | self.pack(side='top', pady=(10, 0)) 82 | 83 | self.bind('', self.mouse_down) 84 | self.bind('', self.mouse_up) 85 | 86 | def mouse_down(self, _): 87 | self.config(fg='gray') 88 | return 'break' 89 | 90 | def mouse_up(self, _): 91 | self.config(fg='black') 92 | self.toggle() 93 | 94 | def toggle(self): 95 | self.pack_forget() 96 | for adjuster in self.parameter_panel.adjusters.values(): 97 | if adjuster.advanced: 98 | if self.shown: 99 | adjuster.frame.pack_forget() 100 | else: 101 | adjuster.pack() 102 | self.shown = not self.shown 103 | if self.shown: 104 | self.parameter_panel.button_frame.pack(side='top') 105 | else: 106 | self.parameter_panel.button_frame.pack_forget() 107 | self.pack(side='top', pady=(10, 0)) 108 | self.config(text='Hide advanced ▲' if self.shown else 'Show advanced ▼') 109 | -------------------------------------------------------------------------------- /ui/parameter_panel/parameter_adjuster_base.py: -------------------------------------------------------------------------------- 1 | from .misc import HoverInfo, InvalidParameter 2 | from parameters import ParameterDocs 3 | import tkinter as tk 4 | 5 | 6 | class ParameterAdjusterBase: 7 | """The base class of parameter adjuster types (like Picker & Entry), and those are subclassed in picker_adjusters.py 8 | into adjusters of specific parameters (like DistrictSizeAdjuster & GridWidthAdjuster)""" 9 | 10 | def __init__(self, parameter_panel, name, pad_y=0, advanced=False, update_on_change=False): 11 | self.parameter_panel = parameter_panel 12 | self.name = name 13 | self.pad_y = pad_y 14 | self.advanced = advanced 15 | self.update_on_change = update_on_change 16 | self.is_changed = False # If is changed from current simulation parameters 17 | 18 | self.default = str(getattr(parameter_panel.root.parameters, name)) 19 | self.var = tk.StringVar(value=self.default) 20 | self.var.trace('w', self.on_var_change) 21 | self.bold_font = self.parameter_panel.root.font + ' bold' 22 | 23 | self.frame = tk.Frame(parameter_panel) 24 | self.label = tk.Label(self.frame, text=name + ':') 25 | self.info = tk.Label(self.frame, text='ⓘ') 26 | HoverInfo(self.info, getattr(ParameterDocs, name)) 27 | self.invalid_hover_info = None 28 | 29 | self.info.pack(side='left', padx=(0, 5)) 30 | self.label.pack(side='left') 31 | if not advanced: 32 | self.pack() 33 | 34 | def pack(self): 35 | self.frame.pack(side='top', padx=(0, 5), pady=self.pad_y) 36 | 37 | def get(self): 38 | """Gets the object that this adjuster represents, not the raw string from self.var.get()""" 39 | value = self.var.get() 40 | if value is None or isinstance(value, InvalidParameter): 41 | return value 42 | return self.get_obj_from_str(value) 43 | 44 | def reset(self): 45 | """Resets to default""" 46 | self.var.set(self.default) 47 | 48 | def revert(self): 49 | """Reverts changes made since last run""" 50 | if not self.is_changed: 51 | return 52 | self.var.set(getattr(self.parameter_panel.root.parameters, self.name)) 53 | 54 | def get_obj_from_str(self, s): 55 | """self.var is always a string, so if it represents another object, subclasses can convert it to the actual \ 56 | object here""" 57 | return s 58 | 59 | def on_var_change(self, *_): 60 | value = self.get() 61 | if self.update_on_change: 62 | if not isinstance(value, InvalidParameter): 63 | self.parameter_panel.set_parameter(self.name, value) 64 | else: 65 | self.update_boldness() 66 | if self.invalid_hover_info is not None: 67 | self.invalid_hover_info.delete() 68 | if isinstance(value, InvalidParameter): 69 | self.invalid_hover_info = HoverInfo(self.label, value.message) 70 | else: 71 | self.invalid_hover_info = None 72 | self.label.config(fg='red' if isinstance(value, InvalidParameter) else 'black') 73 | self.after_choice(value) 74 | 75 | def update_boldness(self): 76 | self.is_changed = self.get() != getattr(self.parameter_panel.root.parameters, self.name) 77 | font = self.bold_font if self.is_changed else self.parameter_panel.root.font 78 | self.label.config(font=font) 79 | 80 | if not hasattr(self.parameter_panel, 'adjusters'): 81 | return 82 | any_changed = any(adjuster.is_changed for adjuster in self.parameter_panel.adjusters.values()) 83 | if any_changed: 84 | self.parameter_panel.root.control_panel.restart_button.start_flashing() 85 | else: 86 | self.parameter_panel.root.control_panel.restart_button.stop_flashing() 87 | 88 | def after_choice(self, choice): 89 | """Overridden by subclasses, called after the variable is changed. Typically used to ensure other entered 90 | parameters are valid""" 91 | pass 92 | --------------------------------------------------------------------------------