Completed
Push — master ( ecc4a9...2079ef )
by Michael
02:51
created

VoteHandler::transferSurplusVotes()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 19
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
dl 0
loc 19
ccs 0
cts 11
cp 0
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 11
nc 3
nop 2
crap 12
1
<?php
2
3
namespace Michaelc\Voting\STV;
4
5
use Psr\Log\LoggerInterface as Logger;
6
7
class VoteHandler
8
{
9
    /**
10
     * Election object.
11
     *
12
     * @var \Michaelc\Voting\STV\Election;
13
     */
14
    protected $election;
15
16
    /**
17
     * Array of all ballots in election.
18
     *
19
     * @var \MichaelC\Voting\STV\Ballot[]
20
     */
21
    protected $ballots;
22
23
    /**
24
     * Quota of votes needed for a candidate to be elected.
25
     *
26
     * @var int
27
     */
28
    protected $quota;
29
30
    /**
31
     * Number of candidates elected so far.
32
     *
33
     * @var int
34
     */
35
    protected $electedCandidates;
36
37
    /**
38
     * Invalid ballots.
39
     *
40
     * @var \MichaelC\Voting\STV\Ballot[]
41
     */
42
    protected $rejectedBallots;
43
44
    /**
45
     * Logger.
46
     *
47
     * @var \Psr\Log\LoggerInterface
48
     */
49
    protected $logger;
50
51
    /**
52
     * Constructor.
53
     *
54
     * @param Election $election
55
     */
56
    public function __construct(Election $election, Logger $logger)
57
    {
58
        $this->logger = $logger;
59
        $this->election = $election;
60
        $this->ballots = $this->election->getBallots();
61
        $this->rejectedBallots = [];
62
    }
63
64
    /**
65
     * Run the election.
66
     *
67
     * @return \MichaelC\Voting\STV\Candidate[] Winning candidates
68
     */
69
    public function run()
70
    {
71
        $this->logger->notice('Starting to run an election');
72
73
        $this->rejectInvalidBallots();
74
        $this->quota = $this->getQuota();
75
76
        $this->firstStep();
77
78
        $candidates = $this->election->getActiveCandidates();
79
80
        while ($this->electedCandidates < $this->election->getWinnersCount()) {
81
            if (!$this->checkCandidates($candidates)) {
82
                $this->eliminateCandidates($candidates);
83
            }
84
        }
85
86
        $this->logger->notice('Election complete');
87
88
        return $this->election->getElectedCandidates();
89
    }
90
91
    /**
92
     * Perform the initial vote allocation.
93
     *
94
     * @return
95
     */
96
    protected function firstStep()
97
    {
98
        $this->logger->info('Beginning the first step');
99
100
        foreach ($this->ballots as $i => $ballot) {
101
            $this->logger->debug("Processing ballot $i in stage 1",
102
                ['ballot' => $ballot]
103
            );
104
105
            $this->allocateVotes($ballot);
106
        }
107
108
        $this->logger->notice('First step complete',
109
            ['candidatesStatus' => $this->election->getCandidatesStatus()]
110
        );
111
112
        return;
113
    }
114
115
    /**
116
     * Check if any candidates have reached the quota and can be elected.
117
     *
118
     * @param array $candidates Array of active candidates to check
119
     *
120
     * @return bool Whether any candidates were changed to elected
121
     */
122
    protected function checkCandidates(array $candidates): bool
123
    {
124
        $elected = false;
125
126
        $this->logger->info('Checking if candidates have passed quota');
127
128
        foreach ($candidates as $i => $candidate) {
129
            $this->logger->debug('Checking candidate', ['candidate' => $candidate]);
130
131
            if ($candidate->getVotes() >= $this->quota) {
132
                $this->electCandidate($candidate);
133
                $elected = true;
134
            }
135
        }
136
137
        $this->logger->info("Candidate checking complete. Someone was elected: $elected");
138
139
        return $elected;
140
    }
141
142
    /**
143
     * Allocate the next votes from a Ballot.
144
     *
145
     * @param Ballot $ballot     The ballot to allocate the votes from
146
     * @param float  $multiplier Number to multiply the weight by (surplus)
147
     * @param float  $divisor    The divisor of the weight (Total number of
148
     *                           candidate votes)
149
     *
150
     * @return Ballot The same ballot passed in modified
151
     */
152
    protected function allocateVotes(Ballot $ballot, float $multiplier = 1.0, float $divisor = 1.0): Ballot
153
    {
154
        $weight = $ballot->setWeight(($ballot->getWeight() * $multiplier) / $divisor);
155
        $candidate = $ballot->getNextChoice();
156
157
        // TODO: Check if candidate is withdrawn
158
159
        $this->logger->debug('Allocating votes of ballot', array(
160
            'ballot' => $ballot,
161
            'weight' => $weight,
162
            'candidate' => $candidate,
163
        ));
164
165
        if ($candidate !== null) {
166
            $this->election->getCandidate($candidate)->addVotes($weight);
167
            $ballot->incrementLevelUsed();
168
            $this->logger->debug('Vote added to candidate');
169
        }
170
171
        return $ballot;
172
    }
173
174
    /**
175
     * Transfer the votes from one candidate to other candidates.
176
     *
177
     * @param float     $surplus   The number of surplus votes to transfer
178
     * @param Candidate $candidate The candidate being elected to transfer
179
     *                             the votes from
180
     *
181
     * @return
182
     */
183
    protected function transferSurplusVotes(float $surplus, Candidate $candidate)
184
    {
185
        $totalVotes = $candidate->getVotes();
186
        $candidateId = $candidate->getId();
187
188
        $this->logger->info('Transfering surplus votes', array(
189
            'surplus' => $surplus,
190
            'candidate' => $candidate,
191
            'totalVotes' => $totalVotes,
192
        ));
193
194
        foreach ($this->ballots as $i => $ballot) {
195
            if ($ballot->getLastChoice() == $candidateId) {
196
                $this->allocateVotes($ballot, $surplus, $totalVotes);
197
            }
198
        }
199
200
        return;
201
    }
202
203
    /**
204
     * Transfer the votes from one eliminated candidate to other candidates.
205
     *
206
     * @param Candidate $candidate Candidate being eliminated to transfer
207
     *                             the votes from
208
     *
209
     * @return
210
     */
211
    protected function transferEliminatedVotes(Candidate $candidate)
212
    {
213
        $candidateId = $candidate->getId();
214
215
        $this->logger->info('Transfering votes from eliminated candidate', array(
216
            'votes' => $candidate->getVotes(),
217
            'candidate' => $candidate,
218
        ));
219
220
        foreach ($this->ballots as $i => $ballot) {
221
            if ($ballot->getLastChoice() == $candidateId) {
222
                $this->allocateVotes($ballot);
223
            }
224
        }
225
226
        return;
227
    }
228
229
    /**
230
     * Elect a candidate after they've passed the threshold.
231
     *
232
     * @param \Michaelc\Voting\STV\Candidate $candidate
233
     */
234
    protected function electCandidate(Candidate $candidate)
235
    {
236
        if ($candidate->getVotes() < $this->quota) {
237
            throw new VotingException("We shouldn't be electing someone who hasn't met the quota");
238
        }
239
240
        $this->logger->notice('Electing a candidate', array(
241
            'candidate' => $candidate,
242
        ));
243
244
        $candidate->setState(Candidate::ELECTED);
245
        ++$this->electedCandidates;
246
247
        if ($this->electedCandidates < $this->election->getWinnersCount()) {
248
            $surplus = $candidate->getVotes() - $this->quota;
249
            $this->transferSurplusVotes($surplus, $candidate);
250
        }
251
252
        return;
253
    }
254
255
    /**
256
     * Eliminate the candidate with the lowest number of votes
257
     * and reallocated their votes.
258
     *
259
     * TODO: Eliminate all lowest candidates after step one, then
260
     * randomly choose.
261
     *
262
     * @param \Michaelc\Voting\STV\Candidate[] $candidates
263
     *                                                     Array of active candidates
264
     *
265
     * @return int Number of candidates eliminated
266
     */
267
    protected function eliminateCandidates(array $candidates): int
268
    {
269
        $minimumCandidates = $this->getLowestCandidates($candidates);
270
271
        foreach ($minimumCandidates as $minimumCandidate) {
272
            $this->logger->notice('Eliminating a candidate', array(
273
                'minimumCandidate' => $minimumCandidate,
274
            ));
275
276
            $this->transferEliminatedVotes($minimumCandidate);
277
            $minimumCandidate->setState(Candidate::DEFEATED);
278
        }
279
280
        return count($minimumCandidates);
281
    }
282
283
    /**
284
     * Get candidates with the lowest number of votes.
285
     *
286
     * @param \Michaelc\Voting\STV\Candidate[] $candidates
287
     *                                                     Array of active candidates
288
     *
289
     * @return \Michaelc\Voting\STV\Candidate[]
290
     *                                          Candidates with lowest score
291
     */
292
    public function getLowestCandidates(array $candidates): array
293
    {
294
        $minimum = 0;
295
        $minimumCandidates = [];
296
297
        foreach ($candidates as $i => $candidate) {
298
            if ($candidate->getVotes() > $minimum) {
299
                $minimum = $candidate->getVotes();
300
                unset($minimumCandidates);
301
                $minimumCandidates[] = $candidate;
302
            } elseif ($candidate->getVotes() == $minimum) {
303
                $minimumCandidates[] = $candidate;
304
            }
305
        }
306
307
        $this->logger->info('Calculated lowest candidates', array(
308
            'minimumCandidates' => $minimumCandidates,
309
        ));
310
311
        return $minimumCandidates;
312
    }
313
314
    /**
315
     * Reject any invalid ballots.
316
     *
317
     * @return int Number of rejected ballots
318
     */
319
    protected function rejectInvalidBallots(): int
320
    {
321
        foreach ($this->ballots as $i => $ballot) {
322
            if (!$this->checkBallotValidity($ballot)) {
323
                $this->rejectedBallots[] = clone $ballot;
324
                unset($this->ballots[$i]);
325
            }
326
        }
327
328
        $count = count($this->rejectedBallots);
329
330
        $this->logger->notice("Found $count rejected ballots", array(
331
            'ballots' => $this->rejectedBallots,
332
        ));
333
334
        return $count;
335
    }
336
337
    /**
338
     * Check if ballot is valid.
339
     *
340
     * @param Ballot $ballot Ballot to test
341
     *
342
     * @return bool True if valid, false if invalid
343
     */
344
    public function checkBallotValidity(Ballot $ballot): bool
345
    {
346
        if (count($ballot->getRanking()) > $this->election->getCandidateCount()) {
347
            $this->logger->debug('Invalid ballot - number of candidates', ['ballot' => $ballot]);
348
349
            return false;
350
        } else {
351
            $candidateIds = $this->election->getCandidateIds();
352
353
            foreach ($ballot->getRanking() as $i => $candidate) {
354
                if (!in_array($candidate, $candidateIds)) {
355
                    $this->logger->debug('Invalid ballot - invalid candidate', array(
356
                        'ballot' => $ballot,
357
                        'candidate' => $candidate,
358
                        'candidates' => $candidateIds,
359
                    ));
360
361
                    return false;
362
                }
363
            }
364
        }
365
366
        $this->logger->debug('Ballot is valid');
367
368
        return true;
369
    }
370
371
    /**
372
     * Get the quota to win.
373
     *
374
     * @return int
375
     */
376
    public function getQuota(): int
377
    {
378
        $quota = floor(
379
            ($this->election->getNumBallots() /
380
                ($this->election->getWinnersCount() + 1)
381
            )
382
            + 1);
383
384
        $this->logger->info("Quota set: $quota");
385
386
        return $quota;
387
    }
388
}
389