Passed
Push — 3.x ( d2d64a...d739e8 )
by Doug
02:33
created

WeightRedistributor::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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

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

188
        $oldVariance = self::calculateVariance($overWeightItemsWeight, /** @scrutinizer ignore-type */ $underWeightItemsWeight);
Loading history...
189 10
        $newVariance = self::calculateVariance($overWeightItemsWeight - $overWeightItem->getWeight(), $underWeightItemsWeight + $overWeightItem->getWeight());
190
191 10
        return $newVariance < $oldVariance;
192 10
    }
193
194 10
    private static function calculateVariance(int $boxAWeight, int $boxBWeight): float
195 8
    {
196
        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
197
    }
198
}
199