com.rcv.ResultsWriter.java Source code

Java tutorial

Introduction

Here is the source code for com.rcv.ResultsWriter.java

Source

/*
 * Ranked Choice Voting Universal Tabulator
 * Copyright (c) 2018 Jonathan Moldover, Louis Eisenberg, and Hylton Edingfield
 *
 * This program is free software: you can redistribute it and/or modify it under the terms of the
 * GNU Affero General Public License as published by the Free Software Foundation, either version 3
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See
 * the GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License along with this
 * program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * Purpose:
 * Helper class takes tabulation results data as input and generates summary files which
 * contains results summary information.
 * Currently we support a csv summary file and a json summary file
 */

package com.rcv;

import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import java.io.BufferedWriter;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;

class ResultsWriter {

    // number of rounds needed to elect winner(s)
    private int numRounds;
    // threshold to win
    private BigDecimal winningThreshold;
    // map from round number to list of candidates eliminated in that round
    private Map<Integer, List<String>> roundToEliminatedCandidates;
    // map from round number to list of candidates winning in that round
    private Map<Integer, List<String>> roundToWinningCandidates;
    // configuration file in use for this contest
    private ContestConfig config;
    // timestampString string to use when generating output file names
    private String timestampString;
    // TallyTransfer object contains totals votes transferred each round
    private TallyTransfers tallyTransfers;
    private Map<Integer, BigDecimal> roundToResidualSurplus;

    static String sequentialSuffixForOutputPath(Integer sequentialTabulationNumber) {
        return sequentialTabulationNumber != null ? "_" + sequentialTabulationNumber : "";
    }

    static String getSummaryOutputPath(String outputDirectory, String timestampString,
            Integer sequentialTabulationNumber) {
        String fileName = String.format("%s_summary" + sequentialSuffixForOutputPath(sequentialTabulationNumber),
                timestampString);
        return Paths.get(outputDirectory, fileName).toAbsolutePath().toString();
    }

    ResultsWriter setRoundToResidualSurplus(Map<Integer, BigDecimal> roundToResidualSurplus) {
        this.roundToResidualSurplus = roundToResidualSurplus;
        return this;
    }

    // function: setTallyTransfers
    // purpose: setter for tally transfer object used when generating json summary output
    // param: TallyTransfer object
    ResultsWriter setTallyTransfers(TallyTransfers tallyTransfers) {
        this.tallyTransfers = tallyTransfers;
        return this;
    }

    // function: setNumRounds
    // purpose: setter for total number of rounds
    // param: numRounds total number of rounds
    ResultsWriter setNumRounds(int numRounds) {
        this.numRounds = numRounds;
        return this;
    }

    // function: setWinningThreshold
    // purpose: setter for winning threshold
    // param: threshold to win
    ResultsWriter setWinningThreshold(BigDecimal threshold) {
        this.winningThreshold = threshold;
        return this;
    }

    // function: setCandidatesToRoundEliminated
    // purpose: setter for candidatesToRoundEliminated object
    // param: candidatesToRoundEliminated map of candidateID to round in which they were eliminated
    ResultsWriter setCandidatesToRoundEliminated(Map<String, Integer> candidatesToRoundEliminated) {
        // roundToEliminatedCandidates is the inverse of candidatesToRoundEliminated map
        // so we can look up who got eliminated for each round
        roundToEliminatedCandidates = new HashMap<>();
        // candidate is used for indexing over all candidates in candidatesToRoundEliminated
        for (String candidate : candidatesToRoundEliminated.keySet()) {
            // round is the current candidate's round of elimination
            int round = candidatesToRoundEliminated.get(candidate);
            roundToEliminatedCandidates.computeIfAbsent(round, k -> new LinkedList<>());
            roundToEliminatedCandidates.get(round).add(candidate);
        }

        return this;
    }

    // function: setWinnerToRound
    // purpose: setter for the winning candidates
    // param: map from winning candidate name to the round in which they won
    ResultsWriter setWinnerToRound(Map<String, Integer> winnerToRound) {
        // very similar to the logic in setCandidatesToRoundEliminated above
        roundToWinningCandidates = new HashMap<>();
        for (String candidate : winnerToRound.keySet()) {
            int round = winnerToRound.get(candidate);
            roundToWinningCandidates.computeIfAbsent(round, k -> new LinkedList<>());
            roundToWinningCandidates.get(round).add(candidate);
        }
        return this;
    }

    // function: setContestConfig
    // purpose: setter for ContestConfig object
    // param: config the ContestConfig object to use when writing results
    ResultsWriter setContestConfig(ContestConfig config) {
        this.config = config;
        return this;
    }

    // function: setTimestampString
    // purpose: setter for timestampString string used for creating output file names
    // param: timestampString string to use for creating output file names
    ResultsWriter setTimestampString(String timestampString) {
        this.timestampString = timestampString;
        return this;
    }

    // function: generateOverallSummaryFiles
    // purpose: creates a summary spreadsheet and JSON for the full contest
    // param: roundTallies is the round-by-round count of votes per candidate
    void generateOverallSummaryFiles(Map<Integer, Map<String, BigDecimal>> roundTallies) throws IOException {
        String outputPath = getSummaryOutputPath(config.getOutputDirectory(), timestampString,
                config.isSequentialMultiSeatEnabled() ? config.getSequentialWinners().size() + 1 : null);
        // generate the spreadsheet
        generateSummarySpreadsheet(roundTallies, null, outputPath);

        // generate json output
        generateSummaryJson(roundTallies, null, outputPath);
    }

    // function: generatePrecinctSummarySpreadsheet
    // purpose: creates a summary spreadsheet for the votes in a particular precinct
    // param: roundTallies is map from precinct to the round-by-round vote count in the precinct
    void generatePrecinctSummarySpreadsheets(
            Map<String, Map<Integer, Map<String, BigDecimal>>> precinctRoundTallies) throws IOException {
        Set<String> filenames = new HashSet<>();
        for (String precinct : precinctRoundTallies.keySet()) {
            // precinctFileString is a unique filesystem-safe string which can be used for creating
            // the precinct output filename
            String precinctFileString = getPrecinctFileString(precinct, filenames);
            // filename for output
            String outputFileName = String.format("%s_%s_precinct_summary", this.timestampString,
                    precinctFileString);
            // full path for output
            String outputPath = Paths.get(config.getOutputDirectory(), outputFileName).toAbsolutePath().toString();
            generateSummarySpreadsheet(precinctRoundTallies.get(precinct), precinct, outputPath);

            // generate json output
            generateSummaryJson(precinctRoundTallies.get(precinct), precinct, outputPath);
        }
    }

    // function: generateSummarySpreadsheet
    // purpose: creates a summary spreadsheet .csv file
    // param: roundTallies is the round-by-count count of votes per candidate
    // param: precinct indicates which precinct we're reporting results for (null means all)
    // param: outputPath is the full path of the file to save
    // file access: write / create
    private void generateSummarySpreadsheet(Map<Integer, Map<String, BigDecimal>> roundTallies, String precinct,
            String outputPath) throws IOException {
        String csvPath = outputPath + ".csv";
        Logger.log(Level.INFO, "Generating summary spreadsheets: %s...", csvPath);

        // Get all candidates sorted by their first round tally. This determines the display order.
        // container for firstRoundTally
        Map<String, BigDecimal> firstRoundTally = roundTallies.get(1);
        // candidates sorted by first round tally
        List<String> sortedCandidates = sortCandidatesByTally(firstRoundTally);

        // totalActiveVotesPerRound is a map of round to total votes cast in each round
        Map<Integer, BigDecimal> totalActiveVotesPerRound = new HashMap<>();
        // round indexes over all rounds plus final results round
        for (int round = 1; round <= numRounds; round++) {
            // tally is map of candidate to tally for the current round
            Map<String, BigDecimal> tallies = roundTallies.get(round);
            // total will contain total votes for all candidates in this round
            // this is used for calculating other derived data
            BigDecimal total = BigDecimal.ZERO;
            // tally indexes over all tallies for the current round
            for (BigDecimal tally : tallies.values()) {
                total = total.add(tally);
            }
            totalActiveVotesPerRound.put(round, total);
        }

        // csvPrinter will be used to write output to csv file
        CSVPrinter csvPrinter;
        try {
            BufferedWriter writer = Files.newBufferedWriter(Paths.get(csvPath));
            csvPrinter = new CSVPrinter(writer, CSVFormat.DEFAULT);
        } catch (IOException exception) {
            Logger.log(Level.SEVERE, "Error creating CSV file: %s\n%s", csvPath, exception.toString());
            throw exception;
        }

        // print contest info
        addHeaderRows(csvPrinter, precinct);

        // add a row header for the round column labels
        csvPrinter.print("Rounds");
        // round indexes over all rounds
        for (int round = 1; round <= numRounds; round++) {
            // label string will have the actual text which goes in the cell
            String label = String.format("Round %d", round);
            // cell for round label
            csvPrinter.print(label);
        }
        csvPrinter.println();

        // actions don't make sense in individual precinct results
        if (precinct == null || precinct.isEmpty()) {
            addActionRows(csvPrinter);
        }

        final BigDecimal totalActiveVotesFirstRound = totalActiveVotesPerRound.get(1);

        // For each candidate: for each round: output total votes
        // candidate indexes over all candidates
        for (String candidate : sortedCandidates) {
            // show each candidate row with their totals for each round
            // text for the candidate name
            String candidateDisplayName = this.config.getNameForCandidateID(candidate);
            csvPrinter.print(candidateDisplayName);

            // round indexes over all rounds
            for (int round = 1; round <= numRounds; round++) {
                // vote tally this round
                BigDecimal thisRoundTally = roundTallies.get(round).get(candidate);
                // not all candidates may have a tally in every round
                if (thisRoundTally == null) {
                    thisRoundTally = BigDecimal.ZERO;
                }
                // total votes cell
                csvPrinter.print(thisRoundTally.toString());
            }
            // advance to next line
            csvPrinter.println();
        }

        // row for the inactive CVR counts
        // inactive CVR header cell
        csvPrinter.print("Inactive ballots");

        // round indexes through all rounds
        for (int round = 1; round <= numRounds; round++) {
            // count of votes inactive this round
            BigDecimal thisRoundInactive = BigDecimal.ZERO;

            if (round > 1) {
                // Exhausted count is the difference between the total votes in round 1 and the total votes
                // in the current round.
                thisRoundInactive = totalActiveVotesFirstRound.subtract(totalActiveVotesPerRound.get(round))
                        .subtract(roundToResidualSurplus.get(round));
            }
            // total votes cell
            csvPrinter.print(thisRoundInactive.toString());
        }
        csvPrinter.println();

        // row for residual surplus (if needed)
        // We check if we accumulated any residual surplus over the course of the tabulation by testing
        // whether the value in the final round is positive.
        if (roundToResidualSurplus.get(numRounds).signum() == 1) {
            csvPrinter.print("Residual surplus");
            for (int round = 1; round <= numRounds; round++) {
                csvPrinter.print(roundToResidualSurplus.get(round).toString());
            }
            csvPrinter.println();
        }

        // write xls to disk
        try {
            // output stream is used to write data to disk
            csvPrinter.flush();
            csvPrinter.close();
        } catch (IOException exception) {
            Logger.log(Level.SEVERE, "Error saving file: %s\n%s", outputPath, exception.toString());
            throw exception;
        }
    }

    // function: addActionRows
    // "action" rows describe which candidates were eliminated or elected
    // purpose: output rows to csv file describing which actions were taken in each round
    // param: csvPrinter object for writing csv file
    private void addActionRows(CSVPrinter csvPrinter) throws IOException {
        // print eliminated candidates in first action row
        csvPrinter.print("Eliminated");
        // for each round print any candidates who were eliminated
        for (int round = 1; round <= numRounds; round++) {
            // list of all candidates eliminated in this round
            List<String> eliminated = roundToEliminatedCandidates.get(round);
            if (eliminated != null && eliminated.size() > 0) {
                addActionRowCandidates(eliminated, csvPrinter);
            } else {
                csvPrinter.print("");
            }
        }
        csvPrinter.println();

        // print elected candidates in second action row
        csvPrinter.print("Elected");
        // for each round print any candidates who were elected
        for (int round = 1; round <= numRounds; round++) {
            // list of all candidates eliminated in this round
            List<String> winners = roundToWinningCandidates.get(round);
            if (winners != null && winners.size() > 0) {
                addActionRowCandidates(winners, csvPrinter);
            } else {
                csvPrinter.print("");
            }
        }
        csvPrinter.println();
    }

    // function: addActionRowCandidates
    // purpose: add the given candidate(s) names to the csv file next cell
    // param: candidates list of candidate names to add to the next cell
    // param: csvPrinter object for output to csv file
    private void addActionRowCandidates(List<String> candidates, CSVPrinter csvPrinter) throws IOException {
        List<String> candidateDisplayNames = new ArrayList<>();
        // build list of display names
        for (String candidate : candidates) {
            candidateDisplayNames.add(config.getNameForCandidateID(candidate));
        }
        // concatenate them using semi-colon for display in a single cell
        String candidateCellText = String.join("; ", candidateDisplayNames);
        // print the candidate name list
        csvPrinter.print(candidateCellText);
    }

    // function: addHeaderRows
    // purpose: add arbitrary header rows and cell to the top of the visualizer spreadsheet
    // param: worksheet to which we will be adding rows and cells
    // param: totalActiveVotesPerRound map of round to votes active in that round
    private void addHeaderRows(CSVPrinter csvPrinter, String precinct) throws IOException {
        csvPrinter.printRecord("Contest", config.getContestName());
        csvPrinter.printRecord("Jurisdiction", config.getContestJurisdiction());
        csvPrinter.printRecord("Office", config.getContestOffice());
        csvPrinter.printRecord("Date", config.getContestDate());
        csvPrinter.printRecord("Threshold", winningThreshold.toString());
        if (precinct != null && !precinct.isEmpty()) {
            csvPrinter.printRecord("Precinct", precinct);
        }
        csvPrinter.println();
    }

    // function: sortCandidatesByTally
    // purpose: given a map of candidates to tally return a list of all input candidates
    // sorted from highest tally to lowest
    // param: tally map of candidateID to tally
    // return: list of all input candidates sorted from highest tally to lowest
    private List<String> sortCandidatesByTally(Map<String, BigDecimal> tally) {
        // entries will contain all the input tally entries in sorted order
        List<Map.Entry<String, BigDecimal>> entries = new ArrayList<>(tally.entrySet());
        // anonymous custom comparator will sort undeclared write in candidates to last place
        entries.sort((firstObject, secondObject) -> {
            // result of the comparison
            int ret;

            if (firstObject.getKey().equals(config.getUndeclaredWriteInLabel())) {
                ret = 1;
            } else if (secondObject.getKey().equals(config.getUndeclaredWriteInLabel())) {
                ret = -1;
            } else {
                ret = (secondObject.getValue()).compareTo(firstObject.getValue());
            }

            return ret;
        });
        // container list for the final results
        List<String> sortedCandidates = new LinkedList<>();
        // index over all entries
        for (Map.Entry<String, BigDecimal> entry : entries) {
            sortedCandidates.add(entry.getKey());
        }
        return sortedCandidates;
    }

    // function: getPrecinctFileString
    // purpose: return a unique, valid string for this precinct's output spreadsheet filename
    // param: precinct is the name of the precinct
    // param: filenames is the set of filenames we've already generated
    // return: the new filename
    private String getPrecinctFileString(String precinct, Set<String> filenames) {
        // sanitized is the precinct name with all special characters converted to underscores
        String sanitized = precinct.replaceAll("[^a-zA-Z0-9._\\-]+", "_");
        // filename is the string that we'll eventually return
        String filename = sanitized;
        // appendNumber is used to find a unique filename (in practice this really shouldn't be
        // necessary because different precinct names shouldn't have the same sanitized name, but we're
        // doing it here to be safe)
        int appendNumber = 2;
        while (filenames.contains(filename)) {
            filename = sanitized + "_" + appendNumber++;
        }
        filenames.add(filename);
        return filename;
    }

    // function: generateSummaryJson
    // purpose: create summary json data for use in visualizer, unit tests and other tools
    // param: outputPath where to write json file
    // param: roundTallies all tally information
    // file access: write to outputPath
    private void generateSummaryJson(Map<Integer, Map<String, BigDecimal>> roundTallies, String precinct,
            String outputPath) throws IOException {

        // mapper converts java objects to json
        ObjectMapper mapper = new ObjectMapper();
        // set mapper to order keys alphabetically for more legible output
        mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
        // create a module to contain a serializer for BigDecimal serialization
        SimpleModule module = new SimpleModule();
        module.addSerializer(BigDecimal.class, new ToStringSerializer());
        // attach serializer to mapper
        mapper.registerModule(module);

        // jsonWriter writes those object to disk
        ObjectWriter jsonWriter = mapper.writer(new DefaultPrettyPrinter());
        // jsonPath for output json summary
        String jsonPath = outputPath + ".json";
        // log output location
        Logger.log(Level.INFO, "Generating summary JSON file: %s...", jsonPath);
        // outFile is the target file
        File outFile = new File(jsonPath);

        // root outputJson dict will have two entries:
        // results - vote totals, transfers, and candidates elected / eliminated
        // config - global config into
        HashMap<String, Object> outputJson = new HashMap<>();
        // config will contain contest configuration info
        HashMap<String, Object> configData = new HashMap<>();
        // add config header info
        configData.put("contest", config.getContestName());
        configData.put("jurisdiction", config.getContestJurisdiction());
        configData.put("office", config.getContestOffice());
        configData.put("date", config.getContestDate());
        configData.put("threshold", winningThreshold);
        if (precinct != null && !precinct.isEmpty()) {
            configData.put("precinct", precinct);
        }
        // results will be a list of round data objects
        ArrayList<Object> results = new ArrayList<>();
        // for each round create objects for json serialization
        for (int round = 1; round <= numRounds; round++) {
            // container for all json data this round:
            HashMap<String, Object> roundData = new HashMap<>();
            // add round number (this is implied by the ordering but for debugging we are explicit)
            roundData.put("round", round);
            // add actions if this is not a precinct summary
            if (precinct == null || precinct.isEmpty()) {
                // actions is a list of one or more action objects
                ArrayList<Object> actions = new ArrayList<>();
                addActionObjects("elected", roundToWinningCandidates.get(round), round, actions);
                // add any elimination actions
                addActionObjects("eliminated", roundToEliminatedCandidates.get(round), round, actions);
                // add action objects
                roundData.put("tallyResults", actions);
            }
            // add tally object
            roundData.put("tally", updateCandidateNamesInTally(roundTallies.get(round)));
            // add roundData to results list
            results.add(roundData);
        }
        // add config data to root object
        outputJson.put("config", configData);
        // add results to root object
        outputJson.put("results", results);
        // write results to disk
        try {
            jsonWriter.writeValue(outFile, outputJson);
        } catch (IOException exception) {
            Logger.log(Level.SEVERE, "Error writing to JSON file: %s\n%s", jsonPath, exception.toString());
            throw exception;
        }
    }

    private Map<String, BigDecimal> updateCandidateNamesInTally(Map<String, BigDecimal> tally) {
        Map<String, BigDecimal> newTally = new HashMap<>();
        for (String key : tally.keySet()) {
            newTally.put(config.getNameForCandidateID(key), tally.get(key));
        }
        return newTally;
    }

    // function: addActionObjects
    // purpose: adds action objects to input action list representing all actions applied this round
    //  each action will have a type followed by a list of 0 or more vote transfers
    //  (sometimes there is no vote transfer if a candidate had no votes to transfer)
    // param: actionType is this an elimination or election action
    // param: candidates list of all candidates action is applied to
    // param: round which this action occurred
    // param: actions list to add new action objects to
    private void addActionObjects(String actionType, List<String> candidates, int round,
            ArrayList<Object> actions) {
        // check for valid candidates:
        // "drop undeclared write-in" may result in no one actually being eliminated
        if (candidates != null && candidates.size() > 0) {
            // 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
            Map<String, Map<String, BigDecimal>> roundTransfers = tallyTransfers.getTransfersForRound(round + 1);

            // candidate iterates over all candidates who had this action applied to them
            for (String candidate : candidates) {
                // for each candidate create an action object
                HashMap<String, Object> action = new HashMap<>();
                // add the specified action type
                action.put(actionType, config.getNameForCandidateID(candidate));
                // check if there are any transfers
                if (roundTransfers != null) {
                    Map<String, BigDecimal> transfersFromCandidate = roundTransfers.get(candidate);
                    if (transfersFromCandidate != null) {
                        // add transfers
                        action.put("transfers", transfersFromCandidate);
                    }
                }
                if (!action.containsKey("transfers")) {
                    // add an empty map
                    action.put("transfers", new HashMap<String, BigDecimal>());
                }
                // add the action object to list
                actions.add(action);
            }
        }
    }
}