Passed
Push — 3.x ( 0f93e4...7d0e6c )
by Doug
03:42
created

WeightRedistributor::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 3
dl 0
loc 6
ccs 5
cts 5
cp 1
crap 1
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\LoggerAwareTrait;
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
 * @author Doug Wright
29
 * @internal
30
 */
31
class WeightRedistributor implements LoggerAwareInterface
32
{
33
    use LoggerAwareTrait;
34
35
    /**
36
     * List of box sizes available to pack items into.
37
     */
38
    private BoxList $boxes;
39
40
    /**
41
     * Quantities available of each box type.
42
     *
43
     * @var SplObjectStorage|int[]
44
     */
45
    private $boxesQtyAvailable;
46
47
    private PackedBoxSorter $packedBoxSorter;
48
49
    /**
50
     * Constructor.
51
     */
52 15
    public function __construct(BoxList $boxList, PackedBoxSorter $packedBoxSorter, SplObjectStorage $boxQuantitiesAvailable)
53
    {
54 15
        $this->boxes = $boxList;
55 15
        $this->packedBoxSorter = $packedBoxSorter;
56 15
        $this->boxesQtyAvailable = $boxQuantitiesAvailable;
57 15
        $this->logger = new NullLogger();
58
    }
59
60
    /**
61
     * Given a solution set of packed boxes, repack them to achieve optimum weight distribution.
62
     */
63 15
    public function redistributeWeight(PackedBoxList $originalBoxes): PackedBoxList
64
    {
65 15
        $targetWeight = $originalBoxes->getMeanItemWeight();
66 15
        $this->logger->log(LogLevel::DEBUG, "repacking for weight distribution, weight variance {$originalBoxes->getWeightVariance()}, target weight {$targetWeight}");
0 ignored issues
show
Bug introduced by
The method log() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

66
        $this->logger->/** @scrutinizer ignore-call */ 
67
                       log(LogLevel::DEBUG, "repacking for weight distribution, weight variance {$originalBoxes->getWeightVariance()}, target weight {$targetWeight}");

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
67
68
        /** @var PackedBox[] $boxes */
69 15
        $boxes = iterator_to_array($originalBoxes);
70
71 15
        usort($boxes, static fn (PackedBox $boxA, PackedBox $boxB) => $boxB->getWeight() <=> $boxA->getWeight());
72
73
        do {
74 15
            $iterationSuccessful = false;
75
76 15
            foreach ($boxes as $a => &$boxA) {
77 15
                foreach ($boxes as $b => &$boxB) {
78 15
                    if ($b <= $a || $boxA->getWeight() === $boxB->getWeight()) {
79 15
                        continue; // no need to evaluate
80
                    }
81
82 10
                    $iterationSuccessful = $this->equaliseWeight($boxA, $boxB, $targetWeight);
83 10
                    if ($iterationSuccessful) {
84 5
                        $boxes = array_filter($boxes, static function (?PackedBox $box) { // remove any now-empty boxes from the list
85 5
                            return $box instanceof PackedBox;
86 5
                        });
87 5
                        break 2;
88
                    }
89
                }
90
            }
91 15
        } while ($iterationSuccessful);
92
93
        // Combine back into a single list
94 15
        $packedBoxes = new PackedBoxList();
95 15
        $packedBoxes->insertFromArray($boxes);
96
97 15
        return $packedBoxes;
98
    }
99
100
    /**
101
     * Attempt to equalise weight distribution between 2 boxes.
102
     *
103
     * @return bool was the weight rebalanced?
104
     */
105 10
    private function equaliseWeight(PackedBox &$boxA, PackedBox &$boxB, float $targetWeight): bool
106
    {
107 10
        $anyIterationSuccessful = false;
108
109 10
        if ($boxA->getWeight() > $boxB->getWeight()) {
110 10
            $overWeightBox = $boxA;
111 10
            $underWeightBox = $boxB;
112
        } else {
113 1
            $overWeightBox = $boxB;
114 1
            $underWeightBox = $boxA;
115
        }
116
117 10
        $overWeightBoxItems = $overWeightBox->getItems()->asItemArray();
118 10
        $underWeightBoxItems = $underWeightBox->getItems()->asItemArray();
119
120 10
        foreach ($overWeightBoxItems as $key => $overWeightItem) {
121 10
            if (!static::wouldRepackActuallyHelp($overWeightBoxItems, $overWeightItem, $underWeightBoxItems, $targetWeight)) {
122 8
                continue; // moving this item would harm more than help
123
            }
124
125 7
            $newLighterBoxes = $this->doVolumeRepack(array_merge($underWeightBoxItems, [$overWeightItem]), $underWeightBox->getBox());
126 7
            if ($newLighterBoxes->count() !== 1) {
127 2
                continue; // only want to move this item if it still fits in a single box
128
            }
129
130 5
            $underWeightBoxItems[] = $overWeightItem;
131
132 5
            if (count($overWeightBoxItems) === 1) { // sometimes a repack can be efficient enough to eliminate a box
133
                $boxB = $newLighterBoxes->top();
134
                $boxA = null;
135
                $this->boxesQtyAvailable[$underWeightBox->getBox()] = $this->boxesQtyAvailable[$underWeightBox->getBox()] - 1;
136
                $this->boxesQtyAvailable[$overWeightBox->getBox()] = $this->boxesQtyAvailable[$overWeightBox->getBox()] + 1;
137
138
                return true;
139
            }
140
141 5
            unset($overWeightBoxItems[$key]);
142 5
            $newHeavierBoxes = $this->doVolumeRepack($overWeightBoxItems, $overWeightBox->getBox());
143 5
            if (count($newHeavierBoxes) !== 1) {
144
                continue; // this should never happen, if we can pack n+1 into the box, we should be able to pack n
145
            }
146
147 5
            $this->boxesQtyAvailable[$overWeightBox->getBox()] = $this->boxesQtyAvailable[$overWeightBox->getBox()] + 1;
148 5
            $this->boxesQtyAvailable[$underWeightBox->getBox()] = $this->boxesQtyAvailable[$underWeightBox->getBox()] + 1;
149 5
            $this->boxesQtyAvailable[$newHeavierBoxes->top()->getBox()] = $this->boxesQtyAvailable[$newHeavierBoxes->top()->getBox()] - 1;
150 5
            $this->boxesQtyAvailable[$newLighterBoxes->top()->getBox()] = $this->boxesQtyAvailable[$newLighterBoxes->top()->getBox()] - 1;
151 5
            $underWeightBox = $boxB = $newLighterBoxes->top();
152 5
            $overWeightBox = $boxA = $newHeavierBoxes->top();
153
154 5
            $anyIterationSuccessful = true;
155
        }
156
157 10
        return $anyIterationSuccessful;
158
    }
159
160
    /**
161
     * Do a volume repack of a set of items.
162
     */
163 7
    private function doVolumeRepack(iterable $items, Box $currentBox): PackedBoxList
164
    {
165 7
        $packer = new Packer();
166 7
        $packer->setLogger($this->logger);
0 ignored issues
show
Bug introduced by
It seems like $this->logger can also be of type null; however, parameter $logger of DVDoug\BoxPacker\Packer::setLogger() does only seem to accept Psr\Log\LoggerInterface, 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

166
        $packer->setLogger(/** @scrutinizer ignore-type */ $this->logger);
Loading history...
167 7
        $packer->setBoxes($this->boxes); // use the full set of boxes to allow smaller/larger for full efficiency
168 7
        foreach ($this->boxes as $box) {
169 7
            $packer->setBoxQuantity($box, $this->boxesQtyAvailable[$box]);
170
        }
171 7
        $packer->setBoxQuantity($currentBox, $this->boxesQtyAvailable[$currentBox] + 1);
172 7
        $packer->setItems($items);
173
174 7
        return $packer->doVolumePacking(true, true);
175
    }
176
177
    /**
178
     * Not every attempted repack is actually helpful - sometimes moving an item between two otherwise identical
179
     * boxes, or sometimes the box used for the now lighter set of items actually weighs more when empty causing
180
     * an increase in total weight.
181
     */
182 10
    private static function wouldRepackActuallyHelp(array $overWeightBoxItems, Item $overWeightItem, array $underWeightBoxItems, float $targetWeight): bool
183
    {
184 10
        $overWeightItemsWeight = array_sum(array_map(static fn (Item $item) => $item->getWeight(), $overWeightBoxItems));
185 10
        $underWeightItemsWeight = array_sum(array_map(static fn (Item $item) => $item->getWeight(), $underWeightBoxItems));
186
187 10
        if ($overWeightItem->getWeight() + $underWeightItemsWeight > $targetWeight) {
188 8
            return false;
189
        }
190
191 7
        $oldVariance = static::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

191
        $oldVariance = static::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

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