Author: humbedooh
Date: Thu Mar 26 19:10:31 2015
New Revision: 1669412
URL: http://svn.apache.org/r1669412
Log:
use stv_tool.py's calculations instead, they actually work.
Modified:
steve/trunk/pysteve/lib/plugins/stv.py
Modified: steve/trunk/pysteve/lib/plugins/stv.py
URL:
http://svn.apache.org/viewvc/steve/trunk/pysteve/lib/plugins/stv.py?rev=1669412&r1=1669411&r2=1669412&view=diff
==============================================================================
--- steve/trunk/pysteve/lib/plugins/stv.py (original)
+++ steve/trunk/pysteve/lib/plugins/stv.py Thu Mar 26 19:10:31 2015
@@ -17,8 +17,19 @@
""" STV Voting Plugin """
import re, json, random
+ELECTED = 1
+HOPEFUL = 2
+ELIMINATED = 4
+ALMOST = 8
+
+ERROR_MARGIN = 0.00001
+BILLIONTH = 0.000000001
+
+
from lib import constants
+debug = []
+
def validateSTV(vote, issue):
"Tries to validate a vote, returns why if not valid, None otherwise"
letters = [chr(i) for i in range(ord('a'), ord('a') +
len(issue['candidates']))]
@@ -64,99 +75,307 @@ def getproportion(votes, winners, step,
-def tallySTV(votes, issue):
- m = re.match(r"stv(\d+)", issue['type'])
- if not m:
- raise Exception("Not an STV vote!")
-
- numseats = int(m.group(1))
- candidates = []
- for c in issue['candidates']:
- candidates.append(c['name'])
-
- debug = []
-
- # Set up letters for mangling
- letters = [chr(i) for i in range(ord('a'), ord('a') +
len(candidates))]
- cc = "".join(letters)
-
- # Keep score of votes
- points = {}
-
- # Set all scores to 0 at first
- for c in cc:
- points[c] = 0
- # keep score of winners
- winners = []
- turn = 0
- # Find quota to win a seat
- quota = ( len(votes) / (numseats + 1) ) + 1
- debug.append("Seats available: %u. Votes cast: %u" % (numseats,
len(votes)))
- debug.append("Votes required to win a seat: %u ( (%u/(%u+1))+1 )" %
(quota, len(votes), numseats))
+def run_vote(names, votes, num_seats):
+ candidates = CandidateList(names)
-
- surplus = 0
- # While we still have seats to fill
- if not len(candidates) < numseats:
- y = 0
- while len(winners) < numseats and len(cc) > 0 and turn < 1000:
#Don't run for > 1000 iterations, that's a bug
- turn += 1
+ # name -> Candidate
+ remap = dict((c.name, c) for c in candidates.l)
- s = 0
+ # Turn VOTES into a list of ordered-lists of Candidate objects
+ votes = [[remap[n] for n in choices] for choices in votes.values()]
+
+ if candidates.count(ELECTED + HOPEFUL) <= num_seats:
+ debug.append('All candidates elected')
+ candidates.change_state(HOPEFUL, ELECTED)
+ return
+ if num_seats <= 0:
+ candidates.change_state(HOPEFUL, ELIMINATED)
+ return
+
+ quota = None # not used on first pass
+ iteration = 1
+ while candidates.count(ELECTED) < num_seats:
+ debug.append('Iteration %d' % iteration)
+ iteration += 1
+ quota = iterate_one(quota, votes, candidates, num_seats)
+ candidates.reverse_random()
+
+ debug.append('All seats full')
+ candidates.change_state(HOPEFUL, ELIMINATED)
+
+ return candidates.retval()
+
+
+class CandidateList(object):
+ def __init__(self, names):
+ num_cand = len(names)
+ randset = generate_random(num_cand)
+
+ self.l = [ ]
+ for n, r in zip(names, randset):
+ c = Candidate(n, r, num_cand-1)
+ self.l.append(c)
+
+ def count(self, state):
+ count = 0
+ for c in self.l:
+ if (c.status & state) != 0:
+ count += 1
+ return count
+ def retval(self):
+ winners = []
+ for c in self.l:
+ if c.status == ELECTED:
+ winners.append(c.name)
+ return winners
- # Get votes
- xpoints = getproportion(votes, winners, 0, surplus)
- surplus = 0
- if turn == 1:
- debug.append("Initial tally: %s" % json.dumps(xpoints))
- else:
- debug.append("Proportional move: %s" %
json.dumps(xpoints))
-
- for x in xpoints:
- points[x] += xpoints[x]
- mq = 0
-
- # For each candidate letter, find if someone won a seat
- for c in cc:
- if len(winners) >= numseats:
- break
- if points[c] >= quota and not c in winners:
- winners.append(c)
- debug.append("WINNER: %s got elected in with %u
votes! %u seats remain" % (c, points[c], numseats - len(winners)))
- cc.replace(c, "")
- mq += 1
- surplus += points[c] - quota
-
- # If we found no winners in this round, eliminate the lowest
scorer and retally
- if mq < 1:
- lowest = 99999999
- lowestC = None
- for c in cc:
- if points[c] < lowest:
- lowest = points[c]
- lowestC = c
-
- debug.append("DRAW: %s is eliminated" % lowestC)
- if lowestC:
- cc.replace(lowestC, "")
- else:
- debug.append("No more canididates?? buggo?")
- break
- y += 1
+ def change_state(self, from_state, to_state):
+ any_changed = False
+ for c in self.l:
+ if (c.status & from_state) != 0:
+ c.status = to_state
+ if to_state == ELECTED:
+ c.elect()
+ elif to_state == ELIMINATED:
+ c.eliminate()
+ any_changed = True
+ return any_changed
+
+ def reverse_random(self):
+ # Flip the values to create a different ordering among candidates.
Note
+ # that this will alternate the domain between [0.0, 1.0) and (0.0,
1.0]
+ for c in self.l:
+ c.rand = 1.0 - c.rand
+
+ def adjust_weights(self, quota):
+ for c in self.l:
+ if c.status == ELECTED:
+ c.adjust_weight(quota)
+
+ def print_results(self):
+ for c in self.l:
+ print '%-40s%selected' % (c.name, c.status == ELECTED and ' ' or '
not ')
+
+ def dbg_display_tables(self, excess):
+ total = excess
+ for c in self.l:
+ debug.append('%-20s %15.9f %15.9f' % (c.name, c.weight, c.vote))
+ total += c.vote
+ debug.append('%-20s %15s %15.9f' %( 'Non-transferable', ' ', excess))
+ debug.append('%-20s %15s %15.9f' % ( 'Total', ' ', total))
+
+
+class Candidate(object):
+ def __init__(self, name, rand, ahead):
+ self.name = name
+ self.rand = rand
+ self.ahead = ahead
+ self.status = HOPEFUL
+ self.weight = 1.0
+ self.vote = None # calculated later
+
+ def elect(self):
+ self.status = ELECTED
+ debug.append('Elected: %s' % self.name)
+
+ def eliminate(self):
+ self.status = ELIMINATED
+ self.weight = 0.0
+ debug.append('Eliminated: %s' % self.name)
+
+ def adjust_weight(self, quota):
+ assert quota is not None
+ self.weight = (self.weight * quota) / self.vote
+
+ def __cmp__(self, other):
+ if self.ahead < other.ahead:
+ return -1
+ if self.ahead == other.ahead:
+ return cmp(self.vote, other.vote)
+ return 1
+
+
+def iterate_one(quota, votes, candidates, num_seats):
+ # assume that: count(ELECTED) < num_seats
+ if candidates.count(ELECTED + HOPEFUL) <= num_seats:
+ debug.append('All remaining candidates elected')
+ candidates.change_state(HOPEFUL, ELECTED)
+ return None
+
+ candidates.adjust_weights(quota)
- # Everyone's a winner!!
+ changed, new_quota, surplus = recalc(votes, candidates, num_seats)
+ if not changed and surplus < ERROR_MARGIN:
+ debug.append('Remove Lowest (forced)')
+ exclude_lowest(candidates.l)
+ return new_quota
+
+
+def recalc(votes, candidates, num_seats):
+ excess = calc_totals(votes, candidates)
+ calc_aheads(candidates)
+ candidates.dbg_display_tables(excess)
+ quota = calc_quota(len(votes), excess, num_seats)
+ any_changed = elect(quota, candidates, num_seats)
+ surplus = calc_surplus(quota, candidates)
+ any_changed |= try_remove_lowest(surplus, candidates)
+ return any_changed, quota, surplus
+
+
+def calc_totals(votes, candidates):
+ for c in candidates.l:
+ c.vote = 0.0
+ excess = 0.0
+ for choices in votes:
+ vote = 1.0
+ for c in choices:
+ if c.status == HOPEFUL:
+ c.vote += vote
+ vote = 0.0
+ break
+ if c.status != ELIMINATED:
+ wv = c.weight * vote # weighted vote
+ c.vote += wv
+ vote -= wv
+ if vote == 0.0: # nothing left to distribute
+ break
+ excess += vote
+ return excess
+
+
+def calc_aheads(candidates):
+ c_sorted = sorted(candidates.l)
+ last = 0
+ for i in range(1, len(c_sorted)+1):
+ if i == len(c_sorted) or c_sorted[last] != c_sorted[i]:
+ for c in c_sorted[last:i]:
+ c.ahead = (i - 1) + last
+ last = i
+
+
+def calc_quota(num_voters, excess, num_seats):
+ if num_seats > 2:
+ quota = (float(num_voters) - excess) / (float(num_seats + 1) +
BILLIONTH)
+ else:
+ quota = (float(num_voters) - excess) / float(num_seats + 1)
+ if quota < ERROR_MARGIN:
+ raise Exception('Internal Error - very low quota')
+ debug.append('Quota = %.9f' % quota)
+ return quota
+
+
+def elect(quota, candidates, num_seats):
+ for c in candidates.l:
+ if c.status == HOPEFUL and c.vote >= quota:
+ c.status = ALMOST
+
+ any_changed = False
+
+ while candidates.count(ELECTED + ALMOST) > num_seats:
+ debug.append('Vote tiebreaker! voters: %d seats: %d' %(
candidates.count(ELECTED + ALMOST), num_seats))
+ candidates.change_state(HOPEFUL, ELIMINATED)
+ exclude_lowest(candidates.l)
+ any_changed = True # we changed the candidates via exclude_lowest()
+
+ any_changed |= candidates.change_state(ALMOST, ELECTED)
+ return any_changed
+
+
+def calc_surplus(quota, candidates):
+ surplus = 0.0
+ for c in candidates.l:
+ if c.status == ELECTED:
+ surplus += c.vote - quota
+ debug.append('Total Surplus = %.9f' % surplus)
+ return surplus
+
+
+def try_remove_lowest(surplus, candidates):
+ lowest1 = 1e18
+ lowest2 = 1e18
+ which = None
+ for c in candidates.l:
+ if c.status == HOPEFUL and c.vote < lowest1:
+ lowest1 = c.vote
+ which = c
+ for c in candidates.l:
+ if c != which and c.status != ELIMINATED and c.vote < lowest2:
+ lowest2 = c.vote
+
+ diff = lowest2 - lowest1
+ if diff >= 0.0:
+ debug.append('Lowest Difference = %.9f - %.9f = %.9f' % ( lowest2,
lowest1, diff))
+ if diff > surplus:
+ debug.append('Remove Lowest (unforced)')
+ which.eliminate()
+ return True
+ return False
+
+
+def exclude_lowest(candidates):
+ ### use: ahead = len(candidates) ??
+ ahead = 1000000000. # greater than any possible candidate.ahead
+ rand = 1.1 # greater than any possible candidate.rand
+ which = None
+ used_rand = False
+
+ for c in candidates:
+ if c.status == HOPEFUL or c.status == ALMOST:
+ if c.ahead < ahead:
+ ahead = c.ahead
+ rand = c.rand
+ which = c
+ use_rand = False
+ elif c.ahead == ahead:
+ use_rand = True
+ if c.rand < rand:
+ ran = c.rand
+ which = c
+
+ if use_rand:
+ debug.append('Random choice used!')
+
+ assert which
+ which.eliminate()
+
+
+def generate_random(count):
+ random.seed(0) ### choose a seed based on input? for now: repeatable.
+ while True:
+ # Generate COUNT values in [0.0, 1.0)
+ values = [random.random() for x in range(count)]
+
+ # Ensure there are no duplicates
+ for value in values:
+ if values.count(value) > 1:
+ break
else:
- winners = letters
+ # The loop finished without breaking out
+ return values
- # Compile list of winner names
+
+def tallySTV(votes, issue):
+
+ m = re.match(r"stv(\d+)", issue['type'])
+ if not m:
+ raise Exception("Not an STV vote!")
+
+ numseats = int(m.group(1))
+ candidates = {}
+ z = 0
+ for c in issue['candidates']:
+ candidates[chr(ord('a') + z)] = c['name']
+ z += 1
+
+ # run the stv calc
+ winners = run_vote(candidates, votes, numseats)
winnernames = []
- random.shuffle(winners)
+
for c in winners:
- i = ord(c) - ord('a')
- winnernames.append(candidates[i])
+ winnernames.append(candidates[c])
# Return the data
return {