Passed
Push — 3.x ( 05eab4...5fb647 )
by Doug
05:29 queued 03:46
created

WeightRedistributor::equaliseWeight()   B

Complexity

Conditions 7
Paths 12

Size

Total Lines 53
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 22.1704

Importance

Changes 4
Bugs 1 Features 0
Metric Value
cc 7
eloc 34
c 4
b 1
f 0
nc 12
nop 3
dl 0
loc 53
ccs 11
cts 34
cp 0.3235
crap 22.1704
rs 8.4426

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * Box packing (3D bin packing, knapsack problem).
4
 *
5
 * @author Doug Wright
6
 */
7
declare(strict_types=1);
8
9
namespace DVDoug\BoxPacker;
10
11
use function array_filter;
12
use function array_map;
13
use function array_merge;
14
use function array_sum;
15
use function count;
16
use function iterator_to_array;
17
use Psr\Log\LoggerAwareInterface;
18
use Psr\Log\LoggerAwareTrait;
19
use Psr\Log\LogLevel;
20
use Psr\Log\NullLogger;
21
use SplObjectStorage;
22
use function usort;
23
24
/**
25
 * Actual packer.
26
 *
27
 * @author Doug Wright
28
 * @internal
29
 */
30
class WeightRedistributor implements LoggerAwareInterface
31
{
32
    use LoggerAwareTrait;
33
34
    /**
35
     * List of box sizes available to pack items into.
36
     *
37
     * @var BoxList
38
     */
39
    private $boxes;
40
41
    /**
42
     * Quantities available of each box type.
43
     *
44
     * @var SplObjectStorage|int[]
45
     */
46
    private $boxesQtyAvailable;
47
48
    /**
49
     * Constructor.
50
     */
51 4
    public function __construct(BoxList $boxList, SplObjectStorage $boxQuantitiesAvailable)
52
    {
53 4
        $this->boxes = $boxList;
54 4
        $this->boxesQtyAvailable = $boxQuantitiesAvailable;
55 4
        $this->logger = new NullLogger();
56 4
    }
57
58
    /**
59
     * Given a solution set of packed boxes, repack them to achieve optimum weight distribution.
60
     */
61 4
    public function redistributeWeight(PackedBoxList $originalBoxes): PackedBoxList
62
    {
63 4
        $targetWeight = $originalBoxes->getMeanItemWeight();
64 4
        $this->logger->log(LogLevel::DEBUG, "repacking for weight distribution, weight variance {$originalBoxes->getWeightVariance()}, target weight {$targetWeight}");
0 ignored issues
show
Bug introduced by
The method log() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

64
        $this->logger->/** @scrutinizer ignore-call */ 
65
                       log(LogLevel::DEBUG, "repacking for weight distribution, weight variance {$originalBoxes->getWeightVariance()}, target weight {$targetWeight}");

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
65
66
        /** @var PackedBox[] $boxes */
67 4
        $boxes = iterator_to_array($originalBoxes);
68
69 4
        usort($boxes, static function (PackedBox $boxA, PackedBox $boxB) {
70 4
            return $boxB->getWeight() <=> $boxA->getWeight();
71 4
        });
72
73
        do {
74 4
            $iterationSuccessful = false;
75
76 4
            foreach ($boxes as $a => &$boxA) {
77 4
                foreach ($boxes as $b => &$boxB) {
78 4
                    if ($b <= $a || $boxA->getWeight() === $boxB->getWeight()) {
79 4
                        continue; //no need to evaluate
80
                    }
81
82 4
                    $iterationSuccessful = $this->equaliseWeight($boxA, $boxB, $targetWeight);
83 4
                    if ($iterationSuccessful) {
84
                        $boxes = array_filter($boxes, static function (?PackedBox $box) { //remove any now-empty boxes from the list
85
                            return $box instanceof PackedBox;
86
                        });
87
                        break 2;
88
                    }
89
                }
90
            }
91 4
        } while ($iterationSuccessful);
92
93
        //Combine back into a single list
94 4
        $packedBoxes = new PackedBoxList();
95 4
        $packedBoxes->insertFromArray($boxes);
96
97 4
        return $packedBoxes;
98
    }
99
100
    /**
101
     * Attempt to equalise weight distribution between 2 boxes.
102
     *
103
     * @return bool was the weight rebalanced?
104
     */
105 4
    private function equaliseWeight(PackedBox &$boxA, PackedBox &$boxB, float $targetWeight): bool
106
    {
107 4
        $anyIterationSuccessful = false;
108
109 4
        if ($boxA->getWeight() > $boxB->getWeight()) {
110 4
            $overWeightBox = $boxA;
111 4
            $underWeightBox = $boxB;
112
        } else {
113
            $overWeightBox = $boxB;
114
            $underWeightBox = $boxA;
115
        }
116
117 4
        $overWeightBoxItems = $overWeightBox->getItems()->asItemArray();
118 4
        $underWeightBoxItems = $underWeightBox->getItems()->asItemArray();
119
120 4
        foreach ($overWeightBoxItems as $key => $overWeightItem) {
121 4
            if (!static::wouldRepackActuallyHelp($overWeightBoxItems, $overWeightItem, $underWeightBoxItems, $targetWeight)) {
122 4
                continue; // moving this item would harm more than help
123
            }
124
125
            $newLighterBoxes = $this->doVolumeRepack(array_merge($underWeightBoxItems, [$overWeightItem]), $underWeightBox->getBox());
126
            if ($newLighterBoxes->count() !== 1) {
127
                continue; //only want to move this item if it still fits in a single box
128
            }
129
130
            $underWeightBoxItems[] = $overWeightItem;
131
132
            if (count($overWeightBoxItems) === 1) { //sometimes a repack can be efficient enough to eliminate a box
133
                $boxB = $newLighterBoxes->top();
134
                $boxA = null;
135
                $this->boxesQtyAvailable[$underWeightBox->getBox()] = $this->boxesQtyAvailable[$underWeightBox->getBox()] - 1;
136
                $this->boxesQtyAvailable[$overWeightBox->getBox()] = $this->boxesQtyAvailable[$overWeightBox->getBox()] + 1;
137
138
                return true;
139
            }
140
141
            unset($overWeightBoxItems[$key]);
142
            $newHeavierBoxes = $this->doVolumeRepack($overWeightBoxItems, $overWeightBox->getBox());
143
            if (count($newHeavierBoxes) !== 1) {
144
                continue; //this should never happen, if we can pack n+1 into the box, we should be able to pack n
145
            }
146
147
            $this->boxesQtyAvailable[$overWeightBox->getBox()] = $this->boxesQtyAvailable[$overWeightBox->getBox()] + 1;
148
            $this->boxesQtyAvailable[$underWeightBox->getBox()] = $this->boxesQtyAvailable[$underWeightBox->getBox()] + 1;
149
            $this->boxesQtyAvailable[$newHeavierBoxes->top()->getBox()] = $this->boxesQtyAvailable[$newHeavierBoxes->top()->getBox()] - 1;
150
            $this->boxesQtyAvailable[$newLighterBoxes->top()->getBox()] = $this->boxesQtyAvailable[$newLighterBoxes->top()->getBox()] - 1;
151
            $underWeightBox = $boxB = $newLighterBoxes->top();
152
            $overWeightBox = $boxA = $newHeavierBoxes->top();
153
154
            $anyIterationSuccessful = true;
155
        }
156
157 4
        return $anyIterationSuccessful;
158
    }
159
160
    /**
161
     * Do a volume repack of a set of items.
162
     */
163
    private function doVolumeRepack(iterable $items, Box $currentBox): PackedBoxList
164
    {
165
        $packer = new Packer();
166
        $packer->setBoxes($this->boxes); // use the full set of boxes to allow smaller/larger for full efficiency
167
        foreach ($this->boxes as $box) {
168
            $packer->setBoxQuantity($box, $this->boxesQtyAvailable[$box]);
169
        }
170
        $packer->setBoxQuantity($currentBox, $this->boxesQtyAvailable[$currentBox] + 1);
171
        $packer->setItems($items);
172
173
        return $packer->doVolumePacking(true, true);
174
    }
175
176
    /**
177
     * Not every attempted repack is actually helpful - sometimes moving an item between two otherwise identical
178
     * boxes, or sometimes the box used for the now lighter set of items actually weighs more when empty causing
179
     * an increase in total weight.
180
     */
181 4
    private static function wouldRepackActuallyHelp(array $overWeightBoxItems, Item $overWeightItem, array $underWeightBoxItems, float $targetWeight): bool
182
    {
183 4
        $overWeightItemsWeight = array_sum(array_map(static function (Item $item) {return $item->getWeight(); }, $overWeightBoxItems));
184 4
        $underWeightItemsWeight = array_sum(array_map(static function (Item $item) {return $item->getWeight(); }, $underWeightBoxItems));
185
186 4
        if ($overWeightItem->getWeight() + $underWeightItemsWeight > $targetWeight) {
187 4
            return false;
188
        }
189
190
        $oldVariance = static::calculateVariance($overWeightItemsWeight, $underWeightItemsWeight);
0 ignored issues
show
Bug introduced by
It seems like $overWeightItemsWeight can also be of type double; however, parameter $boxAWeight of DVDoug\BoxPacker\WeightR...or::calculateVariance() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

190
        $oldVariance = static::calculateVariance(/** @scrutinizer ignore-type */ $overWeightItemsWeight, $underWeightItemsWeight);
Loading history...
Bug introduced by
It seems like $underWeightItemsWeight can also be of type double; however, parameter $boxBWeight of DVDoug\BoxPacker\WeightR...or::calculateVariance() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

190
        $oldVariance = static::calculateVariance($overWeightItemsWeight, /** @scrutinizer ignore-type */ $underWeightItemsWeight);
Loading history...
191
        $newVariance = static::calculateVariance($overWeightItemsWeight - $overWeightItem->getWeight(), $underWeightItemsWeight + $overWeightItem->getWeight());
192
193
        return $newVariance < $oldVariance;
194
    }
195
196
    private static function calculateVariance(int $boxAWeight, int $boxBWeight)
197
    {
198
        return ($boxAWeight - (($boxAWeight + $boxBWeight) / 2)) ** 2; //don't need to calculate B and ÷ 2, for a 2-item population the difference from mean is the same for each box
199
    }
200
}
201