├── .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 | 
53 |
54 | We can then make `person1` part of `district2`, and `person2` part of `district1`.
55 |
56 | 
57 |
58 | In this example, we have flipped one district from being tied to being blue.
59 |
60 | ### Code structure
61 | 
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 |
--------------------------------------------------------------------------------