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

Granular progress bar #834

Merged
merged 2 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
Copy link
Contributor

Choose a reason for hiding this comment

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

Where is this actually used? I don't see any place in the GUI that shows a progress bar for converting to CDF.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's not used, happy to remove though I don't see any downsides leaving it in either

Copy link
Contributor

Choose a reason for hiding this comment

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

No problem, fine to leave as-is. Merge away!

}
};
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
Loading