Passed
Push — 3.x ( 43a424...548f96 )
by Doug
08:16
created

WeightRedistributor::doVolumeRepack()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 2

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 8
c 2
b 0
f 0
nc 2
nop 2
dl 0
loc 12
ccs 9
cts 9
cp 1
crap 2
rs 10
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\LoggerAwareTrait;
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
 * @author Doug Wright
29
 * @internal
30
 */
31
class WeightRedistributor implements LoggerAwareInterface
32
{
33
    use LoggerAwareTrait;
34
35
    /**
36
     * List of box sizes available to pack items into.
37
     *
38
     * @var BoxList
39
     */
40
    private $boxes;
41
42
    /**
43
     * Quantities available of each box type.
44
     *
45
     * @var SplObjectStorage|int[]
46
     */
47
    private $boxesQtyAvailable;
48
49
    /**
50
     * @var PackedBoxSorter
51
     */
52
    private $packedBoxSorter;
53
54
    /**
55
     * Constructor.
56
     */
57 15
    public function __construct(BoxList $boxList, PackedBoxSorter $packedBoxSorter, SplObjectStorage $boxQuantitiesAvailable)
58
    {
59 15
        $this->boxes = $boxList;
60 15
        $this->packedBoxSorter = $packedBoxSorter;
61 15
        $this->boxesQtyAvailable = $boxQuantitiesAvailable;
62 15
        $this->logger = new NullLogger();
63
    }
64
65
    /**
66
     * Given a solution set of packed boxes, repack them to achieve optimum weight distribution.
67
     */
68 15
    public function redistributeWeight(PackedBoxList $originalBoxes): PackedBoxList
69
    {
70 15
        $targetWeight = $originalBoxes->getMeanItemWeight();
71 15
        $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

71
        $this->logger->/** @scrutinizer ignore-call */ 
72
                       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...
72
73
        /** @var PackedBox[] $boxes */
74 15
        $boxes = iterator_to_array($originalBoxes);
75
76 15
        usort($boxes, static function (PackedBox $boxA, PackedBox $boxB) {
77 15
            return $boxB->getWeight() <=> $boxA->getWeight();
78
        });
79
80
        do {
81 15
            $iterationSuccessful = false;
82
83 15
            foreach ($boxes as $a => &$boxA) {
84 15
                foreach ($boxes as $b => &$boxB) {
85 15
                    if ($b <= $a || $boxA->getWeight() === $boxB->getWeight()) {
86 15
                        continue; // no need to evaluate
87
                    }
88
89 10
                    $iterationSuccessful = $this->equaliseWeight($boxA, $boxB, $targetWeight);
90 10
                    if ($iterationSuccessful) {
91 5
                        $boxes = array_filter($boxes, static function (?PackedBox $box) { // remove any now-empty boxes from the list
92 5
                            return $box instanceof PackedBox;
93
                        });
94 5
                        break 2;
95
                    }
96
                }
97
            }
98 15
        } while ($iterationSuccessful);
99
100
        // Combine back into a single list
101 15
        $packedBoxes = new PackedBoxList();
102 15
        $packedBoxes->insertFromArray($boxes);
103
104 15
        return $packedBoxes;
105
    }
106
107
    /**
108
     * Attempt to equalise weight distribution between 2 boxes.
109
     *
110
     * @return bool was the weight rebalanced?
111
     */
112 10
    private function equaliseWeight(PackedBox &$boxA, PackedBox &$boxB, float $targetWeight): bool
113
    {
114 10
        $anyIterationSuccessful = false;
115
116 10
        if ($boxA->getWeight() > $boxB->getWeight()) {
117 10
            $overWeightBox = $boxA;
118 10
            $underWeightBox = $boxB;
119
        } else {
120 1
            $overWeightBox = $boxB;
121 1
            $underWeightBox = $boxA;
122
        }
123
124 10
        $overWeightBoxItems = $overWeightBox->getItems()->asItemArray();
125 10
        $underWeightBoxItems = $underWeightBox->getItems()->asItemArray();
126
127 10
        foreach ($overWeightBoxItems as $key => $overWeightItem) {
128 10
            if (!static::wouldRepackActuallyHelp($overWeightBoxItems, $overWeightItem, $underWeightBoxItems, $targetWeight)) {
129 8
                continue; // moving this item would harm more than help
130
            }
131
132 7
            $newLighterBoxes = $this->doVolumeRepack(array_merge($underWeightBoxItems, [$overWeightItem]), $underWeightBox->getBox());
133 7
            if ($newLighterBoxes->count() !== 1) {
134 2
                continue; // only want to move this item if it still fits in a single box
135
            }
136
137 5
            $underWeightBoxItems[] = $overWeightItem;
138
139 5
            if (count($overWeightBoxItems) === 1) { // sometimes a repack can be efficient enough to eliminate a box
140
                $boxB = $newLighterBoxes->top();
141
                $boxA = null;
142
                $this->boxesQtyAvailable[$underWeightBox->getBox()] = $this->boxesQtyAvailable[$underWeightBox->getBox()] - 1;
143
                $this->boxesQtyAvailable[$overWeightBox->getBox()] = $this->boxesQtyAvailable[$overWeightBox->getBox()] + 1;
144
145
                return true;
146
            }
147
148 5
            unset($overWeightBoxItems[$key]);
149 5
            $newHeavierBoxes = $this->doVolumeRepack($overWeightBoxItems, $overWeightBox->getBox());
150 5
            if (count($newHeavierBoxes) !== 1) {
151
                continue; // this should never happen, if we can pack n+1 into the box, we should be able to pack n
152
            }
153
154 5
            $this->boxesQtyAvailable[$overWeightBox->getBox()] = $this->boxesQtyAvailable[$overWeightBox->getBox()] + 1;
155 5
            $this->boxesQtyAvailable[$underWeightBox->getBox()] = $this->boxesQtyAvailable[$underWeightBox->getBox()] + 1;
156 5
            $this->boxesQtyAvailable[$newHeavierBoxes->top()->getBox()] = $this->boxesQtyAvailable[$newHeavierBoxes->top()->getBox()] - 1;
157 5
            $this->boxesQtyAvailable[$newLighterBoxes->top()->getBox()] = $this->boxesQtyAvailable[$newLighterBoxes->top()->getBox()] - 1;
158 5
            $underWeightBox = $boxB = $newLighterBoxes->top();
159 5
            $overWeightBox = $boxA = $newHeavierBoxes->top();
160
161 5
            $anyIterationSuccessful = true;
162
        }
163
164 10
        return $anyIterationSuccessful;
165
    }
166
167
    /**
168
     * Do a volume repack of a set of items.
169
     */
170 7
    private function doVolumeRepack(iterable $items, Box $currentBox): PackedBoxList
171
    {
172 7
        $packer = new Packer();
173 7
        $packer->setLogger($this->logger);
0 ignored issues
show
Bug introduced by
It seems like $this->logger can also be of type null; however, parameter $logger of DVDoug\BoxPacker\Packer::setLogger() does only seem to accept Psr\Log\LoggerInterface, 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

173
        $packer->setLogger(/** @scrutinizer ignore-type */ $this->logger);
Loading history...
174 7
        $packer->setBoxes($this->boxes); // use the full set of boxes to allow smaller/larger for full efficiency
175 7
        foreach ($this->boxes as $box) {
176 7
            $packer->setBoxQuantity($box, $this->boxesQtyAvailable[$box]);
177
        }
178 7
        $packer->setBoxQuantity($currentBox, $this->boxesQtyAvailable[$currentBox] + 1);
179 7
        $packer->setItems($items);
180
181 7
        return $packer->doVolumePacking(true, true);
182
    }
183
184
    /**
185
     * Not every attempted repack is actually helpful - sometimes moving an item between two otherwise identical
186
     * boxes, or sometimes the box used for the now lighter set of items actually weighs more when empty causing
187
     * an increase in total weight.
188
     */
189 10
    private static function wouldRepackActuallyHelp(array $overWeightBoxItems, Item $overWeightItem, array $underWeightBoxItems, float $targetWeight): bool
190
    {
191 10
        $overWeightItemsWeight = array_sum(array_map(static function (Item $item) {return $item->getWeight(); }, $overWeightBoxItems));
192 10
        $underWeightItemsWeight = array_sum(array_map(static function (Item $item) {return $item->getWeight(); }, $underWeightBoxItems));
193
194 10
        if ($overWeightItem->getWeight() + $underWeightItemsWeight > $targetWeight) {
195 8
            return false;
196
        }
197
198 7
        $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

198
        $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

198
        $oldVariance = static::calculateVariance(/** @scrutinizer ignore-type */ $overWeightItemsWeight, $underWeightItemsWeight);
Loading history...
199 7
        $newVariance = static::calculateVariance($overWeightItemsWeight - $overWeightItem->getWeight(), $underWeightItemsWeight + $overWeightItem->getWeight());
200
201 7
        return $newVariance < $oldVariance;
202
    }
203
204 7
    private static function calculateVariance(int $boxAWeight, int $boxBWeight)
205
    {
206 7
        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
207
    }
208
}
209