diff --git a/docs/options.md b/docs/options.md index 89cdde0..631403f 100644 --- a/docs/options.md +++ b/docs/options.md @@ -7,6 +7,7 @@ The preset dropdown allows you to choose from a hardcoded list of preloaded STV * [Scottish STV](https://www.opavote.com/methods/scottish-stv-rules) * pyRCV STV-C: Our recommended rules for a computerised STV count * [Wright STV](https://www.aph.gov.au/Parliamentary_Business/Committees/House_of_Representatives_Committees?url=em/elect07/subs/sub051.1.pdf) +* [PRSA 1977](https://www.prsa.org.au/rule1977.htm) This functionality is not available on the Python command line. @@ -57,21 +58,23 @@ This dropdown allows you to select in what order surpluses are distributed: * By size (default): When multiple candidates exceed the quota, the largest surplus is transferred (even if it arose at a later stage of the count). * By order: When multiple candidates exceed the quota, the surplus of the candidate elected first is transferred (even if it is smaller than another). Candidates are always declared elected in descending order of number of votes. -Some STV counting rules provide, for example, that ‘no surplus shall be transferred before a surplus that arose earlier in the counting whether larger or not’ ([PRSA 1977 rules](https://www.prsa.org.au/rule1977.htm)). In this case, the option ‘By order’ should be selected. +Some STV counting rules provide, for example, that ‘no surplus shall be transferred before a surplus that arose earlier in the counting whether larger or not’ (PRSA 1977). In this case, the option ‘By order’ should be selected. ## Method (-m/--method) -This dropdown allows you to select how ballots are transferred during surplus transfers or exclusions: +This dropdown allows you to select how ballots are transferred during surplus transfers or exclusions. The 2 recommended methods are: * Weighted inclusive Gregory: During surplus transfers, a transfer value is calculated as the candidate's excess votes divided by the candidate's votes. Ballot weights are multiplied by the transfer value. -* Unweighted inclusive Gregory: During surplus transfers, a transfer value is calculated as the candidate's excess votes divided by the candidate's ballot papers. Ballot weights are set to the transfer value. Transferred votes are credited in parcels to avoid excessive rounding. * Wright STV: Same as weighted inclusive Gregory, but when a candidate is excluded, the count is reset from the beginning (minus the excluded candidate). -Note that, in each of these options, all a candidate's ballot papers are examined during a surplus transfer. The practice used in some rules (e.g. ERS97) where ballot papers are divided into transferable and non-transferable papers, and only transferable papers further examined, is not currently supported. +Other methods are supported, but not recommended: -Note that, when unweighted inclusive Gregory surplus transfers are used, ballot weights are always represented internally as fractions, while candidate totals are represented according to the *Numbers* option. +* Unweighted inclusive Gregory: During surplus transfers, a transfer value is calculated as the candidate's excess votes divided by the candidate's ballot papers. Ballot weights are set to the transfer value. +* Exclusive Gregory (by parcel): During surplus transfers, only transferable papers in the ‘last bundle’ are examined. During exclusions, transfers are performed ‘parcel by parcel’, and each parcel forms a separate stage, i.e. if a parcel allows another candidate to be elected, no further transfers are made to that candidate. -Other surplus transfer methods, such as exclusive Gregory (‘last bundle’) or non-fractional transfers (e.g. random sample) are not recommended and not supported. +Note that, when unweighted inclusive Gregory or exclusive Gregory surplus transfers are used, ballot weights are always represented internally as fractions, while candidate totals are represented according to the *Numbers* option. + +Other surplus transfer methods, such as non-fractional transfers (e.g. random sample) are not supported at this time. ## Ties (-t/--ties) diff --git a/html/index.html b/html/index.html index 80ffebe..ad2246a 100644 --- a/html/index.html +++ b/html/index.html @@ -42,9 +42,11 @@ + + Information and instructions @@ -103,6 +105,7 @@ diff --git a/html/index.js b/html/index.js index e0c75e4..8ea8bfe 100644 --- a/html/index.js +++ b/html/index.js @@ -69,6 +69,17 @@ function changePreset() { document.getElementById('selSurplus').value = 'size'; document.getElementById('selTransfers').value = 'wright'; document.getElementById('selTies').value = 'backwards_random'; + } else if (document.getElementById('selPreset').value === 'prsa77') { + document.getElementById('selQuotaCriterion').value = 'geq'; + document.getElementById('selQuota').value = 'droop'; + document.getElementById('chkProgQuota').checked = false; + document.getElementById('chkBulkElection').checked = true; + document.getElementById('chkBulkExclusion').checked = false; + document.getElementById('selNumbers').value = 'fixed'; + document.getElementById('txtDP').value = '0'; + document.getElementById('selSurplus').value = 'order'; + document.getElementById('selTransfers').value = 'parcelled_eg'; + document.getElementById('selTies').value = 'backwards_random'; } } @@ -176,9 +187,9 @@ async function clickCount() { elTd = document.createElement('td'); elTd.classList.add('count'); - if (countCard.state === py.pyRCV2.model.CandidateState.WITHDRAWN || countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED) { + if (countCard.state === py.pyRCV2.model.CandidateState.WITHDRAWN || countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED || countCard.state === py.pyRCV2.model.CandidateState.EXCLUDING) { elTd.classList.add('excluded'); - } else if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED) { + } else if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED || countCard.state === py.pyRCV2.model.CandidateState.DISTRIBUTING_SURPLUS) { elTd.classList.add('elected'); } elTd.style.borderTop = '1px solid black'; @@ -192,7 +203,7 @@ async function clickCount() { elTd = document.createElement('td'); elTd.classList.add('count'); - if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED) { + if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED || countCard.state === py.pyRCV2.model.CandidateState.DISTRIBUTING_SURPLUS) { elTd.classList.add('elected'); elTd.innerText = countCard.votes; elTr1.querySelector('td:first-child').classList.add('elected'); @@ -202,7 +213,7 @@ async function clickCount() { if (countCard.state === py.pyRCV2.model.CandidateState.WITHDRAWN) { elTd.classList.add('excluded'); elTd.innerText = 'WD'; - } else if (countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED) { + } else if (countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED || countCard.state === py.pyRCV2.model.CandidateState.EXCLUDING) { elTd.classList.add('excluded'); elTd.innerText = 'Ex'; } else { diff --git a/html/worker.js b/html/worker.js index 96950e2..14aa8ba 100644 --- a/html/worker.js +++ b/html/worker.js @@ -43,6 +43,8 @@ onmessage = function(evt) { let counter; if (evt.data.transfers === 'uig') { counter = py.pyRCV2.method.base_stv.UIGSTVCounter(election, evt.data.options); + } else if (evt.data.transfers === 'parcelled_eg') { + counter = py.pyRCV2.method.parcels.ParcelledEGSTVCounter(election, evt.data.options); } else if (evt.data.transfers === 'wright') { counter = py.pyRCV2.method.wright.WrightSTVCounter(election, evt.data.options); } else { diff --git a/pyRCV2/cli/stv.py b/pyRCV2/cli/stv.py index 9cad5d1..63a5cc0 100644 --- a/pyRCV2/cli/stv.py +++ b/pyRCV2/cli/stv.py @@ -19,6 +19,7 @@ import pyRCV2.model import pyRCV2.numbers from pyRCV2.method.base_stv import UIGSTVCounter, WIGSTVCounter +from pyRCV2.method.parcels import ParcelledEGSTVCounter from pyRCV2.method.wright import WrightSTVCounter from pyRCV2.ties import TiesBackwards, TiesPrompt, TiesRandom @@ -35,7 +36,7 @@ def add_parser(subparsers): parser.add_argument('--numbers', '-n', choices=['fixed', 'rational', 'int', 'native'], default='fixed', help='numbers mode (default: fixed)') parser.add_argument('--decimals', type=int, default=5, help='decimal places if --numbers fixed (default: 5)') parser.add_argument('--surplus-order', '-s', choices=['size', 'order'], default='size', help='whether to distribute surpluses by size or by order of election (default: size)') - parser.add_argument('--method', '-m', choices=['wig', 'uig', 'wright'], default='wig', help='method of surpluses and exclusions (default: wig - weighted inclusive Gregory)') + parser.add_argument('--method', '-m', choices=['wig', 'uig', 'parcelled_eg', 'wright'], default='wig', help='method of surpluses and exclusions (default: wig - weighted inclusive Gregory)') parser.add_argument('--ties', '-t', action='append', choices=['backwards', 'prompt', 'random'], default=None, help='how to resolve ties (default: backwards then random)') parser.add_argument('--random-seed', default=None, help='arbitrary string used to seed the RNG for random tie breaking') @@ -81,6 +82,8 @@ def main(args): # Create counter if args.method == 'uig': counter = UIGSTVCounter(election, vars(args)) + elif args.method == 'parcelled_eg': + counter = ParcelledEGSTVCounter(election, vars(args)) elif args.method == 'wright': counter = WrightSTVCounter(election, vars(args)) else: diff --git a/pyRCV2/method/base_stv.py b/pyRCV2/method/base_stv.py index 19168d1..38183af 100644 --- a/pyRCV2/method/base_stv.py +++ b/pyRCV2/method/base_stv.py @@ -67,18 +67,7 @@ class BaseSTVCounter: Does not reset the states of candidates, etc. """ - # Distribute first preferences - for ballot in self.election.ballots: - __pragma__('opov') - candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None) - - if candidate is not None: - self.candidates[candidate].transfers += ballot.value - self.candidates[candidate].ballots.append((ballot, self.cls_ballot_value(ballot.value))) - else: - self.exhausted.transfers += ballot.value - self.exhausted.ballots.append((ballot, self.cls_ballot_value(ballot.value))) - __pragma__('noopov') + self.distribute_first_preferences() self.quota = None self.compute_quota() @@ -98,6 +87,23 @@ class BaseSTVCounter: self.step_results = [result] return result + def distribute_first_preferences(self): + """ + Distribute first preferences (called as part of the reset() step) + """ + + for ballot in self.election.ballots: + __pragma__('opov') + candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None) + + if candidate is not None: + self.candidates[candidate].transfers += ballot.value + self.candidates[candidate].ballots.append((ballot, self.cls_ballot_value(ballot.value))) + else: + self.exhausted.transfers += ballot.value + self.exhausted.ballots.append((ballot, self.cls_ballot_value(ballot.value))) + __pragma__('noopov') + def step(self): """ Public function: @@ -180,10 +186,14 @@ class BaseSTVCounter: Distribute surpluses, if any """ + # Are we distributing a surplus? + has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.DISTRIBUTING_SURPLUS] + # Do surpluses need to be distributed? - __pragma__('opov') - has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED and cc.votes > self.quota] - __pragma__('noopov') + if len(has_surplus) == 0: + __pragma__('opov') + has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED and cc.votes > self.quota] + __pragma__('noopov') if len(has_surplus) > 0: # Distribute surpluses in specified order @@ -196,21 +206,25 @@ class BaseSTVCounter: else: raise STVException('Invalid surplus order option') - count_card.state = CandidateState.ELECTED + count_card.state = CandidateState.DISTRIBUTING_SURPLUS __pragma__('opov') surplus = count_card.votes - self.quota __pragma__('noopov') # Transfer surplus - self.do_surplus(candidate_surplus, count_card, surplus) + is_complete = self.do_surplus(candidate_surplus, count_card, surplus) - __pragma__('opov') - count_card.transfers -= surplus - __pragma__('noopov') + if is_complete: + __pragma__('opov') + count_card.transfers -= surplus + __pragma__('noopov') + + count_card.state = CandidateState.ELECTED + + # Declare elected any candidates meeting the quota as a result of surpluses + self.compute_quota() - # Declare elected any candidates meeting the quota as a result of surpluses - self.compute_quota() self.elect_meeting_quota() __pragma__('opov') @@ -271,17 +285,14 @@ class BaseSTVCounter: """ candidate_excluded, count_card = self.candidate_to_exclude() - count_card.state = CandidateState.EXCLUDED + count_card.state = CandidateState.EXCLUDING # Exclude this candidate - self.do_exclusion(candidate_excluded, count_card) - - __pragma__('opov') - count_card.transfers -= count_card.votes - __pragma__('noopov') + is_complete = self.do_exclusion(candidate_excluded, count_card) # Declare any candidates meeting the quota as a result of exclusion self.compute_quota() + self.elect_meeting_quota() __pragma__('opov') @@ -303,6 +314,11 @@ class BaseSTVCounter: Determine the candidate to exclude """ + # Continue current exclusion if applicable + excluding = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.EXCLUDING] + if len(excluding) > 0: + return excluding[0][0], excluding[0][1] + hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL] hopefuls.sort(key=lambda x: x[1].votes) @@ -444,6 +460,8 @@ class WIGSTVCounter(BaseSTVCounter): self.exhausted.transfers += new_value self.exhausted.ballots.append((ballot, new_value)) __pragma__('noopov') + + return True def do_exclusion(self, candidate_excluded, count_card): for ballot, ballot_value in count_card.ballots: @@ -457,6 +475,12 @@ class WIGSTVCounter(BaseSTVCounter): self.exhausted.transfers += ballot_value self.exhausted.ballots.append((ballot, ballot_value)) __pragma__('noopov') + + __pragma__('opov') + count_card.transfers -= count_card.votes + __pragma__('noopov') + + count_card.state = CandidateState.EXCLUDED class UIGSTVCounter(BaseSTVCounter): """ @@ -514,6 +538,8 @@ class UIGSTVCounter(BaseSTVCounter): new_value = (ballot.value.to_rational() * surplus.to_rational()) / total_ballots.to_rational() __pragma__('noopov') self.exhausted.ballots.append((ballot, new_value)) + + return True def do_exclusion(self, candidate_excluded, count_card): next_preferences = SafeDict([(c, Rational('0')) for c, cc in self.candidates.items()]) @@ -546,4 +572,8 @@ class UIGSTVCounter(BaseSTVCounter): __pragma__('opov') self.exhausted.transfers += next_exhausted.to_num() + + count_card.transfers -= count_card.votes __pragma__('noopov') + + count_card.state = CandidateState.EXCLUDED diff --git a/pyRCV2/method/parcels.py b/pyRCV2/method/parcels.py new file mode 100644 index 0000000..dc4f7e8 --- /dev/null +++ b/pyRCV2/method/parcels.py @@ -0,0 +1,176 @@ +# pyRCV2: Preferential vote counting +# Copyright © 2020 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 . + +__pragma__ = lambda x: None + +from pyRCV2.method.base_stv import UIGSTVCounter +from pyRCV2.model import CandidateState, CountCard +from pyRCV2.numbers import Num, Rational +from pyRCV2.safedict import SafeDict + +class ParcelledEGCountCard(CountCard): + def __init__(self, *args): + CountCard.__init__(self, *args) + del self.ballots + self.parcels = [] + + #@property + #def ballots(self): + # raise Exception('Attempted to access ballots attribute of ParcelledUIGCountCard') + + def clone(self): + """Overrides CountCard.clone""" + result = ParcelledUIGCountCard() + result.orig_votes = self.orig_votes + result.transfers = self.transfers + result.parcels = [[(b[0].clone(), b[1]) for b in p] for p in self.parcels] + result.state = self.state + result.order_elected = self.order_elected + return result + +class ParcelledEGSTVCounter(UIGSTVCounter): + """ + Exclusive Gregory (parcel by parcel) STV implementation + """ + + def __init__(self, *args): + UIGSTVCounter.__init__(self, *args) + self.candidates = SafeDict([(c, ParcelledEGCountCard()) for c in self.election.candidates]) + + def distribute_first_preferences(self): + """Overrides UIGSTVCounter.distribute_first_preferences""" + + for ballot in self.election.ballots: + __pragma__('opov') + candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None) + + if candidate is not None: + self.candidates[candidate].transfers += ballot.value + if len(self.candidates[candidate].parcels) == 0: + self.candidates[candidate].parcels.append([(ballot, self.cls_ballot_value(ballot.value))]) + else: + self.candidates[candidate].parcels[0].append((ballot, self.cls_ballot_value(ballot.value))) + else: + self.exhausted.transfers += ballot.value + self.exhausted.ballots.append((ballot, self.cls_ballot_value(ballot.value))) + __pragma__('noopov') + + def do_surplus(self, candidate_surplus, count_card, surplus): + """Overrides UIGSTVCounter.do_surplus""" + + next_preferences = SafeDict([(c, []) for c, cc in self.candidates.items()]) + total_ballots = Num('0') + total_votes = Rational('0') + + parcel = count_card.parcels[len(count_card.parcels)-1] # Last bundle only + + __pragma__('opov') + + # Count next preferences + for ballot, ballot_value in parcel: + candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None) + + if candidate is not None: + # Examine only transferable ballot papers + total_ballots += ballot.value + total_votes += ballot_value + next_preferences[candidate].append((ballot, ballot_value)) + + # Make transfers + for candidate, cand_ballots in next_preferences.items(): + if len(cand_ballots) == 0: + continue + + new_parcel = [] + self.candidates[candidate].parcels.append(new_parcel) + + if total_votes.to_num() > surplus: + # Reweight according to transfer value + num_ballots = sum((b.value for b, bv in cand_ballots), Num('0')) + self.candidates[candidate].transfers += (num_ballots * surplus) / total_ballots # Multiply first to avoid rounding errors + + for ballot, ballot_value in cand_ballots: + new_value = (ballot.value.to_rational() * surplus.to_rational()) / total_ballots.to_rational() + new_parcel.append((ballot, new_value)) + else: + # Do not reweight + num_votes = sum((bv for b, bv in cand_ballots), Rational('0')) + self.candidates[candidate].transfers += num_votes.to_num() + + for ballot, ballot_value in cand_ballots: + new_parcel.append((ballot, ballot_value)) + + # Credit exhausted votes (only if not reweighting) + if total_votes.to_num() <= surplus: + self.exhausted.transfers += surplus - total_votes.to_num() + + __pragma__('noopov') + + return True + + def do_exclusion(self, candidate_excluded, count_card): + """Overrides UIGSTVCounter.do_exclusion""" + + next_preferences = SafeDict([(c, []) for c, cc in self.candidates.items()]) + next_exhausted = [] + + parcel = count_card.parcels[0] + count_card.parcels.remove(parcel) + + # Count next preferences + for ballot, ballot_value in parcel: + __pragma__('opov') + candidate = next((c for c in ballot.preferences if self.candidates[c].state == CandidateState.HOPEFUL), None) + __pragma__('noopov') + + if candidate is not None: + __pragma__('opov') + next_preferences[candidate].append((ballot, ballot_value)) + __pragma__('noopov') + else: + next_exhausted.append((ballot, ballot_value)) + + # Make transfers + for candidate, cand_ballots in next_preferences.items(): + if len(cand_ballots) == 0: + continue + + new_parcel = [] + __pragma__('opov') + self.candidates[candidate].parcels.append(new_parcel) + __pragma__('noopov') + + num_votes = sum((bv for b, bv in cand_ballots), Rational('0')) + __pragma__('opov') + self.candidates[candidate].transfers += num_votes.to_num() + count_card.transfers -= num_votes.to_num() + __pragma__('noopov') + + for ballot, ballot_value in cand_ballots: + new_parcel.append((ballot, ballot_value)) + + # Credit exhausted votes + num_votes = sum((bv for b, bv in next_exhausted), Rational('0')) + __pragma__('opov') + self.exhausted.transfers += num_votes.to_num() + count_card.transfers -= num_votes.to_num() + __pragma__('noopov') + + for ballot, ballot_value in next_exhausted: + self.exhausted.ballots.append((ballot, ballot_value)) + + if len(count_card.parcels) == 0: + count_card.state = CandidateState.EXCLUDED diff --git a/pyRCV2/model.py b/pyRCV2/model.py index 70fb1c3..fb60f07 100644 --- a/pyRCV2/model.py +++ b/pyRCV2/model.py @@ -30,10 +30,12 @@ class Candidate: class CandidateState: HOPEFUL = 0 - PROVISIONALLY_ELECTED = 1 - ELECTED = 2 - EXCLUDED = 3 - WITHDRAWN = 4 + PROVISIONALLY_ELECTED = 10 + DISTRIBUTING_SURPLUS = 20 + ELECTED = 30 + EXCLUDING = 40 + EXCLUDED = 50 + WITHDRAWN = 60 class Ballot: def __init__(self, value, preferences): @@ -82,7 +84,7 @@ class CountCard: result = CountCard() result.orig_votes = self.orig_votes result.transfers = self.transfers - result.ballots = [b.clone() for b in self.ballots] + result.ballots = [(b[0].clone(), b[1]) for b in self.ballots] result.state = self.state result.order_elected = self.order_elected return result diff --git a/pyRCV2/transcrypt.py b/pyRCV2/transcrypt.py index 752be3a..5f928c9 100644 --- a/pyRCV2/transcrypt.py +++ b/pyRCV2/transcrypt.py @@ -16,7 +16,7 @@ import pyRCV2.blt import pyRCV2.model -import pyRCV2.method, pyRCV2.method.base_stv, pyRCV2.method.wright +import pyRCV2.method, pyRCV2.method.base_stv, pyRCV2.method.parcels, pyRCV2.method.wright import pyRCV2.numbers import pyRCV2.random import pyRCV2.ties