diff --git a/Models/GenerationOptions.cs b/Models/GenerationOptions.cs index 91e895a..aa719ae 100644 --- a/Models/GenerationOptions.cs +++ b/Models/GenerationOptions.cs @@ -11,6 +11,10 @@ public class GenerationOptions public int BLFAttempts { get; set; } + public double MaxLayerStability { get; set; } = 50; + + public double PerSkuTopLayerFraction { get; set; } = 0.5; + public GenerationOptions() { } public GenerationOptions(int maxSolverTime, int maxCandidates, int blfAttempts) @@ -23,7 +27,11 @@ public GenerationOptions(int maxSolverTime, int maxCandidates, int blfAttempts) public static GenerationOptions From(GenerationOptions? source) { if (source == null) return new GenerationOptions(); - return new GenerationOptions(source.MaxSolverTime, source.MaxCPSATCandidates, source.BLFAttempts); + return new GenerationOptions(source.MaxSolverTime, source.MaxCPSATCandidates, source.BLFAttempts) + { + MaxLayerStability = source.MaxLayerStability, + PerSkuTopLayerFraction = source.PerSkuTopLayerFraction + }; } } } diff --git a/Models/Layering/Layer.cs b/Models/Layering/Layer.cs index 299d3cf..ffb320b 100644 --- a/Models/Layering/Layer.cs +++ b/Models/Layering/Layer.cs @@ -15,6 +15,8 @@ public class Layer(string name, List items, LayerMetadata metada public LayerGeometry? Geometry { get; set; } = null; + public LayerMetrics Metrics { get; set; } = new(); + public override string ToString() { return $"{Name} ({Id})\n\n{Metadata}"; diff --git a/Models/Layering/LayerMetrics.cs b/Models/Layering/LayerMetrics.cs new file mode 100644 index 0000000..b434756 --- /dev/null +++ b/Models/Layering/LayerMetrics.cs @@ -0,0 +1,24 @@ +namespace Stack_Solver.Models.Layering +{ + /// + /// Layer-level metrics + /// + public class LayerMetrics + { + /// + /// Fill of the support surface used by this layer, expressed as a percentage in range [0, 100]. + /// + public double Utilization { get; set; } + + /// + /// Distance of layer center of gravity from pallet center, normalized to [0, 100], where 0 is centered. + /// + public double Stability { get; set; } + + public double TotalWeight { get; set; } + + public IReadOnlyCollection UsedSkuTypes { get; set; } = []; + + public IReadOnlyCollection CompatibleTopLayerIds { get; set; } = []; + } +} \ No newline at end of file diff --git a/Services/LayerMetricsCalculator.cs b/Services/LayerMetricsCalculator.cs new file mode 100644 index 0000000..03777be --- /dev/null +++ b/Services/LayerMetricsCalculator.cs @@ -0,0 +1,215 @@ +using Stack_Solver.Models; +using Stack_Solver.Models.Layering; +using Stack_Solver.Models.Supports; + +namespace Stack_Solver.Services +{ + public static class LayerMetricsCalculator + { + public static LayerMetrics Compute(Layer layer, SupportSurface supportSurface) + { + ArgumentNullException.ThrowIfNull(layer); + ArgumentNullException.ThrowIfNull(supportSurface); + + double palletArea = supportSurface.Length * supportSurface.Width; + double palletCenterX = supportSurface.Length / 2.0; + double palletCenterY = supportSurface.Width / 2.0; + double maxCenterDistance = Math.Sqrt((palletCenterX * palletCenterX) + (palletCenterY * palletCenterY)); + + double usedArea = 0; + double totalWeight = 0; + double weightedCenterX = 0; + double weightedCenterY = 0; + var distinctSkuIds = new HashSet(StringComparer.Ordinal); + + foreach (var item in layer.Items) + { + var sku = item.SkuType; + if (sku == null) + continue; + + int xSpan = item.GetXSpan(); + int ySpan = item.GetYSpan(); + + double itemArea = xSpan * ySpan; + double itemWeight = sku.Weight; + double itemCenterX = item.X + (xSpan / 2.0); + double itemCenterY = item.Y + (ySpan / 2.0); + + usedArea += itemArea; + totalWeight += itemWeight; + weightedCenterX += itemCenterX * itemWeight; + weightedCenterY += itemCenterY * itemWeight; + distinctSkuIds.Add(sku.SkuId); + } + + double fillPercent = palletArea <= 0 ? 0 : (usedArea / palletArea) * 100.0; + double centerX = totalWeight > 0 ? (weightedCenterX / totalWeight) : palletCenterX; + double centerY = totalWeight > 0 ? (weightedCenterY / totalWeight) : palletCenterY; + double cogDistance = Math.Sqrt(Math.Pow(centerX - palletCenterX, 2) + Math.Pow(centerY - palletCenterY, 2)); + double stabilityPercent = maxCenterDistance <= 0 ? 0 : (cogDistance / maxCenterDistance) * 100.0; + + return new LayerMetrics + { + Utilization = Math.Clamp(fillPercent, 0, 100), + Stability = Math.Clamp(stabilityPercent, 0, 100), + TotalWeight = totalWeight, + UsedSkuTypes = [.. distinctSkuIds] + }; + } + + public static void ComputeCompatibility(IReadOnlyList layers) + { + ArgumentNullException.ThrowIfNull(layers); + + foreach (var baseLayer in layers) + { + var compatibleTopLayerIds = new List(); + + foreach (var topLayer in layers) + { + if (ReferenceEquals(baseLayer, topLayer)) + continue; + + if (CanStackWithoutOverhang(baseLayer, topLayer)) + compatibleTopLayerIds.Add(topLayer.Id); + } + + baseLayer.Metrics.CompatibleTopLayerIds = compatibleTopLayerIds; + } + } + + public static List FilterLayers(IReadOnlyList layers, GenerationOptions options) + { + ArgumentNullException.ThrowIfNull(layers); + ArgumentNullException.ThrowIfNull(options); + + var stabilityFiltered = FilterByStability(layers, options.MaxLayerStability); + var deduplicated = RemoveIdenticalLayers(stabilityFiltered); + var selected = SelectTopFractionPerSkuByUtilization(deduplicated, options.PerSkuTopLayerFraction); + + ComputeCompatibility(selected); + return selected; + } + + private static List FilterByStability(IEnumerable layers, double maxLayerStability) + { + return [.. layers.Where(layer => layer.Metrics.Stability <= maxLayerStability)]; + } + + private static List RemoveIdenticalLayers(IEnumerable layers) + { + var bestBySignature = new Dictionary(StringComparer.Ordinal); + + foreach (var layer in layers) + { + string signature = BuildLayerSignature(layer); + if (!bestBySignature.TryGetValue(signature, out var currentBest)) + { + bestBySignature[signature] = layer; + continue; + } + + if (IsBetterCandidate(layer, currentBest)) + bestBySignature[signature] = layer; + } + + return [.. bestBySignature.Values]; + } + + private static List SelectTopFractionPerSkuByUtilization(List layers, double topFraction) + { + if (layers.Count == 0) + return []; + + double normalizedFraction = Math.Clamp(topFraction, 0, 1); + if (normalizedFraction <= 0) + return []; + + var layerById = layers.ToDictionary(l => l.Id, StringComparer.Ordinal); + var selectedIds = new HashSet(StringComparer.Ordinal); + + var allSkuIds = layers + .SelectMany(layer => layer.Metrics.UsedSkuTypes) + .Distinct(StringComparer.Ordinal) + .ToList(); + + foreach (var skuId in allSkuIds) + { + var containingLayers = layers + .Where(layer => layer.Metrics.UsedSkuTypes.Contains(skuId)) + .OrderByDescending(layer => layer.Metrics.Utilization) + .ThenBy(layer => layer.Id, StringComparer.Ordinal) + .ToList(); + + if (containingLayers.Count == 0) + continue; + + int keepCount = Math.Max(1, (int)Math.Ceiling(containingLayers.Count * normalizedFraction)); + foreach (var layer in containingLayers.Take(keepCount)) + selectedIds.Add(layer.Id); + } + + return [.. selectedIds + .Select(id => layerById[id]) + .OrderByDescending(layer => layer.Metrics.Utilization) + .ThenBy(layer => layer.Id, StringComparer.Ordinal)]; + } + + private static string BuildLayerSignature(Layer layer) + { + var parts = layer.Items + .Select(item => $"{item.SkuType.SkuId}:{item.X}:{item.Y}:{item.Rotated}") + .OrderBy(part => part, StringComparer.Ordinal); + + return string.Join("|", parts); + } + + private static bool IsBetterCandidate(Layer candidate, Layer incumbent) + { + int utilizationComparison = candidate.Metrics.Utilization.CompareTo(incumbent.Metrics.Utilization); + if (utilizationComparison != 0) + return utilizationComparison > 0; + + int stabilityComparison = candidate.Metrics.Stability.CompareTo(incumbent.Metrics.Stability); + if (stabilityComparison != 0) + return stabilityComparison < 0; + + return string.Compare(candidate.Id, incumbent.Id, StringComparison.Ordinal) < 0; + } + + private static bool CanStackWithoutOverhang(Layer bottomLayer, Layer topLayer) + { + if (topLayer.Geometry == null) + return false; + + if (bottomLayer.Geometry == null) + return false; + + var topMap = topLayer.Geometry.ItemIndexGrid; + var bottomGrid = bottomLayer.Geometry.OccupancyGrid; + + int width = topLayer.Geometry.Width; + int length = topLayer.Geometry.Length; + + for (int y = 0; y < width; y++) + { + for (int x = 0; x < length; x++) + { + int itemIndex = topMap[y, x]; + if (itemIndex < 0) + continue; + + bool hasSupport = y < bottomGrid.GetLength(0) && + x < bottomGrid.GetLength(1) && + bottomGrid[y, x]; + + if (!hasSupport) + return false; + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/Services/Layering/BLFGenerationStrategy.cs b/Services/Layering/BLFGenerationStrategy.cs index 384ea84..682d532 100644 --- a/Services/Layering/BLFGenerationStrategy.cs +++ b/Services/Layering/BLFGenerationStrategy.cs @@ -36,7 +36,9 @@ public List Generate(List skus, SupportSurface supportSurface, Gener candidateLayers.Add(layer); } - return LayerCandidateHelper.SelectBestBySkuCounts(candidateLayers); + var selectedLayers = LayerCandidateHelper.SelectBestBySkuCounts(candidateLayers); + LayerMetricsCalculator.ComputeCompatibility(selectedLayers); + return selectedLayers; } private static Layer? BuildLayerAttempt(IReadOnlyList variants, List skus, int maxWidth, int maxDepth, double area, int attemptIndex, Random rand, SupportSurface supportSurface) @@ -77,6 +79,7 @@ public List Generate(List skus, SupportSurface supportSurface, Gener var metadata = new LayerMetadata(utilization, layerHeight, $"BLF attempt {attemptIndex}, boxes={boxes}, util={utilization:F3}"); var layer = new Layer($"blf_attempt_{attemptIndex}", placements, metadata); layer.Geometry = LayerGeometryBuilder.Build(layer, supportSurface); + layer.Metrics = LayerMetricsCalculator.Compute(layer, supportSurface); return layer; } diff --git a/Services/Layering/CPSATGenerationStrategy.cs b/Services/Layering/CPSATGenerationStrategy.cs index 143a97b..060528a 100644 --- a/Services/Layering/CPSATGenerationStrategy.cs +++ b/Services/Layering/CPSATGenerationStrategy.cs @@ -120,8 +120,11 @@ public List Generate(List skus, SupportSurface supportSurface, Gener var metadata = new LayerMetadata(util, layerHeight, desc); var layer = new Layer("CPSAT", placements, metadata); layer.Geometry = LayerGeometryBuilder.Build(layer, supportSurface); + layer.Metrics = LayerMetricsCalculator.Compute(layer, supportSurface); - return [layer]; + var layers = new List { layer }; + LayerMetricsCalculator.ComputeCompatibility(layers); + return layers; } private static int ComputeGridStep(List skus) diff --git a/Services/Layering/HomogeneousGenerationStrategy.cs b/Services/Layering/HomogeneousGenerationStrategy.cs index f943484..fe59a13 100644 --- a/Services/Layering/HomogeneousGenerationStrategy.cs +++ b/Services/Layering/HomogeneousGenerationStrategy.cs @@ -37,6 +37,7 @@ public List Generate(List skus, SupportSurface supportSurface, Gener candidateLayers.Add(layer); } + LayerMetricsCalculator.ComputeCompatibility(candidateLayers); return candidateLayers; } @@ -77,6 +78,7 @@ public List Generate(List skus, SupportSurface supportSurface, Gener var metadata = new LayerMetadata(utilization, variant.Sku.Height, description); var layer = new Layer($"hom_grid_{variant.Sku.SkuId}_{orientation}", placements, metadata); layer.Geometry = LayerGeometryBuilder.Build(layer, supportSurface); + layer.Metrics = LayerMetricsCalculator.Compute(layer, supportSurface); return layer; } } diff --git a/Services/Layering/StripFillGenerationStrategy.cs b/Services/Layering/StripFillGenerationStrategy.cs index 4fd0746..24c5625 100644 --- a/Services/Layering/StripFillGenerationStrategy.cs +++ b/Services/Layering/StripFillGenerationStrategy.cs @@ -46,7 +46,9 @@ public List Generate(List skus, SupportSurface supportSurface, Gener } } - return LayerCandidateHelper.SelectBestBySkuCounts(candidateLayers); + var selectedLayers = LayerCandidateHelper.SelectBestBySkuCounts(candidateLayers); + LayerMetricsCalculator.ComputeCompatibility(selectedLayers); + return selectedLayers; } @@ -156,6 +158,7 @@ private static bool SequenceFitsSurface(IEnumerable sequence, int ma var metadata = new LayerMetadata(utilization, layerHeight, description); var layer = new Layer(layerId, placements, metadata); layer.Geometry = LayerGeometryBuilder.Build(layer, supportSurface); + layer.Metrics = LayerMetricsCalculator.Compute(layer, supportSurface); return layer; } diff --git a/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs b/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs index 5b0a18d..d25fb43 100644 --- a/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs +++ b/Tests/Stack-Solver.Tests/Services/Strategies/BLFGenerationStrategyTests.cs @@ -71,6 +71,9 @@ public void Generate_MultipleSKUs() Assert.Equal(33, layers.Last().Items.Count); layers.Sort((l1, l2) => l1.Metadata.Utilization.CompareTo(l2.Metadata.Utilization)); Assert.Equal(0.942, layers.Last().Metadata.Utilization, 3); + Assert.InRange(layers.Last().Metrics.Utilization, 0.0, 100.0); + Assert.InRange(layers.Last().Metrics.Stability, 0.0, 100.0); + Assert.NotEmpty(layers.Last().Metrics.UsedSkuTypes); } [Fact] diff --git a/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs b/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs index 20be9ca..607dd76 100644 --- a/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs +++ b/Tests/Stack-Solver.Tests/Services/Strategies/HomogeneousGenerationStrategyTests.cs @@ -52,6 +52,11 @@ public void Generate_SingleRotatableSKU_ProducesNormalAndRotatedLayers() Assert.All(normal.Items, it => Assert.False(it.Rotated)); Assert.Contains(normal.Items, it => it.X == 0 && it.Y == 0); Assert.Contains(normal.Items, it => it.X == 80 && it.Y == 60); + Assert.Equal(100.0, normal.Metrics.Utilization, 5); + Assert.Equal(0.0, normal.Metrics.Stability, 5); + Assert.Equal(0.0, normal.Metrics.TotalWeight, 5); + Assert.Single(normal.Metrics.UsedSkuTypes); + Assert.Contains("A", normal.Metrics.UsedSkuTypes); Assert.Equal(8, rotated.Items.Count); Assert.Equal(9600.0 / 10800.0, rotated.Metadata.Utilization, 6); @@ -63,6 +68,13 @@ public void Generate_SingleRotatableSKU_ProducesNormalAndRotatedLayers() Assert.All(rotated.Items, it => Assert.True(it.Rotated)); Assert.Contains(rotated.Items, it => it.X == 0 && it.Y == 0); Assert.Contains(rotated.Items, it => it.X == 90 && it.Y == 40); + Assert.Equal((9600.0 / 10800.0) * 100.0, rotated.Metrics.Utilization, 6); + Assert.Equal(0.0, rotated.Metrics.Stability, 5); + Assert.Equal(0.0, rotated.Metrics.TotalWeight, 5); + Assert.Single(rotated.Metrics.UsedSkuTypes); + Assert.Contains("A", rotated.Metrics.UsedSkuTypes); + Assert.Contains(rotated.Id, normal.Metrics.CompatibleTopLayerIds); + Assert.DoesNotContain(normal.Id, rotated.Metrics.CompatibleTopLayerIds); } [Fact] diff --git a/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs b/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs index c648f16..a49ebc0 100644 --- a/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs +++ b/Tests/Stack-Solver.Tests/Services/Strategies/StripFillGenerationStrategyTests.cs @@ -42,6 +42,11 @@ public void Generate_SingleNonRotatableSKU_DeterministicCountsAndUtilization() Assert.Equal(9, best.Items.Count); Assert.Equal(1.0, best.Metadata.Utilization, 6); Assert.Equal(10, best.Metadata.Height); + Assert.Equal(100.0, best.Metrics.Utilization, 6); + Assert.Equal(0.0, best.Metrics.Stability, 6); + Assert.Equal(0.0, best.Metrics.TotalWeight, 6); + Assert.Single(best.Metrics.UsedSkuTypes); + Assert.Contains("A", best.Metrics.UsedSkuTypes); Assert.NotNull(best.Geometry); Assert.Equal(90, best.Geometry!.Width); diff --git a/defaults.json b/defaults.json index 54510fc..bd37651 100644 --- a/defaults.json +++ b/defaults.json @@ -24,7 +24,9 @@ "LayerGeneration": { "MaxSolverTime": 60, "MaxCPSATCandidates": 2000, - "BLFAttempts": 200 + "BLFAttempts": 200, + "MaxLayerStability": 50, + "PerSkuTopLayerFraction": 0.5 }, "PalletDefaults": { "DefaultCatalog": "International",