Passed
Push — limited_supply_boxes ( 273b14 )
by Doug
10:27
created

WeightRedistributor::redistributeWeight()   B

Complexity

Conditions 7
Paths 5

Size

Total Lines 37
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 7

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 7
eloc 20
nc 5
nop 1
dl 0
loc 37
ccs 20
cts 20
cp 1
crap 7
rs 8.6666
c 2
b 0
f 0
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
42
     */
43
    private $boxesQtyAvailable;
44
45
    /**
46
     * Constructor.
47
     */
48 13
    public function __construct(BoxList $boxList, array $boxQuantitiesAvailable)
49
    {
50 13
        $this->boxes = $boxList;
51 13
        $this->boxesQtyAvailable = $boxQuantitiesAvailable;
52 13
        $this->logger = new NullLogger();
53 13
    }
54
55
    /**
56
     * Given a solution set of packed boxes, repack them to achieve optimum weight distribution.
57
     */
58 13
    public function redistributeWeight(PackedBoxList $originalBoxes): PackedBoxList
59
    {
60 13
        $targetWeight = $originalBoxes->getMeanItemWeight();
61 13
        $this->logger->log(LogLevel::DEBUG, "repacking for weight distribution, weight variance {$originalBoxes->getWeightVariance()}, target weight {$targetWeight}");
62
63
        /** @var PackedBox[] $boxes */
64 13
        $boxes = iterator_to_array($originalBoxes);
65
66
        usort($boxes, static function (PackedBox $boxA, PackedBox $boxB) {
67 13
            return $boxB->getWeight() <=> $boxA->getWeight();
68 13
        });
69
70
        do {
71 13
            $iterationSuccessful = false;
72
73 13
            foreach ($boxes as $a => &$boxA) {
74 13
                foreach ($boxes as $b => &$boxB) {
75 13
                    if ($b <= $a || $boxA->getWeight() === $boxB->getWeight()) {
76 13
                        continue; //no need to evaluate
77
                    }
78
79 9
                    $iterationSuccessful = $this->equaliseWeight($boxA, $boxB, $targetWeight);
80 9
                    if ($iterationSuccessful) {
81
                        $boxes = array_filter($boxes, static function (?PackedBox $box) { //remove any now-empty boxes from the list
82 6
                            return $box instanceof PackedBox;
83 6
                        });
84 13
                        break 2;
85
                    }
86
                }
87
            }
88 13
        } while ($iterationSuccessful);
89
90
        //Combine back into a single list
91 13
        $packedBoxes = new PackedBoxList();
92 13
        $packedBoxes->insertFromArray($boxes);
93
94 13
        return $packedBoxes;
95
    }
96
97
    /**
98
     * Attempt to equalise weight distribution between 2 boxes.
99
     *
100
     * @return bool was the weight rebalanced?
101
     */
102 9
    private function equaliseWeight(PackedBox &$boxA, PackedBox &$boxB, float $targetWeight): bool
103
    {
104 9
        $anyIterationSuccessful = false;
105
106 9
        if ($boxA->getWeight() > $boxB->getWeight()) {
107 9
            $overWeightBox = $boxA;
108 9
            $underWeightBox = $boxB;
109
        } else {
110 2
            $overWeightBox = $boxB;
111 2
            $underWeightBox = $boxA;
112
        }
113
114 9
        $overWeightBoxItems = $overWeightBox->getItems()->asItemArray();
115 9
        $underWeightBoxItems = $underWeightBox->getItems()->asItemArray();
116
117 9
        foreach ($overWeightBoxItems as $key => $overWeightItem) {
118 9
            if ($overWeightItem->getWeight() + $underWeightBox->getItemWeight() > $targetWeight) {
119 8
                continue; // moving this item would harm more than help
120
            }
121
122 7
            $newLighterBoxes = $this->doVolumeRepack(array_merge($underWeightBoxItems, [$overWeightItem]), $underWeightBox->getBox());
123 7
            if ($newLighterBoxes->count() !== 1) {
124 3
                continue; //only want to move this item if it still fits in a single box
125
            }
126
127 6
            $underWeightBoxItems[] = $overWeightItem;
128
129 6
            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 6
            unset($overWeightBoxItems[$key]);
139 6
            $newHeavierBoxes = $this->doVolumeRepack($overWeightBoxItems, $overWeightBox->getBox());
140 6
            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 6
            if (static::didRepackActuallyHelp($boxA, $boxB, $newHeavierBoxes->top(), $newLighterBoxes->top())) {
145 6
                ++$this->boxesQtyAvailable[spl_object_id($boxA->getBox())];
146 6
                ++$this->boxesQtyAvailable[spl_object_id($boxB->getBox())];
147 6
                --$this->boxesQtyAvailable[spl_object_id($newHeavierBoxes->top()->getBox())];
148 6
                --$this->boxesQtyAvailable[spl_object_id($newLighterBoxes->top()->getBox())];
149 6
                $underWeightBox = $boxB = $newLighterBoxes->top();
150 6
                $boxA = $newHeavierBoxes->top();
151
152 6
                $anyIterationSuccessful = true;
153
            }
154
        }
155
156 9
        return $anyIterationSuccessful;
157
    }
158
159
    /**
160
     * Do a volume repack of a set of items.
161
     */
162 7
    private function doVolumeRepack(iterable $items, Box $currentBox): PackedBoxList
163
    {
164 7
        $packer = new Packer();
165 7
        $packer->setBoxes($this->boxes); // use the full set of boxes to allow smaller/larger for full efficiency
166 7
        foreach ($this->boxes as $box) {
167 7
            $packer->setBoxQuantity($box, $this->boxesQtyAvailable[spl_object_id($box)]);
168
        }
169 7
        $packer->setBoxQuantity($currentBox, max(PHP_INT_MAX, $this->boxesQtyAvailable[spl_object_id($currentBox)] + 1));
170 7
        $packer->setItems($items);
171
172 7
        return $packer->doVolumePacking();
173
    }
174
175
    /**
176
     * Not every attempted repack is actually helpful - sometimes moving an item between two otherwise identical
177
     * boxes, or sometimes the box used for the now lighter set of items actually weighs more when empty causing
178
     * an increase in total weight.
179
     */
180 6
    private static function didRepackActuallyHelp(PackedBox $oldBoxA, PackedBox $oldBoxB, PackedBox $newBoxA, PackedBox $newBoxB): bool
181
    {
182 6
        $oldList = new PackedBoxList();
183 6
        $oldList->insertFromArray([$oldBoxA, $oldBoxB]);
184
185 6
        $newList = new PackedBoxList();
186 6
        $newList->insertFromArray([$newBoxA, $newBoxB]);
187
188 6
        return $newList->getWeightVariance() < $oldList->getWeightVariance();
189
    }
190
}
191