Passed
Push — master ( 6d1077...c09ef5 )
by Doug
01:42
created

WeightRedistributor   A

Complexity

Total Complexity 20

Size/Duplication

Total Lines 172
Duplicated Lines 0 %

Test Coverage

Coverage 89.87%

Importance

Changes 7
Bugs 1 Features 0
Metric Value
wmc 20
eloc 75
dl 0
loc 172
c 7
b 1
f 0
ccs 71
cts 79
cp 0.8987
rs 10

6 Methods

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