From c5d6b8d460b2879e8f5ae56d2c1d6bd9e4be71f1 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Wed, 23 Mar 2022 00:34:43 +1100 Subject: [PATCH] Refactor implementation of --sum-surplus-transfers -> --round-subtransfers in preparation for NSW Local Gov't STV --- docs/options.md | 9 +- html/index.html | 6 +- html/presets.js | 34 ++--- src/cli/stv.rs | 10 +- src/stv/gregory/transfers.rs | 282 +++++++++++++++++++++-------------- src/stv/mod.rs | 38 +++-- src/stv/wasm.rs | 4 +- tests/tests_impl/coe.rs | 4 +- tests/tests_impl/scotland.rs | 6 +- 9 files changed, 233 insertions(+), 160 deletions(-) diff --git a/docs/options.md b/docs/options.md index b471b7e..074e48c 100644 --- a/docs/options.md +++ b/docs/options.md @@ -272,12 +272,13 @@ When *Surplus method* is set to *Meek method*: * --round-votes controls the rounding of the final number of votes credited to each candidate * Keep values, intermediate products and candidate votes are rounded *up* -### (Gregory) Sum surplus transfers (--sum-surplus-transfers) +### (Gregory) Round subtransfers (--round-subtransfers) -When *Surplus method* is set to a Gregory method, this option allows you to specify how the numbers of votes credited to candidates in a surplus transfer is calculated. In each case, votes are grouped according to the next available preference for a continuing candidate. Subsequently: +When *Surplus method* is set to a Gregory method, this option allows you to specify how the numbers of votes credited to candidates in a surplus transfer/exclusion is calculated. In each case, votes are grouped according to the next available preference for a continuing candidate. Subsequently: -* *By value*: The votes expressing a next available preference for that candidate are further divided according to value. For each group of votes at a particular value, the total value of all such votes is multiplied by the surplus fraction. The product is credited to that candidate. -* *Per ballot*: For each individual vote expressing a next available preference for that candidate, the value of the vote is multiplied by the surplus fraction. The product is credited to that candidate. +* *Single step* (default): The total value of all votes expressing a next available preference for that candidate is multiplied by the surplus fraction. The product (rounded if requested) is credited to that candidate. +* *By value*: The votes expressing a next available preference for that candidate are further divided according to value. For each group of votes at a particular value, the total value of all such votes is multiplied by the surplus fraction. The product (rounded if requested) is credited to that candidate. +* *Per ballot*: For each individual vote expressing a next available preference for that candidate, the value of the vote is multiplied by the surplus fraction. The product (rounded if requested) is credited to that candidate. This option affects the result only as far as rounding (due to use of fixed-precision/floating-point arithmetic, or an explicit rounding option) is concerned. diff --git a/html/index.html b/html/index.html index 3673466..1f7d731 100644 --- a/html/index.html +++ b/html/index.html @@ -295,10 +295,10 @@ diff --git a/html/presets.js b/html/presets.js index ff5122a..ed07879 100644 --- a/html/presets.js +++ b/html/presets.js @@ -32,7 +32,7 @@ function changePreset() { document.getElementById('chkRoundVotes').checked = false; document.getElementById('chkRoundSFs').checked = false; document.getElementById('chkRoundValues').checked = false; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_size'; document.getElementById('selMethod').value = 'wig'; document.getElementById('selPapers').value = 'both'; @@ -164,11 +164,11 @@ function changePreset() { document.getElementById('txtRoundVotes').value = '0'; document.getElementById('chkRoundSFs').checked = false; document.getElementById('chkRoundValues').checked = false; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_order'; document.getElementById('selMethod').value = 'uig'; document.getElementById('selPapers').value = 'both'; - document.getElementById('selExclusion').value = 'by_value'; + document.getElementById('selExclusion').value = 'single_step'; document.getElementById('selTies').value = 'backwards,random'; } else if (document.getElementById('selPreset').value === 'wa') { document.getElementById('selQuotaCriterion').value = 'geq'; @@ -212,11 +212,11 @@ function changePreset() { document.getElementById('txtRoundVotes').value = '6'; document.getElementById('chkRoundSFs').checked = false; document.getElementById('chkRoundValues').checked = false; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_order'; document.getElementById('selMethod').value = 'eg'; document.getElementById('selPapers').value = 'transferable'; - document.getElementById('selExclusion').value = 'by_value'; + document.getElementById('selExclusion').value = 'single_step'; document.getElementById('selTies').value = 'backwards,random'; } else if (document.getElementById('selPreset').value === 'nswlg') { document.getElementById('selQuotaCriterion').value = 'geq'; @@ -261,7 +261,7 @@ function changePreset() { document.getElementById('chkRoundSFs').checked = true; document.getElementById('txtRoundSFs').value = '4'; document.getElementById('chkRoundValues').checked = false; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_size'; document.getElementById('selMethod').value = 'wig'; document.getElementById('selPapers').value = 'both'; @@ -283,7 +283,7 @@ function changePreset() { document.getElementById('chkNormaliseBallots').checked = true; document.getElementById('chkRoundQuota').checked = true; document.getElementById('txtRoundQuota').value = '0'; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selMethod').value = 'hare'; document.getElementById('selPapers').value = 'transferable'; document.getElementById('selExclusion').value = 'single_stage'; @@ -304,7 +304,7 @@ function changePreset() { document.getElementById('chkNormaliseBallots').checked = true; document.getElementById('chkRoundQuota').checked = true; document.getElementById('txtRoundQuota').value = '0'; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_order'; document.getElementById('selMethod').value = 'hare'; document.getElementById('selPapers').value = 'transferable'; @@ -328,7 +328,7 @@ function changePreset() { document.getElementById('chkRoundVotes').checked = false; document.getElementById('chkRoundSFs').checked = false; document.getElementById('chkRoundValues').checked = false; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_size'; document.getElementById('selMethod').value = 'wig'; document.getElementById('selPapers').value = 'both'; @@ -355,7 +355,7 @@ function changePreset() { document.getElementById('txtRoundSFs').value = '3'; document.getElementById('chkRoundValues').checked = true; document.getElementById('txtRoundValues').value = '3'; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_order'; document.getElementById('selMethod').value = 'eg'; document.getElementById('selPapers').value = 'transferable'; @@ -382,11 +382,11 @@ function changePreset() { document.getElementById('txtRoundSFs').value = '2'; document.getElementById('chkRoundValues').checked = true; document.getElementById('txtRoundValues').value = '2'; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_size'; document.getElementById('selMethod').value = 'eg'; document.getElementById('selPapers').value = 'transferable'; - document.getElementById('selExclusion').value = 'by_value'; + document.getElementById('selExclusion').value = 'single_step'; document.getElementById('selTies').value = 'forwards,random'; } else if (document.getElementById('selPreset').value === 'ers76') { document.getElementById('selQuotaCriterion').value = 'geq'; @@ -409,11 +409,11 @@ function changePreset() { document.getElementById('txtRoundSFs').value = '2'; document.getElementById('chkRoundValues').checked = true; document.getElementById('txtRoundValues').value = '2'; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_size'; document.getElementById('selMethod').value = 'eg'; document.getElementById('selPapers').value = 'transferable'; - document.getElementById('selExclusion').value = 'by_value'; + document.getElementById('selExclusion').value = 'single_step'; document.getElementById('selTies').value = 'forwards,random'; } else if (document.getElementById('selPreset').value === 'ers73') { document.getElementById('selQuotaCriterion').value = 'geq'; @@ -436,11 +436,11 @@ function changePreset() { document.getElementById('txtRoundSFs').value = '2'; document.getElementById('chkRoundValues').checked = true; document.getElementById('txtRoundValues').value = '2'; - document.getElementById('selSumTransfers').value = 'by_value'; + document.getElementById('selSumTransfers').value = 'single_step'; document.getElementById('selSurplus').value = 'by_size'; document.getElementById('selMethod').value = 'eg'; document.getElementById('selPapers').value = 'transferable'; - document.getElementById('selExclusion').value = 'by_value'; + document.getElementById('selExclusion').value = 'single_step'; document.getElementById('selTies').value = 'forwards,random'; } else if (document.getElementById('selPreset').value === 'cofe') { document.getElementById('selQuotaCriterion').value = 'geq'; @@ -467,7 +467,7 @@ function changePreset() { document.getElementById('selSurplus').value = 'by_size'; document.getElementById('selMethod').value = 'eg'; document.getElementById('selPapers').value = 'transferable'; - document.getElementById('selExclusion').value = 'by_value'; + document.getElementById('selExclusion').value = 'single_step'; document.getElementById('selTies').value = 'forwards,random'; } } diff --git a/src/cli/stv.rs b/src/cli/stv.rs index 09b30e7..d0163c3 100644 --- a/src/cli/stv.rs +++ b/src/cli/stv.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * Copyright © 2021–2022 Lee Yingtong Li (RunasSudo) * * 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 @@ -79,9 +79,9 @@ pub struct SubcmdOptions { #[clap(help_heading=Some("ROUNDING"), long, value_name="dps")] round_quota: Option, - /// (Gregory STV) How to calculate votes to credit to candidates in surplus transfers - #[clap(help_heading=Some("ROUNDING"), long, possible_values=&["by_value", "per_ballot"], default_value="by_value", value_name="mode")] - sum_surplus_transfers: String, + /// (Gregory STV) How to round subtransfers during surpluses/exclusions + #[clap(help_heading=Some("ROUNDING"), long, possible_values=&["single_step", "per_ballot"], default_value="single_step", value_name="mode")] + round_subtransfers: String, /// (Meek STV) Limit for stopping iteration of surplus distribution #[clap(help_heading=Some("ROUNDING"), long, default_value="0.001%", value_name="tolerance")] @@ -284,7 +284,7 @@ where cmd_opts.round_values, cmd_opts.round_votes, cmd_opts.round_quota, - cmd_opts.sum_surplus_transfers.into(), + cmd_opts.round_subtransfers.into(), cmd_opts.meek_surplus_tolerance, cmd_opts.normalise_ballots, cmd_opts.quota.into(), diff --git a/src/stv/gregory/transfers.rs b/src/stv/gregory/transfers.rs index bb41343..ea912db 100644 --- a/src/stv/gregory/transfers.rs +++ b/src/stv/gregory/transfers.rs @@ -22,7 +22,7 @@ use super::prettytable_html::{Cell, Row, Table}; use crate::election::{Candidate, CountState}; use crate::numbers::Number; -use crate::stv::{STVOptions, SumSurplusTransfersMode}; +use crate::stv::{STVOptions, RoundSubtransfersMode}; use std::cmp::max; use std::collections::HashMap; @@ -128,36 +128,8 @@ impl<'e, N: Number> TransferTable<'e, N> { let is_weighted = self.surplus.is_none() || opts.surplus.is_weighted(); // Iterate through columns + // Sum votes_in, etc. for column in self.columns.iter_mut() { - let mut new_value_fraction; - if self.surplus.is_some() && opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot { - if is_weighted { - new_value_fraction = column.value_fraction.clone(); - // If surplus, multiply by surplus fraction - if let Some(n) = &self.surpfrac_numer { - new_value_fraction *= n; - } - } else { - if let Some(n) = &self.surpfrac_numer { - new_value_fraction = n.clone(); - } else { - // Transferred at original value - new_value_fraction = column.value_fraction.clone(); - } - } - - if let Some(n) = &self.surpfrac_denom { - new_value_fraction /= n; - } - - // Round if required - if let Some(dps) = opts.round_values { - new_value_fraction.floor_mut(dps); - } - } else { - new_value_fraction = column.value_fraction.clone(); - } - // Candidate votes for (candidate, cell) in column.cells.iter_mut() { column.total.ballots += &cell.ballots; @@ -169,19 +141,6 @@ impl<'e, N: Number> TransferTable<'e, N> { column.total.votes_in += &votes_in; self.total.cells.get_mut(*candidate).unwrap().votes_in += &votes_in; self.total.total.votes_in += votes_in; - - if self.surplus.is_some() || opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot { - let mut votes_out = cell.ballots.clone() * &new_value_fraction; - // Round if required - if let Some(dps) = opts.round_votes { - votes_out.floor_mut(dps); - } - - cell.votes_out += &votes_out; - column.total.votes_out += &votes_out; - self.total.cells.get_mut(*candidate).unwrap().votes_out += &votes_out; - self.total.total.votes_out += votes_out; - } } // Exhausted votes @@ -194,73 +153,167 @@ impl<'e, N: Number> TransferTable<'e, N> { column.total.votes_in += &votes_in; self.total.exhausted.votes_in += &votes_in; self.total.total.votes_in += votes_in; - - if self.surplus.is_some() || opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot { - if !opts.transferable_only { - let mut votes_out = column.exhausted.ballots.clone() * &new_value_fraction; - // Round if required - if let Some(dps) = opts.round_votes { - votes_out.floor_mut(dps); + } + + match opts.round_subtransfers { + RoundSubtransfersMode::SingleStep => { + // No need to calculate votes_out for each column + + // Calculate total votes_out per candidate + for (_candidate, cell) in self.total.cells.iter_mut() { + if is_weighted { + // Weighted rules + // Multiply votes in by surplus fraction + cell.votes_out = multiply_surpfrac(cell.votes_in.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + } else if self.surpfrac.is_none() { + // Unweighted rules but transfer at values received + cell.votes_out = cell.votes_in.clone(); + } else { + // Unweighted rules + // Multiply ballots in by surplus fraction + cell.votes_out = multiply_surpfrac(cell.ballots.clone(), &self.surpfrac_numer, &self.surpfrac_denom); } - column.exhausted.votes_out += &votes_out; - column.total.votes_out += &votes_out; - self.total.exhausted.votes_out += &votes_out; - self.total.total.votes_out += votes_out; + // Round if required + if let Some(dps) = opts.round_votes { + cell.votes_out.floor_mut(dps); + } } - } - } - - // Need to calculate total candidate votes_out? - if opts.sum_surplus_transfers == SumSurplusTransfersMode::ByValue { - for (_candidate, cell) in self.total.cells.iter_mut() { - let mut votes_out; - if is_weighted || self.surpfrac.is_none() { - // NB: If surplus.is_none, then votes transferred at values received - votes_out = cell.votes_in.clone(); + // Calculate total exhausted votes + if is_weighted { + // Weighted rules + // Multiply votes in by surplus fraction + self.total.exhausted.votes_out = multiply_surpfrac(self.total.exhausted.votes_in.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + } else if self.surpfrac.is_none() { + // Unweighted rules but transfer at values received + // This can only happen with --transferable-only, so this will be calculated in apply_to } else { - votes_out = cell.ballots.clone(); + // Unweighted rules + // Multiply ballots in by surplus fraction + self.total.exhausted.votes_out = multiply_surpfrac(self.total.exhausted.ballots.clone(), &self.surpfrac_numer, &self.surpfrac_denom); } - // If surplus, multiply by surplus fraction - if let Some(n) = &self.surpfrac_numer { - votes_out *= n; + // Round if required + if let Some(dps) = opts.round_votes { + self.total.exhausted.votes_out.floor_mut(dps); } - if let Some(n) = &self.surpfrac_denom { - votes_out /= n; - } - - cell.votes_out = votes_out; // Rounded later } - - if self.surplus.is_none() || !opts.transferable_only { - let mut votes_out; - if is_weighted || self.surpfrac.is_none() { - votes_out = self.total.exhausted.votes_in.clone(); - } else { - votes_out = self.total.exhausted.ballots.clone(); + RoundSubtransfersMode::ByValue => { + // Calculate votes_out for each column + for column in self.columns.iter_mut() { + // Calculate votes_out per candidate in the column + for (_candidate, cell) in column.cells.iter_mut() { + if is_weighted { + // Weighted rules + // Multiply votes in by surplus fraction + cell.votes_out = multiply_surpfrac(cell.votes_in.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + } else if self.surpfrac.is_none() { + // Unweighted rules but transfer at values received + cell.votes_out = cell.votes_in.clone(); + } else { + // Unweighted rules + // Multiply ballots in by surplus fraction + cell.votes_out = multiply_surpfrac(cell.ballots.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + } + + // Round if required + if let Some(dps) = opts.round_votes { + cell.votes_out.floor_mut(dps); + } + } + + // Calculate exhausted votes in the column + if is_weighted { + // Weighted rules + // Multiply votes in by surplus fraction + column.exhausted.votes_out = multiply_surpfrac(column.exhausted.votes_in.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + } else if self.surpfrac.is_none() { + // Unweighted rules but transfer at values received + // This can only happen with --transferable-only, so this will be calculated in apply_to + } else { + // Unweighted rules + // Multiply ballots in by surplus fraction + column.exhausted.votes_out = multiply_surpfrac(column.exhausted.ballots.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + } + + // Round if required + if let Some(dps) = opts.round_votes { + column.exhausted.votes_out.floor_mut(dps); + } } - // If surplus, multiply by surplus fraction - if let Some(n) = &self.surpfrac_numer { - votes_out *= n; - } - if let Some(n) = &self.surpfrac_denom { - votes_out /= n; + // Sum total votes_out per candidate + for (candidate, cell) in self.total.cells.iter_mut() { + cell.votes_out = self.columns.iter().fold(N::new(), |acc, col| acc + &col.cells[candidate].votes_out); } - self.total.exhausted.votes_out = votes_out; // Rounded later + // Sum total exhausted votes + self.total.exhausted.votes_out = self.columns.iter().fold(N::new(), |acc, col| acc + &col.exhausted.votes_out); } - } - - // Round if required - if let Some(dps) = opts.round_votes { - for (_candidate, cell) in self.total.cells.iter_mut() { - cell.votes_out.floor_mut(dps); + RoundSubtransfersMode::PerBallot => { + // Calculate votes_out for each column + for column in self.columns.iter_mut() { + // Calculate votes_out per candidate in the column + for (_candidate, cell) in column.cells.iter_mut() { + if is_weighted { + // Weighted rules + // Multiply ballots in by new value fraction + let mut new_value_fraction = multiply_surpfrac(column.value_fraction.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + if let Some(dps) = opts.round_values { + new_value_fraction.floor_mut(dps); + } + + cell.votes_out = cell.ballots.clone() * new_value_fraction; + } else if self.surpfrac.is_none() { + // Unweighted rules but transfer at values received + cell.votes_out = cell.votes_in.clone(); + } else { + // Unweighted rules + // Multiply ballots in by surplus fraction + cell.votes_out = multiply_surpfrac(cell.ballots.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + } + + // Round if required + if let Some(dps) = opts.round_votes { + cell.votes_out.floor_mut(dps); + } + } + + // Calculate exhausted votes in the column + if is_weighted { + // Weighted rules + // Multiply ballots in by new value fraction + let mut new_value_fraction = multiply_surpfrac(column.value_fraction.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + if let Some(dps) = opts.round_values { + new_value_fraction.floor_mut(dps); + } + + column.exhausted.votes_out = column.exhausted.ballots.clone() * new_value_fraction; + } else if self.surpfrac.is_none() { + // Unweighted rules but transfer at values received + // This can only happen with --transferable-only, so this will be calculated in apply_to + } else { + // Unweighted rules + // Multiply ballots in by surplus fraction + column.exhausted.votes_out = multiply_surpfrac(column.exhausted.ballots.clone(), &self.surpfrac_numer, &self.surpfrac_denom); + } + + // Round if required + if let Some(dps) = opts.round_votes { + column.exhausted.votes_out.floor_mut(dps); + } + } + + // Sum total votes_out per candidate + for (candidate, cell) in self.total.cells.iter_mut() { + cell.votes_out = self.columns.iter().fold(N::new(), |acc, col| acc + &col.cells[candidate].votes_out); + } + + // Sum total exhausted votes + self.total.exhausted.votes_out = self.columns.iter().fold(N::new(), |acc, col| acc + &col.exhausted.votes_out); } - - self.total.exhausted.votes_out.floor_mut(dps); + _ => todo!() } } @@ -310,10 +363,10 @@ impl<'e, N: Number> TransferTable<'e, N> { let mut table = Table::new(); set_table_format(&mut table); - let show_transfers_per_ballot = self.surpfrac.is_some() || opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot; + let show_transfers_per_column = opts.round_subtransfers != RoundSubtransfersMode::SingleStep; let num_cols; - if show_transfers_per_ballot { + if show_transfers_per_column { num_cols = self.columns.len() * 3 + 4; } else { if self.surpfrac.is_none() { @@ -331,7 +384,7 @@ impl<'e, N: Number> TransferTable<'e, N> { for column in self.columns.iter() { row.push(Cell::new(&format!("Ballots @ {:.dps2$}", column.value_fraction, dps2=max(opts.pp_decimals, 2))).style_spec("cH2")); - if show_transfers_per_ballot { + if show_transfers_per_column { if self.surplus.is_some() { row.push(Cell::new(&format!("× {:.dps2$}", self.surpfrac.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2))).style_spec("r")); } else { @@ -342,7 +395,7 @@ impl<'e, N: Number> TransferTable<'e, N> { row.push(Cell::new("Total").style_spec("cH2")); if self.surpfrac.is_some() { row.push(Cell::new(&format!("× {:.dps2$}", self.surpfrac.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2))).style_spec("r")); - } else if show_transfers_per_ballot { + } else if show_transfers_per_column { row.push(Cell::new("=").style_spec("c")); } table.set_titles(Row::new(row)); @@ -357,13 +410,13 @@ impl<'e, N: Number> TransferTable<'e, N> { if let Some(cell) = column.cells.get(candidate) { row.push(Cell::new(&format!("{:.0}", cell.ballots)).style_spec("r")); row.push(Cell::new(&format!("{:.dps$}", cell.votes_in, dps=opts.pp_decimals)).style_spec("r")); - if show_transfers_per_ballot { + if show_transfers_per_column { row.push(Cell::new(&format!("{:.dps$}", cell.votes_out, dps=opts.pp_decimals)).style_spec("r")); } } else { row.push(Cell::new("")); row.push(Cell::new("")); - if show_transfers_per_ballot { + if show_transfers_per_column { row.push(Cell::new("")); } } @@ -373,13 +426,13 @@ impl<'e, N: Number> TransferTable<'e, N> { if let Some(cell) = self.total.cells.get(candidate) { row.push(Cell::new(&format!("{:.0}", cell.ballots)).style_spec("r")); row.push(Cell::new(&format!("{:.dps$}", cell.votes_in, dps=opts.pp_decimals)).style_spec("r")); - if self.surpfrac.is_some() || show_transfers_per_ballot { + if self.surpfrac.is_some() || show_transfers_per_column { row.push(Cell::new(&format!("{:.dps$}", cell.votes_out, dps=opts.pp_decimals)).style_spec("r")); } } else { row.push(Cell::new("")); row.push(Cell::new("")); - if self.surpfrac.is_some() || show_transfers_per_ballot { + if self.surpfrac.is_some() || show_transfers_per_column { row.push(Cell::new("")); } } @@ -396,7 +449,7 @@ impl<'e, N: Number> TransferTable<'e, N> { if !column.exhausted.ballots.is_zero() { row.push(Cell::new(&format!("{:.0}", column.exhausted.ballots)).style_spec("r")); row.push(Cell::new(&format!("{:.dps$}", column.exhausted.votes_in, dps=opts.pp_decimals)).style_spec("r")); - if show_transfers_per_ballot { + if show_transfers_per_column { if column.exhausted.votes_out.is_zero() { row.push(Cell::new("-").style_spec("c")); } else { @@ -406,7 +459,7 @@ impl<'e, N: Number> TransferTable<'e, N> { } else { row.push(Cell::new("")); row.push(Cell::new("")); - if show_transfers_per_ballot { + if show_transfers_per_column { row.push(Cell::new("")); } } @@ -416,7 +469,7 @@ impl<'e, N: Number> TransferTable<'e, N> { if !self.total.exhausted.ballots.is_zero() { row.push(Cell::new(&format!("{:.0}", self.total.exhausted.ballots)).style_spec("r")); row.push(Cell::new(&format!("{:.dps$}", self.total.exhausted.votes_in, dps=opts.pp_decimals)).style_spec("r")); - if self.surpfrac.is_some() || show_transfers_per_ballot { + if self.surpfrac.is_some() || show_transfers_per_column { if self.total.exhausted.votes_out.is_zero() { row.push(Cell::new("-").style_spec("c")); } else { @@ -426,7 +479,7 @@ impl<'e, N: Number> TransferTable<'e, N> { } else { row.push(Cell::new("")); row.push(Cell::new("")); - if self.surpfrac.is_some() || show_transfers_per_ballot { + if self.surpfrac.is_some() || show_transfers_per_column { row.push(Cell::new("")); } } @@ -442,7 +495,7 @@ impl<'e, N: Number> TransferTable<'e, N> { for column in self.columns.iter() { row.push(Cell::new(&format!("{:.0}", column.total.ballots)).style_spec("r")); row.push(Cell::new(&format!("{:.dps$}", column.total.votes_in, dps=opts.pp_decimals)).style_spec("r")); - if show_transfers_per_ballot { + if show_transfers_per_column { row.push(Cell::new(&format!("{:.dps$}", column.total.votes_out, dps=opts.pp_decimals)).style_spec("r")); } } @@ -466,7 +519,7 @@ impl<'e, N: Number> TransferTable<'e, N> { row.push(Cell::new(&format!("{:.0}", gt_ballots)).style_spec("r")); row.push(Cell::new(&format!("{:.dps$}", gt_votes_in, dps=opts.pp_decimals)).style_spec("r")); - if self.surpfrac.is_some() || show_transfers_per_ballot { + if self.surpfrac.is_some() || show_transfers_per_column { row.push(Cell::new(&format!("{:.dps$}", gt_votes_out, dps=opts.pp_decimals)).style_spec("r")); } @@ -493,6 +546,17 @@ impl<'e, N: Number> TransferTable<'e, N> { //} } +/// Multiply the specified number by the surplus fraction (if applicable) +fn multiply_surpfrac(mut number: N, surpfrac_numer: &Option, surpfrac_denom: &Option) -> N { + if let Some(n) = surpfrac_numer { + number *= n; + } + if let Some(n) = surpfrac_denom { + number /= n; + } + return number; +} + /// Column in a [TransferTable] pub struct TransferTableColumn<'e, N: Number> { /// Value fraction of ballots counted in this column diff --git a/src/stv/mod.rs b/src/stv/mod.rs index e336762..50aafbe 100644 --- a/src/stv/mod.rs +++ b/src/stv/mod.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * Copyright © 2021–2022 Lee Yingtong Li (RunasSudo) * * 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 @@ -64,9 +64,9 @@ pub struct STVOptions { #[builder(default="None")] pub round_quota: Option, - /// How to calculate votes to credit to candidates in surplus transfers - #[builder(default="SumSurplusTransfersMode::ByValue")] - pub sum_surplus_transfers: SumSurplusTransfersMode, + /// How to round votes in transfer table + #[builder(default="RoundSubtransfersMode::SingleStep")] + pub round_subtransfers: RoundSubtransfersMode, /// (Meek STV) Limit for stopping iteration of surplus distribution #[builder(default=r#"String::from("0.001%")"#)] @@ -176,7 +176,7 @@ impl STVOptions { if let Some(dps) = self.round_votes { flags.push(format!("--round-votes {}", dps)); } } if let Some(dps) = self.round_quota { flags.push(format!("--round-quota {}", dps)); } - if self.surplus != SurplusMethod::Meek && self.sum_surplus_transfers != SumSurplusTransfersMode::ByValue { flags.push(self.sum_surplus_transfers.describe()); } + if self.surplus != SurplusMethod::Meek && self.round_subtransfers != RoundSubtransfersMode::SingleStep { flags.push(self.round_subtransfers.describe()); } if self.surplus == SurplusMethod::Meek && self.meek_surplus_tolerance != "0.001%" { flags.push(format!("--meek-surplus-tolerance {}", self.meek_surplus_tolerance)); } if self.normalise_ballots { flags.push("--normalise-ballots".to_string()); } if self.quota != QuotaType::Droop { flags.push(self.quota.describe()); } @@ -229,32 +229,40 @@ impl STVOptions { } } -/// Enum of options for [STVOptions::sum_surplus_transfers] +/// Enum of options for [STVOptions::round_subtransfers] #[cfg_attr(feature = "wasm", wasm_bindgen)] #[derive(Clone, Copy)] #[derive(PartialEq)] -pub enum SumSurplusTransfersMode { - /// Sum and round a candidate's surplus transfers separately for ballot papers received at each particular value +pub enum RoundSubtransfersMode { + /// Do not round subtransfers (only round final number of votes credited) + SingleStep, + /// Round in subtransfers according to the value when received ByValue, - /// Sum and round a candidate's surplus transfers individually for each ballot paper + /// Round in subtransfers according to the candidate from who each vote was received, and the value when received + ByValueAndSource, + /// Sum and round transfers individually for each ballot paper PerBallot, } -impl SumSurplusTransfersMode { +impl RoundSubtransfersMode { /// Convert to CLI argument representation fn describe(self) -> String { match self { - SumSurplusTransfersMode::ByValue => "--sum-surplus-transfers by_value", - SumSurplusTransfersMode::PerBallot => "--sum-surplus-transfers per_ballot", + RoundSubtransfersMode::SingleStep => "--sum-surplus-transfers single_step", + RoundSubtransfersMode::ByValue => "--sum-surplus-transfers by_value", + RoundSubtransfersMode::ByValueAndSource => "--sum-surplus-transfers by_value_and_source", + RoundSubtransfersMode::PerBallot => "--sum-surplus-transfers per_ballot", }.to_string() } } -impl> From for SumSurplusTransfersMode { +impl> From for RoundSubtransfersMode { fn from(s: S) -> Self { match s.as_ref() { - "by_value" => SumSurplusTransfersMode::ByValue, - "per_ballot" => SumSurplusTransfersMode::PerBallot, + "single_step" => RoundSubtransfersMode::SingleStep, + "by_value" => RoundSubtransfersMode::ByValue, + "by_value_and_source" => RoundSubtransfersMode::ByValueAndSource, + "per_ballot" => RoundSubtransfersMode::PerBallot, _ => panic!("Invalid --sum-transfers"), } } diff --git a/src/stv/wasm.rs b/src/stv/wasm.rs index b4181ac..7ece0cc 100644 --- a/src/stv/wasm.rs +++ b/src/stv/wasm.rs @@ -239,7 +239,7 @@ impl STVOptions { round_values: Option, round_votes: Option, round_quota: Option, - sum_surplus_transfers: &str, + round_subtransfers: &str, meek_surplus_tolerance: String, normalise_ballots: bool, quota: &str, @@ -268,7 +268,7 @@ impl STVOptions { round_values, round_votes, round_quota, - sum_surplus_transfers.into(), + round_subtransfers.into(), meek_surplus_tolerance, normalise_ballots, quota.into(), diff --git a/tests/tests_impl/coe.rs b/tests/tests_impl/coe.rs index 6ff0e52..c9c0879 100644 --- a/tests/tests_impl/coe.rs +++ b/tests/tests_impl/coe.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * Copyright © 2021–2022 Lee Yingtong Li (RunasSudo) * * 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 @@ -27,7 +27,7 @@ fn ers97_coe_rational() { .round_values(Some(2)) .round_votes(Some(2)) .round_quota(Some(2)) - .sum_surplus_transfers(stv::SumSurplusTransfersMode::PerBallot) + .round_subtransfers(stv::RoundSubtransfersMode::PerBallot) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .surplus(stv::SurplusMethod::EG) .transferable_only(true) diff --git a/tests/tests_impl/scotland.rs b/tests/tests_impl/scotland.rs index 099e382..4d74048 100644 --- a/tests/tests_impl/scotland.rs +++ b/tests/tests_impl/scotland.rs @@ -1,5 +1,5 @@ /* OpenTally: Open-source election vote counting - * Copyright © 2021 Lee Yingtong Li (RunasSudo) + * Copyright © 2021–2022 Lee Yingtong Li (RunasSudo) * * 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 @@ -32,7 +32,7 @@ fn scotland_linn07_fixed5() { //.round_values(Some(5)) //.round_votes(Some(5)) .round_quota(Some(0)) - .sum_surplus_transfers(stv::SumSurplusTransfersMode::PerBallot) + .round_subtransfers(stv::RoundSubtransfersMode::PerBallot) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .early_bulk_elect(false) .pp_decimals(5) @@ -52,7 +52,7 @@ fn scotland_linn07_gfixed5() { .round_values(Some(5)) // Must specify rounding as guarded decimals represented to 10 dps internally .round_votes(Some(5)) .round_quota(Some(0)) - .sum_surplus_transfers(stv::SumSurplusTransfersMode::PerBallot) + .round_subtransfers(stv::RoundSubtransfersMode::PerBallot) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .early_bulk_elect(false) .pp_decimals(5)