Refactor implementation of --sum-surplus-transfers -> --round-subtransfers in preparation for NSW Local Gov't STV

This commit is contained in:
RunasSudo 2022-03-23 00:34:43 +11:00
parent d94549dc42
commit c5d6b8d460
No known key found for this signature in database
GPG Key ID: 7234E476BF21C61A
9 changed files with 233 additions and 160 deletions

View File

@ -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 * --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* * 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. * *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.
* *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. * *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. 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.

View File

@ -295,10 +295,10 @@
</div> </div>
<label class="col-12"> <label class="col-12">
<span class="pill-grey" title="This option has effect only if “Method” is a Gregory method">Gregory</span> <span class="pill-grey" title="This option has effect only if “Method” is a Gregory method">Gregory</span>
Sum surplus transfers: Round subtransfers:
<select id="selSumTransfers"> <select id="selSumTransfers">
<!--<option value="single_step" selected>Single step</option>--> <option value="single_step" selected>Single step</option>
<option value="by_value" selected>By value</option> <option value="by_value">By value</option>
<option value="per_ballot">Per ballot</option> <option value="per_ballot">Per ballot</option>
</select> </select>
</label> </label>

View File

@ -32,7 +32,7 @@ function changePreset() {
document.getElementById('chkRoundVotes').checked = false; document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundSFs').checked = false; document.getElementById('chkRoundSFs').checked = false;
document.getElementById('chkRoundValues').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('selSurplus').value = 'by_size';
document.getElementById('selMethod').value = 'wig'; document.getElementById('selMethod').value = 'wig';
document.getElementById('selPapers').value = 'both'; document.getElementById('selPapers').value = 'both';
@ -164,11 +164,11 @@ function changePreset() {
document.getElementById('txtRoundVotes').value = '0'; document.getElementById('txtRoundVotes').value = '0';
document.getElementById('chkRoundSFs').checked = false; document.getElementById('chkRoundSFs').checked = false;
document.getElementById('chkRoundValues').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('selSurplus').value = 'by_order';
document.getElementById('selMethod').value = 'uig'; document.getElementById('selMethod').value = 'uig';
document.getElementById('selPapers').value = 'both'; document.getElementById('selPapers').value = 'both';
document.getElementById('selExclusion').value = 'by_value'; document.getElementById('selExclusion').value = 'single_step';
document.getElementById('selTies').value = 'backwards,random'; document.getElementById('selTies').value = 'backwards,random';
} else if (document.getElementById('selPreset').value === 'wa') { } else if (document.getElementById('selPreset').value === 'wa') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
@ -212,11 +212,11 @@ function changePreset() {
document.getElementById('txtRoundVotes').value = '6'; document.getElementById('txtRoundVotes').value = '6';
document.getElementById('chkRoundSFs').checked = false; document.getElementById('chkRoundSFs').checked = false;
document.getElementById('chkRoundValues').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('selSurplus').value = 'by_order';
document.getElementById('selMethod').value = 'eg'; document.getElementById('selMethod').value = 'eg';
document.getElementById('selPapers').value = 'transferable'; document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'by_value'; document.getElementById('selExclusion').value = 'single_step';
document.getElementById('selTies').value = 'backwards,random'; document.getElementById('selTies').value = 'backwards,random';
} else if (document.getElementById('selPreset').value === 'nswlg') { } else if (document.getElementById('selPreset').value === 'nswlg') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
@ -261,7 +261,7 @@ function changePreset() {
document.getElementById('chkRoundSFs').checked = true; document.getElementById('chkRoundSFs').checked = true;
document.getElementById('txtRoundSFs').value = '4'; document.getElementById('txtRoundSFs').value = '4';
document.getElementById('chkRoundValues').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('selSurplus').value = 'by_size';
document.getElementById('selMethod').value = 'wig'; document.getElementById('selMethod').value = 'wig';
document.getElementById('selPapers').value = 'both'; document.getElementById('selPapers').value = 'both';
@ -283,7 +283,7 @@ function changePreset() {
document.getElementById('chkNormaliseBallots').checked = true; document.getElementById('chkNormaliseBallots').checked = true;
document.getElementById('chkRoundQuota').checked = true; document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0'; document.getElementById('txtRoundQuota').value = '0';
document.getElementById('selSumTransfers').value = 'by_value'; document.getElementById('selSumTransfers').value = 'single_step';
document.getElementById('selMethod').value = 'hare'; document.getElementById('selMethod').value = 'hare';
document.getElementById('selPapers').value = 'transferable'; document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'single_stage'; document.getElementById('selExclusion').value = 'single_stage';
@ -304,7 +304,7 @@ function changePreset() {
document.getElementById('chkNormaliseBallots').checked = true; document.getElementById('chkNormaliseBallots').checked = true;
document.getElementById('chkRoundQuota').checked = true; document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0'; 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('selSurplus').value = 'by_order';
document.getElementById('selMethod').value = 'hare'; document.getElementById('selMethod').value = 'hare';
document.getElementById('selPapers').value = 'transferable'; document.getElementById('selPapers').value = 'transferable';
@ -328,7 +328,7 @@ function changePreset() {
document.getElementById('chkRoundVotes').checked = false; document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundSFs').checked = false; document.getElementById('chkRoundSFs').checked = false;
document.getElementById('chkRoundValues').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('selSurplus').value = 'by_size';
document.getElementById('selMethod').value = 'wig'; document.getElementById('selMethod').value = 'wig';
document.getElementById('selPapers').value = 'both'; document.getElementById('selPapers').value = 'both';
@ -355,7 +355,7 @@ function changePreset() {
document.getElementById('txtRoundSFs').value = '3'; document.getElementById('txtRoundSFs').value = '3';
document.getElementById('chkRoundValues').checked = true; document.getElementById('chkRoundValues').checked = true;
document.getElementById('txtRoundValues').value = '3'; 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('selSurplus').value = 'by_order';
document.getElementById('selMethod').value = 'eg'; document.getElementById('selMethod').value = 'eg';
document.getElementById('selPapers').value = 'transferable'; document.getElementById('selPapers').value = 'transferable';
@ -382,11 +382,11 @@ function changePreset() {
document.getElementById('txtRoundSFs').value = '2'; document.getElementById('txtRoundSFs').value = '2';
document.getElementById('chkRoundValues').checked = true; document.getElementById('chkRoundValues').checked = true;
document.getElementById('txtRoundValues').value = '2'; 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('selSurplus').value = 'by_size';
document.getElementById('selMethod').value = 'eg'; document.getElementById('selMethod').value = 'eg';
document.getElementById('selPapers').value = 'transferable'; document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'by_value'; document.getElementById('selExclusion').value = 'single_step';
document.getElementById('selTies').value = 'forwards,random'; document.getElementById('selTies').value = 'forwards,random';
} else if (document.getElementById('selPreset').value === 'ers76') { } else if (document.getElementById('selPreset').value === 'ers76') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
@ -409,11 +409,11 @@ function changePreset() {
document.getElementById('txtRoundSFs').value = '2'; document.getElementById('txtRoundSFs').value = '2';
document.getElementById('chkRoundValues').checked = true; document.getElementById('chkRoundValues').checked = true;
document.getElementById('txtRoundValues').value = '2'; 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('selSurplus').value = 'by_size';
document.getElementById('selMethod').value = 'eg'; document.getElementById('selMethod').value = 'eg';
document.getElementById('selPapers').value = 'transferable'; document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'by_value'; document.getElementById('selExclusion').value = 'single_step';
document.getElementById('selTies').value = 'forwards,random'; document.getElementById('selTies').value = 'forwards,random';
} else if (document.getElementById('selPreset').value === 'ers73') { } else if (document.getElementById('selPreset').value === 'ers73') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
@ -436,11 +436,11 @@ function changePreset() {
document.getElementById('txtRoundSFs').value = '2'; document.getElementById('txtRoundSFs').value = '2';
document.getElementById('chkRoundValues').checked = true; document.getElementById('chkRoundValues').checked = true;
document.getElementById('txtRoundValues').value = '2'; 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('selSurplus').value = 'by_size';
document.getElementById('selMethod').value = 'eg'; document.getElementById('selMethod').value = 'eg';
document.getElementById('selPapers').value = 'transferable'; document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'by_value'; document.getElementById('selExclusion').value = 'single_step';
document.getElementById('selTies').value = 'forwards,random'; document.getElementById('selTies').value = 'forwards,random';
} else if (document.getElementById('selPreset').value === 'cofe') { } else if (document.getElementById('selPreset').value === 'cofe') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
@ -467,7 +467,7 @@ function changePreset() {
document.getElementById('selSurplus').value = 'by_size'; document.getElementById('selSurplus').value = 'by_size';
document.getElementById('selMethod').value = 'eg'; document.getElementById('selMethod').value = 'eg';
document.getElementById('selPapers').value = 'transferable'; document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'by_value'; document.getElementById('selExclusion').value = 'single_step';
document.getElementById('selTies').value = 'forwards,random'; document.getElementById('selTies').value = 'forwards,random';
} }
} }

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting /* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo) * Copyright © 20212022 Lee Yingtong Li (RunasSudo)
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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")] #[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
round_quota: Option<usize>, round_quota: Option<usize>,
/// (Gregory STV) How to calculate votes to credit to candidates in surplus transfers /// (Gregory STV) How to round subtransfers during surpluses/exclusions
#[clap(help_heading=Some("ROUNDING"), long, possible_values=&["by_value", "per_ballot"], default_value="by_value", value_name="mode")] #[clap(help_heading=Some("ROUNDING"), long, possible_values=&["single_step", "per_ballot"], default_value="single_step", value_name="mode")]
sum_surplus_transfers: String, round_subtransfers: String,
/// (Meek STV) Limit for stopping iteration of surplus distribution /// (Meek STV) Limit for stopping iteration of surplus distribution
#[clap(help_heading=Some("ROUNDING"), long, default_value="0.001%", value_name="tolerance")] #[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_values,
cmd_opts.round_votes, cmd_opts.round_votes,
cmd_opts.round_quota, cmd_opts.round_quota,
cmd_opts.sum_surplus_transfers.into(), cmd_opts.round_subtransfers.into(),
cmd_opts.meek_surplus_tolerance, cmd_opts.meek_surplus_tolerance,
cmd_opts.normalise_ballots, cmd_opts.normalise_ballots,
cmd_opts.quota.into(), cmd_opts.quota.into(),

View File

@ -22,7 +22,7 @@ use super::prettytable_html::{Cell, Row, Table};
use crate::election::{Candidate, CountState}; use crate::election::{Candidate, CountState};
use crate::numbers::Number; use crate::numbers::Number;
use crate::stv::{STVOptions, SumSurplusTransfersMode}; use crate::stv::{STVOptions, RoundSubtransfersMode};
use std::cmp::max; use std::cmp::max;
use std::collections::HashMap; 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(); let is_weighted = self.surplus.is_none() || opts.surplus.is_weighted();
// Iterate through columns // Iterate through columns
// Sum votes_in, etc.
for column in self.columns.iter_mut() { 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 // Candidate votes
for (candidate, cell) in column.cells.iter_mut() { for (candidate, cell) in column.cells.iter_mut() {
column.total.ballots += &cell.ballots; column.total.ballots += &cell.ballots;
@ -169,19 +141,6 @@ impl<'e, N: Number> TransferTable<'e, N> {
column.total.votes_in += &votes_in; column.total.votes_in += &votes_in;
self.total.cells.get_mut(*candidate).unwrap().votes_in += &votes_in; self.total.cells.get_mut(*candidate).unwrap().votes_in += &votes_in;
self.total.total.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 // Exhausted votes
@ -194,73 +153,167 @@ impl<'e, N: Number> TransferTable<'e, N> {
column.total.votes_in += &votes_in; column.total.votes_in += &votes_in;
self.total.exhausted.votes_in += &votes_in; self.total.exhausted.votes_in += &votes_in;
self.total.total.votes_in += votes_in; self.total.total.votes_in += votes_in;
}
if self.surplus.is_some() || opts.sum_surplus_transfers == SumSurplusTransfersMode::PerBallot { match opts.round_subtransfers {
if !opts.transferable_only { RoundSubtransfersMode::SingleStep => {
let mut votes_out = column.exhausted.ballots.clone() * &new_value_fraction; // No need to calculate votes_out for each column
// Round if required
if let Some(dps) = opts.round_votes { // Calculate total votes_out per candidate
votes_out.floor_mut(dps); 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; // Round if required
column.total.votes_out += &votes_out; if let Some(dps) = opts.round_votes {
self.total.exhausted.votes_out += &votes_out; cell.votes_out.floor_mut(dps);
self.total.total.votes_out += votes_out; }
} }
}
}
// Need to calculate total candidate votes_out? // Calculate total exhausted votes
if opts.sum_surplus_transfers == SumSurplusTransfersMode::ByValue { if is_weighted {
for (_candidate, cell) in self.total.cells.iter_mut() { // Weighted rules
let mut votes_out; // 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);
if is_weighted || self.surpfrac.is_none() { } else if self.surpfrac.is_none() {
// NB: If surplus.is_none, then votes transferred at values received // Unweighted rules but transfer at values received
votes_out = cell.votes_in.clone(); // This can only happen with --transferable-only, so this will be calculated in apply_to
} else { } 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 // Round if required
if let Some(n) = &self.surpfrac_numer { if let Some(dps) = opts.round_votes {
votes_out *= n; 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
} }
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);
}
if self.surplus.is_none() || !opts.transferable_only { // Round if required
let mut votes_out; if let Some(dps) = opts.round_votes {
if is_weighted || self.surpfrac.is_none() { cell.votes_out.floor_mut(dps);
votes_out = self.total.exhausted.votes_in.clone(); }
} else { }
votes_out = self.total.exhausted.ballots.clone();
// 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 // Sum total votes_out per candidate
if let Some(n) = &self.surpfrac_numer { for (candidate, cell) in self.total.cells.iter_mut() {
votes_out *= n; cell.votes_out = self.columns.iter().fold(N::new(), |acc, col| acc + &col.cells[candidate].votes_out);
}
if let Some(n) = &self.surpfrac_denom {
votes_out /= n;
} }
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);
} }
} 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);
}
// Round if required cell.votes_out = cell.ballots.clone() * new_value_fraction;
if let Some(dps) = opts.round_votes { } else if self.surpfrac.is_none() {
for (_candidate, cell) in self.total.cells.iter_mut() { // Unweighted rules but transfer at values received
cell.votes_out.floor_mut(dps); 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);
} }
_ => todo!()
self.total.exhausted.votes_out.floor_mut(dps);
} }
} }
@ -310,10 +363,10 @@ impl<'e, N: Number> TransferTable<'e, N> {
let mut table = Table::new(); let mut table = Table::new();
set_table_format(&mut table); 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; let num_cols;
if show_transfers_per_ballot { if show_transfers_per_column {
num_cols = self.columns.len() * 3 + 4; num_cols = self.columns.len() * 3 + 4;
} else { } else {
if self.surpfrac.is_none() { if self.surpfrac.is_none() {
@ -331,7 +384,7 @@ impl<'e, N: Number> TransferTable<'e, N> {
for column in self.columns.iter() { 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")); 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() { 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")); row.push(Cell::new(&format!("× {:.dps2$}", self.surpfrac.as_ref().unwrap(), dps2=max(opts.pp_decimals, 2))).style_spec("r"));
} else { } else {
@ -342,7 +395,7 @@ impl<'e, N: Number> TransferTable<'e, N> {
row.push(Cell::new("Total").style_spec("cH2")); row.push(Cell::new("Total").style_spec("cH2"));
if self.surpfrac.is_some() { 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")); 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")); row.push(Cell::new("=").style_spec("c"));
} }
table.set_titles(Row::new(row)); 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) { if let Some(cell) = column.cells.get(candidate) {
row.push(Cell::new(&format!("{:.0}", cell.ballots)).style_spec("r")); 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")); 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")); row.push(Cell::new(&format!("{:.dps$}", cell.votes_out, dps=opts.pp_decimals)).style_spec("r"));
} }
} else { } else {
row.push(Cell::new("")); row.push(Cell::new(""));
row.push(Cell::new("")); row.push(Cell::new(""));
if show_transfers_per_ballot { if show_transfers_per_column {
row.push(Cell::new("")); row.push(Cell::new(""));
} }
} }
@ -373,13 +426,13 @@ impl<'e, N: Number> TransferTable<'e, N> {
if let Some(cell) = self.total.cells.get(candidate) { 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!("{:.0}", cell.ballots)).style_spec("r"));
row.push(Cell::new(&format!("{:.dps$}", cell.votes_in, dps=opts.pp_decimals)).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")); row.push(Cell::new(&format!("{:.dps$}", cell.votes_out, dps=opts.pp_decimals)).style_spec("r"));
} }
} else { } else {
row.push(Cell::new("")); row.push(Cell::new(""));
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("")); row.push(Cell::new(""));
} }
} }
@ -396,7 +449,7 @@ impl<'e, N: Number> TransferTable<'e, N> {
if !column.exhausted.ballots.is_zero() { if !column.exhausted.ballots.is_zero() {
row.push(Cell::new(&format!("{:.0}", column.exhausted.ballots)).style_spec("r")); 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")); 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() { if column.exhausted.votes_out.is_zero() {
row.push(Cell::new("-").style_spec("c")); row.push(Cell::new("-").style_spec("c"));
} else { } else {
@ -406,7 +459,7 @@ impl<'e, N: Number> TransferTable<'e, N> {
} else { } else {
row.push(Cell::new("")); row.push(Cell::new(""));
row.push(Cell::new("")); row.push(Cell::new(""));
if show_transfers_per_ballot { if show_transfers_per_column {
row.push(Cell::new("")); row.push(Cell::new(""));
} }
} }
@ -416,7 +469,7 @@ impl<'e, N: Number> TransferTable<'e, N> {
if !self.total.exhausted.ballots.is_zero() { 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!("{:.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")); 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() { if self.total.exhausted.votes_out.is_zero() {
row.push(Cell::new("-").style_spec("c")); row.push(Cell::new("-").style_spec("c"));
} else { } else {
@ -426,7 +479,7 @@ impl<'e, N: Number> TransferTable<'e, N> {
} else { } else {
row.push(Cell::new("")); row.push(Cell::new(""));
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("")); row.push(Cell::new(""));
} }
} }
@ -442,7 +495,7 @@ impl<'e, N: Number> TransferTable<'e, N> {
for column in self.columns.iter() { for column in self.columns.iter() {
row.push(Cell::new(&format!("{:.0}", column.total.ballots)).style_spec("r")); 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")); 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")); 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!("{:.0}", gt_ballots)).style_spec("r"));
row.push(Cell::new(&format!("{:.dps$}", gt_votes_in, dps=opts.pp_decimals)).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")); 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<N: Number>(mut number: N, surpfrac_numer: &Option<N>, surpfrac_denom: &Option<N>) -> N {
if let Some(n) = surpfrac_numer {
number *= n;
}
if let Some(n) = surpfrac_denom {
number /= n;
}
return number;
}
/// Column in a [TransferTable] /// Column in a [TransferTable]
pub struct TransferTableColumn<'e, N: Number> { pub struct TransferTableColumn<'e, N: Number> {
/// Value fraction of ballots counted in this column /// Value fraction of ballots counted in this column

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting /* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo) * Copyright © 20212022 Lee Yingtong Li (RunasSudo)
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published by
@ -64,9 +64,9 @@ pub struct STVOptions {
#[builder(default="None")] #[builder(default="None")]
pub round_quota: Option<usize>, pub round_quota: Option<usize>,
/// How to calculate votes to credit to candidates in surplus transfers /// How to round votes in transfer table
#[builder(default="SumSurplusTransfersMode::ByValue")] #[builder(default="RoundSubtransfersMode::SingleStep")]
pub sum_surplus_transfers: SumSurplusTransfersMode, pub round_subtransfers: RoundSubtransfersMode,
/// (Meek STV) Limit for stopping iteration of surplus distribution /// (Meek STV) Limit for stopping iteration of surplus distribution
#[builder(default=r#"String::from("0.001%")"#)] #[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_votes { flags.push(format!("--round-votes {}", dps)); }
} }
if let Some(dps) = self.round_quota { flags.push(format!("--round-quota {}", 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.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.normalise_ballots { flags.push("--normalise-ballots".to_string()); }
if self.quota != QuotaType::Droop { flags.push(self.quota.describe()); } 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)] #[cfg_attr(feature = "wasm", wasm_bindgen)]
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
#[derive(PartialEq)] #[derive(PartialEq)]
pub enum SumSurplusTransfersMode { pub enum RoundSubtransfersMode {
/// Sum and round a candidate's surplus transfers separately for ballot papers received at each particular value /// Do not round subtransfers (only round final number of votes credited)
SingleStep,
/// Round in subtransfers according to the value when received
ByValue, 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, PerBallot,
} }
impl SumSurplusTransfersMode { impl RoundSubtransfersMode {
/// Convert to CLI argument representation /// Convert to CLI argument representation
fn describe(self) -> String { fn describe(self) -> String {
match self { match self {
SumSurplusTransfersMode::ByValue => "--sum-surplus-transfers by_value", RoundSubtransfersMode::SingleStep => "--sum-surplus-transfers single_step",
SumSurplusTransfersMode::PerBallot => "--sum-surplus-transfers per_ballot", 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() }.to_string()
} }
} }
impl<S: AsRef<str>> From<S> for SumSurplusTransfersMode { impl<S: AsRef<str>> From<S> for RoundSubtransfersMode {
fn from(s: S) -> Self { fn from(s: S) -> Self {
match s.as_ref() { match s.as_ref() {
"by_value" => SumSurplusTransfersMode::ByValue, "single_step" => RoundSubtransfersMode::SingleStep,
"per_ballot" => SumSurplusTransfersMode::PerBallot, "by_value" => RoundSubtransfersMode::ByValue,
"by_value_and_source" => RoundSubtransfersMode::ByValueAndSource,
"per_ballot" => RoundSubtransfersMode::PerBallot,
_ => panic!("Invalid --sum-transfers"), _ => panic!("Invalid --sum-transfers"),
} }
} }

View File

@ -239,7 +239,7 @@ impl STVOptions {
round_values: Option<usize>, round_values: Option<usize>,
round_votes: Option<usize>, round_votes: Option<usize>,
round_quota: Option<usize>, round_quota: Option<usize>,
sum_surplus_transfers: &str, round_subtransfers: &str,
meek_surplus_tolerance: String, meek_surplus_tolerance: String,
normalise_ballots: bool, normalise_ballots: bool,
quota: &str, quota: &str,
@ -268,7 +268,7 @@ impl STVOptions {
round_values, round_values,
round_votes, round_votes,
round_quota, round_quota,
sum_surplus_transfers.into(), round_subtransfers.into(),
meek_surplus_tolerance, meek_surplus_tolerance,
normalise_ballots, normalise_ballots,
quota.into(), quota.into(),

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting /* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo) * Copyright © 20212022 Lee Yingtong Li (RunasSudo)
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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_values(Some(2))
.round_votes(Some(2)) .round_votes(Some(2))
.round_quota(Some(2)) .round_quota(Some(2))
.sum_surplus_transfers(stv::SumSurplusTransfersMode::PerBallot) .round_subtransfers(stv::RoundSubtransfersMode::PerBallot)
.quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual)
.surplus(stv::SurplusMethod::EG) .surplus(stv::SurplusMethod::EG)
.transferable_only(true) .transferable_only(true)

View File

@ -1,5 +1,5 @@
/* OpenTally: Open-source election vote counting /* OpenTally: Open-source election vote counting
* Copyright © 2021 Lee Yingtong Li (RunasSudo) * Copyright © 20212022 Lee Yingtong Li (RunasSudo)
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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_values(Some(5))
//.round_votes(Some(5)) //.round_votes(Some(5))
.round_quota(Some(0)) .round_quota(Some(0))
.sum_surplus_transfers(stv::SumSurplusTransfersMode::PerBallot) .round_subtransfers(stv::RoundSubtransfersMode::PerBallot)
.quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual)
.early_bulk_elect(false) .early_bulk_elect(false)
.pp_decimals(5) .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_values(Some(5)) // Must specify rounding as guarded decimals represented to 10 dps internally
.round_votes(Some(5)) .round_votes(Some(5))
.round_quota(Some(0)) .round_quota(Some(0))
.sum_surplus_transfers(stv::SumSurplusTransfersMode::PerBallot) .round_subtransfers(stv::RoundSubtransfersMode::PerBallot)
.quota_criterion(stv::QuotaCriterion::GreaterOrEqual) .quota_criterion(stv::QuotaCriterion::GreaterOrEqual)
.early_bulk_elect(false) .early_bulk_elect(false)
.pp_decimals(5) .pp_decimals(5)