Completed
Push — master ( b93a8e...b71cc6 )
by Michael
06:15 queued 03:33
created

ElectionRunner::transferSurplusVotes()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

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