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