Skip to content

Commit

Permalink
Granular progress bar (#834)
Browse files Browse the repository at this point in the history
* Granular progress bar

* Refactor to use method overloads.

---------

Co-authored-by: Armin Samii <[email protected]>
Co-authored-by: HEdingfield <[email protected]>
  • Loading branch information
3 people authored Jun 14, 2024
1 parent 6171d29 commit f532e01
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1847,7 +1847,8 @@ protected Task<Boolean> createTask() {
@Override
protected Boolean call() {
TabulatorSession session = new TabulatorSession(configPath);
List<String> errors = session.tabulate(operatorName, expectedCvrStatistics);
List<String> errors = session.tabulate(
operatorName, expectedCvrStatistics, this::updateProgress);
if (errors.isEmpty()) {
succeeded();
} else {
Expand Down Expand Up @@ -1876,7 +1877,7 @@ protected Task<Boolean> createTask() {
@Override
protected Boolean call() {
TabulatorSession session = new TabulatorSession(configPath);
return session.convertToCdf();
return session.convertToCdf(this::updateProgress);
}
};
setUpTaskCompletionTriggers(task,
Expand All @@ -1901,7 +1902,7 @@ protected LoadedCvrData call() {
TabulatorSession session = new TabulatorSession(configPath);
LoadedCvrData cvrStatics = null;
try {
cvrStatics = session.parseAndCountCastVoteRecords();
cvrStatics = session.parseAndCountCastVoteRecords(this::updateProgress);
succeeded();
} catch (TabulatorSession.CastVoteRecordGenericParseException e) {
Logger.severe("Failed to read CVRs: %s", e.getMessage());
Expand Down
105 changes: 105 additions & 0 deletions src/main/java/network/brightspots/rcv/Progress.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* RCTab
* Copyright (c) 2017-2024 Bright Spots Developers.
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/

/*
* Purpose: Track the progress of tabulation for a GUI progress bar.
* Design: Tracks the number of files read, the number of candidates eliminated, and an
* estimate of the percentage of time that will be spent doing each. Uses the percent
* of eliminations completed as a proxy for the progress of tabulation.
* Conditions: During tabulation, validation, and conversion.
* Version history: see https://github.com/BrightSpots/rcv.
*/

package network.brightspots.rcv;

import java.util.function.BiConsumer;

class Progress {
private final BiConsumer<Double, Double> progressUpdate;
private final float estPercentTimeTabulating;
private final int numFilesToRead;
private final int numToEliminate;
private int numFilesRead = 0;
private int numEliminated = 0;

/**
* Uses the contest configuration to determine the number of files to read and the number of
* candidates that may be eliminated.
*
* @param config The contest configuration
* @param estPercentTimeTabulating An estimate of the percentage of time that will be spent
* tabulating, between 0 and 1. Set to 0 if no tabulation will
* be done.
* @param progressUpdate A consumer that will be called with the current progress percentage.
* May be null if not linked to a progress bar anywhere.
*/
public Progress(ContestConfig config,
float estPercentTimeTabulating,
BiConsumer<Double, Double> progressUpdate) {
if (!config.isMultiSeatSequentialWinnerTakesAllEnabled()) {
this.numFilesToRead = config.rawConfig.cvrFileSources.size();
this.numToEliminate = config.getNumCandidates() - config.getNumberOfWinners();
} else {
int numPasses = config.getSequentialWinners().size();

// The maximum number of eliminations in each pass is the number of active candidates
// minus the one winner.
int totalEliminations = 0;
for (int i = 0; i < numPasses; i++) {
totalEliminations += config.getNumCandidates() - i - 1;
}

this.numFilesToRead = config.rawConfig.cvrFileSources.size() * numPasses;
this.numToEliminate = totalEliminations;
}
this.estPercentTimeTabulating = estPercentTimeTabulating;
this.progressUpdate = progressUpdate;
}

/**
* Call this function after each CVR file is read to increment the read count.
*/
public void markFileRead() {
numFilesRead++;

if (numFilesRead > numFilesToRead) {
Logger.warning("Progress Bar error: numFilesRead exceeds numFilesToRead!");
}

updateConsumer();
}

/**
* Call this function with the number of new eliminations that have occurred.
* Do not call with the total number of eliminations. This function increments
* the elimination count by the given number.
*/
public void markCandidatesEliminated(int numEliminated) {
this.numEliminated += numEliminated;

if (this.numEliminated > numToEliminate) {
Logger.warning("Progress Bar error: numBallotsTabulated exceeds numBallotsToTabulate!");
}

updateConsumer();
}

private void updateConsumer() {
double percentFilesRead = (double) numFilesRead / numFilesToRead;
double percentEliminationsComplete = numToEliminate != 0
? (double) numEliminated / numToEliminate
: 0;
if (progressUpdate != null) {
progressUpdate.accept(
percentFilesRead * (1 - estPercentTimeTabulating)
+ percentEliminationsComplete * estPercentTimeTabulating,
1.0);
}
}
}
3 changes: 2 additions & 1 deletion src/main/java/network/brightspots/rcv/Tabulator.java
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ static SortedMap<BigDecimal, LinkedList<String>> buildTallyToCandidates(

// run the main tabulation routine to determine contest results
// returns: set containing winner(s)
Set<String> tabulate() throws TabulationAbortedException {
Set<String> tabulate(Progress progress) throws TabulationAbortedException {
if (config.needsRandomSeed()) {
Random random = new Random(config.getRandomSeed());
if (config.getTiebreakMode() == TiebreakMode.GENERATE_PERMUTATION) {
Expand Down Expand Up @@ -269,6 +269,7 @@ Set<String> tabulate() throws TabulationAbortedException {
for (String loser : eliminated) {
candidateToRoundEliminated.put(loser, currentRound);
}
progress.markCandidatesEliminated(eliminated.size());
}

if (config.getNumberOfWinners() > 1) {
Expand Down
68 changes: 44 additions & 24 deletions src/main/java/network/brightspots/rcv/TabulatorSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
import network.brightspots.rcv.CastVoteRecord.CvrParseException;
import network.brightspots.rcv.ContestConfig.Provider;
import network.brightspots.rcv.ContestConfig.UnrecognizedProviderException;
Expand Down Expand Up @@ -95,27 +96,33 @@ String getConvertedFilePath() {

// special mode to just export the CVR as CDF JSON instead of tabulating
// returns whether it succeeded
boolean convertToCdf() {
boolean convertToCdf(BiConsumer<Double, Double> progressUpdate) {
Logger.info("Starting CDF conversion session...");
ContestConfig config = ContestConfig.loadContestConfig(configPath);
checkConfigVersionMatchesApp(config);
boolean conversionSuccess = false;

Progress progress = new Progress(config, 0, progressUpdate);

if (setUpLogging(config.getOutputDirectory()) && config.validate().isEmpty()) {
Logger.info("Converting CVR(s) to CDF...");
try {
FileUtils.createOutputDirectory(config.getOutputDirectory());
LoadedCvrData castVoteRecords = parseCastVoteRecords(config);
Tabulator.SliceIdSet sliceIds =
new Tabulator(castVoteRecords.getCvrs(), config).getEnabledSliceIds();
ResultsWriter writer =
new ResultsWriter()
.setNumRounds(0)
.setSliceIds(sliceIds)
.setContestConfig(config)
.setTimestampString(timestampString);
writer.generateCdfJson(castVoteRecords.getCvrs());
conversionSuccess = true;
LoadedCvrData castVoteRecords = parseCastVoteRecords(config, progress);
if (!castVoteRecords.successfullyReadAll) {
Logger.severe("Aborting conversion due to cast vote record errors!");
} else {
Tabulator.SliceIdSet sliceIds =
new Tabulator(castVoteRecords.getCvrs(), config).getEnabledSliceIds();
ResultsWriter writer =
new ResultsWriter()
.setNumRounds(0)
.setSliceIds(sliceIds)
.setContestConfig(config)
.setTimestampString(timestampString);
writer.generateCdfJson(castVoteRecords.getCvrs());
conversionSuccess = true;
}
} catch (IOException
| UnableToCreateDirectoryException
| TabulationAbortedException
Expand All @@ -137,17 +144,24 @@ boolean convertToCdf() {
return conversionSuccess;
}

LoadedCvrData parseAndCountCastVoteRecords() throws CastVoteRecordGenericParseException {
boolean convertToCdf() {
return convertToCdf(null);
}

LoadedCvrData parseAndCountCastVoteRecords(BiConsumer<Double, Double> progressUpdate)
throws CastVoteRecordGenericParseException {
ContestConfig config = ContestConfig.loadContestConfig(configPath);
return parseCastVoteRecords(config);
Progress progress = new Progress(config, 0, progressUpdate);
return parseCastVoteRecords(config, progress);
}

// Returns a List of exception class names that were thrown while tabulating.
// Operator name is required for the audit logs.
// Note: An exception MUST be returned any time tabulation does not run.
// In general, that means any Logger.severe in this function should be accompanied
// by an exceptionsEncountered.add(...) call.
List<String> tabulate(String operatorName, LoadedCvrData expectedCvrData) {
List<String> tabulate(String operatorName, LoadedCvrData expectedCvrData,
BiConsumer<Double, Double> progressUpdate) {
Logger.info("Starting tabulation session...");
List<String> exceptionsEncountered = new LinkedList<>();
ContestConfig config = ContestConfig.loadContestConfig(configPath);
Expand Down Expand Up @@ -179,6 +193,8 @@ List<String> tabulate(String operatorName, LoadedCvrData expectedCvrData) {
exceptionsEncountered.add(exception.getClass().toString());
Logger.severe("Error logging config file: %s\n%s", configPath, exception);
}

Progress progress = new Progress(config, 0.5f, progressUpdate);
Logger.info("Tabulating '%s'...", config.getContestName());
if (config.isMultiSeatSequentialWinnerTakesAllEnabled()) {
Logger.info("This is a multi-pass IRV contest.");
Expand All @@ -191,14 +207,14 @@ List<String> tabulate(String operatorName, LoadedCvrData expectedCvrData) {
// Read cast vote records and slice IDs from CVR files
Set<String> newWinnerSet;
try {
LoadedCvrData castVoteRecords = parseCastVoteRecords(config);
LoadedCvrData castVoteRecords = parseCastVoteRecords(config, progress);
if (config.getSequentialWinners().isEmpty()
&& !castVoteRecords.metadataMatches(expectedCvrData)) {
Logger.severe("CVR data has changed between loading the CVRs and reading them!");
exceptionsEncountered.add(TabulationAbortedException.class.toString());
break;
}
newWinnerSet = runTabulationForConfig(config, castVoteRecords.getCvrs());
newWinnerSet = runTabulationForConfig(config, castVoteRecords.getCvrs(), progress);
} catch (TabulationAbortedException | CastVoteRecordGenericParseException exception) {
exceptionsEncountered.add(exception.getClass().toString());
Logger.severe(exception.getMessage());
Expand Down Expand Up @@ -228,12 +244,12 @@ List<String> tabulate(String operatorName, LoadedCvrData expectedCvrData) {
// normal operation (not multi-pass IRV, a.k.a. sequential multi-seat)
// Read cast vote records and precinct IDs from CVR files
try {
LoadedCvrData castVoteRecords = parseCastVoteRecords(config);
LoadedCvrData castVoteRecords = parseCastVoteRecords(config, progress);
if (!castVoteRecords.metadataMatches(expectedCvrData)) {
Logger.severe("CVR data has changed between loading the CVRs and reading them!");
exceptionsEncountered.add(TabulationAbortedException.class.toString());
} else {
runTabulationForConfig(config, castVoteRecords.getCvrs());
runTabulationForConfig(config, castVoteRecords.getCvrs(), progress);
tabulationSuccess = true;
}
} catch (CastVoteRecordGenericParseException exception) {
Expand All @@ -254,12 +270,13 @@ List<String> tabulate(String operatorName, LoadedCvrData expectedCvrData) {
}

List<String> tabulate(String operatorName) {
return tabulate(operatorName, TabulatorSession.LoadedCvrData.MATCHES_ALL);
return tabulate(operatorName, TabulatorSession.LoadedCvrData.MATCHES_ALL, null);
}

Set<String> loadSliceNamesFromCvrs(ContestConfig.TabulateBySlice slice, ContestConfig config) {
Progress progress = new Progress(config, 0, null);
try {
List<CastVoteRecord> castVoteRecords = parseCastVoteRecords(config).getCvrs();
List<CastVoteRecord> castVoteRecords = parseCastVoteRecords(config, progress).getCvrs();
return new Tabulator(castVoteRecords, config).getEnabledSliceIds().get(slice);
} catch (TabulationAbortedException | CastVoteRecordGenericParseException e) {
throw new RuntimeException(e);
Expand All @@ -286,11 +303,11 @@ private boolean setUpLogging(String outputDirectory) {
// execute tabulation for given ContestConfig (a Session may comprise multiple tabulations)
// returns: set of winners from tabulation
private Set<String> runTabulationForConfig(
ContestConfig config, List<CastVoteRecord> castVoteRecords)
ContestConfig config, List<CastVoteRecord> castVoteRecords, Progress progress)
throws TabulationAbortedException {
Set<String> winners;
Tabulator tabulator = new Tabulator(castVoteRecords, config);
winners = tabulator.tabulate();
winners = tabulator.tabulate(progress);
try {
tabulator.generateSummaryFiles(timestampString);
} catch (IOException exception) {
Expand All @@ -302,7 +319,7 @@ private Set<String> runTabulationForConfig(
// parse CVR files referenced in the ContestConfig object into a list of CastVoteRecords
// param: config object containing CVR file paths to parse
// returns: list of parsed CVRs or null if an error was encountered
private LoadedCvrData parseCastVoteRecords(ContestConfig config)
private LoadedCvrData parseCastVoteRecords(ContestConfig config, Progress progress)
throws CastVoteRecordGenericParseException {
Logger.info("Parsing cast vote records...");
List<CastVoteRecord> castVoteRecords = new ArrayList<>();
Expand Down Expand Up @@ -370,6 +387,9 @@ private LoadedCvrData parseCastVoteRecords(ContestConfig config)
Logger.severe("Unexpected error parsing source file: %s\n%s", cvrPath, exception);
encounteredSourceProblem = true;
}

// Update the service % complete
progress.markFileRead();
}

// Output the RCTab-CSV CVR
Expand Down

0 comments on commit f532e01

Please sign in to comment.