Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updates to the per-slice CSV #872

Merged
merged 9 commits into from
Sep 25, 2024
67 changes: 61 additions & 6 deletions src/main/java/network/brightspots/rcv/CastVoteRecord.java
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,67 @@ Map<String, BigDecimal> getWinnerToFractionalValue() {
// as far as tabulation is concerned, all that matters is whether
// it is active or not.
enum StatusForRound {
ACTIVE,
DID_NOT_RANK_ANY_CANDIDATES,
EXHAUSTED_CHOICE,
INVALIDATED_BY_OVERVOTE,
INVALIDATED_BY_SKIPPED_RANKING,
INVALIDATED_BY_REPEATED_RANKING,
ACTIVE(
false,
"Active",
"active"
),
DID_NOT_RANK_ANY_CANDIDATES(
true,
"Did Not Rank Any Candidates",
"didNotRankAnyCandidates"
),
EXHAUSTED_CHOICE(
true,
"Inactive Ballots by Exhausted Choices",
"exhaustedChoices"
),
INVALIDATED_BY_OVERVOTE(
true,
"Inactive Ballots by Overvotes",
"overvotes"
),
INVALIDATED_BY_SKIPPED_RANKING(
true,
"Inactive Ballots by Skipped Rankings",
"skippedRankings"
),
INVALIDATED_BY_REPEATED_RANKING(
true,
"Inactive Ballots by Repeated Rankings",
"repeatedRankings"
),
FINAL_ROUND_SURPLUS(
false,
"Final Round Surplus",
"finalRoundSurplus"
);

private final boolean isInactiveBallot;
private final String titleCaseKey;
private final String camelCaseKey;

StatusForRound(
boolean isInactiveBallot,
String titleCaseKey,
String camelCaseKey
) {
this.isInactiveBallot = isInactiveBallot;
this.titleCaseKey = titleCaseKey;
this.camelCaseKey = camelCaseKey;
}

public boolean isInactiveBallot() {
return isInactiveBallot;
}

public String getTitleCaseKey() {
return titleCaseKey;
}

public String getCamelCaseKey() {
return camelCaseKey;
}
}

enum VoteOutcomeType {
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/network/brightspots/rcv/ContestConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,10 @@ boolean isNonIntegerWinningThresholdEnabled() {
return rawConfig.rules.nonIntegerWinningThreshold;
}

boolean usesSurpluses() {
return getNumberOfWinners() > 1 && !isMultiSeatBottomsUpUntilNWinnersEnabled();
}

boolean isHareQuotaEnabled() {
return rawConfig.rules.hareQuota;
}
Expand Down
158 changes: 96 additions & 62 deletions src/main/java/network/brightspots/rcv/ResultsWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.stream.Collectors;
import javafx.util.Pair;
import network.brightspots.rcv.ContestConfig.TabulateBySlice;
import network.brightspots.rcv.RawContestConfig.CvrSource;
Expand Down Expand Up @@ -78,6 +79,14 @@ class ResultsWriter {
private String timestampString;
// map from round number to residual surplus generated in that round
private Map<Integer, BigDecimal> roundToResidualSurplus;
// statuses to print in all summary files
// (additional fields are added if needed in specific summary filetypes)
private static final List<StatusForRound> STATUSES_TO_PRINT = List.of(
StatusForRound.INVALIDATED_BY_OVERVOTE,
StatusForRound.INVALIDATED_BY_SKIPPED_RANKING,
StatusForRound.EXHAUSTED_CHOICE,
StatusForRound.INVALIDATED_BY_REPEATED_RANKING);


// visible for testing
@SuppressWarnings("WeakerAccess")
Expand Down Expand Up @@ -258,9 +267,11 @@ ResultsWriter setTimestampString(String timestampString) {
// creates summary files for the votes split by a TabulateBySlice
// param: roundTalliesBySlice is map from a slice type to the round-by-round vote tallies
// param: tallyTransfersBySlice is a map from a slice type to tally transfers for that slice
// param: candidateOrder is to allow a consistent ordering of candidates, including across slices
void generateBySliceSummaryFiles(
Tabulator.BreakdownBySlice<RoundTallies> roundTalliesBySlice,
Tabulator.BreakdownBySlice<TallyTransfers> tallyTransfersBySlice)
Tabulator.BreakdownBySlice<TallyTransfers> tallyTransfersBySlice,
List<String> candidateOrder)
throws IOException {
for (ContestConfig.TabulateBySlice slice : config.enabledSlices()) {
Set<String> filenames = new HashSet<>();
Expand All @@ -271,20 +282,34 @@ void generateBySliceSummaryFiles(
String sliceFileString = getFileStringForSlice(slice, sliceId, filenames);
String outputPath = getOutputFilePathFromInstance(
String.format("%s_summary", sliceFileString));
generateSummarySpreadsheet(roundTallies, slice, sliceId, outputPath);
generateSummarySpreadsheet(roundTallies, candidateOrder, slice, sliceId, outputPath);
generateSummaryJson(roundTallies, tallyTransfers, slice, sliceId, outputPath);
}
}
}

// create a summary spreadsheet .csv file
// param: roundTallies is the round-by-count count of votes per candidate
// param: candidateOrder is to allow a consistent ordering of candidates, including across slices
// param: slice indicates which type of slice we're reporting results for (null means all)
// param: sliceId indicates the specific slice ID we're reporting results for (null means all)
// param: outputPath is the path to the output file, minus its extension
private void generateSummarySpreadsheet(
RoundTallies roundTallies, TabulateBySlice slice, String sliceId, String outputPath)
throws IOException {
RoundTallies roundTallies,
List<String> candidateOrder,
TabulateBySlice slice,
String sliceId,
String outputPath) throws IOException {
// Check that all candidates are included in the candidate order
Set<String> expectedCandidates = roundTallies.get(1).getCandidates();
Set<String> providedCandidates = new HashSet<>(candidateOrder);
if (!expectedCandidates.equals(providedCandidates)) {
throw new IllegalArgumentException(
"Candidate order must include all candidates in the contest. "
+ "\nExpected: " + expectedCandidates
+ "\nProvided: " + providedCandidates);
}

AuditableFile csvFile = new AuditableFile(outputPath + ".csv");
Logger.info("Generating summary spreadsheet: %s...", csvFile.getAbsolutePath());

Expand All @@ -310,16 +335,15 @@ private void generateSummarySpreadsheet(
}
csvPrinter.println();

// actions don't make sense in individual by-slice results
if (isNullOrBlank(sliceId)) {
addActionRows(csvPrinter);
}
final boolean isSlice = !isNullOrBlank(sliceId);
csvPrinter.print(isSlice ? "Eliminated*" : "Eliminated");
printActionSummary(csvPrinter, roundToEliminatedCandidates);

// Get all candidates sorted by their first round tally. This determines the display order.
List<String> sortedCandidates = roundTallies.get(1).getSortedCandidatesByTally();
csvPrinter.print(isSlice ? "Elected*" : "Elected");
printActionSummary(csvPrinter, roundToWinningCandidates);

// For each candidate: for each round: output total votes
for (String candidate : sortedCandidates) {
for (String candidate : candidateOrder) {
String candidateDisplayName = config.getNameForCandidate(candidate);
csvPrinter.print(candidateDisplayName);
for (int round = 1; round <= numRounds; round++) {
Expand All @@ -332,8 +356,13 @@ private void generateSummarySpreadsheet(
// Vote count
csvPrinter.print(thisRoundTally);

// Vote %
BigDecimal votePctDivisor = roundTallies.get(round).activeAndLockedInBallotSum();
// Vote % (divisor is 1st round total in STV or 1st round determines threshold)
BigDecimal votePctDivisor;
if (config.isSingleWinnerEnabled() && !config.isFirstRoundDeterminesThresholdEnabled()) {
votePctDivisor = roundTallies.get(round).activeAndLockedInBallotSum();
} else {
votePctDivisor = roundTallies.get(1).activeAndLockedInBallotSum();
}
if (votePctDivisor != BigDecimal.ZERO) {
// Turn a decimal into a human-readable percentage (e.g. 0.1234 -> 12.34%)
BigDecimal divDecimal = thisRoundTally.divide(votePctDivisor, MathContext.DECIMAL32);
Expand Down Expand Up @@ -366,28 +395,19 @@ private void generateSummarySpreadsheet(
}
csvPrinter.println();

csvPrinter.print("Current Round Threshold");
for (int round = 1; round <= numRounds; round++) {
csvPrinter.print(roundTallies.get(round).getWinningThreshold());
csvPrinter.print("");
csvPrinter.print("");
if (!isSlice) {
csvPrinter.print("Current Round Threshold");
for (int round = 1; round <= numRounds; round++) {
csvPrinter.print(roundTallies.get(round).getWinningThreshold());
csvPrinter.print("");
csvPrinter.print("");
}
csvPrinter.println();
}
csvPrinter.println();

List<Pair<String, StatusForRound>> statusesToPrint = new ArrayList<>();
statusesToPrint.add(new Pair<>("Overvotes",
StatusForRound.INVALIDATED_BY_OVERVOTE));
statusesToPrint.add(new Pair<>("Skipped Rankings",
StatusForRound.INVALIDATED_BY_SKIPPED_RANKING));
statusesToPrint.add(new Pair<>("Exhausted Choices",
StatusForRound.EXHAUSTED_CHOICE));
statusesToPrint.add(new Pair<>("Repeated Rankings",
StatusForRound.INVALIDATED_BY_REPEATED_RANKING));

for (Pair<String, StatusForRound> statusToPrint : statusesToPrint) {
csvPrinter.print("Inactive Ballots by " + statusToPrint.getKey());
for (StatusForRound status : STATUSES_TO_PRINT) {
csvPrinter.print(status.getTitleCaseKey());

StatusForRound status = statusToPrint.getValue();
for (int round = 1; round <= numRounds; round++) {
BigDecimal thisRoundInactive = roundTallies.get(round).getBallotStatusTally(status);
csvPrinter.print(thisRoundInactive);
Expand Down Expand Up @@ -435,7 +455,7 @@ private void generateSummarySpreadsheet(
// whether the value in the final round is positive.
// Note that this concept only makes sense when we're reporting the overall tabulation, so we
// omit it when generating results at the individual by-slice level.
if (sliceId == null && roundToResidualSurplus.get(numRounds).signum() == 1) {
if (!isSlice && roundToResidualSurplus.get(numRounds).signum() == 1) {
csvPrinter.print("Residual surplus");
for (int round = 1; round <= numRounds; round++) {
csvPrinter.print(roundToResidualSurplus.get(round));
Expand All @@ -447,6 +467,28 @@ private void generateSummarySpreadsheet(
csvPrinter.println();
}

if (config.usesSurpluses()) {
// row for final round surplus (if needed)
csvPrinter.print(StatusForRound.FINAL_ROUND_SURPLUS.getTitleCaseKey());
for (int round = 1; round <= numRounds; round++) {
BigDecimal finalRoundSurplus =
roundTallies.get(round).getBallotStatusTally(StatusForRound.FINAL_ROUND_SURPLUS);
csvPrinter.print(finalRoundSurplus.equals(BigDecimal.ZERO) ? "" : finalRoundSurplus);

// Don't display transfer or percentage of residual surplus
csvPrinter.print("");
csvPrinter.print("");
}
csvPrinter.println();
}

if (isSlice) {
csvPrinter.println();
csvPrinter.print(String.format("*Elect/Eliminate decisions are from the full contest. "
+ "All other results on this report are at the %s level.", slice.toLowerString()));
csvPrinter.println();
}

try {
csvPrinter.flush();
csvPrinter.close();
Expand All @@ -458,15 +500,6 @@ private void generateSummarySpreadsheet(
Logger.info("Summary spreadsheet generated successfully.");
}

// "action" rows describe which candidates were eliminated or elected
private void addActionRows(CSVPrinter csvPrinter) throws IOException {
csvPrinter.print("Eliminated");
printActionSummary(csvPrinter, roundToEliminatedCandidates);

csvPrinter.print("Elected");
printActionSummary(csvPrinter, roundToWinningCandidates);
}

private void addContestSummaryRows(CSVPrinter csvPrinter, RoundTally round1Tally)
throws IOException {
BigDecimal numNoRankings =
Expand Down Expand Up @@ -533,19 +566,27 @@ private void addContestInformationRows(CSVPrinter csvPrinter,
winners.add(config.getNameForCandidate(candidateName));
}
}

csvPrinter.printRecord("Winner(s)", String.join(", ", winners));
csvPrinter.printRecord("Final Threshold", winningThreshold);

if (!isNullOrBlank(sliceId)) {
// Only slices print the slice information
csvPrinter.printRecord(slice, sliceId);
} else {
// Only non-slices print threshold information
csvPrinter.printRecord("Final Threshold", winningThreshold);
}

csvPrinter.println();
}

// creates a summary spreadsheet and JSON for the full contest (as opposed to a specific slice)
void generateOverallSummaryFiles(
RoundTallies roundTallies, TallyTransfers tallyTransfers) throws IOException {
RoundTallies roundTallies,
TallyTransfers tallyTransfers,
List<String> candidateOrder) throws IOException {
String outputPath = getOutputFilePathFromInstance("summary");
generateSummarySpreadsheet(roundTallies, null, null, outputPath);
generateSummarySpreadsheet(roundTallies, candidateOrder, null, null, outputPath);
generateSummaryJson(roundTallies, tallyTransfers, null, null, outputPath);
}

Expand Down Expand Up @@ -989,23 +1030,16 @@ private Map<String, BigDecimal> updateCandidateNamesInTally(RoundTally roundSumm
}

private Map<String, BigDecimal> getInactiveJsonMap(RoundTally roundTally) {
Map<String, BigDecimal> inactiveMap = new HashMap<>();
Pair<String, StatusForRound>[] statusesToPrint =
new Pair[] {
new Pair<>("overvotes",
StatusForRound.INVALIDATED_BY_OVERVOTE),
new Pair<>("skippedRankings",
StatusForRound.INVALIDATED_BY_SKIPPED_RANKING),
new Pair<>("repeatedRankings",
StatusForRound.INVALIDATED_BY_REPEATED_RANKING),
new Pair<>("exhaustedChoices",
StatusForRound.EXHAUSTED_CHOICE),
};
for (Pair<String, StatusForRound> statusToPrint : statusesToPrint) {
inactiveMap.put(
statusToPrint.getKey(), roundTally.getBallotStatusTally(statusToPrint.getValue()));
Map<String, BigDecimal> result = STATUSES_TO_PRINT.stream()
.collect(Collectors.toMap(StatusForRound::getCamelCaseKey,
roundTally::getBallotStatusTally));

if (config.usesSurpluses() && roundTally.getRoundNumber() == numRounds) {
result.put(StatusForRound.FINAL_ROUND_SURPLUS.getCamelCaseKey(),
roundTally.getBallotStatusTally(StatusForRound.FINAL_ROUND_SURPLUS));
}
return inactiveMap;

return result;
}

// adds action objects to input action list representing all actions applied this round
Expand All @@ -1024,7 +1058,7 @@ private void addActionObjects(
TallyTransfers tallyTransfers) {
// check for valid candidates:
// "drop undeclared write-in" may result in no one actually being eliminated
if (candidates != null && candidates.size() > 0) {
if (candidates != null && !candidates.isEmpty()) {
// transfers contains all vote transfers for this round
// we add one to the round since transfers are currently stored under the round AFTER
// the tallies which triggered them
Expand Down
7 changes: 4 additions & 3 deletions src/main/java/network/brightspots/rcv/RoundTally.java
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,15 @@ public List<String> getSortedCandidatesByTally() {

private void countBallots() {
inactiveBallotSum = BigDecimal.ZERO;
activeBallotSum = BigDecimal.ZERO;
ballotStatusTallies.forEach(
(statusForRound, tally) -> {
if (statusForRound != StatusForRound.ACTIVE) {
if (statusForRound.isInactiveBallot()) {
inactiveBallotSum = inactiveBallotSum.add(tally);
} else {
activeBallotSum = activeBallotSum.add(tally);
}
});

activeBallotSum = ballotStatusTallies.get(StatusForRound.ACTIVE);
}

private void ensureFinalized() {
Expand Down
Loading
Loading