Test Failed
Push — master ( 09012b...794d70 )
by Doug
04:43
created

WeightRedistributor::doVolumeRepack()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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

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

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