From 3b41eae11b425b76dd92ed55d2b0e8edfb734edb Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Thu, 9 Sep 2021 04:07:18 +1000 Subject: [PATCH] Implement eSTV-style CSV report --- src/cli/stv.rs | 212 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 203 insertions(+), 9 deletions(-) diff --git a/src/cli/stv.rs b/src/cli/stv.rs index 959efd7..f6f3527 100644 --- a/src/cli/stv.rs +++ b/src/cli/stv.rs @@ -16,13 +16,14 @@ */ use crate::constraints::Constraints; -use crate::election::{CandidateState, CountState, Election}; +use crate::election::{CandidateState, CountState, Election, StageKind}; use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational}; use crate::parser::{bin, blt}; use crate::stv::{self, STVOptions}; use crate::ties; use clap::{AppSettings, Clap}; +use itertools::Itertools; use std::cmp::max; use std::fs::File; @@ -174,19 +175,22 @@ pub struct SubcmdOptions { #[clap(help_heading=Some("CONSTRAINTS"), long, possible_values=&["guard_doom"], default_value="guard_doom")] constraint_mode: String, - // ---------------------- - // -- Display settings -- + // --------------------- + // -- Output settings -- + + #[clap(help_heading=Some("OUTPUT"), short, long, possible_values=&["text", "csv"], default_value="text")] + output: String, /// Hide excluded candidates from results report - #[clap(help_heading=Some("DISPLAY"), long)] + #[clap(help_heading=Some("OUTPUT"), long)] hide_excluded: bool, /// Sort candidates by votes in results report - #[clap(help_heading=Some("DISPLAY"), long)] + #[clap(help_heading=Some("OUTPUT"), long)] sort_votes: bool, /// Print votes to specified decimal places in results report - #[clap(help_heading=Some("DISPLAY"), long, default_value="2", value_name="dps")] + #[clap(help_heading=Some("OUTPUT"), long, default_value="2", value_name="dps")] pp_decimals: usize, } @@ -244,7 +248,7 @@ fn maybe_load_constraints(election: &mut Election, constraints: &O } } -fn count_election(mut election: Election, cmd_opts: SubcmdOptions) -> Result<(), i32> +fn count_election(election: Election, cmd_opts: SubcmdOptions) -> Result<(), i32> where for<'r> &'r N: ops::Add<&'r N, Output=N>, for<'r> &'r N: ops::Sub<&'r N, Output=N>, @@ -293,9 +297,27 @@ where } } + match cmd_opts.output.as_str() { + "text" => { return count_election_text(election, &cmd_opts.filename, opts); } + "csv" => { return count_election_csv(election, opts); } + _ => unreachable!() + } +} + +// --------------- +// CLI text report + +fn count_election_text(mut election: Election, filename: &str, opts: STVOptions) -> Result<(), i32> +where + for<'r> &'r N: ops::Add<&'r N, Output=N>, + for<'r> &'r N: ops::Sub<&'r N, Output=N>, + for<'r> &'r N: ops::Mul<&'r N, Output=N>, + for<'r> &'r N: ops::Div<&'r N, Output=N>, + for<'r> &'r N: ops::Neg +{ // Describe count let total_ballots = election.ballots.iter().fold(N::zero(), |acc, b| { acc + &b.orig_value }); - print!("Count computed by OpenTally (revision {}). Read {:.0} ballots from \"{}\" for election \"{}\". There are {} candidates for {} vacancies. ", crate::VERSION, total_ballots, cmd_opts.filename, election.name, election.candidates.len(), election.seats); + print!("Count computed by OpenTally (revision {}). Read {:.0} ballots from \"{}\" for election \"{}\". There are {} candidates for {} vacancies. ", crate::VERSION, total_ballots, filename, election.name, election.candidates.len(), election.seats); let opts_str = opts.describe::(); if opts_str.len() > 0 { println!("Counting using options \"{}\".", opts_str); @@ -359,7 +381,7 @@ where return Ok(()); } -fn print_stage(stage_num: usize, state: &CountState, opts: &STVOptions) { +fn print_stage(stage_num: u32, state: &CountState, opts: &STVOptions) { // Print stage details println!("{}. {}", stage_num, state.title); println!("{}", state.logger.render().join(" ")); @@ -372,3 +394,175 @@ fn print_stage(stage_num: usize, state: &CountState, opts: &STVOpt println!(""); } + +// --------------------- +// eSTV-style CSV report + +fn count_election_csv(mut election: Election, opts: STVOptions) -> Result<(), i32> +where + for<'r> &'r N: ops::Add<&'r N, Output=N>, + for<'r> &'r N: ops::Sub<&'r N, Output=N>, + for<'r> &'r N: ops::Mul<&'r N, Output=N>, + for<'r> &'r N: ops::Div<&'r N, Output=N>, + for<'r> &'r N: ops::Neg +{ + // Header rows + let total_ballots = election.ballots.iter().fold(N::zero(), |acc, b| { acc + &b.orig_value }); + + // eSTV does not consistently quote records, so we won't use a CSV library here + println!(r#""Election for","{}""#, election.name); + println!(r#""Date"," / / ""#); + println!(r#""Number to be elected",{}"#, election.seats); + + stv::preprocess_election(&mut election, &opts); + + // Initialise count state + let mut state = CountState::new(&election); + + let mut stage_results = vec![Vec::new(); election.candidates.len() + 5]; + + // ----------- + // First stage + + // Distribute first preferences + match stv::count_init(&mut state, &opts) { + Ok(_) => {} + Err(err) => { + println!("Error: {}", err.describe()); + return Err(1); + } + } + + // Subtract this from progressive NTs + // FIXME: May fail to round correctly with minivoters + let invalid_votes = state.exhausted.votes.clone(); + let valid_votes = total_ballots - &invalid_votes; + + // Stage number row + stage_results[0].push(String::new()); + stage_results[0].push(String::new()); + + // Stage kind row + stage_results[1].push(String::new()); + stage_results[1].push(String::from(r#""First""#)); + + // Stage title row + stage_results[2].push(String::from(r#""Candidates""#)); + stage_results[2].push(String::from(r#""Preferences""#)); + + for (i, candidate) in election.candidates.iter().enumerate() { + let count_card = &state.candidates[candidate]; + stage_results[3 + i].push(format!(r#""{}""#, candidate.name)); + stage_results[3 + i].push(format!(r#"{:.0}"#, count_card.votes)); // FIXME: May fail to round correctly with minivoters + } + + stage_results[3 + election.candidates.len()].push(String::from(r#""Non-transferable""#)); + stage_results[3 + election.candidates.len()].push(String::new()); // FIXME: May fail to round correctly with minivoters + + stage_results[4 + election.candidates.len()].push(String::from(r#""Totals""#)); + stage_results[4 + election.candidates.len()].push(format!(r#"{:.0}"#, valid_votes)); + + // ----------------- + // Subsequent stages + + let mut stage_num: u32 = 1; + loop { + match stv::count_one_stage(&mut state, &opts) { + Ok(is_done) => { + if is_done { + break; + } + } + Err(err) => { + println!("Error: {}", err.describe()); + return Err(1); + } + } + + stage_num += 1; + + // Stage number row + stage_results[0].push(String::from(r#""Stage""#)); + stage_results[0].push(format!(r#"{}"#, stage_num)); + + // Stage kind row + stage_results[1].push(format!(r#""{}""#, state.title.kind_as_string())); + stage_results[1].push(String::new()); + + // Stage title row + match &state.title { + StageKind::FirstPreferences => unreachable!(), + StageKind::SurplusOf(candidate) => { + stage_results[2].push(format!(r#""{}""#, candidate.name)); + } + StageKind::ExclusionOf(candidates) => { + stage_results[2].push(format!(r#""{}""#, candidates.iter().map(|c| &c.name).join("+"))); + } + StageKind::SurplusesDistributed => todo!(), + StageKind::BulkElection => todo!(), + } + stage_results[2].push(String::from(r#""#)); + + for (i, candidate) in election.candidates.iter().enumerate() { + let count_card = &state.candidates[candidate]; + + if count_card.transfers.is_zero() { + stage_results[3 + i].push(String::new()); + } else if count_card.transfers > N::zero() { + stage_results[3 + i].push(format!(r#"+{:.dps$}"#, count_card.transfers, dps=opts.pp_decimals)); + } else { + stage_results[3 + i].push(format!(r#"{:.dps$}"#, count_card.transfers, dps=opts.pp_decimals)); + } + + if count_card.votes.is_zero() { + stage_results[3 + i].push(String::from(r#""-""#)); + } else { + stage_results[3 + i].push(format!(r#"{:.dps$}"#, count_card.votes, dps=opts.pp_decimals)); + } + } + + // Nontransferable + let nt_transfers = state.exhausted.transfers.clone() + &state.loss_fraction.transfers; + if nt_transfers.is_zero() { + stage_results[3 + election.candidates.len()].push(String::new()); + } else if nt_transfers > N::zero() { + stage_results[3 + election.candidates.len()].push(format!(r#"+{:.dps$}"#, nt_transfers, dps=opts.pp_decimals)); + } else { + stage_results[3 + election.candidates.len()].push(format!(r#"{:.dps$}"#, nt_transfers, dps=opts.pp_decimals)); + } + stage_results[3 + election.candidates.len()].push(format!(r#"{:.dps$}"#, &state.exhausted.votes + &state.loss_fraction.votes, dps=opts.pp_decimals)); + + // Totals + stage_results[4 + election.candidates.len()].push(String::new()); + stage_results[4 + election.candidates.len()].push(format!(r#"{:.dps$}"#, valid_votes, dps=opts.pp_decimals)); + } + + // ---------------- + // Candidate states + + stage_results[3 + election.candidates.len()].push(String::new()); // Nontransferable row + + for (i, candidate) in election.candidates.iter().enumerate() { + let count_card = &state.candidates[candidate]; + if count_card.state == CandidateState::Elected { + stage_results[3 + i].push(String::from(r#""Elected""#)); + } else { + stage_results[3 + i].push(String::new()); + } + } + + // -------------------- + // Output stages to CSV + + println!(r#""Valid votes",{:.0}"#, valid_votes); + println!(r#""Invalid votes",{:.0}"#, invalid_votes); + println!(r#""Quota",{:.dps$}"#, state.quota.as_ref().unwrap(), dps=opts.pp_decimals); + println!(r#""OpenTally","{}""#, crate::VERSION); + println!(r#""Election rules","{}""#, opts.describe::()); + + for row in stage_results { + println!("{}", row.join(",")); + } + + return Ok(()); +}