From dcc3dcc5e7aeb50b96581837c090c6eb31f5aa35 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Sun, 18 Oct 2020 17:54:50 +1100 Subject: [PATCH] Customisable options for STV count in backend --- pyRCV2/method/{STVCCounter.py => base_stv.py} | 74 ++++++++++++++++--- pyRCV2/model.py | 1 + pyRCV2/numbers/fixed_js.py | 3 + pyRCV2/numbers/fixed_py.py | 4 + pyRCV2/numbers/int_js.py | 3 + pyRCV2/numbers/int_py.py | 5 ++ pyRCV2/numbers/native_js.py | 3 + pyRCV2/numbers/native_py.py | 5 ++ pyRCV2/numbers/rational_js.py | 3 + pyRCV2/numbers/rational_py.py | 4 + pyRCV2/transcrypt.py | 2 +- test.html | 1 - worker.js | 2 +- 13 files changed, 95 insertions(+), 15 deletions(-) rename pyRCV2/method/{STVCCounter.py => base_stv.py} (74%) diff --git a/pyRCV2/method/STVCCounter.py b/pyRCV2/method/base_stv.py similarity index 74% rename from pyRCV2/method/STVCCounter.py rename to pyRCV2/method/base_stv.py index 8d3f167..2623bab 100644 --- a/pyRCV2/method/STVCCounter.py +++ b/pyRCV2/method/base_stv.py @@ -20,11 +20,25 @@ from pyRCV2.model import CandidateState, CountCard, CountCompleted, CountStepRes from pyRCV2.numbers import Num from pyRCV2.safedict import SafeDict -class STVCCounter: - """Count an STV election using pyRCV STV-C rules""" +class STVException(Exception): + pass + +class BaseSTVCounter: + """Basic STV counter for various different variations""" - def __init__(self, election): + def __init__(self, election, options=None): self.election = election + + # Default options + self.options = { + 'prog_quota': False, # Progressively reducing quota? + 'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact' + 'quota_criterion': 'geq', # 'geq' or 'gt' + 'surplus_order': 'size', # 'size' or 'order' + } + + if options is not None: + self.options.update(options) def reset(self): self.candidates = SafeDict([(c, CountCard()) for c in self.election.candidates]) @@ -33,6 +47,8 @@ class STVCCounter: self.total_orig = sum((b.value for b in self.election.ballots), Num('0')) + self.num_elected = 0 + # Withdraw candidates for candidate in self.election.withdrawn: __pragma__('opov') @@ -52,6 +68,7 @@ class STVCCounter: self.exhausted.ballots.append((ballot, ballot.value)) __pragma__('noopov') + self.quota = None self.compute_quota() self.elect_meeting_quota() @@ -74,15 +91,19 @@ class STVCCounter: self.loss_fraction.step() # Have sufficient candidates been elected? - if sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED or cc.state == CandidateState.ELECTED) >= self.election.seats: + if self.num_elected >= self.election.seats: return CountCompleted() # Are there just enough candidates to fill all the seats - if sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED or cc.state == CandidateState.ELECTED or cc.state == CandidateState.HOPEFUL) <= self.election.seats: + if self.num_elected + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL) <= self.election.seats: + # TODO: Sort by size + # Declare elected all remaining candidates for candidate, count_card in self.candidates.items(): if count_card.state == CandidateState.HOPEFUL: count_card.state = CandidateState.PROVISIONALLY_ELECTED + count_card.order_elected = self.num_elected + self.num_elected += 1 __pragma__('opov') return CountStepResult( @@ -101,8 +122,14 @@ class STVCCounter: __pragma__('noopov') if len(has_surplus) > 0: - # Distribute surpluses in order of size - has_surplus.sort(lambda x: x[1].votes, True) + # Distribute surpluses in specified order + if self.options['surplus_order'] == 'size': + has_surplus.sort(lambda x: x[1].votes, True) + elif self.options['surplus_order'] == 'order': + has_surplus.sort(lambda x: x[1].order_elected) + else: + raise STVException('Invalid surplus order option') + candidate_surplus, count_card = has_surplus[0] count_card.state = CandidateState.ELECTED @@ -185,20 +212,43 @@ class STVCCounter: __pragma__('noopov') def compute_quota(self): - # Compute quota + """Recount total votes and (if applicable) recalculate the quota""" __pragma__('opov') self.total = sum((cc.votes for c, cc in self.candidates.items()), Num('0')) self.loss_fraction.transfers += (self.total_orig - self.total - self.exhausted.votes) - self.loss_fraction.votes - self.quota = self.total / Num(self.election.seats + 1) + + if self.quota is None or self.options['prog_quota']: + if self.options['quota'] == 'droop': + self.quota = (self.total / Num(self.election.seats + 1)).__floor__() + Num('1') + elif self.options['quota'] == 'droop_exact': + self.quota = self.total / Num(self.election.seats + 1) + elif self.options['quota'] == 'hare': + self.quota = (self.total / Num(self.election.seats)).__floor__() + Num('1') + elif self.options['quota'] == 'hare_exact': + self.quota = self.total / Num(self.election.seats) + else: + raise STVException('Invalid quota option') __pragma__('noopov') + def meets_quota(self, count_card): + if self.options['quota_criterion'] == 'geq': + __pragma__('opov') + return count_card.votes >= self.quota + __pragma__('noopov') + elif self.options['quota_criterion'] == 'gt': + __pragma__('opov') + return count_card.votes > self.quota + __pragma__('noopov') + else: + raise STVException('Invalid quota criterion') + def elect_meeting_quota(self): # Does a candidate meet the quota? - __pragma__('opov') - meets_quota = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL and cc.votes > self.quota] - __pragma__('noopov') + meets_quota = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL and self.meets_quota(cc)] if len(meets_quota) > 0: # Declare elected any candidate who meets the quota for candidate, count_card in meets_quota: count_card.state = CandidateState.PROVISIONALLY_ELECTED + count_card.order_elected = self.num_elected + self.num_elected += 1 diff --git a/pyRCV2/model.py b/pyRCV2/model.py index 4fbb22d..04ebcb9 100644 --- a/pyRCV2/model.py +++ b/pyRCV2/model.py @@ -61,6 +61,7 @@ class CountCard: self.transfers = Num('0') self.ballots = [] self.state = CandidateState.HOPEFUL + self.order_elected = None @property def votes(self): diff --git a/pyRCV2/numbers/fixed_js.py b/pyRCV2/numbers/fixed_js.py index 77e11ca..6485797 100644 --- a/pyRCV2/numbers/fixed_js.py +++ b/pyRCV2/numbers/fixed_js.py @@ -48,3 +48,6 @@ class Fixed: return self.impl.lt(other.impl) def __le__(self, other): return self.impl.lte(other.impl) + + def __floor__(self): + return Fixed(Math.floor(self.impl)) diff --git a/pyRCV2/numbers/fixed_py.py b/pyRCV2/numbers/fixed_py.py index 1b2f5fd..7a595d5 100644 --- a/pyRCV2/numbers/fixed_py.py +++ b/pyRCV2/numbers/fixed_py.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from decimal import Decimal +import math _quantize_exp = 6 @@ -51,3 +52,6 @@ class Fixed: return self.impl < other.impl def __le__(self, other): return self.impl <= other.impl + + def __floor__(self): + return Fixed(math.floor(self.impl)) diff --git a/pyRCV2/numbers/int_js.py b/pyRCV2/numbers/int_js.py index 6769278..928dae3 100644 --- a/pyRCV2/numbers/int_js.py +++ b/pyRCV2/numbers/int_js.py @@ -43,3 +43,6 @@ class NativeInt: return self.impl < other.impl def __le__(self, other): return self.impl <= other.impl + + def __floor__(self): + return NativeInt(Math.floor(self.impl)) diff --git a/pyRCV2/numbers/int_py.py b/pyRCV2/numbers/int_py.py index 40d7c99..41c7b34 100644 --- a/pyRCV2/numbers/int_py.py +++ b/pyRCV2/numbers/int_py.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import math + class NativeInt: """ Wrapper for Python int @@ -43,3 +45,6 @@ class NativeInt: return self.impl < other.impl def __le__(self, other): return self.impl <= other.impl + + def __floor__(self): + return NativeInt(math.floor(self.impl)) diff --git a/pyRCV2/numbers/native_js.py b/pyRCV2/numbers/native_js.py index 4ebd0d3..b23afef 100644 --- a/pyRCV2/numbers/native_js.py +++ b/pyRCV2/numbers/native_js.py @@ -43,3 +43,6 @@ class Native: return self.impl < other.impl def __le__(self, other): return self.impl <= other.impl + + def __floor__(self): + return Native(Math.floor(self.impl)) diff --git a/pyRCV2/numbers/native_py.py b/pyRCV2/numbers/native_py.py index 8af6f4f..702e9bf 100644 --- a/pyRCV2/numbers/native_py.py +++ b/pyRCV2/numbers/native_py.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import math + class Native: """ Wrapper for Python float (naive floating-point arithmetic) @@ -43,3 +45,6 @@ class Native: return self.impl < other.impl def __le__(self, other): return self.impl <= other.impl + + def __floor__(self): + return Native(math.floor(self.impl)) diff --git a/pyRCV2/numbers/rational_js.py b/pyRCV2/numbers/rational_js.py index 9109cf2..b7db956 100644 --- a/pyRCV2/numbers/rational_js.py +++ b/pyRCV2/numbers/rational_js.py @@ -43,3 +43,6 @@ class Rational: return self.impl.lesser(other.impl) def __le__(self, other): return self.impl.lesserOrEquals(other.impl) + + def __floor__(self): + return Rational(self.impl.floor()) diff --git a/pyRCV2/numbers/rational_py.py b/pyRCV2/numbers/rational_py.py index c161e85..6d4a56e 100644 --- a/pyRCV2/numbers/rational_py.py +++ b/pyRCV2/numbers/rational_py.py @@ -15,6 +15,7 @@ # along with this program. If not, see . from fractions import Fraction +import math class Rational: """ @@ -45,3 +46,6 @@ class Rational: return self.impl < other.impl def __le__(self, other): return self.impl <= other.impl + + def __floor__(self): + return Rational(math.floor(self.impl)) diff --git a/pyRCV2/transcrypt.py b/pyRCV2/transcrypt.py index c0228e0..20795f0 100644 --- a/pyRCV2/transcrypt.py +++ b/pyRCV2/transcrypt.py @@ -16,7 +16,7 @@ import pyRCV2.blt import pyRCV2.model -import pyRCV2.method, pyRCV2.method.STVCCounter +import pyRCV2.method, pyRCV2.method.base_stv import pyRCV2.numbers __pragma__('js', '{}', 'export {pyRCV2, isinstance, repr, str};') diff --git a/test.html b/test.html index 54d7a6e..b417c59 100644 --- a/test.html +++ b/test.html @@ -167,7 +167,6 @@ } else if (countCard.state === py.pyRCV2.model.CandidateState.ELECTED || countCard.state === py.pyRCV2.model.CandidateState.PROVISIONALLY_ELECTED) { elTd.classList.add('elected'); elTd.innerText = countCard.votes; - elTd.style.fontWeight = 'bold'; elTr1.querySelector('td:first-child').classList.add('elected'); } else if (countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED) { diff --git a/worker.js b/worker.js index 02d1b84..9f919f8 100644 --- a/worker.js +++ b/worker.js @@ -24,7 +24,7 @@ onmessage = async function(evt) { }}); // Create counter - let counter = py.pyRCV2.method.STVCCounter.STVCCounter(election); + let counter = py.pyRCV2.method.base_stv.BaseSTVCounter(election); // Reset let result = counter.reset();