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