Completed
Push — master ( 3a0296...bf76e4 )
by Michael
03:03
created

VoteHandler::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

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