Passed
Push — master ( 946e2e...244ac2 )
by Doug
03:15
created

WeightRedistributor::didRepackActuallyHelp()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

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