Passed
Push — master ( 79d4e4...fedc6a )
by Doug
03:25
created

WeightRedistributor   A

Complexity

Total Complexity 21

Size/Duplication

Total Lines 162
Duplicated Lines 0 %

Test Coverage

Coverage 89.47%

Importance

Changes 14
Bugs 1 Features 0
Metric Value
eloc 73
c 14
b 1
f 0
dl 0
loc 162
ccs 68
cts 76
cp 0.8947
rs 10
wmc 21

7 Methods

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

183
        $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

183
        $oldVariance = self::calculateVariance($overWeightItemsWeight, /** @scrutinizer ignore-type */ $underWeightItemsWeight);
Loading history...
184 6
        $newVariance = self::calculateVariance($overWeightItemsWeight - $overWeightItem->getWeight(), $underWeightItemsWeight + $overWeightItem->getWeight());
185
186 6
        return $newVariance < $oldVariance;
187
    }
188
189 6
    private static function calculateVariance(int $boxAWeight, int $boxBWeight): float
190
    {
191 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
192
    }
193
}
194