Passed
Push — 3.x ( d2d64a...d739e8 )
by Doug
02:33
created

WeightRedistributor::equaliseWeight()   B

Complexity

Conditions 7
Paths 12

Size

Total Lines 53
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 23
CRAP Score 7.4339

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 23
cts 29
cp 0.7931
crap 7.4339
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 Psr\Log\LoggerAwareInterface;
12
use Psr\Log\LoggerInterface;
13
use Psr\Log\LogLevel;
14
use Psr\Log\NullLogger;
15
use SplObjectStorage;
16
17
use function array_filter;
18
use function array_map;
19
use function array_merge;
20
use function array_sum;
21
use function count;
22
use function iterator_to_array;
23
use function usort;
24
25
/**
26
 * Actual packer.
27
 *
28
 * @internal
29
 */
30
class WeightRedistributor implements LoggerAwareInterface
31
{
32
    private LoggerInterface $logger;
33
34
    private BoxList $boxes;
35
36
    /**
37
     * @var SplObjectStorage<Box, int>
38
     */
39
    private SplObjectStorage $boxQuantitiesAvailable;
40
41
    private PackedBoxSorter $packedBoxSorter;
42
43
    public function __construct(BoxList $boxList, PackedBoxSorter $packedBoxSorter, SplObjectStorage $boxQuantitiesAvailable)
44
    {
45
        $this->boxes = $boxList;
46
        $this->packedBoxSorter = $packedBoxSorter;
47
        $this->boxQuantitiesAvailable = $boxQuantitiesAvailable;
48
        $this->logger = new NullLogger();
49
    }
50
51
    public function setLogger(LoggerInterface $logger): void
52
    {
53
        $this->logger = $logger;
54
    }
55
56
    /**
57 15
     * Given a solution set of packed boxes, repack them to achieve optimum weight distribution.
58
     */
59 15
    public function redistributeWeight(PackedBoxList $originalBoxes): PackedBoxList
60 15
    {
61 15
        $targetWeight = $originalBoxes->getMeanItemWeight();
62 15
        $this->logger->log(LogLevel::DEBUG, "repacking for weight distribution, weight variance {$originalBoxes->getWeightVariance()}, target weight {$targetWeight}");
63
64
        /** @var PackedBox[] $boxes */
65
        $boxes = iterator_to_array($originalBoxes);
66
67
        usort($boxes, static fn (PackedBox $boxA, PackedBox $boxB) => $boxB->getWeight() <=> $boxA->getWeight());
68 15
69
        do {
70 15
            $iterationSuccessful = false;
71 15
72
            foreach ($boxes as $a => &$boxA) {
73
                foreach ($boxes as $b => &$boxB) {
74 15
                    if ($b <= $a || $boxA->getWeight() === $boxB->getWeight()) {
75
                        continue; // no need to evaluate
76 15
                    }
77 15
78 15
                    $iterationSuccessful = $this->equaliseWeight($boxA, $boxB, $targetWeight);
79
                    if ($iterationSuccessful) {
80
                        $boxes = array_filter($boxes, static fn (?PackedBox $box) => $box instanceof PackedBox); // remove any now-empty boxes from the list
81 15
                        break 2;
82
                    }
83 15
                }
84 15
            }
85 15
        } while ($iterationSuccessful);
86 15
87
        // Combine back into a single list
88
        $packedBoxes = new PackedBoxList($this->packedBoxSorter);
89 10
        $packedBoxes->insertFromArray($boxes);
90 10
91 5
        return $packedBoxes;
92 5
    }
93 5
94 5
    /**
95
     * Attempt to equalise weight distribution between 2 boxes.
96
     *
97
     * @return bool was the weight rebalanced?
98 15
     */
99
    private function equaliseWeight(PackedBox &$boxA, PackedBox &$boxB, float $targetWeight): bool
100
    {
101 15
        $anyIterationSuccessful = false;
102 15
103
        if ($boxA->getWeight() > $boxB->getWeight()) {
104 15
            $overWeightBox = $boxA;
105
            $underWeightBox = $boxB;
106
        } else {
107
            $overWeightBox = $boxB;
108
            $underWeightBox = $boxA;
109
        }
110
111
        $overWeightBoxItems = $overWeightBox->getItems()->asItemArray();
112 10
        $underWeightBoxItems = $underWeightBox->getItems()->asItemArray();
113
114 10
        foreach ($overWeightBoxItems as $key => $overWeightItem) {
115
            if (!self::wouldRepackActuallyHelp($overWeightBoxItems, $overWeightItem, $underWeightBoxItems, $targetWeight)) {
116 10
                continue; // moving this item would harm more than help
117 10
            }
118 10
119
            $newLighterBoxes = $this->doVolumeRepack(array_merge($underWeightBoxItems, [$overWeightItem]), $underWeightBox->getBox());
120 1
            if ($newLighterBoxes->count() !== 1) {
121 1
                continue; // only want to move this item if it still fits in a single box
122
            }
123
124 10
            $underWeightBoxItems[] = $overWeightItem;
125 10
126
            if (count($overWeightBoxItems) === 1) { // sometimes a repack can be efficient enough to eliminate a box
127 10
                $boxB = $newLighterBoxes->top();
128 10
                $boxA = null;
129 8
                $this->boxQuantitiesAvailable[$underWeightBox->getBox()] = $this->boxQuantitiesAvailable[$underWeightBox->getBox()] - 1;
130
                $this->boxQuantitiesAvailable[$overWeightBox->getBox()] = $this->boxQuantitiesAvailable[$overWeightBox->getBox()] + 1;
131
132 7
                return true;
133 7
            }
134 2
135
            unset($overWeightBoxItems[$key]);
136
            $newHeavierBoxes = $this->doVolumeRepack($overWeightBoxItems, $overWeightBox->getBox());
137 5
            if (count($newHeavierBoxes) !== 1) {
138
                continue; // this should never happen, if we can pack n+1 into the box, we should be able to pack n
139 5
            }
140
141
            $this->boxQuantitiesAvailable[$overWeightBox->getBox()] = $this->boxQuantitiesAvailable[$overWeightBox->getBox()] + 1;
142
            $this->boxQuantitiesAvailable[$underWeightBox->getBox()] = $this->boxQuantitiesAvailable[$underWeightBox->getBox()] + 1;
143
            $this->boxQuantitiesAvailable[$newHeavierBoxes->top()->getBox()] = $this->boxQuantitiesAvailable[$newHeavierBoxes->top()->getBox()] - 1;
144
            $this->boxQuantitiesAvailable[$newLighterBoxes->top()->getBox()] = $this->boxQuantitiesAvailable[$newLighterBoxes->top()->getBox()] - 1;
145
            $underWeightBox = $boxB = $newLighterBoxes->top();
146
            $overWeightBox = $boxA = $newHeavierBoxes->top();
147
148 5
            $anyIterationSuccessful = true;
149 5
        }
150 5
151
        return $anyIterationSuccessful;
152
    }
153
154 5
    /**
155 5
     * Do a volume repack of a set of items.
156 5
     * @param iterable<Item> $items
157 5
     */
158 5
    private function doVolumeRepack(iterable $items, Box $currentBox): PackedBoxList
159 5
    {
160
        $packer = new Packer();
161 5
        $packer->setLogger($this->logger);
162
        $packer->setBoxes($this->boxes); // use the full set of boxes to allow smaller/larger for full efficiency
163
        foreach ($this->boxes as $box) {
164 10
            $packer->setBoxQuantity($box, $this->boxQuantitiesAvailable[$box]);
165
        }
166
        $packer->setBoxQuantity($currentBox, $this->boxQuantitiesAvailable[$currentBox] + 1);
167
        $packer->setItems($items);
168
169
        return $packer->doVolumePacking(true, true);
170 7
    }
171
172 7
    /**
173 7
     * Not every attempted repack is actually helpful - sometimes moving an item between two otherwise identical
174 7
     * boxes, or sometimes the box used for the now lighter set of items actually weighs more when empty causing
175 7
     * an increase in total weight.
176 7
     * @param array<Item> $overWeightBoxItems
177
     * @param array<Item> $underWeightBoxItems
178 7
     */
179 7
    private static function wouldRepackActuallyHelp(array $overWeightBoxItems, Item $overWeightItem, array $underWeightBoxItems, float $targetWeight): bool
180
    {
181 7
        $overWeightItemsWeight = array_sum(array_map(static fn (Item $item) => $item->getWeight(), $overWeightBoxItems));
182
        $underWeightItemsWeight = array_sum(array_map(static fn (Item $item) => $item->getWeight(), $underWeightBoxItems));
183
184
        if ($overWeightItem->getWeight() + $underWeightItemsWeight > $targetWeight) {
185
            return false;
186
        }
187
188
        $oldVariance = self::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

188
        $oldVariance = self::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

188
        $oldVariance = self::calculateVariance($overWeightItemsWeight, /** @scrutinizer ignore-type */ $underWeightItemsWeight);
Loading history...
189 10
        $newVariance = self::calculateVariance($overWeightItemsWeight - $overWeightItem->getWeight(), $underWeightItemsWeight + $overWeightItem->getWeight());
190
191 10
        return $newVariance < $oldVariance;
192 10
    }
193
194 10
    private static function calculateVariance(int $boxAWeight, int $boxBWeight): float
195 8
    {
196
        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
197
    }
198
}
199