From 30d2c39580e943275ddee6e5f87dadfda8b38118 Mon Sep 17 00:00:00 2001 From: Jim Martens Date: Sun, 2 Jul 2023 18:04:03 +0200 Subject: [PATCH] Added calculation of constituency results --- .../twomartens/wahlrecht/model/Candidate.java | 16 ++ .../wahlrecht/model/Constituency.java | 9 ++ .../twomartens/wahlrecht/model/Elected.java | 7 + .../wahlrecht/model/ElectedResult.java | 12 ++ .../twomartens/wahlrecht/model/Election.java | 10 ++ .../wahlrecht/model/Nomination.java | 45 ++++++ .../de/twomartens/wahlrecht/model/Party.java | 21 +++ .../wahlrecht/model/VotingResult.java | 27 ++++ .../wahlrecht/model/VotingThreshold.java | 15 ++ .../ConstituencyCalculationService.java | 141 ++++++++++++++++ .../ConstituencyCalculationServiceTest.java | 152 ++++++++++++++++++ 11 files changed, 455 insertions(+) create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/model/Candidate.java create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/model/Constituency.java create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/model/Elected.java create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/model/ElectedResult.java create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/model/Election.java create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/model/Nomination.java create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/model/Party.java create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/model/VotingResult.java create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/model/VotingThreshold.java create mode 100644 module-server/src/main/java/de/twomartens/wahlrecht/service/ConstituencyCalculationService.java create mode 100644 module-server/src/test/java/de/twomartens/wahlrecht/service/ConstituencyCalculationServiceTest.java diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/model/Candidate.java b/module-server/src/main/java/de/twomartens/wahlrecht/model/Candidate.java new file mode 100644 index 0000000..5392991 --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/model/Candidate.java @@ -0,0 +1,16 @@ +package de.twomartens.wahlrecht.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@RequiredArgsConstructor +@Getter +@Setter +@ToString +public class Candidate { + private final String name; + private final String profession; + private Elected elected = Elected.NOT_ELECTED; +} diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/model/Constituency.java b/module-server/src/main/java/de/twomartens/wahlrecht/model/Constituency.java new file mode 100644 index 0000000..9dd8ded --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/model/Constituency.java @@ -0,0 +1,9 @@ +package de.twomartens.wahlrecht.model; + +public record Constituency(String name, int numberOfSeats) { + + @Override + public String toString() { + return name; + } +} diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/model/Elected.java b/module-server/src/main/java/de/twomartens/wahlrecht/model/Elected.java new file mode 100644 index 0000000..9913097 --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/model/Elected.java @@ -0,0 +1,7 @@ +package de.twomartens.wahlrecht.model; + +public enum Elected { + NOT_ELECTED, + CONSTITUENCY, + OVERALL +} diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/model/ElectedResult.java b/module-server/src/main/java/de/twomartens/wahlrecht/model/ElectedResult.java new file mode 100644 index 0000000..f2d4e59 --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/model/ElectedResult.java @@ -0,0 +1,12 @@ +package de.twomartens.wahlrecht.model; + +import java.util.Collection; +import java.util.Deque; +import java.util.Map; +import lombok.Builder; + +@Builder +public record ElectedResult(Map> electedCandidates, + Deque usedElectionNumbers) { + +} diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/model/Election.java b/module-server/src/main/java/de/twomartens/wahlrecht/model/Election.java new file mode 100644 index 0000000..87d84dd --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/model/Election.java @@ -0,0 +1,10 @@ +package de.twomartens.wahlrecht.model; + +import java.time.LocalDate; +import java.util.Collection; + +public record Election(String name, LocalDate day, VotingThreshold votingThreshold, + int totalNumberOfSeats, + Collection constituencies) { + +} diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/model/Nomination.java b/module-server/src/main/java/de/twomartens/wahlrecht/model/Nomination.java new file mode 100644 index 0000000..c9bc36b --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/model/Nomination.java @@ -0,0 +1,45 @@ +package de.twomartens.wahlrecht.model; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import lombok.ToString.Include; + +@RequiredArgsConstructor +@ToString(onlyExplicitlyIncluded = true) +public class Nomination { + private final List candidates = new ArrayList<>(); + private final Map candidateToPosition = new HashMap<>(); + private final Map positionToCandidate = new HashMap<>(); + @Include + private final String name; + @Getter + private final boolean supportVotesOnNomination; + @Getter + @Setter + private VotingResult votingResult; + + private void addCandidate(Candidate candidate) { + candidates.add(candidate); + candidateToPosition.put(candidate, candidates.size()); + positionToCandidate.put(candidates.size(), candidate); + } + + public void addCandidates(Candidate... candidates) { + Arrays.stream(candidates).forEach(this::addCandidate); + } + + public Candidate getCandidate(int position) { + return positionToCandidate.get(position); + } + + public int getPosition(Candidate candidate) { + return candidateToPosition.get(candidate); + } +} diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/model/Party.java b/module-server/src/main/java/de/twomartens/wahlrecht/model/Party.java new file mode 100644 index 0000000..a3c252f --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/model/Party.java @@ -0,0 +1,21 @@ +package de.twomartens.wahlrecht.model; + +import java.util.Map; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@RequiredArgsConstructor +@Setter +@Getter +public class Party { + private final String name; + private Nomination overallNomination; + private Map constituencyNominations; + private boolean passesVotingThreshold; + + @Override + public String toString() { + return name; + } +} diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/model/VotingResult.java b/module-server/src/main/java/de/twomartens/wahlrecht/model/VotingResult.java new file mode 100644 index 0000000..100f787 --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/model/VotingResult.java @@ -0,0 +1,27 @@ +package de.twomartens.wahlrecht.model; + +import java.util.Map; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class VotingResult { + + private final int votesOnNomination; + private final Map votesPerPosition; + private Integer totalVotes = null; + + @Builder + public VotingResult(int votesOnNomination, Map votesPerPosition) { + this.votesOnNomination = votesOnNomination; + this.votesPerPosition = votesPerPosition; + } + + public int getTotalVotes() { + if (totalVotes == null) { + totalVotes = votesOnNomination + votesPerPosition.values().stream() + .reduce(0, Integer::sum); + } + return totalVotes; + } +} diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/model/VotingThreshold.java b/module-server/src/main/java/de/twomartens/wahlrecht/model/VotingThreshold.java new file mode 100644 index 0000000..f5412f2 --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/model/VotingThreshold.java @@ -0,0 +1,15 @@ +package de.twomartens.wahlrecht.model; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum VotingThreshold { + NONE(0.00), + THREE(0.03), + FIVE(0.05); + + private final double multiplier; +} diff --git a/module-server/src/main/java/de/twomartens/wahlrecht/service/ConstituencyCalculationService.java b/module-server/src/main/java/de/twomartens/wahlrecht/service/ConstituencyCalculationService.java new file mode 100644 index 0000000..480bda8 --- /dev/null +++ b/module-server/src/main/java/de/twomartens/wahlrecht/service/ConstituencyCalculationService.java @@ -0,0 +1,141 @@ +package de.twomartens.wahlrecht.service; + +import de.twomartens.wahlrecht.model.Candidate; +import de.twomartens.wahlrecht.model.Constituency; +import de.twomartens.wahlrecht.model.ElectedResult; +import de.twomartens.wahlrecht.model.Nomination; +import de.twomartens.wahlrecht.model.VotingResult; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; + +@Service +public class ConstituencyCalculationService { + private final Deque electionNumberHistory = new ArrayDeque<>(); + + public ElectedResult calculateConstituency(@NonNull Constituency constituency, + @NonNull Collection nominations) { + electionNumberHistory.clear(); + int totalVotes = nominations.stream() + .map(Nomination::getVotingResult) + .map(VotingResult::getTotalVotes) + .reduce(0, Integer::sum); + int numberOfSeats = constituency.numberOfSeats(); + double initialElectionNumber = totalVotes / (double) numberOfSeats; + + Map assignedSeatsPerNomination = calculateAssignedSeatsPerNomination( + nominations, numberOfSeats, initialElectionNumber); + + return ElectedResult.builder() + .electedCandidates(findElectedCandidates(assignedSeatsPerNomination)) + .usedElectionNumbers(electionNumberHistory) + .build(); + } + + @NonNull + private Map> findElectedCandidates( + @NonNull Map assignedSeatsPerNomination) { + + Map> electedCandidates = new HashMap<>(); + assignedSeatsPerNomination.entrySet().stream() + .filter(entry -> entry.getValue() > 0) + .forEach(entry -> + electedCandidates.put(entry.getKey(), findCandidates(entry.getKey(), entry.getValue()))); + + return electedCandidates; + } + + @NonNull + private Collection findCandidates(@NonNull Nomination nomination, long numberOfSeats) { + List positions = nomination.getVotingResult().getVotesPerPosition().entrySet().stream() + .sorted(Comparator.comparing(Entry::getValue).reversed()) + .map(Entry::getKey) + .toList(); + Collection elected = new ArrayList<>(); + for (int i = 0; i < numberOfSeats; i++) { + Integer position = positions.get(i); + elected.add(nomination.getCandidate(position)); + } + + return elected; + } + + private Map calculateAssignedSeatsPerNomination( + @NonNull Collection nominations, + int numberOfSeats, Double initialElectionNumber) { + + double electionNumber = initialElectionNumber; + long assignedSeats; + Map assignedSeatsPerNomination; + + do { + electionNumberHistory.add(electionNumber); + Map seatsPerNomination = new HashMap<>(); + for (Nomination nomination : nominations) { + double seatNumber = calculateAssignedSeatNumber(electionNumber, + nomination.getVotingResult().getTotalVotes()); + seatsPerNomination.put(nomination, seatNumber); + } + assignedSeatsPerNomination = seatsPerNomination.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), Math.round(entry.getValue()))) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + assignedSeats = assignedSeatsPerNomination.values().stream().reduce(0L, Long::sum); + + if (assignedSeats < numberOfSeats) { + // election number was too big, decrease + electionNumber = calculateLowerElectionNumber(initialElectionNumber, seatsPerNomination, + assignedSeatsPerNomination); + } else if (assignedSeats > numberOfSeats) { + // election number was too small, increase + electionNumber = calculateHigherElectionNumber(initialElectionNumber, seatsPerNomination, + assignedSeatsPerNomination); + } + } while (assignedSeats != numberOfSeats); + + + return assignedSeatsPerNomination; + } + + private static double calculateHigherElectionNumber(Double initialElectionNumber, + @NonNull Map seatsPerNomination, + Map assignedSeatsPerNomination) { + double electionNumber; + electionNumber = seatsPerNomination.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), + entry.getValue() - assignedSeatsPerNomination.get(entry.getKey()))) + .min(Comparator.comparing(Entry::getValue)) + .map(entry -> entry.getKey().getVotingResult().getTotalVotes() + / (assignedSeatsPerNomination.get(entry.getKey()) - 0.5)) + .orElse(initialElectionNumber); + return electionNumber; + } + + private static double calculateLowerElectionNumber(Double initialElectionNumber, + @NonNull Map seatsPerNomination, + Map assignedSeatsPerNomination) { + double electionNumber; + electionNumber = seatsPerNomination.entrySet().stream() + .map(entry -> Map.entry(entry.getKey(), + entry.getValue() - assignedSeatsPerNomination.get(entry.getKey()))) + .max(Comparator.comparing(Entry::getValue)) + .map(entry -> entry.getKey().getVotingResult().getTotalVotes() + / (assignedSeatsPerNomination.get(entry.getKey()) + 0.5)) + .orElse(initialElectionNumber); + return electionNumber; + } + + + + private double calculateAssignedSeatNumber(double electionNumber, int totalVotesOfNomination) { + return totalVotesOfNomination / electionNumber; + } +} diff --git a/module-server/src/test/java/de/twomartens/wahlrecht/service/ConstituencyCalculationServiceTest.java b/module-server/src/test/java/de/twomartens/wahlrecht/service/ConstituencyCalculationServiceTest.java new file mode 100644 index 0000000..74b02af --- /dev/null +++ b/module-server/src/test/java/de/twomartens/wahlrecht/service/ConstituencyCalculationServiceTest.java @@ -0,0 +1,152 @@ +package de.twomartens.wahlrecht.service; + +import de.twomartens.wahlrecht.model.Candidate; +import de.twomartens.wahlrecht.model.Constituency; +import de.twomartens.wahlrecht.model.ElectedResult; +import de.twomartens.wahlrecht.model.Nomination; +import de.twomartens.wahlrecht.model.VotingResult; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ConstituencyCalculationServiceTest { + private static final Nomination SPD_NIENDORF = buildNomination("SPD-Niendorf"); + private static final Nomination CDU_NIENDORF = buildNomination("CDU-Niendorf"); + private static final Nomination FDP_NIENDORF = buildNomination("FDP-Niendorf"); + private static final Nomination LINKE_NIENDORF = buildNomination("LINKE-Niendorf"); + private static final Nomination GRUENE_NIENDORF = buildNomination("GRUENE-Niendorf"); + private static final Nomination AFD_NIENDORF = buildNomination("AFD-Niendorf"); + + private ConstituencyCalculationService service; + + @BeforeEach + void setService() { + service = new ConstituencyCalculationService(); + } + + @Test + void shouldCalculateConstituencyCorrectly() { + Constituency constituency = new Constituency("Niendorf", 5); + Collection nominations = List.of( + SPD_NIENDORF, + CDU_NIENDORF, + LINKE_NIENDORF, + FDP_NIENDORF, + GRUENE_NIENDORF, + AFD_NIENDORF + ); + setUpCandidates(); + setUpResults(); + + ElectedResult elected = service.calculateConstituency(constituency, nominations); + + Assertions.assertThat(elected.electedCandidates()) + .containsKey(SPD_NIENDORF) + .containsKey(CDU_NIENDORF) + .containsKey(GRUENE_NIENDORF) + .doesNotContainKey(LINKE_NIENDORF) + .doesNotContainKey(AFD_NIENDORF) + .doesNotContainKey(FDP_NIENDORF); + } + + private void setUpCandidates() { + SPD_NIENDORF.addCandidates(new Candidate("Schwarzarius, Ines", "")); + SPD_NIENDORF.addCandidates(new Candidate("Urbanski, Torge", "")); + SPD_NIENDORF.addCandidates(new Candidate("Fischbach-Pyttel, Carola", "")); + SPD_NIENDORF.addCandidates(new Candidate("Bäcker, Guido", "")); + SPD_NIENDORF.addCandidates(new Candidate("Porr, Ulrike", "")); + SPD_NIENDORF.addCandidates(new Candidate("Petersen, Johannes", "")); + SPD_NIENDORF.addCandidates(new Candidate("Kröger, Manuela-Andrea", "")); + SPD_NIENDORF.addCandidates(new Candidate("Steppat, Wolfgang", "")); + SPD_NIENDORF.addCandidates(new Candidate("Canbay, Berfin", "")); + SPD_NIENDORF.addCandidates(new Candidate("Schumacher, Wolfgang", "")); + + CDU_NIENDORF.addCandidates(new Candidate("Kuhn, Rüdiger", "")); + CDU_NIENDORF.addCandidates(new Candidate("Ahrens, Mareike", "")); + CDU_NIENDORF.addCandidates(new Candidate("Wysocki, Michael", "")); + CDU_NIENDORF.addCandidates(new Candidate("Dönselmann, Malte", "")); + CDU_NIENDORF.addCandidates(new Candidate("Seehaber, Daniel", "")); + + LINKE_NIENDORF.addCandidates(new Candidate("Dr. Khenkhar, Malik", "")); + LINKE_NIENDORF.addCandidates(new Candidate("Radom, Claudia", "")); + LINKE_NIENDORF.addCandidates(new Candidate("Kara, Başak", "")); + + FDP_NIENDORF.addCandidates(new Candidate("Häffs, Marvin", "")); + FDP_NIENDORF.addCandidates(new Candidate("Bahmer, Larissa", "")); + FDP_NIENDORF.addCandidates(new Candidate("Patzer, Heinrich-Otto", "")); + + GRUENE_NIENDORF.addCandidates(new Candidate("Demirhan, Sina", "")); + GRUENE_NIENDORF.addCandidates(new Candidate("Schmidt, Lutz", "Redakteur")); + + AFD_NIENDORF.addCandidates(new Candidate("Hanssen, Wolfgang", "")); + } + + private void setUpResults() { + SPD_NIENDORF.setVotingResult(VotingResult.builder() + .votesOnNomination(0) + .votesPerPosition(Map.of( + 1, 11882, + 2, 7617, + 3, 1946, + 4, 1743, + 5, 1363, + 6, 762, + 7, 895, + 8, 1598, + 9, 1786, + 10, 781 + )) + .build()); + + CDU_NIENDORF.setVotingResult(VotingResult.builder() + .votesOnNomination(0) + .votesPerPosition(Map.of( + 1, 10733, + 2, 4550, + 3, 2310, + 4, 2333, + 5, 2565 + )) + .build()); + + LINKE_NIENDORF.setVotingResult(VotingResult.builder() + .votesOnNomination(0) + .votesPerPosition(Map.of( + 1, 3782, + 2, 1584, + 3, 1185 + )) + .build()); + + FDP_NIENDORF.setVotingResult(VotingResult.builder() + .votesOnNomination(0) + .votesPerPosition(Map.of( + 1, 2498, + 2, 3200, + 3, 992 + )) + .build()); + + GRUENE_NIENDORF.setVotingResult(VotingResult.builder() + .votesOnNomination(0) + .votesPerPosition(Map.of( + 1, 14838, + 2, 12501 + )) + .build()); + + AFD_NIENDORF.setVotingResult(VotingResult.builder() + .votesOnNomination(0) + .votesPerPosition(Map.of( + 1, 6616 + )) + .build()); + } + + private static Nomination buildNomination(String name) { + return new Nomination(name, false); + } +} \ No newline at end of file