Completed
Push — master ( 863a23...b998b5 )
by Michael
05:14 queued 55s
created

VoteHandler::getQuota()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 12
ccs 0
cts 7
cp 0
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 7
nc 1
nop 0
crap 2
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 1
    public function __construct(Election $election, Logger $logger)
57
    {
58 1
        $this->logger = $logger;
59 1
        $this->election = $election;
60 1
        $this->ballots = $this->election->getBallots();
61 1
        $this->rejectedBallots = [];
62 1
    }
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
        $this->logger->debug('Allocating votes of ballot', array(
158
            'ballot' => $ballot,
159
            'weight' => $weight,
160
            'candidate' => $candidate,
161
        ));
162
163
        if ($candidate !== null) {
164
            $this->election->getCandidate($candidate)->addVotes($weight);
165
            $ballot->incrementLevelUsed();
166
            $this->logger->debug('Vote added to candidate');
167
        }
168
169
        return $ballot;
170
    }
171
172
    /**
173
     * Transfer the votes from one candidate to other candidates.
174
     *
175
     * @param float     $surplus   The number of surplus votes to transfer
176
     * @param Candidate $candidate The candidate being elected to transfer
177
     *                             the votes from
178
     *
179
     * @return
180
     */
181
    protected function transferSurplusVotes(float $surplus, Candidate $candidate)
182
    {
183
        $totalVotes = $candidate->getVotes();
184
        $candidateId = $candidate->getId();
185
186
        $this->logger->info('Transfering surplus votes', array(
187
            'surplus' => $surplus,
188
            'candidate' => $candidate,
189
            'totalVotes' => $totalVotes,
190
        ));
191
192
        foreach ($this->ballots as $i => $ballot) {
193
            if ($ballot->getLastChoice() == $candidateId) {
194
                $this->allocateVotes($ballot, $surplus, $totalVotes);
195
            }
196
        }
197
198
        return;
199
    }
200
201
    /**
202
     * Transfer the votes from one eliminated candidate to other candidates.
203
     *
204
     * @param Candidate $candidate Candidate being eliminated to transfer
205
     *                             the votes from
206
     *
207
     * @return
208
     */
209
    protected function transferEliminatedVotes(Candidate $candidate)
210
    {
211
        $candidateId = $candidate->getId();
212
213
        $this->logger->info('Transfering votes from eliminated candidate', array(
214
            'votes' => $candidate->getVotes(),
215
            'candidate' => $candidate,
216
        ));
217
218
        foreach ($this->ballots as $i => $ballot) {
219
            if ($ballot->getLastChoice() == $candidateId) {
220
                $this->allocateVotes($ballot);
221
            }
222
        }
223
224
        return;
225
    }
226
227
    /**
228
     * Elect a candidate after they've passed the threshold.
229
     *
230
     * @param \Michaelc\Voting\STV\Candidate $candidate
231
     */
232
    protected function electCandidate(Candidate $candidate)
233
    {
234
        if ($candidate->getVotes() < $this->quota) {
235
            throw new VotingException("We shouldn't be electing someone who hasn't met the quota");
236
        }
237
238
        $this->logger->notice('Electing a candidate', array(
239
            'candidate' => $candidate,
240
        ));
241
242
        $candidate->setState(Candidate::ELECTED);
243
        ++$this->electedCandidates;
244
245
        if ($this->electedCandidates < $this->election->getWinnersCount()) {
246
            $surplus = $candidate->getVotes() - $this->quota;
247
            $this->transferSurplusVotes($surplus, $candidate);
248
        }
249
250
        return;
251
    }
252
253
    /**
254
     * Eliminate the candidate with the lowest number of votes
255
     * and reallocated their votes.
256
     *
257
     * TODO: Eliminate all lowest candidates after step one, then
258
     * randomly choose.
259
     *
260
     * @param \Michaelc\Voting\STV\Candidate[] $candidates
261
     *                                                     Array of active candidates
262
     *
263
     * @return int Number of candidates eliminated
264
     */
265
    protected function eliminateCandidates(array $candidates): int
266
    {
267
        $minimumCandidates = $this->getLowestCandidates($candidates);
268
269
        foreach ($minimumCandidates as $minimumCandidate) {
270
            $this->logger->notice('Eliminating a candidate', array(
271
                'minimumCandidate' => $minimumCandidate,
272
            ));
273
274
            $this->transferEliminatedVotes($minimumCandidate);
275
            $minimumCandidate->setState(Candidate::DEFEATED);
276
        }
277
278
        return count($minimumCandidates);
279
    }
280
281
    /**
282
     * Get candidates with the lowest number of votes.
283
     *
284
     * @param \Michaelc\Voting\STV\Candidate[] $candidates
285
     *                                                     Array of active candidates
286
     *
287
     * @return \Michaelc\Voting\STV\Candidate[]
288
     *                                          Candidates with lowest score
289
     */
290
    public function getLowestCandidates(array $candidates): array
291
    {
292
        $minimum = 0;
293
        $minimumCandidates = [];
294
295
        foreach ($candidates as $i => $candidate) {
296
            if ($candidate->getVotes() > $minimum) {
297
                $minimum = $candidate->getVotes();
298
                unset($minimumCandidates);
299
                $minimumCandidates[] = $candidate;
300
            } elseif ($candidate->getVotes() == $minimum) {
301
                $minimumCandidates[] = $candidate;
302
            }
303
        }
304
305
        $this->logger->info('Calculated lowest candidates', array(
306
            'minimumCandidates' => $minimumCandidates,
307
        ));
308
309
        return $minimumCandidates;
310
    }
311
312
    /**
313
     * Reject any invalid ballots.
314
     *
315
     * @return int Number of rejected ballots
316
     */
317
    protected function rejectInvalidBallots(): int
318
    {
319
        foreach ($this->ballots as $i => $ballot) {
320
            if (!$this->checkBallotValidity($ballot)) {
321
                $this->rejectedBallots[] = clone $ballot;
322
                unset($this->ballots[$i]);
323
            }
324
        }
325
326
        $count = count($this->rejectedBallots);
327
328
        $this->logger->notice("Found $count rejected ballots", array(
329
            'ballots' => $this->rejectedBallots,
330
        ));
331
332
        return $count;
333
    }
334
335
    /**
336
     * Check if ballot is valid.
337
     *
338
     * @param Ballot $ballot Ballot to test
339
     *
340
     * @return bool True if valid, false if invalid
341
     */
342
    public function checkBallotValidity(Ballot $ballot): bool
343
    {
344
        if (count($ballot->getRanking()) > $this->election->getCandidateCount()) {
345
            $this->logger->debug('Invalid ballot - number of candidates', ['ballot' => $ballot,]);
346
347
            return false;
348
        } else {
349
            $candidateIds = $this->election->getCandidateIds();
350
351
            foreach ($ballot->getRanking() as $i => $candidate) {
352
                if (!in_array($candidate, $candidateIds)) {
353
                    $this->logger->debug('Invalid ballot - invalid candidate', array(
354
                        'ballot' => $ballot,
355
                        'candidate' => $candidate,
356
                        'candidates' => $candidateIds,
357
                    ));
358
359
                    return false;
360
                }
361
            }
362
        }
363
364
        $this->logger->debug('No invalid ballots found');
365
366
        return true;
367
    }
368
369
    /**
370
     * Get the quota to win.
371
     *
372
     * @return int
373
     */
374
    public function getQuota(): int
375
    {
376
        $quota = floor(
377
            ($this->election->getNumBallots() /
378
                ($this->election->getWinnersCount() + 1)
379
            )
380
            + 1);
381
382
        $this->logger->info("Quota set: $quota");
383
384
        return $quota;
385
    }
386
}
387