Customisable options for STV count in backend

This commit is contained in:
RunasSudo 2020-10-18 17:54:50 +11:00
parent 4d9846d8b7
commit dcc3dcc5e7
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
13 changed files with 95 additions and 15 deletions

View File

@ -20,12 +20,26 @@ from pyRCV2.model import CandidateState, CountCard, CountCompleted, CountStepRes
from pyRCV2.numbers import Num from pyRCV2.numbers import Num
from pyRCV2.safedict import SafeDict from pyRCV2.safedict import SafeDict
class STVCCounter: class STVException(Exception):
"""Count an STV election using pyRCV STV-C rules""" pass
def __init__(self, election): class BaseSTVCounter:
"""Basic STV counter for various different variations"""
def __init__(self, election, options=None):
self.election = election 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): def reset(self):
self.candidates = SafeDict([(c, CountCard()) for c in self.election.candidates]) self.candidates = SafeDict([(c, CountCard()) for c in self.election.candidates])
self.exhausted = CountCard() self.exhausted = CountCard()
@ -33,6 +47,8 @@ class STVCCounter:
self.total_orig = sum((b.value for b in self.election.ballots), Num('0')) self.total_orig = sum((b.value for b in self.election.ballots), Num('0'))
self.num_elected = 0
# Withdraw candidates # Withdraw candidates
for candidate in self.election.withdrawn: for candidate in self.election.withdrawn:
__pragma__('opov') __pragma__('opov')
@ -52,6 +68,7 @@ class STVCCounter:
self.exhausted.ballots.append((ballot, ballot.value)) self.exhausted.ballots.append((ballot, ballot.value))
__pragma__('noopov') __pragma__('noopov')
self.quota = None
self.compute_quota() self.compute_quota()
self.elect_meeting_quota() self.elect_meeting_quota()
@ -74,15 +91,19 @@ class STVCCounter:
self.loss_fraction.step() self.loss_fraction.step()
# Have sufficient candidates been elected? # 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() return CountCompleted()
# Are there just enough candidates to fill all the seats # 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 # Declare elected all remaining candidates
for candidate, count_card in self.candidates.items(): for candidate, count_card in self.candidates.items():
if count_card.state == CandidateState.HOPEFUL: if count_card.state == CandidateState.HOPEFUL:
count_card.state = CandidateState.PROVISIONALLY_ELECTED count_card.state = CandidateState.PROVISIONALLY_ELECTED
count_card.order_elected = self.num_elected
self.num_elected += 1
__pragma__('opov') __pragma__('opov')
return CountStepResult( return CountStepResult(
@ -101,8 +122,14 @@ class STVCCounter:
__pragma__('noopov') __pragma__('noopov')
if len(has_surplus) > 0: if len(has_surplus) > 0:
# Distribute surpluses in order of size # Distribute surpluses in specified order
if self.options['surplus_order'] == 'size':
has_surplus.sort(lambda x: x[1].votes, True) 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] candidate_surplus, count_card = has_surplus[0]
count_card.state = CandidateState.ELECTED count_card.state = CandidateState.ELECTED
@ -185,20 +212,43 @@ class STVCCounter:
__pragma__('noopov') __pragma__('noopov')
def compute_quota(self): def compute_quota(self):
# Compute quota """Recount total votes and (if applicable) recalculate the quota"""
__pragma__('opov') __pragma__('opov')
self.total = sum((cc.votes for c, cc in self.candidates.items()), Num('0')) 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.loss_fraction.transfers += (self.total_orig - self.total - self.exhausted.votes) - self.loss_fraction.votes
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) 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') __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): def elect_meeting_quota(self):
# Does a candidate meet the quota? # 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 self.meets_quota(cc)]
meets_quota = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL and cc.votes > self.quota]
__pragma__('noopov')
if len(meets_quota) > 0: if len(meets_quota) > 0:
# Declare elected any candidate who meets the quota # Declare elected any candidate who meets the quota
for candidate, count_card in meets_quota: for candidate, count_card in meets_quota:
count_card.state = CandidateState.PROVISIONALLY_ELECTED count_card.state = CandidateState.PROVISIONALLY_ELECTED
count_card.order_elected = self.num_elected
self.num_elected += 1

View File

@ -61,6 +61,7 @@ class CountCard:
self.transfers = Num('0') self.transfers = Num('0')
self.ballots = [] self.ballots = []
self.state = CandidateState.HOPEFUL self.state = CandidateState.HOPEFUL
self.order_elected = None
@property @property
def votes(self): def votes(self):

View File

@ -48,3 +48,6 @@ class Fixed:
return self.impl.lt(other.impl) return self.impl.lt(other.impl)
def __le__(self, other): def __le__(self, other):
return self.impl.lte(other.impl) return self.impl.lte(other.impl)
def __floor__(self):
return Fixed(Math.floor(self.impl))

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from decimal import Decimal from decimal import Decimal
import math
_quantize_exp = 6 _quantize_exp = 6
@ -51,3 +52,6 @@ class Fixed:
return self.impl < other.impl return self.impl < other.impl
def __le__(self, other): def __le__(self, other):
return self.impl <= other.impl return self.impl <= other.impl
def __floor__(self):
return Fixed(math.floor(self.impl))

View File

@ -43,3 +43,6 @@ class NativeInt:
return self.impl < other.impl return self.impl < other.impl
def __le__(self, other): def __le__(self, other):
return self.impl <= other.impl return self.impl <= other.impl
def __floor__(self):
return NativeInt(Math.floor(self.impl))

View File

@ -14,6 +14,8 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import math
class NativeInt: class NativeInt:
""" """
Wrapper for Python int Wrapper for Python int
@ -43,3 +45,6 @@ class NativeInt:
return self.impl < other.impl return self.impl < other.impl
def __le__(self, other): def __le__(self, other):
return self.impl <= other.impl return self.impl <= other.impl
def __floor__(self):
return NativeInt(math.floor(self.impl))

View File

@ -43,3 +43,6 @@ class Native:
return self.impl < other.impl return self.impl < other.impl
def __le__(self, other): def __le__(self, other):
return self.impl <= other.impl return self.impl <= other.impl
def __floor__(self):
return Native(Math.floor(self.impl))

View File

@ -14,6 +14,8 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import math
class Native: class Native:
""" """
Wrapper for Python float (naive floating-point arithmetic) Wrapper for Python float (naive floating-point arithmetic)
@ -43,3 +45,6 @@ class Native:
return self.impl < other.impl return self.impl < other.impl
def __le__(self, other): def __le__(self, other):
return self.impl <= other.impl return self.impl <= other.impl
def __floor__(self):
return Native(math.floor(self.impl))

View File

@ -43,3 +43,6 @@ class Rational:
return self.impl.lesser(other.impl) return self.impl.lesser(other.impl)
def __le__(self, other): def __le__(self, other):
return self.impl.lesserOrEquals(other.impl) return self.impl.lesserOrEquals(other.impl)
def __floor__(self):
return Rational(self.impl.floor())

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from fractions import Fraction from fractions import Fraction
import math
class Rational: class Rational:
""" """
@ -45,3 +46,6 @@ class Rational:
return self.impl < other.impl return self.impl < other.impl
def __le__(self, other): def __le__(self, other):
return self.impl <= other.impl return self.impl <= other.impl
def __floor__(self):
return Rational(math.floor(self.impl))

View File

@ -16,7 +16,7 @@
import pyRCV2.blt import pyRCV2.blt
import pyRCV2.model import pyRCV2.model
import pyRCV2.method, pyRCV2.method.STVCCounter import pyRCV2.method, pyRCV2.method.base_stv
import pyRCV2.numbers import pyRCV2.numbers
__pragma__('js', '{}', 'export {pyRCV2, isinstance, repr, str};') __pragma__('js', '{}', 'export {pyRCV2, isinstance, repr, str};')

View File

@ -167,7 +167,6 @@
} 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) {
elTd.classList.add('elected'); elTd.classList.add('elected');
elTd.innerText = countCard.votes; elTd.innerText = countCard.votes;
elTd.style.fontWeight = 'bold';
elTr1.querySelector('td:first-child').classList.add('elected'); elTr1.querySelector('td:first-child').classList.add('elected');
} else if (countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED) { } else if (countCard.state === py.pyRCV2.model.CandidateState.EXCLUDED) {

View File

@ -24,7 +24,7 @@ onmessage = async function(evt) {
}}); }});
// Create counter // Create counter
let counter = py.pyRCV2.method.STVCCounter.STVCCounter(election); let counter = py.pyRCV2.method.base_stv.BaseSTVCounter(election);
// Reset // Reset
let result = counter.reset(); let result = counter.reset();