Implement --round-{tvs,weights,quota}

Add test case for PRSA example election
This commit is contained in:
RunasSudo 2021-06-01 21:20:38 +10:00
parent f1a730e885
commit f6fba85049
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
12 changed files with 388 additions and 100 deletions

View File

@ -61,10 +61,22 @@ struct STV {
// -----------------------
// -- Rounding settings --
/// Round transfer values to specified decimal places
#[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
round_tvs: Option<usize>,
/// Round ballot weights to specified decimal places
#[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
round_weights: Option<usize>,
/// Round votes to specified decimal places
#[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
round_votes: Option<usize>,
/// Round quota to specified decimal places
#[clap(help_heading=Some("ROUNDING"), long, value_name="dps")]
round_quota: Option<usize>,
// ------------------
// -- STV variants --
@ -126,7 +138,10 @@ where
{
// Copy applicable options
let stv_opts = stv::STVOptions::new(
cmd_opts.round_tvs,
cmd_opts.round_weights,
cmd_opts.round_votes,
cmd_opts.round_quota,
&cmd_opts.surplus,
&cmd_opts.surplus_order,
cmd_opts.transferable_only,

View File

@ -33,11 +33,19 @@ pub trait Assign<Src=Self> {
fn assign(&mut self, src: Src);
}
pub trait From<T> {
fn from(n: T) -> Self;
}
//pub trait Number: NumRef + NumAssignRef + PartialOrd + Assign + Clone + fmt::Display where for<'a> &'a Self: RefNum<&'a Self> {
pub trait Number: NumRef + NumAssignRef + ops::Neg<Output=Self> + Ord + Assign + Clone + fmt::Display where for<'a> Self: Assign<&'a Self> {
pub trait Number:
NumRef + NumAssignRef + ops::Neg<Output=Self> + Ord + Assign + From<usize> + From<f64> + Clone + fmt::Display
where
for<'a> Self: Assign<&'a Self>
{
fn new() -> Self;
fn from(n: usize) -> Self;
fn pow_assign(&mut self, exponent: i32);
fn floor_mut(&mut self, dps: usize);
fn parse(s: &str) -> Self {

View File

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use super::{Assign, Number};
use super::{Assign, From, Number};
use num_traits::{Num, One, Zero};
@ -31,7 +31,9 @@ pub struct NativeFloat64(ImplType);
impl Number for NativeFloat64 {
fn new() -> Self { Self(0.0) }
fn from(n: usize) -> Self { Self(n as ImplType) }
fn pow_assign(&mut self, exponent: i32) {
self.0 = self.0.powi(exponent);
}
fn floor_mut(&mut self, dps: usize) {
let factor = 10.0_f64.powi(dps as i32);
@ -58,6 +60,14 @@ impl Assign<&NativeFloat64> for NativeFloat64 {
fn assign(&mut self, src: &NativeFloat64) { self.0 = src.0 }
}
impl From<usize> for NativeFloat64 {
fn from(n: usize) -> Self { Self(n as ImplType) }
}
impl From<f64> for NativeFloat64 {
fn from(n: f64) -> Self { Self(n as ImplType) }
}
impl Clone for NativeFloat64 {
fn clone(&self) -> Self { Self(self.0) }
}
@ -167,9 +177,7 @@ impl ops::SubAssign for NativeFloat64 {
}
impl ops::MulAssign for NativeFloat64 {
fn mul_assign(&mut self, _rhs: Self) {
todo!()
}
fn mul_assign(&mut self, rhs: Self) { self.0 *= rhs.0 }
}
impl ops::DivAssign for NativeFloat64 {

View File

@ -15,7 +15,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use super::{Assign, Number};
use super::{Assign, From, Number};
use num_bigint::{BigInt, ParseBigIntError};
use num_rational::BigRational; // TODO: Can we do Ratio<IBig> and combine with ibig?
@ -30,7 +30,9 @@ pub struct Rational(BigRational);
impl Number for Rational {
fn new() -> Self { Self(BigRational::zero()) }
fn from(n: usize) -> Self { Self(BigRational::from_integer(BigInt::from(n))) }
fn pow_assign(&mut self, exponent: i32) {
self.0 = self.0.pow(exponent);
}
fn floor_mut(&mut self, dps: usize) {
if dps == 0 {
@ -65,6 +67,17 @@ impl Assign<&Rational> for Rational {
fn assign(&mut self, src: &Rational) { self.0 = src.0.clone() }
}
impl From<usize> for Rational {
fn from(n: usize) -> Self { Self(BigRational::from_integer(BigInt::from(n))) }
}
impl From<f64> for Rational {
fn from(n: f64) -> Self {
// FIXME: This is very broken!
return Self(BigRational::from_float(n).unwrap() * BigRational::from_integer(BigInt::from(100000)).round() / BigRational::from_integer(BigInt::from(100000)));
}
}
impl Clone for Rational {
fn clone(&self) -> Self { Self(self.0.clone()) }
}
@ -201,15 +214,11 @@ impl ops::SubAssign for Rational {
}
impl ops::MulAssign for Rational {
fn mul_assign(&mut self, _rhs: Self) {
todo!()
}
fn mul_assign(&mut self, rhs: Self) { self.0 *= rhs.0 }
}
impl ops::DivAssign for Rational {
fn div_assign(&mut self, rhs: Self) {
self.0 /= &rhs.0;
}
fn div_assign(&mut self, rhs: Self) { self.0 /= &rhs.0 }
}
impl ops::RemAssign for Rational {

View File

@ -15,10 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use super::{Assign, Number};
use super::{Assign, From, Number};
use num_traits::{Num, One, Zero};
use rug::{self, ops::Pow, rational::ParseRationalError};
use rug::{self, ops::Pow, ops::PowAssign, rational::ParseRationalError};
use rug::Assign as RugAssign;
use std::cmp::{Ord, Ordering, PartialEq, PartialOrd};
@ -30,7 +30,9 @@ pub struct Rational(rug::Rational);
impl Number for Rational {
fn new() -> Self { Self(rug::Rational::new()) }
fn from(n: usize) -> Self { Self(rug::Rational::from(n)) }
fn pow_assign(&mut self, exponent: i32) {
self.0.pow_assign(exponent);
}
fn floor_mut(&mut self, dps: usize) {
if dps == 0 {
@ -63,6 +65,17 @@ impl Assign<&Rational> for Rational {
fn assign(&mut self, src: &Rational) { self.0.assign(&src.0) }
}
impl From<usize> for Rational {
fn from(n: usize) -> Self { Self(rug::Rational::from(n)) }
}
impl From<f64> for Rational {
fn from(n: f64) -> Self {
// FIXME: This is very broken!
return Self((rug::Rational::from_f64(n).unwrap() * rug::Rational::from(100000)).round() / rug::Rational::from(100000));
}
}
impl Clone for Rational {
fn clone(&self) -> Self { Self(self.0.clone()) }
}
@ -199,15 +212,11 @@ impl ops::SubAssign for Rational {
}
impl ops::MulAssign for Rational {
fn mul_assign(&mut self, _rhs: Self) {
todo!()
}
fn mul_assign(&mut self, rhs: Self) { self.0 *= rhs.0 }
}
impl ops::DivAssign for Rational {
fn div_assign(&mut self, rhs: Self) {
self.0 /= &rhs.0;
}
fn div_assign(&mut self, rhs: Self) { self.0 /= rhs.0 }
}
impl ops::RemAssign for Rational {
@ -225,9 +234,7 @@ impl ops::SubAssign<&Rational> for Rational {
}
impl ops::MulAssign<&Rational> for Rational {
fn mul_assign(&mut self, _rhs: &Rational) {
todo!()
}
fn mul_assign(&mut self, rhs: &Rational) { self.0 *= &rhs.0 }
}
impl ops::DivAssign<&Rational> for Rational {

View File

@ -30,7 +30,10 @@ use std::ops;
#[wasm_bindgen]
pub struct STVOptions {
pub round_tvs: Option<usize>,
pub round_weights: Option<usize>,
pub round_votes: Option<usize>,
pub round_quota: Option<usize>,
pub surplus: SurplusMethod,
pub surplus_order: SurplusOrder,
pub transferable_only: bool,
@ -41,7 +44,10 @@ pub struct STVOptions {
#[wasm_bindgen]
impl STVOptions {
pub fn new(
round_tvs: Option<usize>,
round_weights: Option<usize>,
round_votes: Option<usize>,
round_quota: Option<usize>,
surplus: &str,
surplus_order: &str,
transferable_only: bool,
@ -49,7 +55,10 @@ impl STVOptions {
pp_decimals: usize,
) -> Self {
return STVOptions {
round_votes: round_votes,
round_tvs,
round_weights,
round_votes,
round_quota,
surplus: match surplus {
"wig" => SurplusMethod::WIG,
"uig" => SurplusMethod::UIG,
@ -62,14 +71,14 @@ impl STVOptions {
"by_order" => SurplusOrder::ByOrder,
_ => panic!("Invalid --surplus-order"),
},
transferable_only: transferable_only,
transferable_only,
exclusion: match exclusion {
"single_stage" => ExclusionMethod::SingleStage,
"by_value" => ExclusionMethod::ByValue,
"parcels_by_order" => ExclusionMethod::ParcelsByOrder,
_ => panic!("Invalid --exclusion"),
},
pp_decimals: pp_decimals,
pp_decimals,
};
}
}
@ -250,9 +259,16 @@ fn calculate_quota<N: Number>(state: &mut CountState<N>, opts: &STVOptions) {
// TODO: Different quotas
state.quota /= N::from(state.election.seats + 1);
// TODO: Different rounding rules
state.quota += N::one();
state.quota.floor_mut(0);
// Increment to next available increment
if let Some(dps) = opts.round_quota {
let mut factor = N::from(10);
factor.pow_assign(dps as i32);
state.quota *= &factor;
state.quota.floor_mut(0);
state.quota += N::one();
state.quota /= factor;
}
log.push_str(format!("{:.dps$}.", state.quota, dps=opts.pp_decimals).as_str());
state.logger.log_literal(log);
@ -299,7 +315,8 @@ where
if has_surplus.len() > 0 {
match opts.surplus_order {
SurplusOrder::BySize => {
has_surplus.sort_unstable_by(|a, b| a.1.votes.partial_cmp(&b.1.votes).unwrap());
// Compare b with a to sort high-low
has_surplus.sort_unstable_by(|a, b| b.1.votes.partial_cmp(&a.1.votes).unwrap());
}
SurplusOrder::ByOrder => {
has_surplus.sort_unstable_by(|a, b| a.1.order_elected.partial_cmp(&b.1.order_elected).unwrap());
@ -340,19 +357,47 @@ where
}
}
fn reweight_vote<N: Number>(num_votes: &N, num_ballots: &N, surplus: &N, weighted: bool, transfer_denom: &Option<N>) -> N {
fn reweight_vote<N: Number>(
num_votes: &N,
num_ballots: &N,
surplus: &N,
weighted: bool,
transfer_value: &Option<N>,
transfer_denom: &Option<N>,
round_tvs: Option<usize>,
rounding: Option<usize>) -> N
{
let mut result;
match transfer_denom {
Some(v) => {
if weighted {
return num_votes.clone() * surplus / v;
if let Some(_) = round_tvs {
// Rounding requested: use the rounded transfer value
if weighted {
result = num_votes.clone() * transfer_value.as_ref().unwrap();
} else {
result = num_ballots.clone() * transfer_value.as_ref().unwrap();
}
} else {
return num_ballots.clone() * surplus / v;
// Avoid unnecessary rounding error by first multiplying by the surplus
if weighted {
result = num_votes.clone() * surplus / v;
} else {
result = num_ballots.clone() * surplus / v;
}
}
}
None => {
return num_votes.clone();
result = num_votes.clone();
}
}
// Round down if requested
if let Some(dps) = rounding {
result.floor_mut(dps);
}
return result;
}
fn distribute_surplus<N: Number>(state: &mut CountState<N>, opts: &STVOptions, elected_candidate: &Candidate)
@ -395,13 +440,20 @@ where
let transferable_votes = &result.total_votes - &result.exhausted.num_votes;
let transfer_denom = calculate_transfer_denom(&surplus, &result, &transferable_votes, is_weighted, opts.transferable_only);
let transfer_value;
let mut transfer_value;
match transfer_denom {
Some(ref v) => {
transfer_value = surplus.clone() / v;
state.logger.log_literal(format!("Surplus of {} distributed at value {:.dps$}.", elected_candidate.name, transfer_value, dps=opts.pp_decimals));
transfer_value = Some(surplus.clone() / v);
// Round down if requested
if let Some(dps) = opts.round_tvs {
transfer_value.as_mut().unwrap().floor_mut(dps);
}
state.logger.log_literal(format!("Surplus of {} distributed at value {:.dps$}.", elected_candidate.name, transfer_value.as_ref().unwrap(), dps=opts.pp_decimals));
}
None => {
transfer_value = None;
state.logger.log_literal(format!("Surplus of {} distributed at values received.", elected_candidate.name));
}
}
@ -413,17 +465,13 @@ where
// Reweight votes
for vote in parcel.iter_mut() {
vote.value = reweight_vote(&vote.value, &vote.ballot.orig_value, &surplus, is_weighted, &transfer_denom);
vote.value = reweight_vote(&vote.value, &vote.ballot.orig_value, &surplus, is_weighted, &transfer_value, &transfer_denom, opts.round_tvs, opts.round_weights);
}
let count_card = state.candidates.get_mut(candidate).unwrap();
count_card.parcels.push(parcel);
let mut candidate_transfers = reweight_vote(&entry.num_votes, &entry.num_ballots, &surplus, is_weighted, &transfer_denom);
// Round transfers
if let Some(dps) = opts.round_votes {
candidate_transfers.floor_mut(dps);
}
let candidate_transfers = reweight_vote(&entry.num_votes, &entry.num_ballots, &surplus, is_weighted, &transfer_value, &transfer_denom, opts.round_tvs, opts.round_votes);
count_card.transfer(&candidate_transfers);
checksum += candidate_transfers;
}
@ -441,7 +489,7 @@ where
exhausted_transfers = &surplus - &transferable_votes;
}
} else {
exhausted_transfers = reweight_vote(&result.exhausted.num_votes, &result.exhausted.num_ballots, &surplus, is_weighted, &transfer_denom);
exhausted_transfers = reweight_vote(&result.exhausted.num_votes, &result.exhausted.num_ballots, &surplus, is_weighted, &transfer_value, &transfer_denom, opts.round_tvs, opts.round_votes);
}
if let Some(dps) = opts.round_votes {

View File

@ -15,15 +15,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use opentally::election::{CandidateState, CountState, Election};
use opentally::numbers::{Number, Rational};
mod utils;
use opentally::election::Election;
use opentally::numbers::Rational;
use opentally::stv;
use csv::StringRecord;
use flate2::bufread::GzDecoder;
use std::fs::File;
use std::io::{self, BufRead};
use std::io::{self, BufRead};
#[test]
fn aec_tas19_rational() {
@ -38,6 +40,8 @@ fn aec_tas19_rational() {
// Remove exhausted/LBF rows
candidates.truncate(candidates.len() - 2);
// TODO: Validate candidate names
let stages: Vec<usize> = records.first().unwrap().iter().skip(1).step_by(2).map(|s| s.parse().unwrap()).collect();
// Decompress BLT
@ -50,11 +54,11 @@ fn aec_tas19_rational() {
// Read BLT
let election: Election<Rational> = Election::from_blt(lines.map(|r| r.expect("IO Error").to_string()).into_iter());
// TODO: Validate candidate names
// Initialise options
let stv_opts = stv::STVOptions {
round_tvs: None,
round_weights: None,
round_votes: Some(0),
round_quota: Some(0),
surplus: stv::SurplusMethod::UIG,
surplus_order: stv::SurplusOrder::ByOrder,
transferable_only: false,
@ -62,49 +66,5 @@ fn aec_tas19_rational() {
pp_decimals: 2,
};
// Initialise count state
let mut state = CountState::new(&election);
// Distribute first preferences
stv::count_init(&mut state, &stv_opts);
let mut stage_num = 1;
for (idx, stage) in stages.into_iter().enumerate() {
while stage_num < stage {
// Step through stages
// Assert count not yet done
assert_eq!(stv::count_one_stage(&mut state, &stv_opts), false);
stage_num += 1;
}
validate_stage(idx, &state, &records);
}
}
fn validate_stage<N: Number>(idx: usize, state: &CountState<N>, records: &Vec<StringRecord>) {
// Validate candidate votes
let mut candidate_votes: Vec<N> = records.iter().skip(2).map(|r| N::parse(&r[idx*2 + 1])).collect();
// Remove exhausted/LBF rows
candidate_votes.truncate(candidate_votes.len() - 2);
for (candidate, votes) in state.election.candidates.iter().zip(candidate_votes) {
let count_card = state.candidates.get(candidate).unwrap();
assert!(count_card.votes == votes);
}
// Validate candidate states
let mut candidate_states: Vec<&str> = records.iter().skip(2).map(|r| &r[idx*2 + 2]).collect();
candidate_states.truncate(candidate_states.len() - 2);
for (candidate, candidate_state) in state.election.candidates.iter().zip(candidate_states) {
let count_card = state.candidates.get(candidate).unwrap();
if candidate_state == "" {
assert!(count_card.state == CandidateState::HOPEFUL);
} else if candidate_state == "EL" || candidate_state == "PEL" {
assert!(count_card.state == CandidateState::ELECTED);
} else if candidate_state == "EX" || candidate_state == "EXCLUDING" {
assert!(count_card.state == CandidateState::EXCLUDED);
} else {
panic!("Unknown state descriptor {}", candidate_state);
}
}
utils::validate_election(stages, records, election, stv_opts);
}

75
tests/data/prsa1.blt Normal file
View File

@ -0,0 +1,75 @@
7 4
1 1 3 0
1 2 4 7 0
1 2 6 1 7 0
1 2 6 5 3 0
1 3 0
1 4 0
1 3 0
1 4 0
1 4 0
1 3 0
1 5 7 0
1 4 1 0
1 6 0
1 4 0
1 2 3 0
1 3 0
1 7 0
1 4 0
1 2 4 7 0
1 2 4 7 0
1 4 0
1 2 3 0
1 4 0
1 7 0
1 2 6 0
1 2 1 0
1 7 0
1 2 4 1 3 0
1 4 0
1 6 0
1 6 0
1 2 4 3 7 0
1 6 0
1 2 7 0
1 2 7 0
1 2 6 1 7 0
1 3 0
1 2 4 7 0
1 2 6 0
1 2 4 3 7 0
1 2 6 0
1 2 4 7 0
1 4 0
1 2 6 7 0
1 2 6 1 0
1 2 4 7 0
1 6 0
1 2 4 7 0
1 2 3 0
1 6 0
1 2 6 5 3 0
1 2 4 1 7 0
1 6 0
1 6 0
1 2 1 0
1 2 4 7 0
1 2 6 5 1 7 0
1 2 7 0
1 2 4 3 7 0
1 6 0
1 2 3 0
1 2 5 7 0
1 2 4 5 1 3 0
1 6 0
1 6 0
0
"Evans"
"Grey"
"Thomson"
"Ames"
"Reid"
"Spears"
"White"
"1976 Committee of the Utopia Tennis Club"

11
tests/data/prsa1.csv Normal file
View File

@ -0,0 +1,11 @@
Stage:,1,,2,,3,,4,,5,,6,,7,,8,,9,,10,,11,,12,,13,,14,,15,,16,,17,
Comment:,First preferences,,Surplus of Grey,,Surplus of Ames,,Surplus of Spears,,Exclusion of Reid,,Exclusion of Reid,,Exclusion of Reid,,Exclusion of Reid,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Evans,,Exclusion of Thomson,,Exclusion of Thomson,,Exclusion of Thomson,
Evans,1,,2.234,,3.038,,4.823,,4.823,,4.823,,5.225,,5.82,,4.82,EX,3.586,EX,2.782,EX,0.997,EX,0.595,EX,0,EX,0,EX,0,EX,0,EX
Grey,34,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL
Thomson,5,,7.468,,8.674,,8.674,,8.674,,8.674,,8.674,,9.864,,10.864,,10.864,,11.266,,11.266,,11.668,,11.668,,6.668,EX,4.2,EX,2.994,EX
Ames,10,,18.638,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL
Reid,1,,1.617,,2.019,,3.804,,2.804,EX,2.187,EX,1.785,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX,0,EX
Spears,11,,17.17,EL,17.17,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL,13.001,EL
White,3,,4.851,,8.067,,8.662,,9.662,,10.279,,10.279,,10.279,,10.279,,10.279,,10.681,,11.871,,11.871,,12.466,,12.466,,12.466,,13.672,EL
Exhausted,0,,0,,0,,0,,0,,0,,0,,0,,0,,1.234,,1.234,,1.829,,1.829,,1.829,,6.829,,9.297,,9.297,
Loss to fraction,0,,0.021,,0.03,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,,0.034,
1 Stage: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
2 Comment: First preferences Surplus of Grey Surplus of Ames Surplus of Spears Exclusion of Reid Exclusion of Reid Exclusion of Reid Exclusion of Reid Exclusion of Evans Exclusion of Evans Exclusion of Evans Exclusion of Evans Exclusion of Evans Exclusion of Evans Exclusion of Thomson Exclusion of Thomson Exclusion of Thomson
3 Evans 1 2.234 3.038 4.823 4.823 4.823 5.225 5.82 4.82 EX 3.586 EX 2.782 EX 0.997 EX 0.595 EX 0 EX 0 EX 0 EX 0 EX
4 Grey 34 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL
5 Thomson 5 7.468 8.674 8.674 8.674 8.674 8.674 9.864 10.864 10.864 11.266 11.266 11.668 11.668 6.668 EX 4.2 EX 2.994 EX
6 Ames 10 18.638 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL
7 Reid 1 1.617 2.019 3.804 2.804 EX 2.187 EX 1.785 EX 0 EX 0 EX 0 EX 0 EX 0 EX 0 EX 0 EX 0 EX 0 EX 0 EX
8 Spears 11 17.17 EL 17.17 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL 13.001 EL
9 White 3 4.851 8.067 8.662 9.662 10.279 10.279 10.279 10.279 10.279 10.681 11.871 11.871 12.466 12.466 12.466 13.672 EL
10 Exhausted 0 0 0 0 0 0 0 0 0 1.234 1.234 1.829 1.829 1.829 6.829 9.297 9.297
11 Loss to fraction 0 0.021 0.03 0.034 0.034 0.034 0.034 0.034 0.034 0.034 0.034 0.034 0.034 0.034 0.034 0.034 0.034

BIN
tests/data/prsa1.ods Normal file

Binary file not shown.

37
tests/prsa.rs Normal file
View File

@ -0,0 +1,37 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 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
* 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 <https://www.gnu.org/licenses/>.
*/
mod utils;
use opentally::numbers::Rational;
use opentally::stv;
#[test]
fn prsa1_rational() {
let stv_opts = stv::STVOptions {
round_tvs: Some(3),
round_weights: Some(3),
round_votes: Some(3),
round_quota: Some(3),
surplus: stv::SurplusMethod::EG,
surplus_order: stv::SurplusOrder::ByOrder,
transferable_only: true,
exclusion: stv::ExclusionMethod::ParcelsByOrder,
pp_decimals: 2,
};
utils::read_validate_election::<Rational>("tests/data/prsa1.csv", "tests/data/prsa1.blt", stv_opts);
}

110
tests/utils/mod.rs Normal file
View File

@ -0,0 +1,110 @@
/* OpenTally: Open-source election vote counting
* Copyright © 2021 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
* 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 <https://www.gnu.org/licenses/>.
*/
use opentally::election::{CandidateState, CountState, Election};
use opentally::numbers::{Number, Rational};
use opentally::stv;
use csv::StringRecord;
use std::fs::File;
use std::io::{self, BufRead};
use std::ops;
#[allow(dead_code)] // Suppress false positive
pub fn read_validate_election<N: Number>(csv_file: &str, blt_file: &str, stv_opts: stv::STVOptions) {
// Read CSV file
let reader = csv::ReaderBuilder::new()
.has_headers(false)
.from_path(csv_file)
.expect("IO Error");
let records: Vec<StringRecord> = reader.into_records().map(|r| r.expect("Syntax Error")).collect();
let mut candidates: Vec<&str> = records.iter().skip(2).map(|r| &r[0]).collect();
// Remove exhausted/LBF rows
candidates.truncate(candidates.len() - 2);
// TODO: Validate candidate names
let stages: Vec<usize> = records.first().unwrap().iter().skip(1).step_by(2).map(|s| s.parse().unwrap()).collect();
// Decompress BLT
let file = File::open(blt_file).expect("IO Error");
let file_reader = io::BufReader::new(file);
let lines = file_reader.lines();
// Read BLT
let election: Election<Rational> = Election::from_blt(lines.map(|r| r.expect("IO Error").to_string()).into_iter());
validate_election(stages, records, election, stv_opts);
}
pub fn validate_election<N: Number>(stages: Vec<usize>, records: Vec<StringRecord>, election: Election<N>, stv_opts: stv::STVOptions)
where
for<'r> &'r N: ops::Sub<&'r N, Output=N>,
for<'r> &'r N: ops::Div<&'r N, Output=N>,
for<'r> &'r N: ops::Neg<Output=N>,
{
// Initialise count state
let mut state = CountState::new(&election);
// Distribute first preferences
stv::count_init(&mut state, &stv_opts);
let mut stage_num = 1;
for (idx, stage) in stages.into_iter().enumerate() {
while stage_num < stage {
// Step through stages
// Assert count not yet done
assert_eq!(stv::count_one_stage(&mut state, &stv_opts), false);
stage_num += 1;
}
validate_stage(idx, &state, &records);
}
}
fn validate_stage<N: Number>(idx: usize, state: &CountState<N>, records: &Vec<StringRecord>) {
println!("Col at idx {}", idx);
// Validate candidate votes
//let mut candidate_votes: Vec<N> = records.iter().skip(2).map(|r| N::parse(&r[idx*2 + 1])).collect();
let mut candidate_votes: Vec<N> = records.iter().skip(2).map(|r| N::from(r[idx*2 + 1].parse::<f64>().expect("Syntax Error"))).collect();
// Remove exhausted/LBF rows
candidate_votes.truncate(candidate_votes.len() - 2);
for (candidate, votes) in state.election.candidates.iter().zip(candidate_votes) {
let count_card = state.candidates.get(candidate).unwrap();
assert!(count_card.votes == votes, "Failed to validate votes for candidate {}. Expected {:}, got {:}", candidate.name, votes, count_card.votes);
}
// Validate candidate states
let mut candidate_states: Vec<&str> = records.iter().skip(2).map(|r| &r[idx*2 + 2]).collect();
candidate_states.truncate(candidate_states.len() - 2);
for (candidate, candidate_state) in state.election.candidates.iter().zip(candidate_states) {
let count_card = state.candidates.get(candidate).unwrap();
if candidate_state == "" {
assert!(count_card.state == CandidateState::HOPEFUL);
} else if candidate_state == "EL" || candidate_state == "PEL" {
assert!(count_card.state == CandidateState::ELECTED);
} else if candidate_state == "EX" || candidate_state == "EXCLUDING" {
assert!(count_card.state == CandidateState::EXCLUDED);
} else {
panic!("Unknown state descriptor {}", candidate_state);
}
}
}