Completed
Push — master ( 23a0ba...e10981 )
by Michael
02:17
created

ElectionRunner::electCandidate()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 19
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 19
ccs 11
cts 11
cp 1
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 12
nc 3
nop 1
crap 3
1
<?php
2
3
namespace Michaelc\Voting\STV;
4
5
use Psr\Log\LoggerInterface as Logger;
6
use Michaelc\Voting\Exception\VotingLogicException as LogicException;
7
use Michaelc\Voting\Exception\VotingRuntimeException as RuntimeException;
8
9
/**
10
 * This class will calculate the outcome of an election
11
 *
12
 * References throughout are provided as to what is going on.
13
 * It follows a system similar to Scottish STV and all paragraph
14
 * references are for: http://www.legislation.gov.uk/sdsi/2011/9780111014639/pdfs/sdsi_9780111014639_en.pdf
15
 */
16
class ElectionRunner
17
{
18
    /**
19
     * Logger.
20
     *
21
     * @var \Psr\Log\LoggerInterface
22
     */
23
    protected $logger;
24
25
    /**
26
     * Election object.
27
     *
28
     * @var \Michaelc\Voting\STV\Election;
29
     */
30
    protected $election;
31
32
    /**
33
     * Array of all ballots in election.
34
     *
35
     * @var \MichaelC\Voting\STV\Ballot[]
36
     */
37
    protected $ballots;
38
39
    /**
40
     * Quota of votes needed for a candidate to be elected.
41
     *
42
     * @var int
43
     */
44
    public $quota;
45
46
    /**
47
     * Number of candidates elected so far.
48
     *
49
     * @var int
50
     */
51
    public $electedCandidates;
52
53
    /**
54
     * Invalid ballots.
55
     *
56
     * @var \MichaelC\Voting\STV\Ballot[]
57
     */
58
    public $rejectedBallots;
59
60
    /**
61
     * Number of valid ballots
62
     *
63
     * @var int
64
     */
65
    public $validBallots;
66
67
    /**
68
     * Number of winners to still be elected (at current stage)
69
     *
70
     * @var int
71
     */
72
    public $candidatesToElect;
73
74
    /**
75
     * Constructor.
76
     *
77
     * @param Election $election
78
     */
79 2
    public function __construct(Election $election, Logger $logger)
80
    {
81 2
        $this->logger = $logger;
82 2
        $this->election = $election;
83
84 2
        $this->ballots = $this->election->getBallots();
85 2
        $this->rejectedBallots = [];
86 2
        $this->candidatesToElect = $this->election->getWinnersCount();
87 2
        $this->validBallots = $this->election->getNumBallots();
88 2
    }
89
90
    /**
91
     * Run the election.
92
     *
93
     * @return Candidate[] Winning candidates
94
     */
95 1
    public function run()
96
    {
97 1
        $this->logger->notice('Starting to run an election');
98 1
        $this->logger->notice(sprintf('There are %d candidates, %d ballots and to be %d winners', $this->election->getCandidateCount(), $this->validBallots, $this->election->getWinnersCount()));
99
100
        // Reject invalid ballots, then calculate the quota based on remaining valid ballots
101
        // p. 46(3) and p 47
102 1
        $this->rejectInvalidBallots();
103 1
        $this->setQuota();
104
105
        // First step of standard allocation of ballots
106
        // p. 46(1) and 46(2)
107 1
        $this->firstStep();
108
109 1
        $candidates = $this->election->getActiveCandidates();
110
111
        // All the re-allocation rounds until we have filled all seats or
112
        // have the same number of seats left to fill and candidates remaining
113
        // (then elect them).
114 1
        $this->processReallocationRounds($candidates);
115
        // p. 53
116 1
        $this->reallocateRemainingVotes($candidates);
117
118 1
        $this->logger->notice('Election complete');
119
120 1
        return $this->election->getElectedCandidates();
121
    }
122
123
    /**
124
     * Perform the initial vote allocation.
125
     * p. 46
126
     *
127
     * @return
128
     */
129 1
    protected function firstStep()
130
    {
131 1
        $this->logger->info('Beginning the first step');
132
133
        // Allocate all the ballots
134 1
        foreach ($this->ballots as $i => $ballot) {
135 1
            $this->allocateVotes($ballot);
136
        }
137
138 1
        $this->logger->notice('Step 1 complete',
139 1
            ['candidatesStatus' => $this->election->getCandidatesStatus()]
140
        );
141
142 1
        return;
143
    }
144
145
    /**
146
     * Process re-allocation rounds (elimination re-allocations and surplus re-allocations)
147
     *
148
     * @param  Candidate[] $candidates All active candidates to elect
149
     *
150
     * @return Candidate[]
151
     */
152 1
    protected function processReallocationRounds(array &$candidates): array
153
    {
154 1
        $counter = 1;
155 1
        while (($this->candidatesToElect > 0) && ($this->election->getActiveCandidateCount() > $this->candidatesToElect)) {
156 1
            if (!$this->checkCandidates($candidates)) {
157
                // p. 51(1)
158 1
                $this->eliminateCandidates($candidates);
159
            }
160
161 1
            $candidates = $this->election->getActiveCandidates();
162
163 1
            $counter++;
164
165 1
            $this->logger->notice("Step $counter complete",
166 1
                ['candidatesStatus' => $this->election->getCandidatesStatus()]
167
            );
168
        }
169
170 1
        return $candidates;
171
    }
172
173
    /**
174
     * Check if any candidates have reached the quota and can be elected.
175
     *
176
     * @param Candidate[] $candidates Array of active candidates to check
177
     *
178
     * @return bool Whether any candidates were changed to elected
179
     */
180 1
    protected function checkCandidates(array $candidates): bool
181
    {
182 1
        $elected = false;
183 1
        $candidatesToElect = [];
184
185 1
        $this->logger->info('Checking if candidates have passed quota');
186
187 1
        if (empty($candidates))
188
        {
189
            throw new LogicException("There are no more candidates left");
190
        }
191
192 1
        foreach ($candidates as $i => $candidate) {
193 1
            if ($candidate->getState() !== Candidate::RUNNING)
194
            {
195
                throw new LogicException('Candidate is not marked as not running but has not been excluded');
196
            }
197
198 1
            $votes = $candidate->getVotes();
199
200 1
            $this->logger->debug("Checking candidate ($candidate) with $votes", ['candidate' => $candidate]);
201
202
            // p. 48(1)
203
            // Immediately elect a candidate if they hit the quota
204
            // We check all the candidates, see who has hit the quota,
205
            // add them to a queue, then elect those who have hit the quota to prevent
206
            // surplus allocations pushing a candidate over the quota early.
207 1
            if ($votes >= $this->quota) {
208 1
                $candidatesToElect[] = $candidate;
209 1
                $elected = true;
210
            }
211
        }
212
213
        // TODO: Put this in a try, and catch the RuntimeError.
214
        // Then sort by the surplus, and fill available seats.
215
        // If have same surplus then select randomly (Contary to Scottish STV)
216
        // p. 50
217 1
        $this->electCandidates($candidatesToElect);
218
219 1
        $this->logger->info(('Candidate checking complete. Elected: ' . count($candidatesToElect)));
220
221 1
        return $elected;
222
    }
223
224
    /**
225
     * Elect an array of candidates
226
     *
227
     * @param  Candidate[] $candidates Array of candidates to elect
228
     *
229
     * @return
230
     */
231 1
    protected function electCandidates(array $candidates)
232
    {
233 1
        if ($this->candidatesToElect < count($candidates))
234
        {
235
            throw new RuntimeException('Cannot elect candidate as not enough seats to fill');
236
        }
237
238 1
        foreach ($candidates as $i => $candidate) {
239 1
            $this->electCandidate($candidate);
240
        }
241
242 1
        return;
243
    }
244
245
    /**
246
     * Allocate the next votes from a Ballot.
247
     * p. 49
248
     *
249
     * @param Ballot $ballot     The ballot to allocate the votes from
250
     * @param float  $multiplier Number to multiply the weight by (surplus)
251
     * @param float  $divisor    The divisor of the weight (Total number of
252
     *                           candidate votes)
253
     *
254
     * @return Ballot The same ballot passed in modified
255
     */
256 1
    protected function allocateVotes(Ballot $ballot, float $multiplier = 1.0, float $divisor = 1.0): Ballot
257
    {
258 1
        $currentWeight = $ballot->getWeight();
259
        // p. 49(3)
260
        // "A divided by B" Where A = the value which is calculated
261
        // by multiplying the surplus of the transferring candidate
262
        // by the value of the ballot paper when received by that candidate; and
263
        // B = the total number of votes credited to that candidate
264 1
        $weight = $ballot->setWeight(($currentWeight * $multiplier) / $divisor);
265
266
        // Get the next candidate on their ballot paper which has not been assigned
267
        // a vote this could be the first candidate in round 1
268 1
        $candidate = $ballot->getNextChoice();
269
270 1
        $this->logger->debug("Allocating vote of weight $weight to $candidate. Previous weight: $currentWeight", array(
271 1
            'ballot' => $ballot,
272
        ));
273
274
        // Check there was a next candidate, if only x candidates where listed where
275
        // x < the number of candidates standing this will occur.
276 1
        if ($candidate !== null) {
277
            // Allocate those surplus votes. p. 49(1) and p. 49(2)
278 1
            $this->election->getCandidate($candidate)->addVotes($weight);
279 1
            $ballot->incrementLevelUsed();
280 1
            $this->logger->debug('Vote added to candidate');
281
282
            // If the candidate is no longer running due to being defeated or
283
            // elected then we re-allocate their vote again.
284 1
            if (!in_array($candidate, $this->election->getActiveCandidateIds()))
285
            {
286 1
                $this->allocateVotes($ballot);
287
            }
288
        }
289
290 1
        return $ballot;
291
    }
292
293
    /**
294
     * Transfer the votes from one candidate to other candidates.
295
     *
296
     * @param float     $surplus   The number of surplus votes to transfer
297
     * @param Candidate $candidate The candidate being elected to transfer
298
     *                             the votes from
299
     *
300
     * @return
301
     */
302 1
    protected function transferSurplusVotes(float $surplus, Candidate $candidate)
303
    {
304 1
        $totalVotes = $candidate->getVotes();
305 1
        $candidateId = $candidate->getId();
306
307 1
        $this->logger->info('Transfering surplus votes');
308
309 1
        foreach ($this->ballots as $i => $ballot) {
310 1
            if ($ballot->getLastChoice() == $candidateId) {
311 1
                $this->allocateVotes($ballot, $surplus, $totalVotes);
312
            }
313
        }
314
315 1
        return;
316
    }
317
318
    /**
319
     * Transfer the votes from one eliminated candidate to other candidates.
320
     * p. 51(2)
321
     *
322
     * @param Candidate $candidate Candidate being eliminated to transfer
323
     *                             the votes from
324
     *
325
     * @return
326
     */
327 1
    protected function transferEliminatedVotes(Candidate $candidate)
328
    {
329 1
        $candidateId = $candidate->getId();
330
331 1
        $votes = $candidate->getVotes();
332
333 1
        $this->logger->info("Transfering votes from eliminated candidate ($candidate) with $votes votes");
334
335
        // p. 51(2)(a) - Sort into next preference candidates
336
        // p. 51(3) - Add votes to candidates
337
        // p. 51(4) - Use previous weighting
338 1
        foreach ($this->ballots as $i => $ballot) {
339 1
            if ($ballot->getLastChoice() == $candidateId) {
340 1
                $this->allocateVotes($ballot);
341
            }
342
        }
343
344 1
        return;
345
    }
346
347
    /**
348
     * Elect a candidate after they've passed the threshold.
349
     *
350
     * @param Candidate $candidate
351
     */
352 1
    protected function electCandidate(Candidate $candidate)
353
    {
354 1
        $this->logger->notice("Electing a candidate: $candidate");
355
356 1
        $candidate->setState(Candidate::ELECTED);
357 1
        $this->electedCandidates++;
358 1
        $this->candidatesToElect--;
359
360 1
        if ($this->electedCandidates < $this->election->getWinnersCount()) {
361 1
            $surplus = $candidate->getVotes() - $this->quota;
362 1
            if ($surplus > 0) {
363 1
                $this->transferSurplusVotes($surplus, $candidate);
364
            } else {
365 1
                $this->logger->notice("No surplus votes from $candidate to reallocate");
366
            }
367
        }
368
369 1
        return;
370
    }
371
372
    /**
373
     * Eliminate the candidate with the lowest number of votes
374
     * and reallocated their votes.
375
     * p. 51
376
     *
377
     * @param Candidate[] $candidates Array of active candidates
378
     *
379
     * @return int Number of candidates eliminated
380
     */
381 1
    protected function eliminateCandidates(array $candidates): int
382
    {
383 1
        $minimumCandidates = $this->getLowestCandidates($candidates);
384 1
        $count = count($minimumCandidates);
385
386
        // p. 52(2)(b) - the returning officer shall decide, by lot, which of those
387
        // candidates is to be excluded.
388
        // We do not look back on previous rounds at all as per p. 52(2)(a)
389 1
        $minimumCandidate = $minimumCandidates[(array_rand($minimumCandidates))];
390
391 1
        $this->logger->notice(sprintf("There were %d joint lowest candidates,
392 1
            %d was randomly selected to be eliminated", $count, $minimumCandidate->getId()));
393
394 1
        $this->transferEliminatedVotes($minimumCandidate);
395 1
        $minimumCandidate->setState(Candidate::DEFEATED);
396
397 1
        return count($minimumCandidates);
398
    }
399
400
    /**
401
     * Get candidates with the lowest number of votes
402
     * p. 51 and p. 52(1)
403
     *
404
     * @param Candidate[] $candidates
405
     *                                                     Array of active candidates
406
     *
407
     * @return Candidate[]
408
     *                                          Candidates with lowest score
409
     */
410 1
    public function getLowestCandidates(array $candidates): array
411
    {
412 1
        $minimum = count($this->election->getBallots());
413 1
        $minimumCandidates = [];
414
415 1
        foreach ($candidates as $i => $candidate) {
416 1
            if ($candidate->getVotes() < $minimum) {
417 1
                $minimum = $candidate->getVotes();
418 1
                unset($minimumCandidates);
419 1
                $minimumCandidates[] = $candidate;
420 1
            } elseif ($candidate->getVotes() == $minimum) {
421 1
                $minimumCandidates[] = $candidate;
422 1
                $this->logger->info("Calculated as a lowest candidate: $candidate");
423
            }
424
        }
425
426 1
        return $minimumCandidates;
427
    }
428
429
    /**
430
     * Reject any invalid ballots.
431
     * p. 46(3)
432
     *
433
     * @return int Number of rejected ballots
434
     */
435 1
    protected function rejectInvalidBallots(): int
436
    {
437 1
        foreach ($this->ballots as $i => $ballot) {
438 1
            if (!$this->checkBallotValidity($ballot)) {
439 1
                $this->rejectedBallots[] = clone $ballot;
440 1
                unset($this->ballots[$i]);
441
            }
442
        }
443
444 1
        $count = count($this->rejectedBallots);
445
446 1
        $this->logger->notice("Found $count rejected ballots");
447
448 1
        $this->validBallots = $this->validBallots - $count;
449
450 1
        return $count;
451
    }
452
453
    /**
454
     * Check if ballot is valid.
455
     *
456
     * @param Ballot $ballot Ballot to test
457
     *
458
     * @return bool True if valid, false if invalid
459
     */
460 2
    public function checkBallotValidity(Ballot $ballot): bool
461
    {
462 2
        $ranking = $ballot->getRanking();
463
464 2
        if (count($ranking) > $this->election->getCandidateCount()) {
465 2
            $this->logger->debug('Invalid ballot - number of candidates', ['ballot' => $ballot]);
466
467 2
            return false;
468 2
        } elseif (count($ranking) !== count(array_unique($ranking))) {
469 1
            return false;
470
        } else {
471 2
            $candidateIds = $this->election->getCandidateIds();
472
473 2
            foreach ($ranking as $i => $candidate) {
474 2
                if (!in_array($candidate, $candidateIds)) {
475 2
                    $this->logger->debug('Invalid ballot - invalid candidate');
476
477 2
                    return false;
478
                }
479
            }
480
        }
481
482
        // TODO: Check for candidates multiple times on the same ballot paper
483
484 2
        $this->logger->debug('Ballot is valid', ['ballot' => $ballot]);
485
486 2
        return true;
487
    }
488
489
    /**
490
     * Reallocate any remaining votes
491
     * p. 53
492
     *
493
     * @param  Candidate[] $candidates All remaining candidates to elect
494
     * @return
495
     */
496 1
    protected function reallocateRemainingVotes(array &$candidates)
497
    {
498 1
        if (!empty($candidates))
499
        {
500 1
            $this->logger->info('All votes re-allocated. Electing all remaining candidates');
501
502 1
            if ($this->candidatesToElect < count($candidates))
503
            {
504
                throw new LogicException('Cannot elect candidate as no more seats to fill');
505
            }
506
507 1
            $this->electCandidates($candidates);
508
        }
509
510 1
        return;
511
    }
512
513
    /**
514
     * Get the quota to win.
515
     * p. 47
516
     *
517
     * TODO: Move this out of this method and use params/args
518
     *
519
     * @return int
520
     */
521 1
    public function setQuota(): int
522
    {
523 1
        $this->quota = floor(
0 ignored issues
show
Documentation Bug introduced by
The property $quota was declared of type integer, but floor($this->validBallot...innersCount() + 1) + 1) is of type double. Maybe add a type cast?

This check looks for assignments to scalar types that may be of the wrong type.

To ensure the code behaves as expected, it may be a good idea to add an explicit type cast.

$answer = 42;

$correct = false;

$correct = (bool) $answer;
Loading history...
524 1
            ($this->validBallots /
525 1
                ($this->election->getWinnersCount() + 1)
526
            ) // p. 47 (1)
527 1
            + 1); // p. 47 (2)
528
529 1
        $this->logger->info(sprintf("Quota set at %d based on %d winners and %d valid ballots", $this->quota, $this->election->getWinnersCount(), $this->validBallots));
530
531 1
        return $this->quota;
532
    }
533
}
534