Implement ERS97

This commit is contained in:
RunasSudo 2021-01-01 22:26:57 +11:00
parent 27c0638f98
commit fb33a96d37
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
17 changed files with 509 additions and 155 deletions

View File

@ -48,6 +48,7 @@
<option value="senate">Australian Senate STV</option> <option value="senate">Australian Senate STV</option>
<option value="wright">Wright STV</option> <option value="wright">Wright STV</option>
<option value="prsa77">PRSA 1977</option> <option value="prsa77">PRSA 1977</option>
<option value="ers97">ERS97</option>
</select> </select>
</label> </label>
<i class="sep"></i> <i class="sep"></i>
@ -58,6 +59,20 @@
</div> </div>
<div id="divAdvancedOptions" style="display: none;"> <div id="divAdvancedOptions" style="display: none;">
<label>
Numbers:
<select id="selNumbers">
<option value="native">Native</option>
<option value="rational">Rational</option>
<option value="fixed" selected>Fixed</option>
</select>
</label>
<i class="sep"></i>
<label>
Decimal places (if Numbers = Fixed):
<input type="number" id="txtDP" value="5" style="width: 3em;">
</label>
<br>
<label> <label>
Quota: Quota:
<select id="selQuotaCriterion"> <select id="selQuotaCriterion">
@ -73,33 +88,36 @@
<option value="hare_exact">Hare (exact)</option> <option value="hare_exact">Hare (exact)</option>
</select> </select>
</label> </label>
<i class="sep"></i>
<label> <label>
<input type="checkbox" id="chkProgQuota"> <select id="selQuotaMode">
Progressive quota <option value="static" selected>Static quota</option>
<option value="progressive">Progressive quota</option>
<option value="ers97">Static with ERS97 rules</option>
</select>
</label> </label>
<i class="sep"></i> <i class="sep"></i>
<label> <label>
<input type="checkbox" id="chkBulkElection" checked> <input type="checkbox" id="chkBulkElection" checked>
Bulk election Bulk election
</label> </label>
<label style="display: none;"> <i class="sep"></i>
<input type="checkbox" id="chkBulkExclusion">
Bulk exclusion (NYI)
</label>
<br>
<label> <label>
Numbers: <input type="checkbox" id="chkBulkExclusion">
<select id="selNumbers"> Bulk exclusion
<option value="native">Native</option>
<option value="rational">Rational</option>
<option value="fixed" selected>Fixed</option>
</select>
</label> </label>
<i class="sep"></i> <i class="sep"></i>
<label> <label>
Decimal places (if Numbers = Fixed): <input type="checkbox" id="chkDeferSurpluses">
<input type="number" id="txtDP" value="5" style="width: 3em;"> Defer surpluses
</label>
<br>
<label>
<input type="checkbox" id="chkRoundQuota" checked>
Round quota to
</label>
<label>
<input type="number" id="txtRoundQuota" value="0" style="width: 3em;">
d.p.
</label> </label>
<i class="sep"></i> <i class="sep"></i>
<label> <label>
@ -111,6 +129,15 @@
d.p. d.p.
</label> </label>
<i class="sep"></i> <i class="sep"></i>
<label>
<input type="checkbox" id="chkRoundTVs">
Round transfer values to
</label>
<label>
<input type="number" id="txtRoundTVs" value="0" style="width: 3em;">
d.p.
</label>
<i class="sep"></i>
<label> <label>
<input type="checkbox" id="chkRoundWeights"> <input type="checkbox" id="chkRoundWeights">
Round ballot weights to Round ballot weights to

View File

@ -1,6 +1,6 @@
/* /*
pyRCV2: Preferential vote counting pyRCV2: Preferential vote counting
Copyright © 2020 Lee Yingtong Li (RunasSudo) Copyright © 20202021 Lee Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU Affero General Public License as published by
@ -30,12 +30,16 @@ function changePreset() {
if (document.getElementById('selPreset').value === 'scottish') { if (document.getElementById('selPreset').value === 'scottish') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop'; document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false; document.getElementById('selQuotaMode').value = 'static';
document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'fixed'; document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5'; document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0';
document.getElementById('chkRoundVotes').checked = false; document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false; document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'size'; document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig'; document.getElementById('selTransfers').value = 'wig';
@ -45,11 +49,14 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'stvc') { } else if (document.getElementById('selPreset').value === 'stvc') {
document.getElementById('selQuotaCriterion').value = 'gt'; document.getElementById('selQuotaCriterion').value = 'gt';
document.getElementById('selQuota').value = 'droop_exact'; document.getElementById('selQuota').value = 'droop_exact';
document.getElementById('chkProgQuota').checked = true; document.getElementById('selQuotaMode').value = 'progressive';
document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true; document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'rational'; document.getElementById('selNumbers').value = 'rational';
document.getElementById('chkRoundQuota').checked = false;
document.getElementById('chkRoundVotes').checked = false; document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false; document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'size'; document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig'; document.getElementById('selTransfers').value = 'wig';
@ -59,13 +66,17 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'senate') { } else if (document.getElementById('selPreset').value === 'senate') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop'; document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false; document.getElementById('selQuotaMode').value = 'static';
document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true; document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'fixed'; document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5'; document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0';
document.getElementById('chkRoundVotes').checked = true; document.getElementById('chkRoundVotes').checked = true;
document.getElementById('txtRoundVotes').value = '0'; document.getElementById('txtRoundVotes').value = '0';
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false; document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'order'; document.getElementById('selSurplus').value = 'order';
document.getElementById('selTransfers').value = 'uig'; document.getElementById('selTransfers').value = 'uig';
@ -75,12 +86,16 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'wright') { } else if (document.getElementById('selPreset').value === 'wright') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop'; document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false; document.getElementById('selQuotaMode').value = 'static';
document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true; document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('chkDeferSurpluses').checked = false;
document.getElementById('selNumbers').value = 'fixed'; document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5'; document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0';
document.getElementById('chkRoundVotes').checked = false; document.getElementById('chkRoundVotes').checked = false;
document.getElementById('chkRoundTVs').checked = false;
document.getElementById('chkRoundWeights').checked = false; document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'size'; document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'wig'; document.getElementById('selTransfers').value = 'wig';
@ -90,19 +105,46 @@ function changePreset() {
} else if (document.getElementById('selPreset').value === 'prsa77') { } else if (document.getElementById('selPreset').value === 'prsa77') {
document.getElementById('selQuotaCriterion').value = 'geq'; document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop'; document.getElementById('selQuota').value = 'droop';
document.getElementById('chkProgQuota').checked = false; document.getElementById('selQuotaMode').value = 'static';
document.getElementById('chkBulkElection').checked = true; document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = false; document.getElementById('chkBulkExclusion').checked = false;
document.getElementById('chkDeferSurpluses').checked = true;
document.getElementById('selNumbers').value = 'fixed'; document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5'; document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '0';
document.getElementById('chkRoundVotes').checked = true; document.getElementById('chkRoundVotes').checked = true;
document.getElementById('txtRoundVotes').value = '0'; document.getElementById('txtRoundVotes').value = '0';
document.getElementById('chkRoundTVs').checked = true;
document.getElementById('txtRoundTVs').value = '0';
document.getElementById('chkRoundWeights').checked = false; document.getElementById('chkRoundWeights').checked = false;
document.getElementById('selSurplus').value = 'order'; document.getElementById('selSurplus').value = 'order';
document.getElementById('selTransfers').value = 'eg'; document.getElementById('selTransfers').value = 'eg';
document.getElementById('selPapers').value = 'transferable'; document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'parcels_by_order'; document.getElementById('selExclusion').value = 'parcels_by_order';
document.getElementById('selTies').value = 'backwards_random'; document.getElementById('selTies').value = 'backwards_random';
} else if (document.getElementById('selPreset').value === 'ers97') {
document.getElementById('selQuotaCriterion').value = 'geq';
document.getElementById('selQuota').value = 'droop_exact';
document.getElementById('selQuotaMode').value = 'ers97';
document.getElementById('chkBulkElection').checked = true;
document.getElementById('chkBulkExclusion').checked = true;
document.getElementById('chkDeferSurpluses').checked = true;
document.getElementById('selNumbers').value = 'fixed';
document.getElementById('txtDP').value = '5';
document.getElementById('chkRoundQuota').checked = true;
document.getElementById('txtRoundQuota').value = '2';
document.getElementById('chkRoundVotes').checked = true;
document.getElementById('txtRoundVotes').value = '2';
document.getElementById('chkRoundTVs').checked = true;
document.getElementById('txtRoundTVs').value = '2';
document.getElementById('chkRoundWeights').checked = true;
document.getElementById('txtRoundTVs').value = '2';
document.getElementById('selSurplus').value = 'size';
document.getElementById('selTransfers').value = 'eg';
document.getElementById('selPapers').value = 'transferable';
document.getElementById('selExclusion').value = 'by_value';
document.getElementById('selTies').value = 'backwards_random';
} }
} }
@ -308,14 +350,17 @@ async function clickCount() {
'options': { 'options': {
'quota_criterion': document.getElementById('selQuotaCriterion').value, 'quota_criterion': document.getElementById('selQuotaCriterion').value,
'quota': document.getElementById('selQuota').value, 'quota': document.getElementById('selQuota').value,
'prog_quota': document.getElementById('chkProgQuota').checked, 'quota_mode': document.getElementById('selQuotaMode').value,
'bulk_elect': document.getElementById('chkBulkElection').checked, 'bulk_elect': document.getElementById('chkBulkElection').checked,
'bulk_exclude': document.getElementById('chkBulkExclusion').checked, 'bulk_exclude': document.getElementById('chkBulkExclusion').checked,
'defer_surpluses': document.getElementById('chkDeferSurpluses').checked,
'surplus_order': document.getElementById('selSurplus').value, 'surplus_order': document.getElementById('selSurplus').value,
'papers': document.getElementById('selPapers').value, 'papers': document.getElementById('selPapers').value,
'exclusion': document.getElementById('selExclusion').value, 'exclusion': document.getElementById('selExclusion').value,
'ties': document.getElementById('selTies').value, 'ties': document.getElementById('selTies').value,
'round_quota': document.getElementById('chkRoundQuota').checked ? parseInt(document.getElementById('txtRoundQuota').value) : null,
'round_votes': document.getElementById('chkRoundVotes').checked ? parseInt(document.getElementById('txtRoundVotes').value) : null, 'round_votes': document.getElementById('chkRoundVotes').checked ? parseInt(document.getElementById('txtRoundVotes').value) : null,
'round_tvs': document.getElementById('chkRoundTVs').checked ? parseInt(document.getElementById('txtRoundTVs').value) : null,
'round_weights': document.getElementById('chkRoundWeights').checked ? parseInt(document.getElementById('txtRoundWeights').value) : null, 'round_weights': document.getElementById('chkRoundWeights').checked ? parseInt(document.getElementById('txtRoundWeights').value) : null,
}, },
'seed': document.getElementById('txtSeed').value, 'seed': document.getElementById('txtSeed').value,

View File

@ -29,11 +29,16 @@ def add_parser(subparsers):
parser.add_argument('--quota', '-q', choices=['droop', 'droop_exact', 'hare', 'hare_exact'], default='droop', help='quota calculation (default: droop)') parser.add_argument('--quota', '-q', choices=['droop', 'droop_exact', 'hare', 'hare_exact'], default='droop', help='quota calculation (default: droop)')
parser.add_argument('--quota-criterion', '-c', choices=['geq', 'gt'], default='geq', help='quota criterion (default: geq)') parser.add_argument('--quota-criterion', '-c', choices=['geq', 'gt'], default='geq', help='quota criterion (default: geq)')
parser.add_argument('--prog-quota', action='store_true', help='progressively reducing quota') parser.add_argument('--quota-mode', choices=['static', 'progressive', 'ers97'], default='static', help='whether to apply a form of progressive quota (default: static)')
parser.add_argument('--no-bulk-election', action='store_true', help='disable bulk election unless absolutely required') parser.add_argument('--no-bulk-elect', action='store_true', help='disable bulk election unless absolutely required')
parser.add_argument('--bulk-exclude', action='store_true', help='use bulk exclusion')
parser.add_argument('--defer-surpluses', action='store_true', help='defer surplus transfers if possible')
parser.add_argument('--numbers', '-n', choices=['fixed', 'rational', 'native'], default='fixed', help='numbers mode (default: fixed)') parser.add_argument('--numbers', '-n', choices=['fixed', 'rational', '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('--decimals', type=int, default=5, help='decimal places if --numbers fixed (default: 5)')
parser.add_argument('--no-round-quota', action='store_true', help='do not round the quota')
parser.add_argument('--round-quota', type=int, help='round quota to specified decimal places')
parser.add_argument('--round-votes', type=int, help='round votes to specified decimal places') parser.add_argument('--round-votes', type=int, help='round votes to specified decimal places')
parser.add_argument('--round-tvs', type=int, help='round transfer values to specified decimal places')
parser.add_argument('--round-weights', type=int, help='round ballot weights to specified decimal places') parser.add_argument('--round-weights', type=int, help='round ballot weights to specified decimal places')
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('--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', 'eg'], default='wig', help='method of transferring surpluses (default: wig - weighted inclusive Gregory)') parser.add_argument('--method', '-m', choices=['wig', 'uig', 'eg'], default='wig', help='method of transferring surpluses (default: wig - weighted inclusive Gregory)')
@ -72,7 +77,11 @@ def print_step(args, stage, result):
print('Exhausted: {} ({})'.format(result.exhausted.votes.pp(2), result.exhausted.transfers.pp(2))) print('Exhausted: {} ({})'.format(result.exhausted.votes.pp(2), result.exhausted.transfers.pp(2)))
print('Loss to fraction: {} ({})'.format(result.loss_fraction.votes.pp(2), result.loss_fraction.transfers.pp(2))) print('Loss to fraction: {} ({})'.format(result.loss_fraction.votes.pp(2), result.loss_fraction.transfers.pp(2)))
print('Total votes: {}'.format(result.total.pp(2))) print('Total votes: {}'.format(result.total.pp(2)))
print('Quota: {}'.format(result.quota.pp(2)))
if args.quota_mode == 'ers97' and result.vote_required_election < result.quota:
print('Vote required for election: {}'.format(result.vote_required_election.pp(2)))
else:
print('Quota: {}'.format(result.quota.pp(2)))
print() print()
@ -97,6 +106,9 @@ def main(args):
else: else:
counter = WIGSTVCounter(election, vars(args)) counter = WIGSTVCounter(election, vars(args))
if args.no_round_quota:
counter.options['round_quota'] = None
if args.ties is None: if args.ties is None:
args.ties = ['prompt'] args.ties = ['prompt']
@ -108,11 +120,11 @@ def main(args):
counter.options['ties'].append(TiesPrompt()) counter.options['ties'].append(TiesPrompt())
elif t == 'random': elif t == 'random':
if args.random_seed is None: if args.random_seed is None:
print('A --random-seed is required to use random tie breaking') print('A --random-seed is required to use random tie breaking.')
sys.exit(1) sys.exit(1)
counter.options['ties'].append(TiesRandom(args.random_seed)) counter.options['ties'].append(TiesRandom(args.random_seed))
counter.options['bulk_elect'] = not args.no_bulk_election counter.options['bulk_elect'] = not args.no_bulk_elect
counter.options['papers'] = 'transferable' if args.transferable_only else 'both' counter.options['papers'] = 'transferable' if args.transferable_only else 'both'
# Reset # Reset

View File

@ -61,15 +61,19 @@ class BaseSTVCounter:
# Default options # Default options
self.options = { self.options = {
'prog_quota': False, # Progressively reducing quota?
'bulk_elect': True, # Bulk election? 'bulk_elect': True, # Bulk election?
'bulk_exclude': False, # Bulk exclusion?
'defer_surpluses': False, # Defer surpluses?
'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact' 'quota': 'droop', # 'droop', 'droop_exact', 'hare' or 'hare_exact'
'quota_criterion': 'geq', # 'geq' or 'gt' 'quota_criterion': 'geq', # 'geq' or 'gt'
'quota_mode': 'static', # 'static', 'progressive' or 'ers97'
'surplus_order': 'size', # 'size' or 'order' 'surplus_order': 'size', # 'size' or 'order'
'papers': 'both', # 'both' or 'transferable' 'papers': 'both', # 'both' or 'transferable'
'exclusion': 'one_round', # 'one_round', 'parcels_by_order', 'by_value' or 'wright' 'exclusion': 'one_round', # 'one_round', 'parcels_by_order', 'by_value' or 'wright'
'ties': [], # List of tie strategies (e.g. TiesRandom) 'ties': [], # List of tie strategies (e.g. TiesRandom)
'round_quota': None, # Number of decimal places or None
'round_votes': None, # Number of decimal places or None 'round_votes': None, # Number of decimal places or None
'round_tvs': None, # Number of decimal places or None
'round_weights': None, # Number of decimal places or None 'round_weights': None, # Number of decimal places or None
} }
@ -97,9 +101,12 @@ class BaseSTVCounter:
Does not reset the states of candidates, etc. Does not reset the states of candidates, etc.
""" """
self._exclusion = None # Optimisation to avoid re-collating/re-sorting ballots
self.distribute_first_preferences() self.distribute_first_preferences()
self.quota = None self.quota = None
self.vote_required_election = None # For ERS97
self.compute_quota() self.compute_quota()
self.elect_meeting_quota() self.elect_meeting_quota()
@ -110,7 +117,8 @@ class BaseSTVCounter:
self.exhausted, self.exhausted,
self.loss_fraction, self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes, self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota self.quota,
self.vote_required_election,
) )
__pragma__('noopov') __pragma__('noopov')
@ -162,8 +170,8 @@ class BaseSTVCounter:
return result return result
# Insufficient winners and no surpluses to distribute # Insufficient winners and no surpluses to distribute
# Exclude the lowest ranked hopeful # Exclude the lowest ranked hopeful(s)
result = self.exclude_candidate() result = self.exclude_candidates()
if result: if result:
return result return result
@ -205,13 +213,43 @@ class BaseSTVCounter:
self.exhausted, self.exhausted,
self.loss_fraction, self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes, self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota self.quota,
self.vote_required_election,
) )
__pragma__('noopov') __pragma__('noopov')
self.step_results.append(result) self.step_results.append(result)
return result return result
def can_defer_surpluses(self, has_surplus):
"""
Determine if the specified surpluses can be deferred
"""
# Do not defer if this could change the last 2 candidates
__pragma__('opov')
total_surpluses = sum((cc.votes - self.quota for c, cc in has_surplus), Num(0))
__pragma__('noopov')
hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
hopefuls.sort(key=lambda x: x[1].votes)
__pragma__('opov')
if total_surpluses > hopefuls[1][1].votes - hopefuls[0][1].votes:
return False
__pragma__('noopov')
# Do not defer if this could affect a bulk exclusion
if self.options['bulk_exclude']:
to_bulk_exclude = self.candidates_to_bulk_exclude(hopefuls)
if len(to_bulk_exclude) > 0:
total_excluded = sum((cc.votes for c, cc in to_bulk_exclude), Num(0))
__pragma__('opov')
if total_surpluses > hopefuls[len(to_bulk_exclude) + 1][1].votes - total_excluded:
return False
__pragma__('opov')
# Can defer surpluses
return True
def distribute_surpluses(self): def distribute_surpluses(self):
""" """
Distribute surpluses, if any Distribute surpluses, if any
@ -221,26 +259,40 @@ class BaseSTVCounter:
if any(cc.state == CandidateState.EXCLUDING for c, cc in self.candidates.items()): if any(cc.state == CandidateState.EXCLUDING for c, cc in self.candidates.items()):
return return
candidate_surplus, count_card = None, None
# Are we distributing a surplus? # Are we distributing a surplus?
has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.DISTRIBUTING_SURPLUS] has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.DISTRIBUTING_SURPLUS]
if len(has_surplus) > 0:
# Do surpluses need to be distributed? candidate_surplus, count_card = has_surplus[0]
if len(has_surplus) == 0: else:
# Do surpluses need to be distributed?
__pragma__('opov') __pragma__('opov')
has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED and cc.votes > self.quota] has_surplus = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.PROVISIONALLY_ELECTED and cc.votes > self.quota]
__pragma__('noopov') __pragma__('noopov')
if len(has_surplus) > 0:
# Distribute surpluses in specified order
if self.options['surplus_order'] == 'size':
has_surplus.sort(key=lambda x: x[1].votes, reverse=True)
candidate_surplus, count_card = self.choose_highest(has_surplus) # May need to break ties
elif self.options['surplus_order'] == 'order':
has_surplus.sort(key=lambda x: x[1].order_elected)
candidate_surplus, count_card = has_surplus[0] # Ties were already broken when these were assigned
else:
raise STVException('Invalid surplus order option')
if len(has_surplus) > 0:
# Distribute surpluses in specified order
if self.options['surplus_order'] == 'size':
has_surplus.sort(key=lambda x: x[1].votes, reverse=True)
elif self.options['surplus_order'] == 'order':
has_surplus.sort(key=lambda x: x[1].order_elected)
else:
raise STVException('Invalid surplus order option')
# Attempt to defer all remaining surpluses if possible
if self.options['defer_surpluses']:
if self.can_defer_surpluses(has_surplus):
has_surplus = []
if len(has_surplus) > 0:
# Cannot defer any surpluses
if self.options['surplus_order'] == 'size':
candidate_surplus, count_card = self.choose_highest(has_surplus) # May need to break ties
elif self.options['surplus_order'] == 'order':
candidate_surplus, count_card = has_surplus[0] # Ties were already broken when these were assigned
if candidate_surplus is not None:
count_card.state = CandidateState.DISTRIBUTING_SURPLUS count_card.state = CandidateState.DISTRIBUTING_SURPLUS
__pragma__('opov') __pragma__('opov')
@ -262,7 +314,8 @@ class BaseSTVCounter:
self.exhausted, self.exhausted,
self.loss_fraction, self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes, self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota self.quota,
self.vote_required_election,
) )
__pragma__('noopov') __pragma__('noopov')
@ -300,24 +353,27 @@ class BaseSTVCounter:
self.exhausted, self.exhausted,
self.loss_fraction, self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes, self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota self.quota,
self.vote_required_election,
) )
__pragma__('noopov') __pragma__('noopov')
self.step_results.append(result) self.step_results.append(result)
return result return result
def exclude_candidate(self): def exclude_candidates(self):
""" """
Exclude the lowest ranked hopeful Exclude the lowest ranked hopeful(s)
""" """
candidate_excluded, count_card = self.candidate_to_exclude() candidates_excluded = self.candidates_to_exclude()
count_card.state = CandidateState.EXCLUDING for candidate, count_card in candidates_excluded:
count_card.state = CandidateState.EXCLUDING
# Handle Wright STV # Handle Wright STV
if self.options['exclusion'] == 'wright': if self.options['exclusion'] == 'wright':
count_card.state = CandidateState.EXCLUDED for candidate, count_card in candidates_excluded:
count_card.state = CandidateState.EXCLUDED
# Reset the count # Reset the count
# Carry over certain candidate states # Carry over certain candidate states
@ -342,12 +398,12 @@ class BaseSTVCounter:
step_results = self.step_results # Carry over step results step_results = self.step_results # Carry over step results
result = self.reset() result = self.reset()
self.step_results = step_results self.step_results = step_results
result.comment = 'Exclusion of ' + candidate_excluded.name result.comment = 'Exclusion of ' + ', '.join([c.name for c, cc in candidates_excluded])
return result return result
# Exclude this candidate # Exclude this candidate
self.do_exclusion(candidate_excluded, count_card) self.do_exclusion(candidates_excluded)
# Declare any candidates meeting the quota as a result of exclusion # Declare any candidates meeting the quota as a result of exclusion
self.compute_quota() self.compute_quota()
@ -356,36 +412,87 @@ class BaseSTVCounter:
__pragma__('opov') __pragma__('opov')
result = CountStepResult( result = CountStepResult(
'Exclusion of ' + candidate_excluded.name, 'Exclusion of ' + ', '.join([c.name for c, cc in candidates_excluded]),
self.candidates, self.candidates,
self.exhausted, self.exhausted,
self.loss_fraction, self.loss_fraction,
self.total + self.exhausted.votes + self.loss_fraction.votes, self.total + self.exhausted.votes + self.loss_fraction.votes,
self.quota self.quota,
self.vote_required_election,
) )
__pragma__('noopov') __pragma__('noopov')
self.step_results.append(result) self.step_results.append(result)
return result return result
def candidate_to_exclude(self): def candidates_to_bulk_exclude(self, hopefuls):
""" """
Determine the candidate to exclude Determine which candidates can be bulk excluded
Returns List[Tuple[Candidate, CountCard]]
"""
remaining_candidates = self.num_elected + sum(1 for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL)
__pragma__('opov')
total_surpluses = sum((cc.votes - self.quota for c, cc in self.candidates.items() if cc.votes > self.quota), Num(0))
__pragma__('noopov')
# Attempt to exclude as many candidates as possible
for i in range(0, len(hopefuls)):
try_exclude = hopefuls[0:len(hopefuls)-i]
# Do not exclude if this splits tied candidates
__pragma__('opov')
if i != 0 and try_exclude[len(hopefuls)-i-1][1].votes == hopefuls[len(hopefuls)-i][1].votes:
continue
__pragma__('noopov')
# Do not exclude if this leaves insufficient candidates
if remaining_candidates - len(try_exclude) < self.election.seats:
continue
# Do not exclude if this could change the order of exclusion
total_votes = sum((cc.votes for c, cc in try_exclude), Num(0))
__pragma__('opov')
if i != 0 and total_votes + total_surpluses > hopefuls[len(hopefuls)-i][1].votes:
continue
__pragma__('noopov')
# Can bulk exclude
return try_exclude
return []
def candidates_to_exclude(self):
"""
Determine the candidate(s) to exclude
Returns List[Tuple[Candidate, CountCard]]
""" """
# Continue current exclusion if applicable # Continue current exclusion if applicable
excluding = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.EXCLUDING] if self._exclusion is not None:
if len(excluding) > 0: __pragma__('opov')
return excluding[0][0], excluding[0][1] return self._exclusion[0]
__pragma__('noopov')
hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL] hopefuls = [(c, cc) for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL]
hopefuls.sort(key=lambda x: x[1].votes) hopefuls.sort(key=lambda x: x[1].votes)
candidate_excluded, count_card = self.choose_lowest(hopefuls) candidates_excluded = []
return candidate_excluded, count_card # Bulk exclusion
if self.options['bulk_exclude']:
if self.options['exclusion'] == 'parcels_by_order':
# Ordering of parcels is not defined in this case
raise STVException('Cannot use bulk_exclude with parcels_by_order')
candidates_excluded = self.candidates_to_bulk_exclude(hopefuls)
if len(candidates_excluded) == 0:
candidates_excluded = [self.choose_lowest(hopefuls)]
return candidates_excluded
def do_exclusion(self, candidate_excluded, count_card): def do_exclusion(self, candidates_excluded):
""" """
Exclude the given candidate and transfer the votes Exclude the given candidate and transfer the votes
Subclasses must override this function Subclasses must override this function
@ -401,18 +508,32 @@ class BaseSTVCounter:
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.quota is None or self.options['quota_mode'] == 'progressive':
if self.options['quota'] == 'droop': if self.options['quota'] == 'droop' or self.options['quota'] == 'droop_exact':
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': elif self.options['quota'] == 'hare' or self.options['quota'] == 'hare_exact':
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) self.quota = self.total / Num(self.election.seats)
else: else:
raise STVException('Invalid quota option') raise STVException('Invalid quota option')
if self.options['round_quota'] is not None:
if self.options['quota'] == 'droop' or self.options['quota'] == 'hare':
# Increment to next available increment
factor = Num(10).__pow__(self.options['round_quota'])
__pragma__('opov')
self.quota = ((self.quota * factor).__floor__() + Num(1)) / factor
__pragma__('noopov')
else:
# Round up (preserving the original quota if exact)
self.quota = self.quota.round(self.options['round_quota'], self.quota.ROUND_UP)
__pragma__('noopov') __pragma__('noopov')
if self.options['quota_mode'] == 'ers97':
# Calculate the total active vote
__pragma__('opov')
total_active_vote = sum((cc.votes for c, cc in self.candidates.items() if cc.state == CandidateState.HOPEFUL), Num('0')) + sum((cc.votes - self.quota for c, cc in self.candidates.items() if cc.votes > self.quota), Num('0'))
self.vote_required_election = total_active_vote / Num(self.election.seats - self.num_elected + 1)
__pragma__('noopov')
def meets_quota(self, count_card): def meets_quota(self, count_card):
""" """
@ -421,11 +542,11 @@ class BaseSTVCounter:
if self.options['quota_criterion'] == 'geq': if self.options['quota_criterion'] == 'geq':
__pragma__('opov') __pragma__('opov')
return count_card.votes >= self.quota return count_card.votes >= self.quota or (self.options['quota_mode'] == 'ers97' and count_card.votes >= self.vote_required_election)
__pragma__('noopov') __pragma__('noopov')
elif self.options['quota_criterion'] == 'gt': elif self.options['quota_criterion'] == 'gt':
__pragma__('opov') __pragma__('opov')
return count_card.votes > self.quota return count_card.votes > self.quota or (self.options['quota_mode'] == 'ers97' and count_card.votes > self.vote_required_election)
__pragma__('noopov') __pragma__('noopov')
else: else:
raise STVException('Invalid quota criterion') raise STVException('Invalid quota criterion')
@ -451,6 +572,9 @@ class BaseSTVCounter:
self.num_elected += 1 self.num_elected += 1
meets_quota.remove(x) meets_quota.remove(x)
if self.options['quota_mode'] == 'ers97':
self.elect_meeting_quota() # Repeat as the vote required for election may have changed
# ----------------- # -----------------
# UTILITY FUNCTIONS # UTILITY FUNCTIONS
@ -458,7 +582,7 @@ class BaseSTVCounter:
def next_preferences(self, parcels): def next_preferences(self, parcels):
""" """
Examine the specified parcels and group ballot papers by next available preference Examine the specified ballots and group ballot papers by next available preference
""" """
# SafeDict: Candidate -> [List[Ballot], ballots, votes] # SafeDict: Candidate -> [List[Ballot], ballots, votes]
next_preferences = SafeDict([(c, [[], Num('0'), Num('0')]) for c, cc in self.candidates.items()]) next_preferences = SafeDict([(c, [[], Num('0'), Num('0')]) for c, cc in self.candidates.items()])
@ -546,6 +670,11 @@ class BaseSTVCounter:
if self.options['round_weights'] is None: if self.options['round_weights'] is None:
return num return num
return num.round(self.options['round_weights'], num.ROUND_DOWN) return num.round(self.options['round_weights'], num.ROUND_DOWN)
def round_tv(self, num):
if self.options['round_tvs'] is None:
return num
return num.round(self.options['round_tvs'], num.ROUND_DOWN)
class WIGSTVCounter(BaseSTVCounter): class WIGSTVCounter(BaseSTVCounter):
""" """
@ -569,28 +698,43 @@ class WIGSTVCounter(BaseSTVCounter):
if len(cand_ballots) > 0: if len(cand_ballots) > 0:
__pragma__('opov') __pragma__('opov')
self.candidates[candidate].parcels.append(new_parcel) self.candidates[candidate].parcels.append(new_parcel)
self.candidates[candidate]._parcels_sorted = False
__pragma__('noopov') __pragma__('noopov')
__pragma__('opov') __pragma__('opov')
if self.options['papers'] == 'transferable': if self.options['papers'] == 'transferable':
if transferable_votes > surplus: if transferable_votes > surplus:
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / transferable_votes) if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / transferable_votes)
else:
tv = self.round_tv(surplus / transferable_votes)
self.candidates[candidate].transfers += self.round_votes(num_votes * tv)
else: else:
self.candidates[candidate].transfers += self.round_votes(num_votes) # Do not allow weight to increase self.candidates[candidate].transfers += self.round_votes(num_votes) # Do not allow weight to increase
else: else:
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / total_votes) if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_votes * surplus) / total_votes)
else:
tv = self.round_tv(surplus / total_votes)
self.candidates[candidate].transfers += self.round_votes(num_votes * tv)
__pragma__('noopov') __pragma__('noopov')
for ballot, ballot_value in cand_ballots: for ballot, ballot_value in cand_ballots:
__pragma__('opov') __pragma__('opov')
if self.options['papers'] == 'transferable': if self.options['papers'] == 'transferable':
if transferable_votes > surplus: if transferable_votes > surplus:
new_value = (ballot_value * surplus) / transferable_votes if self.options['round_tvs'] is None:
new_value = (ballot_value * surplus) / transferable_votes
else:
tv = self.round_tv(surplus / transferable_votes)
new_value = ballot_value * tv
else: else:
new_value = ballot_value new_value = ballot_value
else: else:
new_value = (ballot_value * surplus) / total_votes if self.options['round_tvs'] is None:
new_value = (ballot_value * surplus) / total_votes
else:
tv = self.round_tv(surplus / total_votes)
new_value = ballot_value * tv
new_parcel.append((ballot, self.round_weight(new_value))) new_parcel.append((ballot, self.round_weight(new_value)))
__pragma__('noopov') __pragma__('noopov')
@ -611,48 +755,49 @@ class WIGSTVCounter(BaseSTVCounter):
count_card.state = CandidateState.ELECTED count_card.state = CandidateState.ELECTED
def do_exclusion(self, candidate_excluded, count_card): def do_exclusion(self, candidates_excluded):
if self.options['exclusion'] == 'parcels_by_order': # Optimisation: Pre-sort exclusion ballots if applicable
if len(count_card.parcels) > 0: # self._exclusion[1] -> list of ballots-per-stage, ballots-per-stage = List[Tuple[Candidate,List[Ballot+Value]]]
parcel = count_card.parcels[0] if self._exclusion is None:
next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences([parcel]) if self.options['exclusion'] == 'one_round':
else: self._exclusion = (candidates_excluded, [[(c, [b for p in cc.parcels for b in p]) for c, cc in candidates_excluded]])
# TODO: Skip this entirely if this is the case elif self.options['exclusion'] == 'parcels_by_order':
parcel = [] c, cc = candidates_excluded[0]
count_card.parcels.remove(parcel) self._exclusion = (candidates_excluded, [[(c, p)] for p in cc.parcels])
elif self.options['exclusion'] == 'by_value': elif self.options['exclusion'] == 'by_value':
# Sort the ballots by value ballots = [(c, b, bv) for c, cc in candidates_excluded for p in cc.parcels for b, bv in p]
if not count_card._parcels_sorted:
ballots = [(b, bv) for p in count_card.parcels for b, bv in p] # Sort ballots by value
__pragma__('opov') __pragma__('opov')
ballots.sort(key=lambda x: x[1] / x[0].value, reverse=True) ballots.sort(key=lambda x: x[2] / x[1].value, reverse=True)
# Round to 8 decimal places to consider equality # Round to 8 decimal places to consider equality
# FIXME: Work out a better way of doing this # FIXME: Work out a better way of doing this
count_card.parcels = groupby(ballots, lambda x: (x[1] / x[0].value).round(8, x[1].ROUND_DOWN)) if self.options['round_tvs']:
count_card._parcels_sorted = True ballots_by_value = groupby(ballots, lambda x: self.round_tv(x[2] / x[1].value))
else:
ballots_by_value = groupby(ballots, lambda x: (x[2] / x[1].value).round(8, x[2].ROUND_DOWN))
__pragma__('noopov') __pragma__('noopov')
if len(count_card.parcels) > 0: # TODO: Can we combine ballots for each candidate within each stage?
parcel = count_card.parcels[0] self._exclusion = (candidates_excluded, [[(c, [(b, bv)]) for c, b, bv in x] for x in ballots_by_value])
count_card.parcels.remove(parcel)
else: else:
parcel = [] raise STVException('Invalid exclusion mode')
next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences([parcel])
else: # one_round
next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences(count_card.parcels)
count_card.parcels = []
#print([[bv / b.value for c, bb in stage for b, bv in bb] for stage in self._exclusion[1]])
this_exclusion = self._exclusion[1][0]
self._exclusion[1].remove(this_exclusion)
# Transfer votes
next_preferences, total_ballots, total_votes, next_exhausted, exhausted_ballots, exhausted_votes = self.next_preferences([bb for c, bb in this_exclusion])
for candidate, x in next_preferences.items(): for candidate, x in next_preferences.items():
cand_ballots = x[0] cand_ballots, num_ballots, num_votes = x[0], x[1], x[2]
num_ballots = x[1]
num_votes = x[2]
new_parcel = [] new_parcel = []
if len(cand_ballots) > 0: if len(cand_ballots) > 0:
__pragma__('opov') __pragma__('opov')
self.candidates[candidate].parcels.append(new_parcel) self.candidates[candidate].parcels.append(new_parcel)
self.candidates[candidate]._parcels_sorted = False
__pragma__('noopov') __pragma__('noopov')
__pragma__('opov') __pragma__('opov')
@ -664,19 +809,30 @@ class WIGSTVCounter(BaseSTVCounter):
new_parcel.append((ballot, ballot_value)) new_parcel.append((ballot, ballot_value))
__pragma__('noopov') __pragma__('noopov')
# Subtract votes
__pragma__('opov') __pragma__('opov')
self.exhausted.transfers += self.round_votes(exhausted_votes) self.exhausted.transfers += self.round_votes(exhausted_votes)
__pragma__('noopov') __pragma__('noopov')
__pragma__('opov') for candidate, ballots in this_exclusion:
count_card.transfers -= total_votes total_votes = Num(0)
__pragma__('noopov') for ballot, ballot_value in ballots:
__pragma__('opov')
if len(count_card.parcels) == 0: total_votes += ballot_value
__pragma__('noopov')
__pragma__('opov') __pragma__('opov')
count_card.transfers -= count_card.votes self.candidates[candidate].transfers -= total_votes
__pragma__('noopov') __pragma__('noopov')
count_card.state = CandidateState.EXCLUDED
if len(self._exclusion[1]) == 0:
for candidate_excluded, count_card in candidates_excluded:
__pragma__('opov')
count_card.transfers -= count_card.votes
__pragma__('noopov')
count_card.state = CandidateState.EXCLUDED
self._exclusion = None
class UIGSTVCounter(WIGSTVCounter): class UIGSTVCounter(WIGSTVCounter):
""" """
@ -704,28 +860,43 @@ class UIGSTVCounter(WIGSTVCounter):
if len(cand_ballots) > 0: if len(cand_ballots) > 0:
__pragma__('opov') __pragma__('opov')
self.candidates[candidate].parcels.append(new_parcel) self.candidates[candidate].parcels.append(new_parcel)
self.candidates[candidate]._parcels_sorted = False
__pragma__('noopov') __pragma__('noopov')
__pragma__('opov') __pragma__('opov')
if self.options['papers'] == 'transferable': if self.options['papers'] == 'transferable':
if transferable_votes > surplus: if transferable_votes > surplus:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots) if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots)
else:
tv = self.round_tv(surplus / transferable_ballots)
self.candidates[candidate].transfers += self.round_votes(num_ballots * tv)
else: else:
self.candidates[candidate].transfers += self.round_votes(num_votes) self.candidates[candidate].transfers += self.round_votes(num_votes)
else: else:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots) if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots)
else:
tv = self.round_tv(surplus / total_ballots)
self.candidates[candidate].transfers += self.round_votes(num_ballots * tv)
__pragma__('noopov') __pragma__('noopov')
for ballot, ballot_value in cand_ballots: for ballot, ballot_value in cand_ballots:
__pragma__('opov') __pragma__('opov')
if self.options['papers'] == 'transferable': if self.options['papers'] == 'transferable':
if transferable_votes > surplus: if transferable_votes > surplus:
new_value = (ballot.value * surplus) / transferable_ballots if self.options['round_tvs'] is None:
new_value = (ballot.value * surplus) / transferable_ballots
else:
tv = self.round_tv(surplus / transferable_ballots)
new_value = ballot.value * tv
else: else:
new_value = ballot_value new_value = ballot_value
else: else:
new_value = (ballot.value * surplus) / total_ballots if self.options['round_tvs'] is None:
new_value = (ballot.value * surplus) / total_ballots
else:
tv = self.round_tv(surplus / total_ballots)
new_value = ballot.value * tv
new_parcel.append((ballot, self.round_weight(new_value))) new_parcel.append((ballot, self.round_weight(new_value)))
__pragma__('noopov') __pragma__('noopov')
@ -772,28 +943,43 @@ class EGSTVCounter(UIGSTVCounter):
if len(cand_ballots) > 0: if len(cand_ballots) > 0:
__pragma__('opov') __pragma__('opov')
self.candidates[candidate].parcels.append(new_parcel) self.candidates[candidate].parcels.append(new_parcel)
self.candidates[candidate]._parcels_sorted = False
__pragma__('noopov') __pragma__('noopov')
__pragma__('opov') __pragma__('opov')
if self.options['papers'] == 'transferable': if self.options['papers'] == 'transferable':
if transferable_votes > surplus: if transferable_votes > surplus:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots) if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / transferable_ballots)
else:
tv = self.round_tv(surplus / transferable_ballots)
self.candidates[candidate].transfers += self.round_votes(num_ballots * tv)
else: else:
self.candidates[candidate].transfers += self.round_votes(num_votes) self.candidates[candidate].transfers += self.round_votes(num_votes)
else: else:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots) if self.options['round_tvs'] is None:
self.candidates[candidate].transfers += self.round_votes((num_ballots * surplus) / total_ballots)
else:
tv = self.round_tv(surplus / total_ballots)
self.candidates[candidate].transfers += self.round_votes(num_ballots * tv)
__pragma__('noopov') __pragma__('noopov')
for ballot, ballot_value in cand_ballots: for ballot, ballot_value in cand_ballots:
__pragma__('opov') __pragma__('opov')
if self.options['papers'] == 'transferable': if self.options['papers'] == 'transferable':
if transferable_votes > surplus: if transferable_votes > surplus:
new_value = (ballot.value * surplus) / transferable_ballots if self.options['round_tvs'] is None:
new_value = (ballot.value * surplus) / transferable_ballots
else:
tv = self.round_tv(surplus / transferable_ballots)
new_value = ballot.value * tv
else: else:
new_value = ballot_value new_value = ballot_value
else: else:
new_value = (ballot.value * surplus) / total_ballots if self.options['round_tvs'] is None:
new_value = (ballot.value * surplus) / total_ballots
else:
tv = self.round_tv(surplus / total_ballots)
new_value = ballot.value * tv
new_parcel.append((ballot, self.round_weight(new_value))) new_parcel.append((ballot, self.round_weight(new_value)))
__pragma__('noopov') __pragma__('noopov')

View File

@ -1,5 +1,5 @@
# pyRCV2: Preferential vote counting # pyRCV2: Preferential vote counting
# Copyright © 2020 Lee Yingtong Li (RunasSudo) # Copyright © 2020–2021 Lee Yingtong Li (RunasSudo)
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@ -98,7 +98,7 @@ class CountCompleted:
pass pass
class CountStepResult: class CountStepResult:
def __init__(self, comment, candidates, exhausted, loss_fraction, total, quota): def __init__(self, comment, candidates, exhausted, loss_fraction, total, quota, vote_required_election):
self.comment = comment self.comment = comment
self.candidates = candidates # SafeDict: Candidate -> CountCard self.candidates = candidates # SafeDict: Candidate -> CountCard
@ -107,6 +107,7 @@ class CountStepResult:
self.total = total self.total = total
self.quota = quota self.quota = quota
self.vote_required_election = vote_required_election
def clone(self): def clone(self):
"""Return a clone of this result as a record of this stage""" """Return a clone of this result as a record of this stage"""

View File

@ -1,5 +1,5 @@
# pyRCV2: Preferential vote counting # pyRCV2: Preferential vote counting
# Copyright © 2020 Lee Yingtong Li (RunasSudo) # Copyright © 2020–2021 Lee Yingtong Li (RunasSudo)
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@ -22,12 +22,12 @@ __pragma__('noskip')
if is_py: if is_py:
__pragma__('skip') __pragma__('skip')
from pyRCV2.numbers.fixed_py import Fixed, set_dps from pyRCV2.numbers.fixed_py import Fixed, set_dps, get_dps
from pyRCV2.numbers.native_py import Native from pyRCV2.numbers.native_py import Native
from pyRCV2.numbers.rational_py import Rational from pyRCV2.numbers.rational_py import Rational
__pragma__('noskip') __pragma__('noskip')
else: else:
from pyRCV2.numbers.fixed_js import Fixed, set_dps from pyRCV2.numbers.fixed_js import Fixed, set_dps, get_dps
from pyRCV2.numbers.native_js import Native from pyRCV2.numbers.native_js import Native
from pyRCV2.numbers.rational_js import Rational from pyRCV2.numbers.rational_js import Rational

View File

@ -116,6 +116,9 @@ class BaseNum:
def __le__(self, other): def __le__(self, other):
raise NotImplementedError('Method not implemented') raise NotImplementedError('Method not implemented')
def __pow__(self, power):
raise NotImplementedError('Method not implemented')
def round(self, dps, mode): def round(self, dps, mode):
""" """
Round to the specified number of decimal places, using the ROUND_* mode specified Round to the specified number of decimal places, using the ROUND_* mode specified
@ -214,6 +217,10 @@ class BasePyNum(BaseNum):
"""Implements BaseNum.__le__""" """Implements BaseNum.__le__"""
return self.impl <= other.impl return self.impl <= other.impl
def __pow__(self, power):
"""Implements BaseNum.__pow__"""
return self._from_impl(self.impl ** power)
@compatible_types @compatible_types
def __iadd__(self, other): def __iadd__(self, other):
"""Overrides BaseNum.__iadd__""" """Overrides BaseNum.__iadd__"""

View File

@ -21,6 +21,9 @@ Big.DP = 6
def set_dps(dps): def set_dps(dps):
Big.DP = dps Big.DP = dps
def get_dps():
return Big.DP
class Fixed(BaseNum): class Fixed(BaseNum):
""" """
Wrapper for big.js (fixed-point arithmetic) Wrapper for big.js (fixed-point arithmetic)
@ -73,6 +76,10 @@ class Fixed(BaseNum):
"""Implements BaseNum.__le__""" """Implements BaseNum.__le__"""
return self.impl.lte(other.impl) return self.impl.lte(other.impl)
def __pow__(self, power):
"""Implements BaseNum.__pow__"""
return Fixed._from_impl(self.impl.pow(power))
def round(self, dps, mode): def round(self, dps, mode):
"""Implements BaseNum.round""" """Implements BaseNum.round"""
return Fixed(self.impl.round(dps, mode)) return Fixed._from_impl(self.impl.round(dps, mode))

View File

@ -18,12 +18,17 @@ from pyRCV2.numbers.base import BasePyNum, compatible_types
import decimal import decimal
_quantize_exp = 6 _dps = 6
_quantize_exp = decimal.Decimal('10') ** -_dps
def set_dps(dps): def set_dps(dps):
global _quantize_exp global _dps, _quantize_exp
_dps = dps
_quantize_exp = decimal.Decimal('10') ** -dps _quantize_exp = decimal.Decimal('10') ** -dps
def get_dps():
return _dps
class Fixed(BasePyNum): class Fixed(BasePyNum):
""" """
Wrapper for Python Decimal (for fixed-point arithmetic) Wrapper for Python Decimal (for fixed-point arithmetic)
@ -43,4 +48,4 @@ class Fixed(BasePyNum):
def round(self, dps, mode): def round(self, dps, mode):
"""Implements BaseNum.round""" """Implements BaseNum.round"""
return Fixed(self.impl.quantize(decimal.Decimal('10') ** -dps, mode)) return Fixed._from_impl(self.impl.quantize(decimal.Decimal('10') ** -dps, mode))

View File

@ -74,17 +74,21 @@ class Native(BaseNum):
def __floor__(self): def __floor__(self):
"""Overrides BaseNum.__floor__""" """Overrides BaseNum.__floor__"""
return Native(Math.floor(self.impl)) return Native._from_impl(Math.floor(self.impl))
def __pow__(self, power):
"""Implements BaseNum.__pow__"""
return Native._from_impl(Math.pow(self.impl, power))
def round(self, dps, mode): def round(self, dps, mode):
"""Implements BaseNum.round""" """Implements BaseNum.round"""
if mode == Native.ROUND_DOWN: if mode == Native.ROUND_DOWN:
return Native(Math.floor(self.impl * Math.pow(10, dps)) / Math.pow(10, dps)) return Native._from_impl(Math.floor(self.impl * Math.pow(10, dps)) / Math.pow(10, dps))
elif mode == Native.ROUND_HALF_UP: elif mode == Native.ROUND_HALF_UP:
return Native(Math.round(self.impl * Math.pow(10, dps)) / Math.pow(10, dps)) return Native._from_impl(Math.round(self.impl * Math.pow(10, dps)) / Math.pow(10, dps))
elif mode == Native.ROUND_HALF_EVEN: elif mode == Native.ROUND_HALF_EVEN:
raise NotImplementedError('ROUND_HALF_EVEN is not implemented in JS Native context') raise NotImplementedError('ROUND_HALF_EVEN is not implemented in JS Native context')
elif mode == Native.ROUND_UP: elif mode == Native.ROUND_UP:
return Native(Math.ceil(self.impl * Math.pow(10, dps)) / Math.pow(10, dps)) return Native._from_impl(Math.ceil(self.impl * Math.pow(10, dps)) / Math.pow(10, dps))
else: else:
raise ValueError('Invalid rounding mode') raise ValueError('Invalid rounding mode')

View File

@ -29,12 +29,12 @@ class Native(BasePyNum):
"""Implements BaseNum.round""" """Implements BaseNum.round"""
factor = 10 ** dps factor = 10 ** dps
if mode == Native.ROUND_DOWN: if mode == Native.ROUND_DOWN:
return Native(math.floor(self.impl * factor) / factor) return Native._from_impl(math.floor(self.impl * factor) / factor)
elif mode == Native.ROUND_HALF_UP: elif mode == Native.ROUND_HALF_UP:
raise NotImplementedError('ROUND_HALF_UP is not implemented in Python Native context') raise NotImplementedError('ROUND_HALF_UP is not implemented in Python Native context')
elif mode == Native.ROUND_HALF_EVEN: elif mode == Native.ROUND_HALF_EVEN:
return Native(round(self.impl * factor) / factor) return Native._from_impl(round(self.impl * factor) / factor)
elif mode == Native.ROUND_UP: elif mode == Native.ROUND_UP:
return Native(math.ceil(self.impl * factor) / factor) return Native._from_impl(math.ceil(self.impl * factor) / factor)
else: else:
raise ValueError('Invalid rounding mode') raise ValueError('Invalid rounding mode')

View File

@ -71,18 +71,22 @@ class Rational(BaseNum):
def __floor__(self): def __floor__(self):
"""Overrides BaseNum.__floor__""" """Overrides BaseNum.__floor__"""
return Rational(self.impl.floor()) return Rational._from_impl(self.impl.floor())
def __pow__(self, power):
"""Implements BaseNum.__pow__"""
return Rational._from_impl(self.impl.pow(power))
def round(self, dps, mode): def round(self, dps, mode):
"""Implements BaseNum.round""" """Implements BaseNum.round"""
factor = bigRat(10).pow(dps) factor = bigRat(10).pow(dps)
if mode == Rational.ROUND_DOWN: if mode == Rational.ROUND_DOWN:
return Rational(self.impl.multiply(factor).floor().divide(factor)) return Rational._from_impl(self.impl.multiply(factor).floor().divide(factor))
elif mode == Rational.ROUND_HALF_UP: elif mode == Rational.ROUND_HALF_UP:
return Rational(self.impl.multiply(factor).round().divide(factor)) return Rational._from_impl(self.impl.multiply(factor).round().divide(factor))
elif mode == Rational.ROUND_HALF_EVEN: elif mode == Rational.ROUND_HALF_EVEN:
raise NotImplementedError('ROUND_HALF_EVEN is not implemented in JS Native context') raise NotImplementedError('ROUND_HALF_EVEN is not implemented in JS Native context')
elif mode == Rational.ROUND_UP: elif mode == Rational.ROUND_UP:
return Rational(self.impl.multiply(factor).ceil().divide(factor)) return Rational._from_impl(self.impl.multiply(factor).ceil().divide(factor))
else: else:
raise ValueError('Invalid rounding mode') raise ValueError('Invalid rounding mode')

View File

@ -35,12 +35,12 @@ class Rational(BasePyNum):
"""Implements BaseNum.round""" """Implements BaseNum.round"""
factor = Fraction(10) ** dps factor = Fraction(10) ** dps
if mode == Rational.ROUND_DOWN: if mode == Rational.ROUND_DOWN:
return Rational(math.floor(self.impl * factor) / factor) return Rational._from_impl(math.floor(self.impl * factor) / factor)
elif mode == Rational.ROUND_HALF_UP: elif mode == Rational.ROUND_HALF_UP:
raise NotImplementedError('ROUND_HALF_UP is not implemented in Python Rational context') raise NotImplementedError('ROUND_HALF_UP is not implemented in Python Rational context')
elif mode == Rational.ROUND_HALF_EVEN: elif mode == Rational.ROUND_HALF_EVEN:
return Rational(round(self.impl * factor) / factor) return Rational._from_impl(round(self.impl * factor) / factor)
elif mode == Rational.ROUND_UP: elif mode == Rational.ROUND_UP:
return Rational(math.ceil(self.impl * factor) / factor) return Rational._from_impl(math.ceil(self.impl * factor) / factor)
else: else:
raise ValueError('Invalid rounding mode') raise ValueError('Invalid rounding mode')

51
tests/data/ers97.blt Normal file
View File

@ -0,0 +1,51 @@
11 6
35 1 2
3 1 3 9
11 1 3 6
5 1 3 7
6 1 3 8
20 1 4 0
7 1 4 9
4 1 4 11
8 1 5
4 1 8
1 1 10 5 7
1 1 10 9
18 1 11
11 1 0
81 2
23 3 1 2 0
2 3 1 2 7
1 3 1 2 8
1 3 1 0
3 4 1 3 7
1 4 8
11 4 5 6
3 4 2 0
5 4 2 11
1 4 3 0
105 5
91 6
64 7
5 8
42 8 9
12 8 7
55 9
2 10 5
5 10 8
14 10 9
2 10 1 11
90 11
0
"Smith"
"Carpenter"
"Wright"
"Glazier"
"Duke"
"Prince"
"Baron"
"Abbot"
"Vicar"
"Monk"
"Freeman"
"ERS97 Model Election"

View File

@ -1,5 +1,5 @@
# pyRCV2: Preferential vote counting # pyRCV2: Preferential vote counting
# Copyright © 2020 Lee Yingtong Li (RunasSudo) # Copyright © 2020–2021 Lee Yingtong Li (RunasSudo)
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@ -47,6 +47,7 @@ def test_aec_tas19():
counter = UIGSTVCounter(election, { counter = UIGSTVCounter(election, {
'surplus_order': 'order', 'surplus_order': 'order',
'exclusion': 'by_value', 'exclusion': 'by_value',
'round_quota': 0,
'round_votes': 0, 'round_votes': 0,
}) })
result = counter.reset() result = counter.reset()

View File

@ -43,7 +43,10 @@ def test_csm15():
cands = {c.name: c for c in election.candidates} cands = {c.name: c for c in election.candidates}
counter = WIGSTVCounter(election, {'exclusion': 'wright'}) counter = WIGSTVCounter(election, {
'exclusion': 'wright',
'round_quota': 0,
})
# Round 1 # Round 1
result = counter.reset() result = counter.reset()

View File

@ -1,5 +1,5 @@
# pyRCV2: Preferential vote counting # pyRCV2: Preferential vote counting
# Copyright © 2020 Lee Yingtong Li (RunasSudo) # Copyright © 2020–2021 Lee Yingtong Li (RunasSudo)
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@ -45,7 +45,8 @@ def test_prsa1():
counter = EGSTVCounter(election, { counter = EGSTVCounter(election, {
'surplus_order': 'order', 'surplus_order': 'order',
'papers': 'transferable', 'papers': 'transferable',
'exclusion': 'parcels_by_order' 'exclusion': 'parcels_by_order',
'round_quota': 0,
}) })
# Stage 1 # Stage 1