Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Models/GenerationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
};
}
}
}
2 changes: 2 additions & 0 deletions Models/Layering/Layer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public class Layer(string name, List<PositionedItem> 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}";
Expand Down
24 changes: 24 additions & 0 deletions Models/Layering/LayerMetrics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Stack_Solver.Models.Layering
{
/// <summary>
/// Layer-level metrics
/// </summary>
public class LayerMetrics
{
/// <summary>
/// Fill of the support surface used by this layer, expressed as a percentage in range [0, 100].
/// </summary>
public double Utilization { get; set; }

/// <summary>
/// Distance of layer center of gravity from pallet center, normalized to [0, 100], where 0 is centered.
/// </summary>
public double Stability { get; set; }

public double TotalWeight { get; set; }

public IReadOnlyCollection<string> UsedSkuTypes { get; set; } = [];

public IReadOnlyCollection<string> CompatibleTopLayerIds { get; set; } = [];
}
}
215 changes: 215 additions & 0 deletions Services/LayerMetricsCalculator.cs
Original file line number Diff line number Diff line change
@@ -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<string>(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<Layer> layers)
{
ArgumentNullException.ThrowIfNull(layers);

foreach (var baseLayer in layers)
{
var compatibleTopLayerIds = new List<string>();

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<Layer> FilterLayers(IReadOnlyList<Layer> 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<Layer> FilterByStability(IEnumerable<Layer> layers, double maxLayerStability)
{
return [.. layers.Where(layer => layer.Metrics.Stability <= maxLayerStability)];
}

private static List<Layer> RemoveIdenticalLayers(IEnumerable<Layer> layers)
{
var bestBySignature = new Dictionary<string, Layer>(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<Layer> SelectTopFractionPerSkuByUtilization(List<Layer> 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<string>(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;
}
}
}
5 changes: 4 additions & 1 deletion Services/Layering/BLFGenerationStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ public List<Layer> Generate(List<SKU> 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<SkuVariant> variants, List<SKU> skus, int maxWidth, int maxDepth, double area, int attemptIndex, Random rand, SupportSurface supportSurface)
Expand Down Expand Up @@ -77,6 +79,7 @@ public List<Layer> Generate(List<SKU> 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;
}

Expand Down
5 changes: 4 additions & 1 deletion Services/Layering/CPSATGenerationStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,11 @@ public List<Layer> Generate(List<SKU> 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> { layer };
LayerMetricsCalculator.ComputeCompatibility(layers);
return layers;
}

private static int ComputeGridStep(List<SKU> skus)
Expand Down
2 changes: 2 additions & 0 deletions Services/Layering/HomogeneousGenerationStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public List<Layer> Generate(List<SKU> skus, SupportSurface supportSurface, Gener
candidateLayers.Add(layer);
}

LayerMetricsCalculator.ComputeCompatibility(candidateLayers);
return candidateLayers;
}

Expand Down Expand Up @@ -77,6 +78,7 @@ public List<Layer> Generate(List<SKU> 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;
}
}
Expand Down
5 changes: 4 additions & 1 deletion Services/Layering/StripFillGenerationStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ public List<Layer> Generate(List<SKU> skus, SupportSurface supportSurface, Gener
}
}

return LayerCandidateHelper.SelectBestBySkuCounts(candidateLayers);
var selectedLayers = LayerCandidateHelper.SelectBestBySkuCounts(candidateLayers);
LayerMetricsCalculator.ComputeCompatibility(selectedLayers);
return selectedLayers;
}


Expand Down Expand Up @@ -156,6 +158,7 @@ private static bool SequenceFitsSurface(IEnumerable<SkuVariant> 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading