Passed
Push — master ( 8de8ac...4e9f9e )
by Doug
05:17 queued 02:42
created

WeightRedistributor::doVolumeRepack()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 2

Importance

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