Passed
Push — 3.x ( 539084...e58d28 )
by Doug
02:52
created

WeightRedistributor   A

Complexity

Total Complexity 20

Size/Duplication

Total Lines 169
Duplicated Lines 0 %

Test Coverage

Coverage 92%

Importance

Changes 6
Bugs 1 Features 0
Metric Value
eloc 75
dl 0
loc 169
ccs 69
cts 75
cp 0.92
rs 10
c 6
b 1
f 0
wmc 20

6 Methods

Rating   Name   Duplication   Size   Complexity  
A calculateVariance() 0 3 1
A doVolumeRepack() 0 11 2
A __construct() 0 5 1
B equaliseWeight() 0 53 7
A wouldRepackActuallyHelp() 0 13 2
B redistributeWeight() 0 37 7
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 function max;
18
use const PHP_INT_MAX;
19
use Psr\Log\LoggerAwareInterface;
20
use Psr\Log\LoggerAwareTrait;
21
use Psr\Log\LogLevel;
22
use Psr\Log\NullLogger;
23
use SplObjectStorage;
24
use function usort;
25
26
/**
27
 * Actual packer.
28
 *
29
 * @author Doug Wright
30
 * @internal
31
 */
32
class WeightRedistributor implements LoggerAwareInterface
33
{
34
    use LoggerAwareTrait;
35
36
    /**
37
     * List of box sizes available to pack items into.
38
     *
39
     * @var BoxList
40
     */
41
    private $boxes;
42
43
    /**
44
     * Quantities available of each box type.
45
     *
46
     * @var SplObjectStorage|int[]
47
     */
48
    private $boxesQtyAvailable;
49
50
    /**
51
     * Constructor.
52
     */
53 12
    public function __construct(BoxList $boxList, SplObjectStorage $boxQuantitiesAvailable)
54
    {
55 12
        $this->boxes = $boxList;
56 12
        $this->boxesQtyAvailable = $boxQuantitiesAvailable;
57 12
        $this->logger = new NullLogger();
58 12
    }
59
60
    /**
61
     * Given a solution set of packed boxes, repack them to achieve optimum weight distribution.
62
     */
63 12
    public function redistributeWeight(PackedBoxList $originalBoxes): PackedBoxList
64
    {
65 12
        $targetWeight = $originalBoxes->getMeanItemWeight();
66 12
        $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

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

192
        $oldVariance = static::calculateVariance($overWeightItemsWeight, /** @scrutinizer ignore-type */ $underWeightItemsWeight);
Loading history...
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

192
        $oldVariance = static::calculateVariance(/** @scrutinizer ignore-type */ $overWeightItemsWeight, $underWeightItemsWeight);
Loading history...
193 6
        $newVariance = static::calculateVariance($overWeightItemsWeight - $overWeightItem->getWeight(), $underWeightItemsWeight + $overWeightItem->getWeight());
194
195 6
        return $newVariance < $oldVariance;
196
    }
197
198 6
    private static function calculateVariance(int $boxAWeight, int $boxBWeight)
199
    {
200 6
        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
201
    }
202
}
203