Skip to content

Conversation

@prismika
Copy link
Collaborator

Pursuant to issue #222. This PR adds support for animating STV elections using Manim. Given a votekit STV election called election, the user can render an animation using, for example,

animation = STVAnimation(election, title=f"Animation of STV Election")
animation.render(preview=True)

Here is an example of a rendered scene.

ElectionScene2.mp4

This PR is a draft. I have yet to write robust testing for the animation pipeline (such as snapshot testing). I also have yet to implement a few requested features:

  • Letting users specify which candidates are on screen, batching offscreen eliminations into a single animation event.
  • Letting users specify nicknames for candidates to appear onscreen.
  • "Light mode"

Before implementing these I wanted some feedback on my code so far.

Thanks!

@prismika prismika requested review from cdonnay and peterrrock2 July 25, 2025 16:08
Copy link
Collaborator

@cdonnay cdonnay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @prismika. Switching computers here so just submitting a partial review for now, will pick it back up in an hour or so.

dict: A dictionary whose keys are candidate names and whose values are themselves dictionaries with details about each candidate.
"""
candidates = {
name: {"support": support}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you anticipate having to include other information about the candidates later on? If yes, I can see why the nested dict structure is used. if not, this could just be a dict.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, candidate nicknames will go here as well.

self.rounds = self._make_event_list(election)
self.title = title

def _make_candidate_dict(self, election: STV) -> dict:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you be more specific about type setting here? dict[str, dict[str, float]] for example.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now dict[str, dict[str, object]]

Returns:
List[dict]: A list of dictionaries corresponding to the rounds of the election. Each dictionary records salient attributes of the corresponding round.
"""
events = []
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know ahead of time how long this list will be? If so it might be more efficient to pre-populate it with dummy entries.

Copy link
Collaborator

@cdonnay cdonnay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright review complete!

  1. This looks great already! I think you should feel confident moving onto your other todos.
  2. I'd encourage a little more modularization. You can see the code for PreferenceProfile as an example of super tiny private methods that build into the bigger methods. Peter is a big fan of keeping functions short.

elected_candidates = []
for fset in election_round.elected:
if len(fset) > 0:
(name,) = fset
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does assume only one candidate is elected at a time? I.e. that the fset has exactly one element?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah it assumes the fset has one candidate, but there can be multiple fsets. Can you double check that the STV election class never elects two cands in one fset? Or add a check here for that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I replaced this code with a list comprehension like the ones that exist throughout the codebase. Of the form:
[c for s in election_round.elected for c in s]

event_type = "win"
elected_candidates_str = elected_candidates[0]
for candidate_name in elected_candidates[1:]:
elected_candidates_str += ", " + candidate_name
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly does this do? Formats the name of the elected cand?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or concatenates all of the names of all of the winners? If it's this one, can you do this with a .join statement on a list?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not know you could do this with a join! It's slick. Added.

support_transferred : dict[str, dict[str, float]] = {}
if round_number == len(election):
# If it's the last round, don't worry about the transferred votes
support_transferred = {cand: {} for cand in elected_candidates}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you init support_transferred on line 91 to be what you need in line 94? why start with it empty?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe I added this to appease mypy. It liked that the type was clearly declared to make sure the type didn't depend on the control flow.

support_transferred=support_transferred,
quota=election.threshold,
message=message,
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

never seen this way of init-ing a dict, does it make all of the keywords without you having to put it in strings?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly.

message=message,
)
)
elif len(eliminated_candidates) > 0:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it can only be 1, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that should be true. Just hedging my bets I suppose.

event_type = "elimination"
eliminated_candidates_str = eliminated_candidates[0]
for candidate_name in eliminated_candidates[1:]:
eliminated_candidates_str += ", " + candidate_name
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, only one can be eliminated, right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. This code has been tightened up.

@cdonnay
Copy link
Collaborator

cdonnay commented Jul 25, 2025

Since I am not a manim guy, I'm going to skip looking at that code for now since the animation clearly works. But the same overall review comments would apply; aim for super tiny private methods that build into bigger ones (up to reason). @peterrrock2 will check out your manim code when he gets a chance.

@prismika
Copy link
Collaborator Author

prismika commented Aug 5, 2025

Thanks for the code review! I addressed or responded to all the comments.

Some changes since the last comments:

  • Created dataclasses for animation events instead of using complicated ad-hoc dictionaries.
  • Support for off-screen candidates. The user can specify which candidates they want on-screen with an optional "focus" keyword argument. If multiple rounds in a row result in off-screen candidates being eliminated, those rounds are condensed into a single step in the animation. (This helps in elections with lots of candidates.)
  • Bug fixes

Regarding the long methods: the longest two are _animate_win and _animate_elimination at about 100 lines. I've thought about how to factor these but in the end this is simply the amount of Manim code necessary to animate those steps, and the code animating each individual step coheres tightly. It's not idea, but I've decided to leave those methods long.

Given an STV election object election, this code:

from votekit.animations import STVAnimation

animation = STVAnimation(
	election,
	title = "STV Election for Two Seats",
	focus = ['W2', 'W5'] # Elected candidates are automatically focused, so we don't have to list them
)
animation.render(preview = True)

produces this animation:

ElectionScene.mp4

If we like the look of the animation, I can start making snapshot tests!

Tasks remaining:

  • Snapshot testing
  • Let users provide nicknames for display purposes
  • Light mode

Thanks!

@peterrrock2
Copy link
Collaborator

Looking good to @cdonnay and I so far! Let us know if you need more support on this, but so far you are killing it 😁

Base automatically changed from 3.3.0 to main October 2, 2025 17:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants