Add subcommand for BLT/CSP file conversion
This commit is contained in:
parent
e7bae376e9
commit
88ab06d633
120
src/cli/convert.rs
Normal file
120
src/cli/convert.rs
Normal file
@ -0,0 +1,120 @@
|
||||
/* 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 crate::election::Election;
|
||||
use crate::numbers::Rational;
|
||||
use crate::parser;
|
||||
use crate::writer;
|
||||
|
||||
use clap::{AppSettings, Clap};
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
/// Convert between different ballot data formats
|
||||
#[derive(Clap)]
|
||||
#[clap(setting=AppSettings::DeriveDisplayOrder)]
|
||||
pub struct SubcmdOptions {
|
||||
/// Path to the input data file
|
||||
#[clap(help_heading=Some("INPUT/OUTPUT"))]
|
||||
infile: String,
|
||||
|
||||
/// Path to the output data file
|
||||
#[clap(help_heading=Some("INPUT/OUTPUT"))]
|
||||
outfile: String,
|
||||
|
||||
/// Format of input file
|
||||
#[clap(help_heading=Some("INPUT/OUTPUT"), short, long, possible_values=&["blt", "csp"], value_name="format")]
|
||||
r#in: Option<String>,
|
||||
|
||||
/// Format of output file
|
||||
#[clap(help_heading=Some("INPUT/OUTPUT"), short, long, possible_values=&["blt", "csp"], value_name="format")]
|
||||
out: Option<String>,
|
||||
|
||||
/// Number of seats
|
||||
#[clap(help_heading=Some("ELECTION SPECIFICATION"), long)]
|
||||
seats: Option<usize>,
|
||||
}
|
||||
|
||||
/// Entrypoint for subcommand
|
||||
pub fn main(mut cmd_opts: SubcmdOptions) -> Result<(), i32> {
|
||||
// Auto-detect input/output formats
|
||||
if cmd_opts.r#in == None {
|
||||
if cmd_opts.infile.ends_with(".blt") {
|
||||
cmd_opts.r#in = Some("blt".to_string());
|
||||
} else if cmd_opts.infile.ends_with(".csp") {
|
||||
cmd_opts.r#in = Some("csp".to_string());
|
||||
} else {
|
||||
println!("Error: --in not specified and format cannot be determined from input filename");
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
if cmd_opts.out == None {
|
||||
if cmd_opts.outfile.ends_with(".blt") {
|
||||
cmd_opts.out = Some("blt".to_string());
|
||||
} else if cmd_opts.outfile.ends_with(".csp") {
|
||||
cmd_opts.out = Some("csp".to_string());
|
||||
} else {
|
||||
println!("Error: --out not specified and format cannot be determined from output filename");
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Read input file
|
||||
let election: Election<Rational>;
|
||||
|
||||
match cmd_opts.r#in.as_deref().unwrap() {
|
||||
"blt" => {
|
||||
match parser::blt::parse_path(cmd_opts.infile) {
|
||||
Ok(e) => {
|
||||
election = e;
|
||||
}
|
||||
Err(err) => {
|
||||
println!("Syntax Error: {}", err);
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
"csp" => {
|
||||
match cmd_opts.seats {
|
||||
Some(seats) => {
|
||||
let file = File::open(cmd_opts.infile).expect("IO Error");
|
||||
election = parser::csp::parse_reader(file, seats);
|
||||
}
|
||||
None => {
|
||||
println!("Error: --seats must be specified with CSP input");
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => unreachable!()
|
||||
};
|
||||
|
||||
// Write output file
|
||||
let output = File::create(cmd_opts.outfile).expect("IO Error");
|
||||
|
||||
match cmd_opts.out.as_deref().unwrap() {
|
||||
"blt" => {
|
||||
writer::blt::write(election, output);
|
||||
}
|
||||
"csp" => {
|
||||
writer::csp::write(election, output);
|
||||
}
|
||||
_ => unreachable!()
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
21
src/cli/mod.rs
Normal file
21
src/cli/mod.rs
Normal file
@ -0,0 +1,21 @@
|
||||
/* 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/>.
|
||||
*/
|
||||
|
||||
/// Convert between different ballot data formats
|
||||
pub mod convert;
|
||||
/// Count a single transferable vote (STV) election
|
||||
pub mod stv;
|
369
src/cli/stv.rs
Normal file
369
src/cli/stv.rs
Normal file
@ -0,0 +1,369 @@
|
||||
/* 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 crate::constraints::Constraints;
|
||||
use crate::election::{CandidateState, CountState, Election};
|
||||
use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational};
|
||||
use crate::parser::blt;
|
||||
use crate::stv::{self, STVOptions};
|
||||
use crate::ties;
|
||||
|
||||
use clap::{AppSettings, Clap};
|
||||
|
||||
use std::cmp::max;
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufRead};
|
||||
use std::ops;
|
||||
|
||||
/// Count a single transferable vote (STV) election
|
||||
#[derive(Clap)]
|
||||
#[clap(setting=AppSettings::DeriveDisplayOrder)]
|
||||
pub struct SubcmdOptions {
|
||||
// ----------------
|
||||
// -- File input --
|
||||
|
||||
/// Path to the BLT file to be counted
|
||||
filename: String,
|
||||
|
||||
// ----------------------
|
||||
// -- Numbers settings --
|
||||
|
||||
/// Numbers mode
|
||||
#[clap(help_heading=Some("NUMBERS"), short, long, possible_values=&["rational", "fixed", "gfixed", "float64"], default_value="rational", value_name="mode")]
|
||||
numbers: String,
|
||||
|
||||
/// Decimal places if --numbers fixed
|
||||
#[clap(help_heading=Some("NUMBERS"), long, default_value="5", value_name="dps")]
|
||||
decimals: usize,
|
||||
|
||||
/// Convert ballots with value >1 to multiple ballots of value 1
|
||||
#[clap(help_heading=Some("NUMBERS"), long)]
|
||||
normalise_ballots: bool,
|
||||
|
||||
// -----------------------
|
||||
// -- Rounding settings --
|
||||
|
||||
/// Round surplus fractions to specified decimal places
|
||||
#[clap(help_heading=Some("ROUNDING"), long, alias="round-tvs", value_name="dps")]
|
||||
round_surplus_fractions: Option<usize>,
|
||||
|
||||
/// Round ballot values to specified decimal places
|
||||
#[clap(help_heading=Some("ROUNDING"), long, alias="round-weights", value_name="dps")]
|
||||
round_values: 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>,
|
||||
|
||||
/// (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,
|
||||
|
||||
/// (Meek STV) Limit for stopping iteration of surplus distribution
|
||||
#[clap(help_heading=Some("ROUNDING"), long, default_value="0.001%", value_name="tolerance")]
|
||||
meek_surplus_tolerance: String,
|
||||
|
||||
// -----------
|
||||
// -- Quota --
|
||||
|
||||
/// Quota type
|
||||
#[clap(help_heading=Some("QUOTA"), short, long, possible_values=&["droop", "hare", "droop_exact", "hare_exact"], default_value="droop")]
|
||||
quota: String,
|
||||
|
||||
/// Whether to elect candidates on meeting (geq) or strictly exceeding (gt) the quota
|
||||
#[clap(help_heading=Some("QUOTA"), short='c', long, possible_values=&["geq", "gt"], default_value="gt", value_name="criterion")]
|
||||
quota_criterion: String,
|
||||
|
||||
/// Whether to apply a form of progressive quota
|
||||
#[clap(help_heading=Some("QUOTA"), long, possible_values=&["static", "ers97", "ers76", "dynamic_by_total", "dynamic_by_active"], default_value="static", value_name="mode")]
|
||||
quota_mode: String,
|
||||
|
||||
// ------------------
|
||||
// -- STV variants --
|
||||
|
||||
/// Tie-breaking method
|
||||
#[clap(help_heading=Some("STV VARIANTS"), short='t', long, possible_values=&["forwards", "backwards", "random", "prompt"], default_value="prompt", value_name="methods")]
|
||||
ties: Vec<String>,
|
||||
|
||||
/// Random seed to use with --ties random
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long, value_name="seed")]
|
||||
random_seed: Option<String>,
|
||||
|
||||
/// Method of surplus distributions
|
||||
#[clap(help_heading=Some("STV VARIANTS"), short='s', long, possible_values=&["wig", "uig", "eg", "meek", "cincinnati", "hare"], default_value="wig", value_name="method")]
|
||||
surplus: String,
|
||||
|
||||
/// (Gregory STV) Order to distribute surpluses
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["by_size", "by_order"], default_value="by_size", value_name="order")]
|
||||
surplus_order: String,
|
||||
|
||||
/// (Gregory STV) Examine only transferable papers during surplus distributions
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long)]
|
||||
transferable_only: bool,
|
||||
|
||||
/// (Gregory STV) Method of exclusions
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["single_stage", "by_value", "by_source", "parcels_by_order", "wright"], default_value="single_stage", value_name="method")]
|
||||
exclusion: String,
|
||||
|
||||
/// (Meek STV) NZ Meek STV behaviour: Iterate keep values one round before candidate exclusion
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long)]
|
||||
meek_nz_exclusion: bool,
|
||||
|
||||
/// (Cincinnati/Hare) Method of drawing a sample
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["stratified", "by_order", "nth_ballot"], default_value="stratified", value_name="method")]
|
||||
sample: String,
|
||||
|
||||
/// (Cincinnati/Hare) Sample-based methods: Check for candidate election after each individual ballot paper transfer
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long)]
|
||||
sample_per_ballot: bool,
|
||||
|
||||
// -------------------------
|
||||
// -- Count optimisations --
|
||||
|
||||
/// Continue count even if continuing candidates fill all remaining vacancies
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
||||
no_early_bulk_elect: bool,
|
||||
|
||||
/// Use bulk exclusion
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
||||
bulk_exclude: bool,
|
||||
|
||||
/// Defer surplus distributions if possible
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
||||
defer_surpluses: bool,
|
||||
|
||||
/// Elect candidates only when their surpluses are distributed; (Meek STV) Wait for keep values to converge before electing candidates
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
||||
no_immediate_elect: bool,
|
||||
|
||||
/// On exclusion, exclude any candidate with fewer than this many votes
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long, default_value="0", value_name="votes")]
|
||||
min_threshold: String,
|
||||
|
||||
// -----------------
|
||||
// -- Constraints --
|
||||
|
||||
/// Path to a CON file specifying constraints
|
||||
#[clap(help_heading=Some("CONSTRAINTS"), long)]
|
||||
constraints: Option<String>,
|
||||
|
||||
/// Mode of handling constraints
|
||||
#[clap(help_heading=Some("CONSTRAINTS"), long, possible_values=&["guard_doom"], default_value="guard_doom")]
|
||||
constraint_mode: String,
|
||||
|
||||
// ----------------------
|
||||
// -- Display settings --
|
||||
|
||||
/// Hide excluded candidates from results report
|
||||
#[clap(help_heading=Some("DISPLAY"), long)]
|
||||
hide_excluded: bool,
|
||||
|
||||
/// Sort candidates by votes in results report
|
||||
#[clap(help_heading=Some("DISPLAY"), long)]
|
||||
sort_votes: bool,
|
||||
|
||||
/// Print votes to specified decimal places in results report
|
||||
#[clap(help_heading=Some("DISPLAY"), long, default_value="2", value_name="dps")]
|
||||
pp_decimals: usize,
|
||||
}
|
||||
|
||||
/// Entrypoint for subcommand
|
||||
pub fn main(cmd_opts: SubcmdOptions) -> Result<(), i32> {
|
||||
// Read and count election according to --numbers
|
||||
if cmd_opts.numbers == "rational" {
|
||||
let mut election = election_from_file(&cmd_opts.filename)?;
|
||||
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
||||
|
||||
// Must specify ::<N> here and in a few other places because ndarray causes E0275 otherwise
|
||||
count_election::<Rational>(election, cmd_opts)?;
|
||||
} else if cmd_opts.numbers == "float64" {
|
||||
let mut election = election_from_file(&cmd_opts.filename)?;
|
||||
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
||||
count_election::<NativeFloat64>(election, cmd_opts)?;
|
||||
} else if cmd_opts.numbers == "fixed" {
|
||||
Fixed::set_dps(cmd_opts.decimals);
|
||||
|
||||
let mut election = election_from_file(&cmd_opts.filename)?;
|
||||
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
||||
count_election::<Fixed>(election, cmd_opts)?;
|
||||
} else if cmd_opts.numbers == "gfixed" {
|
||||
GuardedFixed::set_dps(cmd_opts.decimals);
|
||||
|
||||
let mut election = election_from_file(&cmd_opts.filename)?;
|
||||
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
||||
count_election::<GuardedFixed>(election, cmd_opts)?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fn election_from_file<N: Number>(path: &str) -> Result<Election<N>, i32> {
|
||||
match blt::parse_path(path) {
|
||||
Ok(e) => return Ok(e),
|
||||
Err(err) => {
|
||||
println!("Syntax Error: {}", err);
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_load_constraints<N: Number>(election: &mut Election<N>, constraints: &Option<String>) {
|
||||
if let Some(c) = constraints {
|
||||
let file = File::open(c).expect("IO Error");
|
||||
let lines = io::BufReader::new(file).lines();
|
||||
election.constraints = Some(Constraints::from_con(lines.map(|r| r.expect("IO Error").to_string()).into_iter()));
|
||||
}
|
||||
}
|
||||
|
||||
fn count_election<N: Number>(mut election: Election<N>, cmd_opts: SubcmdOptions) -> Result<(), i32>
|
||||
where
|
||||
for<'r> &'r N: ops::Add<&'r N, Output=N>,
|
||||
for<'r> &'r N: ops::Sub<&'r N, Output=N>,
|
||||
for<'r> &'r N: ops::Mul<&'r N, Output=N>,
|
||||
for<'r> &'r N: ops::Div<&'r N, Output=N>,
|
||||
for<'r> &'r N: ops::Neg<Output=N>
|
||||
{
|
||||
// Copy applicable options
|
||||
let opts = STVOptions::new(
|
||||
cmd_opts.round_surplus_fractions,
|
||||
cmd_opts.round_values,
|
||||
cmd_opts.round_votes,
|
||||
cmd_opts.round_quota,
|
||||
cmd_opts.sum_surplus_transfers.into(),
|
||||
cmd_opts.meek_surplus_tolerance.into(),
|
||||
cmd_opts.normalise_ballots,
|
||||
cmd_opts.quota.into(),
|
||||
cmd_opts.quota_criterion.into(),
|
||||
cmd_opts.quota_mode.into(),
|
||||
ties::from_strs(cmd_opts.ties, cmd_opts.random_seed),
|
||||
cmd_opts.surplus.into(),
|
||||
cmd_opts.surplus_order.into(),
|
||||
cmd_opts.transferable_only,
|
||||
cmd_opts.exclusion.into(),
|
||||
cmd_opts.meek_nz_exclusion,
|
||||
cmd_opts.sample.into(),
|
||||
cmd_opts.sample_per_ballot,
|
||||
!cmd_opts.no_early_bulk_elect,
|
||||
cmd_opts.bulk_exclude,
|
||||
cmd_opts.defer_surpluses,
|
||||
!cmd_opts.no_immediate_elect,
|
||||
cmd_opts.min_threshold,
|
||||
cmd_opts.constraints,
|
||||
cmd_opts.constraint_mode.into(),
|
||||
cmd_opts.hide_excluded,
|
||||
cmd_opts.sort_votes,
|
||||
cmd_opts.pp_decimals,
|
||||
);
|
||||
|
||||
// Validate options
|
||||
match opts.validate() {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
println!("Error: {}", err.describe());
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Describe count
|
||||
let total_ballots = election.ballots.iter().fold(N::zero(), |acc, b| { acc + &b.orig_value });
|
||||
print!("Count computed by OpenTally (revision {}). Read {:.0} ballots from \"{}\" for election \"{}\". There are {} candidates for {} vacancies. ", crate::VERSION, total_ballots, cmd_opts.filename, election.name, election.candidates.len(), election.seats);
|
||||
let opts_str = opts.describe::<N>();
|
||||
if opts_str.len() > 0 {
|
||||
println!("Counting using options \"{}\".", opts_str);
|
||||
} else {
|
||||
println!("Counting using default options.");
|
||||
}
|
||||
println!();
|
||||
|
||||
// Normalise ballots if requested
|
||||
if cmd_opts.normalise_ballots {
|
||||
election.normalise_ballots();
|
||||
}
|
||||
|
||||
// Initialise count state
|
||||
let mut state = CountState::new(&election);
|
||||
|
||||
// Distribute first preferences
|
||||
match stv::count_init(&mut state, &opts) {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
println!("Error: {}", err.describe());
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
|
||||
let mut stage_num = 1;
|
||||
print_stage(stage_num, &state, &opts);
|
||||
|
||||
loop {
|
||||
match stv::count_one_stage(&mut state, &opts) {
|
||||
Ok(is_done) => {
|
||||
if is_done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
println!("Error: {}", err.describe());
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
|
||||
stage_num += 1;
|
||||
print_stage(stage_num, &state, &opts);
|
||||
}
|
||||
|
||||
println!("Count complete. The winning candidates are, in order of election:");
|
||||
|
||||
let mut winners = Vec::new();
|
||||
for (candidate, count_card) in state.candidates.iter() {
|
||||
if count_card.state == CandidateState::Elected {
|
||||
winners.push((candidate, count_card));
|
||||
}
|
||||
}
|
||||
winners.sort_unstable_by(|a, b| a.1.order_elected.cmp(&b.1.order_elected));
|
||||
|
||||
for (i, (winner, count_card)) in winners.into_iter().enumerate() {
|
||||
if let Some(kv) = &count_card.keep_value {
|
||||
println!("{}. {} (kv = {:.dps2$})", i + 1, winner.name, kv, dps2=max(opts.pp_decimals, 2));
|
||||
} else {
|
||||
println!("{}. {}", i + 1, winner.name);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fn print_stage<N: Number>(stage_num: usize, state: &CountState<N>, opts: &STVOptions) {
|
||||
// Print stage details
|
||||
match state.kind {
|
||||
None => { println!("{}. {}", stage_num, state.title); }
|
||||
Some(kind) => { println!("{}. {} {}", stage_num, kind, state.title); }
|
||||
};
|
||||
println!("{}", state.logger.render().join(" "));
|
||||
|
||||
// Print candidates
|
||||
print!("{}", state.describe_candidates(opts));
|
||||
|
||||
// Print summary rows
|
||||
print!("{}", state.describe_summary(opts));
|
||||
|
||||
println!("");
|
||||
}
|
@ -18,22 +18,11 @@
|
||||
use crate::constraints::{Constraints, ConstraintMatrix};
|
||||
use crate::logger::Logger;
|
||||
use crate::numbers::Number;
|
||||
use crate::parser::blt::{BLTParser, ParseError};
|
||||
use crate::sharandom::SHARandom;
|
||||
use crate::stv::{QuotaMode, STVOptions};
|
||||
|
||||
use std::cmp::max;
|
||||
use std::collections::HashMap;
|
||||
use std::iter::Peekable;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use utf8_chars::BufReadCharsExt;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::fs::File;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::BufReader;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::Path;
|
||||
|
||||
/// An election to be counted
|
||||
pub struct Election<N> {
|
||||
@ -52,21 +41,6 @@ pub struct Election<N> {
|
||||
}
|
||||
|
||||
impl<N: Number> Election<N> {
|
||||
/// Parse the given BLT file and return an [Election]
|
||||
pub fn from_blt<I: Iterator<Item=char>>(input: Peekable<I>) -> Result<Self, ParseError> {
|
||||
let mut parser = BLTParser::new(input);
|
||||
parser.parse_blt()?;
|
||||
return Ok(parser.as_election());
|
||||
}
|
||||
|
||||
/// Parse the BLT file at the given path and return an [Election]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ParseError> {
|
||||
let mut reader = BufReader::new(File::open(path).expect("IO Error"));
|
||||
let chars = reader.chars().map(|r| r.expect("IO Error")).peekable();
|
||||
return Ok(Election::from_blt(chars)?);
|
||||
}
|
||||
|
||||
/// Convert ballots with weight >1 to multiple ballots of weight 1
|
||||
pub fn normalise_ballots(&mut self) {
|
||||
let mut normalised_ballots = Vec::new();
|
||||
|
11
src/lib.rs
11
src/lib.rs
@ -19,8 +19,6 @@
|
||||
|
||||
//! Open source counting software for various preferential voting election systems
|
||||
|
||||
/// File parsers
|
||||
pub mod parser;
|
||||
/// Data types and logic for constraints on elections
|
||||
pub mod constraints;
|
||||
/// Data types for representing abstract elections
|
||||
@ -29,12 +27,21 @@ pub mod election;
|
||||
pub mod logger;
|
||||
/// Implementations of different numeric representations
|
||||
pub mod numbers;
|
||||
/// File parsers
|
||||
pub mod parser;
|
||||
/// Deterministic random number generation using SHA256
|
||||
pub mod sharandom;
|
||||
/// STV counting logic
|
||||
pub mod stv;
|
||||
/// Tie-breaking methods
|
||||
pub mod ties;
|
||||
/// File writers
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod writer;
|
||||
|
||||
/// CLI implementations
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod cli;
|
||||
|
||||
use git_version::git_version;
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
|
354
src/main.rs
354
src/main.rs
@ -15,18 +15,9 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use opentally::constraints::Constraints;
|
||||
use opentally::election::{CandidateState, CountState, Election};
|
||||
use opentally::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational};
|
||||
use opentally::stv::{self, STVOptions};
|
||||
use opentally::ties;
|
||||
use opentally::cli;
|
||||
|
||||
use clap::{AppSettings, Clap};
|
||||
|
||||
use std::cmp::max;
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufRead};
|
||||
use std::ops;
|
||||
use clap::Clap;
|
||||
|
||||
/// Open-source election vote counting
|
||||
#[derive(Clap)]
|
||||
@ -38,163 +29,8 @@ struct Opts {
|
||||
|
||||
#[derive(Clap)]
|
||||
enum Command {
|
||||
STV(STV),
|
||||
}
|
||||
|
||||
/// Count a single transferable vote (STV) election
|
||||
#[derive(Clap)]
|
||||
#[clap(setting=AppSettings::DeriveDisplayOrder)]
|
||||
struct STV {
|
||||
// ----------------
|
||||
// -- File input --
|
||||
|
||||
/// Path to the BLT file to be counted
|
||||
filename: String,
|
||||
|
||||
// ----------------------
|
||||
// -- Numbers settings --
|
||||
|
||||
/// Numbers mode
|
||||
#[clap(help_heading=Some("NUMBERS"), short, long, possible_values=&["rational", "fixed", "gfixed", "float64"], default_value="rational", value_name="mode")]
|
||||
numbers: String,
|
||||
|
||||
/// Decimal places if --numbers fixed
|
||||
#[clap(help_heading=Some("NUMBERS"), long, default_value="5", value_name="dps")]
|
||||
decimals: usize,
|
||||
|
||||
/// Convert ballots with value >1 to multiple ballots of value 1
|
||||
#[clap(help_heading=Some("NUMBERS"), long)]
|
||||
normalise_ballots: bool,
|
||||
|
||||
// -----------------------
|
||||
// -- Rounding settings --
|
||||
|
||||
/// Round surplus fractions to specified decimal places
|
||||
#[clap(help_heading=Some("ROUNDING"), long, alias="round-tvs", value_name="dps")]
|
||||
round_surplus_fractions: Option<usize>,
|
||||
|
||||
/// Round ballot values to specified decimal places
|
||||
#[clap(help_heading=Some("ROUNDING"), long, alias="round-weights", value_name="dps")]
|
||||
round_values: 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>,
|
||||
|
||||
/// (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,
|
||||
|
||||
/// (Meek STV) Limit for stopping iteration of surplus distribution
|
||||
#[clap(help_heading=Some("ROUNDING"), long, default_value="0.001%", value_name="tolerance")]
|
||||
meek_surplus_tolerance: String,
|
||||
|
||||
// -----------
|
||||
// -- Quota --
|
||||
|
||||
/// Quota type
|
||||
#[clap(help_heading=Some("QUOTA"), short, long, possible_values=&["droop", "hare", "droop_exact", "hare_exact"], default_value="droop")]
|
||||
quota: String,
|
||||
|
||||
/// Whether to elect candidates on meeting (geq) or strictly exceeding (gt) the quota
|
||||
#[clap(help_heading=Some("QUOTA"), short='c', long, possible_values=&["geq", "gt"], default_value="gt", value_name="criterion")]
|
||||
quota_criterion: String,
|
||||
|
||||
/// Whether to apply a form of progressive quota
|
||||
#[clap(help_heading=Some("QUOTA"), long, possible_values=&["static", "ers97", "ers76", "dynamic_by_total", "dynamic_by_active"], default_value="static", value_name="mode")]
|
||||
quota_mode: String,
|
||||
|
||||
// ------------------
|
||||
// -- STV variants --
|
||||
|
||||
/// Tie-breaking method
|
||||
#[clap(help_heading=Some("STV VARIANTS"), short='t', long, possible_values=&["forwards", "backwards", "random", "prompt"], default_value="prompt", value_name="methods")]
|
||||
ties: Vec<String>,
|
||||
|
||||
/// Random seed to use with --ties random
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long, value_name="seed")]
|
||||
random_seed: Option<String>,
|
||||
|
||||
/// Method of surplus distributions
|
||||
#[clap(help_heading=Some("STV VARIANTS"), short='s', long, possible_values=&["wig", "uig", "eg", "meek", "cincinnati", "hare"], default_value="wig", value_name="method")]
|
||||
surplus: String,
|
||||
|
||||
/// (Gregory STV) Order to distribute surpluses
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["by_size", "by_order"], default_value="by_size", value_name="order")]
|
||||
surplus_order: String,
|
||||
|
||||
/// (Gregory STV) Examine only transferable papers during surplus distributions
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long)]
|
||||
transferable_only: bool,
|
||||
|
||||
/// (Gregory STV) Method of exclusions
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["single_stage", "by_value", "by_source", "parcels_by_order", "wright"], default_value="single_stage", value_name="method")]
|
||||
exclusion: String,
|
||||
|
||||
/// (Meek STV) NZ Meek STV behaviour: Iterate keep values one round before candidate exclusion
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long)]
|
||||
meek_nz_exclusion: bool,
|
||||
|
||||
/// (Cincinnati/Hare) Method of drawing a sample
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long, possible_values=&["stratified", "by_order", "nth_ballot"], default_value="stratified", value_name="method")]
|
||||
sample: String,
|
||||
|
||||
/// (Cincinnati/Hare) Sample-based methods: Check for candidate election after each individual ballot paper transfer
|
||||
#[clap(help_heading=Some("STV VARIANTS"), long)]
|
||||
sample_per_ballot: bool,
|
||||
|
||||
// -------------------------
|
||||
// -- Count optimisations --
|
||||
|
||||
/// Continue count even if continuing candidates fill all remaining vacancies
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
||||
no_early_bulk_elect: bool,
|
||||
|
||||
/// Use bulk exclusion
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
||||
bulk_exclude: bool,
|
||||
|
||||
/// Defer surplus distributions if possible
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
||||
defer_surpluses: bool,
|
||||
|
||||
/// Elect candidates only when their surpluses are distributed; (Meek STV) Wait for keep values to converge before electing candidates
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long)]
|
||||
no_immediate_elect: bool,
|
||||
|
||||
/// On exclusion, exclude any candidate with fewer than this many votes
|
||||
#[clap(help_heading=Some("COUNT OPTIMISATIONS"), long, default_value="0", value_name="votes")]
|
||||
min_threshold: String,
|
||||
|
||||
// -----------------
|
||||
// -- Constraints --
|
||||
|
||||
/// Path to a CON file specifying constraints
|
||||
#[clap(help_heading=Some("CONSTRAINTS"), long)]
|
||||
constraints: Option<String>,
|
||||
|
||||
/// Mode of handling constraints
|
||||
#[clap(help_heading=Some("CONSTRAINTS"), long, possible_values=&["guard_doom"], default_value="guard_doom")]
|
||||
constraint_mode: String,
|
||||
|
||||
// ----------------------
|
||||
// -- Display settings --
|
||||
|
||||
/// Hide excluded candidates from results report
|
||||
#[clap(help_heading=Some("DISPLAY"), long)]
|
||||
hide_excluded: bool,
|
||||
|
||||
/// Sort candidates by votes in results report
|
||||
#[clap(help_heading=Some("DISPLAY"), long)]
|
||||
sort_votes: bool,
|
||||
|
||||
/// Print votes to specified decimal places in results report
|
||||
#[clap(help_heading=Some("DISPLAY"), long, default_value="2", value_name="dps")]
|
||||
pp_decimals: usize,
|
||||
Convert(cli::convert::SubcmdOptions),
|
||||
STV(cli::stv::SubcmdOptions),
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@ -209,185 +45,9 @@ fn main() {
|
||||
fn main_() -> Result<(), i32> {
|
||||
// Read arguments
|
||||
let opts: Opts = Opts::parse();
|
||||
let Command::STV(cmd_opts) = opts.command;
|
||||
|
||||
// Read and count election according to --numbers
|
||||
if cmd_opts.numbers == "rational" {
|
||||
let mut election = election_from_file(&cmd_opts.filename)?;
|
||||
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
||||
|
||||
// Must specify ::<N> here and in a few other places because ndarray causes E0275 otherwise
|
||||
count_election::<Rational>(election, cmd_opts)?;
|
||||
} else if cmd_opts.numbers == "float64" {
|
||||
let mut election = election_from_file(&cmd_opts.filename)?;
|
||||
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
||||
count_election::<NativeFloat64>(election, cmd_opts)?;
|
||||
} else if cmd_opts.numbers == "fixed" {
|
||||
Fixed::set_dps(cmd_opts.decimals);
|
||||
|
||||
let mut election = election_from_file(&cmd_opts.filename)?;
|
||||
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
||||
count_election::<Fixed>(election, cmd_opts)?;
|
||||
} else if cmd_opts.numbers == "gfixed" {
|
||||
GuardedFixed::set_dps(cmd_opts.decimals);
|
||||
|
||||
let mut election = election_from_file(&cmd_opts.filename)?;
|
||||
maybe_load_constraints(&mut election, &cmd_opts.constraints);
|
||||
count_election::<GuardedFixed>(election, cmd_opts)?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fn election_from_file<N: Number>(path: &str) -> Result<Election<N>, i32> {
|
||||
match Election::from_file(path) {
|
||||
Ok(e) => return Ok(e),
|
||||
Err(err) => {
|
||||
println!("Syntax Error: {}", err);
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_load_constraints<N: Number>(election: &mut Election<N>, constraints: &Option<String>) {
|
||||
if let Some(c) = constraints {
|
||||
let file = File::open(c).expect("IO Error");
|
||||
let lines = io::BufReader::new(file).lines();
|
||||
election.constraints = Some(Constraints::from_con(lines.map(|r| r.expect("IO Error").to_string()).into_iter()));
|
||||
}
|
||||
}
|
||||
|
||||
fn count_election<N: Number>(mut election: Election<N>, cmd_opts: STV) -> Result<(), i32>
|
||||
where
|
||||
for<'r> &'r N: ops::Add<&'r N, Output=N>,
|
||||
for<'r> &'r N: ops::Sub<&'r N, Output=N>,
|
||||
for<'r> &'r N: ops::Mul<&'r N, Output=N>,
|
||||
for<'r> &'r N: ops::Div<&'r N, Output=N>,
|
||||
for<'r> &'r N: ops::Neg<Output=N>
|
||||
{
|
||||
// Copy applicable options
|
||||
let opts = STVOptions::new(
|
||||
cmd_opts.round_surplus_fractions,
|
||||
cmd_opts.round_values,
|
||||
cmd_opts.round_votes,
|
||||
cmd_opts.round_quota,
|
||||
cmd_opts.sum_surplus_transfers.into(),
|
||||
cmd_opts.meek_surplus_tolerance.into(),
|
||||
cmd_opts.normalise_ballots,
|
||||
cmd_opts.quota.into(),
|
||||
cmd_opts.quota_criterion.into(),
|
||||
cmd_opts.quota_mode.into(),
|
||||
ties::from_strs(cmd_opts.ties, cmd_opts.random_seed),
|
||||
cmd_opts.surplus.into(),
|
||||
cmd_opts.surplus_order.into(),
|
||||
cmd_opts.transferable_only,
|
||||
cmd_opts.exclusion.into(),
|
||||
cmd_opts.meek_nz_exclusion,
|
||||
cmd_opts.sample.into(),
|
||||
cmd_opts.sample_per_ballot,
|
||||
!cmd_opts.no_early_bulk_elect,
|
||||
cmd_opts.bulk_exclude,
|
||||
cmd_opts.defer_surpluses,
|
||||
!cmd_opts.no_immediate_elect,
|
||||
cmd_opts.min_threshold,
|
||||
cmd_opts.constraints,
|
||||
cmd_opts.constraint_mode.into(),
|
||||
cmd_opts.hide_excluded,
|
||||
cmd_opts.sort_votes,
|
||||
cmd_opts.pp_decimals,
|
||||
);
|
||||
|
||||
// Validate options
|
||||
match opts.validate() {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
println!("Error: {}", err.describe());
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Describe count
|
||||
let total_ballots = election.ballots.iter().fold(N::zero(), |acc, b| { acc + &b.orig_value });
|
||||
print!("Count computed by OpenTally (revision {}). Read {:.0} ballots from \"{}\" for election \"{}\". There are {} candidates for {} vacancies. ", opentally::VERSION, total_ballots, cmd_opts.filename, election.name, election.candidates.len(), election.seats);
|
||||
let opts_str = opts.describe::<N>();
|
||||
if opts_str.len() > 0 {
|
||||
println!("Counting using options \"{}\".", opts_str);
|
||||
} else {
|
||||
println!("Counting using default options.");
|
||||
}
|
||||
println!();
|
||||
|
||||
// Normalise ballots if requested
|
||||
if cmd_opts.normalise_ballots {
|
||||
election.normalise_ballots();
|
||||
}
|
||||
|
||||
// Initialise count state
|
||||
let mut state = CountState::new(&election);
|
||||
|
||||
// Distribute first preferences
|
||||
match stv::count_init(&mut state, &opts) {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
println!("Error: {}", err.describe());
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
|
||||
let mut stage_num = 1;
|
||||
print_stage(stage_num, &state, &opts);
|
||||
|
||||
loop {
|
||||
match stv::count_one_stage(&mut state, &opts) {
|
||||
Ok(is_done) => {
|
||||
if is_done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
println!("Error: {}", err.describe());
|
||||
return Err(1);
|
||||
}
|
||||
}
|
||||
|
||||
stage_num += 1;
|
||||
print_stage(stage_num, &state, &opts);
|
||||
}
|
||||
|
||||
println!("Count complete. The winning candidates are, in order of election:");
|
||||
|
||||
let mut winners = Vec::new();
|
||||
for (candidate, count_card) in state.candidates.iter() {
|
||||
if count_card.state == CandidateState::Elected {
|
||||
winners.push((candidate, count_card));
|
||||
}
|
||||
}
|
||||
winners.sort_unstable_by(|a, b| a.1.order_elected.cmp(&b.1.order_elected));
|
||||
|
||||
for (i, (winner, count_card)) in winners.into_iter().enumerate() {
|
||||
if let Some(kv) = &count_card.keep_value {
|
||||
println!("{}. {} (kv = {:.dps2$})", i + 1, winner.name, kv, dps2=max(opts.pp_decimals, 2));
|
||||
} else {
|
||||
println!("{}. {}", i + 1, winner.name);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
fn print_stage<N: Number>(stage_num: usize, state: &CountState<N>, opts: &STVOptions) {
|
||||
// Print stage details
|
||||
match state.kind {
|
||||
None => { println!("{}. {}", stage_num, state.title); }
|
||||
Some(kind) => { println!("{}. {} {}", stage_num, kind, state.title); }
|
||||
return match opts.command {
|
||||
Command::Convert(cmd_opts) => cli::convert::main(cmd_opts),
|
||||
Command::STV(cmd_opts) => cli::stv::main(cmd_opts),
|
||||
};
|
||||
println!("{}", state.logger.render().join(" "));
|
||||
|
||||
// Print candidates
|
||||
print!("{}", state.describe_candidates(opts));
|
||||
|
||||
// Print summary rows
|
||||
print!("{}", state.describe_summary(opts));
|
||||
|
||||
println!("");
|
||||
}
|
||||
|
@ -18,9 +18,19 @@
|
||||
use crate::election::{Ballot, Candidate, Election};
|
||||
use crate::numbers::Number;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use utf8_chars::BufReadCharsExt;
|
||||
|
||||
use std::fmt;
|
||||
use std::iter::Peekable;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::fs::File;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::BufReader;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::Path;
|
||||
|
||||
/// Utility for parsing a BLT file
|
||||
pub struct BLTParser<N: Number, I: Iterator<Item=char>> {
|
||||
/// The peekable iterator of chars representing the BLT file
|
||||
@ -366,3 +376,18 @@ impl<N: Number, I: Iterator<Item=char>> BLTParser<N, I> {
|
||||
return self.election;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the given BLT file and return an [Election]
|
||||
pub fn parse_iterator<I: Iterator<Item=char>, N: Number>(input: Peekable<I>) -> Result<Election<N>, ParseError> {
|
||||
let mut parser = BLTParser::new(input);
|
||||
parser.parse_blt()?;
|
||||
return Ok(parser.as_election());
|
||||
}
|
||||
|
||||
/// Parse the BLT file at the given path and return an [Election]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn parse_path<P: AsRef<Path>, N: Number>(path: P) -> Result<Election<N>, ParseError> {
|
||||
let mut reader = BufReader::new(File::open(path).expect("IO Error"));
|
||||
let chars = reader.chars().map(|r| r.expect("IO Error")).peekable();
|
||||
return Ok(parse_iterator(chars)?);
|
||||
}
|
||||
|
102
src/parser/csp.rs
Normal file
102
src/parser/csp.rs
Normal file
@ -0,0 +1,102 @@
|
||||
/* 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 crate::election::{Ballot, Candidate, Election};
|
||||
use crate::numbers::Number;
|
||||
|
||||
use csv::ReaderBuilder;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
/// Parse the given CSP file
|
||||
pub fn parse_reader<R: Read, N: Number>(reader: R, seats: usize) -> Election<N> {
|
||||
// Read CSV file
|
||||
let mut reader = ReaderBuilder::new()
|
||||
.has_headers(true)
|
||||
.from_reader(reader);
|
||||
|
||||
// Read candidates
|
||||
let mut candidates = Vec::new();
|
||||
let mut idx_map = HashMap::new(); // Map csp index -> candidates index
|
||||
|
||||
for (i, cand_name) in reader.headers().expect("Syntax Error").into_iter().enumerate() {
|
||||
if cand_name == "$mult" {
|
||||
continue;
|
||||
}
|
||||
|
||||
idx_map.insert(i, candidates.len());
|
||||
candidates.push(Candidate {
|
||||
name: cand_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Read ballots
|
||||
let mut ballots = Vec::new();
|
||||
|
||||
for record in reader.into_records() {
|
||||
let record = record.expect("Syntax Error");
|
||||
|
||||
let mut value = N::one();
|
||||
|
||||
// Record preferences
|
||||
let mut preferences = Vec::new(); // Vec of (ranking, candidate index)
|
||||
for (csv_index, preference) in record.into_iter().enumerate() {
|
||||
match idx_map.get(&csv_index) {
|
||||
Some(cand_index) => {
|
||||
// Preference
|
||||
if preference.len() == 0 || preference == "-" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let preference: usize = preference.parse().expect("Syntax Error");
|
||||
if preference == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
preferences.push((preference, cand_index));
|
||||
}
|
||||
None => {
|
||||
// $mult column
|
||||
let mult: usize = preference.parse().expect("Syntax Error");
|
||||
if mult == 1 {
|
||||
continue;
|
||||
}
|
||||
value = N::from(mult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by ranking
|
||||
// FIXME: Handle equal rankings and skipped rankings
|
||||
preferences.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
ballots.push(Ballot {
|
||||
orig_value: value,
|
||||
preferences: preferences.into_iter().map(|(_, i)| *i).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
return Election {
|
||||
name: String::new(),
|
||||
seats: seats,
|
||||
candidates: candidates,
|
||||
withdrawn_candidates: Vec::new(),
|
||||
ballots: ballots,
|
||||
constraints: None,
|
||||
};
|
||||
}
|
@ -17,3 +17,6 @@
|
||||
|
||||
/// BLT file parser
|
||||
pub mod blt;
|
||||
/// CSP file parser
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod csp;
|
||||
|
@ -21,6 +21,7 @@
|
||||
use crate::constraints::Constraints;
|
||||
use crate::election::{CandidateState, CountState, Election};
|
||||
use crate::numbers::{Fixed, GuardedFixed, NativeFloat64, Number, Rational};
|
||||
use crate::parser::blt;
|
||||
use crate::stv;
|
||||
use crate::ties;
|
||||
|
||||
@ -65,14 +66,14 @@ macro_rules! impl_type {
|
||||
($type:ident) => { paste::item! {
|
||||
// Counting
|
||||
|
||||
/// Wrapper for [Election::from_blt]
|
||||
/// Wrapper for [blt::parse_iterator]
|
||||
#[wasm_bindgen]
|
||||
#[allow(non_snake_case)]
|
||||
pub fn [<election_from_blt_$type>](text: String) -> [<Election$type>] {
|
||||
// Install panic! hook
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
let election: Election<$type> = match Election::from_blt(text.chars().peekable()) {
|
||||
let election: Election<$type> = match blt::parse_iterator(text.chars().peekable()) {
|
||||
Ok(e) => e,
|
||||
Err(err) => wasm_error!("Syntax Error", err),
|
||||
};
|
||||
|
55
src/writer/blt.rs
Normal file
55
src/writer/blt.rs
Normal file
@ -0,0 +1,55 @@
|
||||
/* 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 crate::election::Election;
|
||||
use crate::numbers::Number;
|
||||
|
||||
use std::io::{BufWriter, Write};
|
||||
|
||||
/// Write the [Election] into BLT format
|
||||
pub fn write<W: Write, N: Number>(election: Election<N>, output: W) {
|
||||
let mut output = BufWriter::new(output);
|
||||
|
||||
// Writer header row
|
||||
output.write_fmt(format_args!("{} {}\n", election.candidates.len(), election.seats)).expect("IO Error");
|
||||
|
||||
// Write withdrawn candidates
|
||||
if !election.withdrawn_candidates.is_empty() {
|
||||
todo!();
|
||||
}
|
||||
|
||||
// Write ballots
|
||||
for ballot in election.ballots {
|
||||
output.write_fmt(format_args!("{}", ballot.orig_value)).expect("IO Error");
|
||||
|
||||
for preference in ballot.preferences {
|
||||
output.write_fmt(format_args!(" {}", preference + 1)).expect("IO Error");
|
||||
}
|
||||
|
||||
output.write(b" 0\n").expect("IO Error");
|
||||
}
|
||||
|
||||
output.write(b"0\n").expect("IO Error");
|
||||
|
||||
// Write candidate names
|
||||
for candidate in election.candidates {
|
||||
output.write_fmt(format_args!("\"{}\"\n", candidate.name)).expect("IO Error");
|
||||
}
|
||||
|
||||
// Write election name
|
||||
output.write_fmt(format_args!("\"{}\"\n", election.name)).expect("IO Error");
|
||||
}
|
53
src/writer/csp.rs
Normal file
53
src/writer/csp.rs
Normal file
@ -0,0 +1,53 @@
|
||||
/* 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 crate::election::Election;
|
||||
use crate::numbers::Number;
|
||||
|
||||
use csv::Writer;
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
/// Write the [Election] into CSP format
|
||||
pub fn write<W: Write, N: Number>(election: Election<N>, output: W) {
|
||||
// Open writer
|
||||
// csv::Writer performs its own buffering
|
||||
let mut output = Writer::from_writer(output);
|
||||
|
||||
// Write header row
|
||||
for candidate in election.candidates.iter() {
|
||||
output.write_field(&candidate.name).expect("IO Error");
|
||||
}
|
||||
output.write_field("$mult").expect("IO Error");
|
||||
output.write_record(None::<&[u8]>).expect("IO Error");
|
||||
|
||||
// Write ballots
|
||||
for ballot in election.ballots {
|
||||
// Code preferences to rankings
|
||||
let mut rankings = vec![0_usize; election.candidates.len()];
|
||||
for (i, preference) in ballot.preferences.into_iter().enumerate() {
|
||||
rankings[preference] = i + 1;
|
||||
}
|
||||
|
||||
// Write rankings
|
||||
for ranking in rankings {
|
||||
output.write_field(format!("{}", ranking)).expect("IO Error");
|
||||
}
|
||||
output.write_field(format!("{}", ballot.orig_value)).expect("IO Error");
|
||||
output.write_record(None::<&[u8]>).expect("IO Error");
|
||||
}
|
||||
}
|
21
src/writer/mod.rs
Normal file
21
src/writer/mod.rs
Normal file
@ -0,0 +1,21 @@
|
||||
/* 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/>.
|
||||
*/
|
||||
|
||||
/// BLT file writer
|
||||
pub mod blt;
|
||||
/// CSP file writer
|
||||
pub mod csp;
|
66
test.csp
Normal file
66
test.csp
Normal file
@ -0,0 +1,66 @@
|
||||
Evans,Grey,Thomson,Ames,Reid,Spears,White,$mult
|
||||
1,0,2,0,0,0,0,1
|
||||
0,1,0,2,0,0,3,1
|
||||
3,1,0,0,0,2,4,1
|
||||
0,1,4,0,3,2,0,1
|
||||
0,0,1,0,0,0,0,1
|
||||
0,0,0,1,0,0,0,1
|
||||
0,0,1,0,0,0,0,1
|
||||
0,0,0,1,0,0,0,1
|
||||
0,0,0,1,0,0,0,1
|
||||
0,0,1,0,0,0,0,1
|
||||
0,0,0,0,1,0,2,1
|
||||
2,0,0,1,0,0,0,1
|
||||
0,0,0,0,0,1,0,1
|
||||
0,0,0,1,0,0,0,1
|
||||
0,1,2,0,0,0,0,1
|
||||
0,0,1,0,0,0,0,1
|
||||
0,0,0,0,0,0,1,1
|
||||
0,0,0,1,0,0,0,1
|
||||
0,1,0,2,0,0,3,1
|
||||
0,1,0,2,0,0,3,1
|
||||
0,0,0,1,0,0,0,1
|
||||
0,1,2,0,0,0,0,1
|
||||
0,0,0,1,0,0,0,1
|
||||
0,0,0,0,0,0,1,1
|
||||
0,1,0,0,0,2,0,1
|
||||
2,1,0,0,0,0,0,1
|
||||
0,0,0,0,0,0,1,1
|
||||
3,1,4,2,0,0,0,1
|
||||
0,0,0,1,0,0,0,1
|
||||
0,0,0,0,0,1,0,1
|
||||
0,0,0,0,0,1,0,1
|
||||
0,1,3,2,0,0,4,1
|
||||
0,0,0,0,0,1,0,1
|
||||
0,1,0,0,0,0,2,1
|
||||
0,1,0,0,0,0,2,1
|
||||
3,1,0,0,0,2,4,1
|
||||
0,0,1,0,0,0,0,1
|
||||
0,1,0,2,0,0,3,1
|
||||
0,1,0,0,0,2,0,1
|
||||
0,1,3,2,0,0,4,1
|
||||
0,1,0,0,0,2,0,1
|
||||
0,1,0,2,0,0,3,1
|
||||
0,0,0,1,0,0,0,1
|
||||
0,1,0,0,0,2,3,1
|
||||
3,1,0,0,0,2,0,1
|
||||
0,1,0,2,0,0,3,1
|
||||
0,0,0,0,0,1,0,1
|
||||
0,1,0,2,0,0,3,1
|
||||
0,1,2,0,0,0,0,1
|
||||
0,0,0,0,0,1,0,1
|
||||
0,1,4,0,3,2,0,1
|
||||
3,1,0,2,0,0,4,1
|
||||
0,0,0,0,0,1,0,1
|
||||
0,0,0,0,0,1,0,1
|
||||
2,1,0,0,0,0,0,1
|
||||
0,1,0,2,0,0,3,1
|
||||
4,1,0,0,3,2,5,1
|
||||
0,1,0,0,0,0,2,1
|
||||
0,1,3,2,0,0,4,1
|
||||
0,0,0,0,0,1,0,1
|
||||
0,1,2,0,0,0,0,1
|
||||
0,1,0,0,2,0,3,1
|
||||
4,1,5,2,3,0,0,1
|
||||
0,0,0,0,0,1,0,1
|
||||
0,0,0,0,0,1,0,1
|
@ -19,6 +19,7 @@ mod utils;
|
||||
|
||||
use opentally::election::Election;
|
||||
use opentally::numbers::Rational;
|
||||
use opentally::parser::blt;
|
||||
use opentally::stv;
|
||||
|
||||
use csv::StringRecord;
|
||||
@ -52,7 +53,7 @@ fn aec_tas19_rational() {
|
||||
let chars = reader.chars().map(|r| r.expect("IO Error")).peekable();
|
||||
|
||||
// Read BLT
|
||||
let election: Election<Rational> = Election::from_blt(chars).expect("Syntax Error");
|
||||
let election: Election<Rational> = blt::parse_iterator(chars).expect("Syntax Error");
|
||||
|
||||
// Validate candidate names
|
||||
for (i, candidate) in candidates.iter().enumerate() {
|
||||
|
@ -20,6 +20,7 @@ mod utils;
|
||||
use opentally::constraints::Constraints;
|
||||
use opentally::election::{CandidateState, CountState, Election};
|
||||
use opentally::numbers::Rational;
|
||||
use opentally::parser::blt;
|
||||
use opentally::stv;
|
||||
|
||||
use std::fs::File;
|
||||
@ -30,7 +31,7 @@ fn prsa1_constr1_rational() {
|
||||
// FIXME: This is unvalidated!
|
||||
|
||||
// Read BLT
|
||||
let mut election: Election<Rational> = Election::from_file("tests/data/prsa1.blt").expect("Syntax Error");
|
||||
let mut election: Election<Rational> = blt::parse_path("tests/data/prsa1.blt").expect("Syntax Error");
|
||||
|
||||
// Read CON
|
||||
let file = File::open("tests/data/prsa1_constr1.con").expect("IO Error");
|
||||
@ -79,7 +80,7 @@ fn prsa1_constr2_rational() {
|
||||
// FIXME: This is unvalidated!
|
||||
|
||||
// Read BLT
|
||||
let mut election: Election<Rational> = Election::from_file("tests/data/prsa1.blt").expect("Syntax Error");
|
||||
let mut election: Election<Rational> = blt::parse_path("tests/data/prsa1.blt").expect("Syntax Error");
|
||||
|
||||
// Read CON
|
||||
let file = File::open("tests/data/prsa1_constr2.con").expect("IO Error");
|
||||
@ -128,7 +129,7 @@ fn prsa1_constr3_rational() {
|
||||
// FIXME: This is unvalidated!
|
||||
|
||||
// Read BLT
|
||||
let mut election: Election<Rational> = Election::from_file("tests/data/prsa1.blt").expect("Syntax Error");
|
||||
let mut election: Election<Rational> = blt::parse_path("tests/data/prsa1.blt").expect("Syntax Error");
|
||||
|
||||
// Read CON
|
||||
let file = File::open("tests/data/prsa1_constr3.con").expect("IO Error");
|
||||
@ -189,7 +190,7 @@ fn ers97old_cantbulkexclude_rational() {
|
||||
let stages: Vec<usize> = records.first().unwrap().iter().skip(1).step_by(2).map(|s| s.parse().unwrap()).collect();
|
||||
|
||||
// Read BLT
|
||||
let mut election: Election<Rational> = Election::from_file("tests/data/ers97old.blt").expect("Syntax Error");
|
||||
let mut election: Election<Rational> = blt::parse_path("tests/data/ers97old.blt").expect("Syntax Error");
|
||||
|
||||
// Read CON
|
||||
let file = File::open("tests/data/ers97old_cantbulkexclude.con").expect("IO Error");
|
||||
|
@ -19,6 +19,7 @@ mod utils;
|
||||
|
||||
use opentally::election::{CandidateState, CountState, Election};
|
||||
use opentally::numbers::{Fixed, NativeFloat64, Number};
|
||||
use opentally::parser::blt;
|
||||
use opentally::stv;
|
||||
|
||||
// Compare ers97old.blt count with result produced by 1987 Hill–Wichmann–Woodall reference implementation
|
||||
@ -54,7 +55,7 @@ fn meek06_ers97old_fixed12() {
|
||||
Fixed::set_dps(12);
|
||||
|
||||
// Read BLT
|
||||
let election: Election<Fixed> = Election::from_file("tests/data/ers97old.blt").expect("Syntax Error");
|
||||
let election: Election<Fixed> = blt::parse_path("tests/data/ers97old.blt").expect("Syntax Error");
|
||||
|
||||
// Initialise count state
|
||||
let mut state = CountState::new(&election);
|
||||
@ -114,7 +115,7 @@ fn meeknz_ers97old_fixed12() {
|
||||
Fixed::set_dps(12);
|
||||
|
||||
// Read BLT
|
||||
let election: Election<Fixed> = Election::from_file("tests/data/ers97old.blt").expect("Syntax Error");
|
||||
let election: Election<Fixed> = blt::parse_path("tests/data/ers97old.blt").expect("Syntax Error");
|
||||
|
||||
// Initialise count state
|
||||
let mut state = CountState::new(&election);
|
||||
|
@ -17,6 +17,7 @@
|
||||
|
||||
use opentally::election::{CandidateState, CountState, Election};
|
||||
use opentally::numbers::{Fixed, GuardedFixed, Number};
|
||||
use opentally::parser::blt;
|
||||
use opentally::stv;
|
||||
|
||||
use xmltree::Element;
|
||||
@ -85,7 +86,7 @@ where
|
||||
let num_stages = root.get_child("headerrow").expect("Syntax Error").children.len();
|
||||
|
||||
// Read BLT
|
||||
let mut election: Election<N> = Election::from_file("tests/data/linn07.blt").expect("Syntax Error");
|
||||
let mut election: Election<N> = blt::parse_path("tests/data/linn07.blt").expect("Syntax Error");
|
||||
|
||||
// !!! FOR SCOTTISH STV !!!
|
||||
election.normalise_ballots();
|
||||
|
@ -17,9 +17,10 @@
|
||||
|
||||
use opentally::election::{CandidateState, CountState, Election};
|
||||
use opentally::numbers::Number;
|
||||
use opentally::parser::blt;
|
||||
use opentally::stv;
|
||||
|
||||
use csv::StringRecord;
|
||||
use csv::{ReaderBuilder, StringRecord};
|
||||
|
||||
use std::ops;
|
||||
|
||||
@ -33,7 +34,7 @@ where
|
||||
for<'r> &'r N: ops::Neg<Output=N>,
|
||||
{
|
||||
// Read CSV file
|
||||
let reader = csv::ReaderBuilder::new()
|
||||
let reader = ReaderBuilder::new()
|
||||
.has_headers(false)
|
||||
.from_path(csv_file)
|
||||
.expect("IO Error");
|
||||
@ -46,7 +47,7 @@ where
|
||||
let stages: Vec<usize> = records.first().unwrap().iter().skip(1).step_by(2).map(|s| s.parse().unwrap()).collect();
|
||||
|
||||
// Read BLT
|
||||
let election: Election<N> = Election::from_file(blt_file).expect("Syntax Error");
|
||||
let election: Election<N> = blt::parse_path(blt_file).expect("Syntax Error");
|
||||
|
||||
// Validate candidate names
|
||||
for (i, candidate) in candidates.iter().enumerate() {
|
||||
|
Loading…
Reference in New Issue
Block a user