diff --git a/goals.go b/goals.go index 2d7d091..4f1b64f 100644 --- a/goals.go +++ b/goals.go @@ -1,131 +1,71 @@ package boxpacker3 -import ( - "math" -) - -// MetricFunc calculates a specific numeric value from a packing Result. -type MetricFunc func(res *Result) float64 +// MinimizeBoxesGoal prioritizes using the fewest number of boxes possible. +// This is the classic bin packing goal, ideal for reducing shipping label costs. +// +// 1. Maximize items packed (minimize unfit items). +// 2. Minimize number of boxes used. +// 3. Minimize total volume of boxes used (prefer smaller boxes). +func MinimizeBoxesGoal(cand, best *Result) bool { + if best == nil { + return true + } -// Direction defines optimization direction. -type Direction int + // 1. Unfit count (lower is better) + if len(cand.UnfitItems) != len(best.UnfitItems) { + return len(cand.UnfitItems) < len(best.UnfitItems) + } -const ( - LessIsBetter Direction = iota - MoreIsBetter -) + // 2. Box count (lower is better) + cBox, bBox := countUsedBoxes(cand.Boxes), countUsedBoxes(best.Boxes) + if cBox != bBox { + return cBox < bBox + } -// Criterion represents a single step in the decision-making process. -type Criterion struct { - Metric MetricFunc - Direction Direction + // 3. Total Box Volume (lower is better - means smaller boxes were used) + return getUsedVolume(cand.Boxes) < getUsedVolume(best.Boxes) } -// MakeGoal creates a ComparatorFunc from a list of criteria. -// It compares results sequentially. -func MakeGoal(criteria ...Criterion) ComparatorFunc { - return func(cand, best *Result) bool { - // If there is no current best, the candidate automatically wins - if best == nil { - return true - } - - const epsilon = 0.00001 - - for _, c := range criteria { - valCand := c.Metric(cand) - valBest := c.Metric(best) - - // If values are essentially equal, continue to the next tie-breaker - if math.Abs(valCand-valBest) < epsilon { - continue - } - - if c.Direction == LessIsBetter { - return valCand < valBest - } - - return valCand > valBest - } - - // If all criteria are equal, the candidate is not strictly "better" - return false +// TightestPackingGoal prioritizes high density / volume utilization. +// Ideal when shipping costs are calculated based on dimensional weight or total volume. +// +// 1. Maximize items packed (minimize unfit items). +// 2. Minimize total volume of boxes used. +// 3. Minimize number of boxes used. +func TightestPackingGoal(cand, best *Result) bool { + if best == nil { + return true } -} -//nolint:gochecknoglobals // Metrics are stateless functions defined as vars for composition. -var ( - // UnfitCountMetric: Number of unpacked items. - UnfitCountMetric MetricFunc = func(res *Result) float64 { - return float64(len(res.UnfitItems)) + // 1. Unfit count (lower is better) + if len(cand.UnfitItems) != len(best.UnfitItems) { + return len(cand.UnfitItems) < len(best.UnfitItems) } - // BoxCountMetric: Number of non-empty boxes. - BoxCountMetric MetricFunc = func(res *Result) float64 { - return float64(countUsedBoxes(res.Boxes)) + // 2. Total Box Volume (lower is better) + // Using less container volume for the same items = higher density + cVol, bVol := getUsedVolume(cand.Boxes), getUsedVolume(best.Boxes) + // Float comparison tolerance could be added here, but simple < is usually sufficient for volume + if cVol != bVol { + return cVol < bVol } - // TotalVolumeMetric: Sum of the capacity of all used boxes (shipping air + items). - TotalVolumeMetric MetricFunc = func(res *Result) float64 { - return getUsedVolume(res.Boxes) - } + // 3. Box count (lower is better) + return countUsedBoxes(cand.Boxes) < countUsedBoxes(best.Boxes) +} - // AverageFillRateMetric: Average percentage of box volume used (0.0 - 1.0). - AverageFillRateMetric MetricFunc = func(res *Result) float64 { - return getAverageFillRate(res.Boxes) +// MaximizeItemsGoal prioritizes fitting the maximum number of items, regardless of box efficiency. +// Ideal for fixed-container scenarios (e.g., loading a truck) where leaving items behind is the worst outcome. +// +// 1. Maximize items packed (minimize unfit items). +func MaximizeItemsGoal(cand, best *Result) bool { + if best == nil { + return true } - // WeightStdDevMetric: Standard deviation of box weights (lower is more balanced). - WeightStdDevMetric MetricFunc = func(res *Result) float64 { - return getWeightStdDev(res.Boxes) - } -) - -//nolint:gochecknoglobals // Strategies are stateless configurations. -var ( - // MinimizeBoxesGoal prioritizes using the fewest number of boxes possible. - // This is the classic bin packing goal, ideal for reducing shipping label costs. - // - // 1. Maximize items packed (minimize unfit items). - // 2. Minimize number of boxes used. - // 3. Minimize total capacity of boxes used (prefer smaller boxes -> higher fill rate). - MinimizeBoxesGoal = MakeGoal( - Criterion{UnfitCountMetric, LessIsBetter}, - Criterion{BoxCountMetric, LessIsBetter}, - Criterion{TotalVolumeMetric, LessIsBetter}, - ) - - // TightestPackingGoal prioritizes high density / volume utilization. - // - // 1. Maximize items packed (minimize unfit items). - // 2. Minimize total capacity of boxes used (highest density). - // 3. Minimize number of boxes used. - TightestPackingGoal = MakeGoal( - Criterion{UnfitCountMetric, LessIsBetter}, - Criterion{TotalVolumeMetric, LessIsBetter}, - Criterion{BoxCountMetric, LessIsBetter}, - ) - - // MaxAverageFillRateGoal prioritizes the "Unboxing Experience". - // - // 1. Maximize items packed (minimize unfit items). - // 2. Maximize average fill rate (each box should be as full as possible). - MaxAverageFillRateGoal = MakeGoal( - Criterion{UnfitCountMetric, LessIsBetter}, - Criterion{AverageFillRateMetric, MoreIsBetter}, - ) - - // BalancedPackingGoal prioritizes safe distribution and handling. - // - // 1. Maximize items packed (minimize unfit items). - // 2. Minimize weight deviation (balance the load). - // 3. Minimize number of boxes used. - BalancedPackingGoal = MakeGoal( - Criterion{UnfitCountMetric, LessIsBetter}, - Criterion{WeightStdDevMetric, LessIsBetter}, - Criterion{BoxCountMetric, LessIsBetter}, - ) -) + // 1. Unfit count (lower is better) + return len(cand.UnfitItems) < len(best.UnfitItems) +} // countUsedBoxes helps calculate how many boxes actually contain items. func countUsedBoxes(boxes []*Box) int { @@ -140,69 +80,13 @@ func countUsedBoxes(boxes []*Box) int { return n } -// getUsedVolume calculates the total capacity of all boxes that contain items. +// getUsedVolume calculates the total volume of all boxes that contain items. func getUsedVolume(boxes []*Box) float64 { var v float64 for _, b := range boxes { - // Only count boxes that actually have items in them - if len(b.items) > 0 { - v += b.volume // Use the box's volume (capacity), not itemsVolume - } + v += b.itemsVolume } return v } - -// getAverageFillRate calculates the average fill percentage of used boxes. -func getAverageFillRate(boxes []*Box) float64 { - var ( - totalRate float64 - count int - ) - - for _, b := range boxes { - if len(b.items) > 0 && b.volume > 0 { - totalRate += b.itemsVolume / b.volume - count++ - } - } - - if count == 0 { - return 0 - } - - return totalRate / float64(count) -} - -// getWeightStdDev calculates the standard deviation of box weights. -func getWeightStdDev(boxes []*Box) float64 { - var ( - weights []float64 - sum float64 - ) - - for _, b := range boxes { - if len(b.items) > 0 { - w := b.itemsWeight - weights = append(weights, w) - sum += w - } - } - - count := float64(len(weights)) - if count <= 1 { - return 0 - } - - mean := sum / count - - var variance float64 - - for _, w := range weights { - diff := w - mean - variance += diff * diff - } - - return math.Sqrt(variance / count) -} diff --git a/goals_test.go b/goals_test.go deleted file mode 100644 index f0d8f39..0000000 --- a/goals_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package boxpacker3_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/bavix/boxpacker3" -) - -func TestGoals_ConflictScenarios(t *testing.T) { - t.Parallel() - - // A: 1 Huge Box (100L capacity). - // - Items inside take 10L. - // - Fill rate: 10%. - // - Total Volume: 100L. - // - Box Count: 1. - // - // B: 2 Small Boxes (10L capacity each). - // - Items inside take 10L (5L per box). - // - Fill rate: 50% per box. - // - Total Volume: 20L. - // - Box Count: 2. - - resA := &boxpacker3.Result{ - Boxes: []*boxpacker3.Box{ - makeBoxWithProps(100, 10, 10), - }, - UnfitItems: []*boxpacker3.Item{}, - } - - resB := &boxpacker3.Result{ - Boxes: []*boxpacker3.Box{ - makeBoxWithProps(10, 5, 5), - makeBoxWithProps(10, 5, 5), - }, - UnfitItems: []*boxpacker3.Item{}, - } - - // MinimizeBoxes should prefer A (1 box < 2 boxes). - require.True(t, boxpacker3.MinimizeBoxesGoal(resA, resB), - "MinimizeBoxes should prefer 1 huge box over 2 small ones") - - // TightestPacking should prefer B (20L total volume < 100L total volume). - require.True(t, boxpacker3.TightestPackingGoal(resB, resA), - "TightestPacking should prefer 2 small boxes (20L) over 1 huge box (100L)") - - // MaxAverageFillRate should prefer B (50% fill vs 10% fill). - require.True(t, boxpacker3.MaxAverageFillRateGoal(resB, resA), - "MaxAverageFillRate should prefer higher density") -} - -func TestGoals_BalancedPacking(t *testing.T) { - t.Parallel() - - // Candidate A: Balanced (10kg, 10kg). StdDev = 0. - resA := &boxpacker3.Result{ - Boxes: []*boxpacker3.Box{ - makeBoxWithProps(20, 10, 10), // 10 kg - makeBoxWithProps(20, 10, 10), // 10 kg - }, - } - - // Candidate B: Unbalanced (1kg, 19kg). High StdDev. - resB := &boxpacker3.Result{ - Boxes: []*boxpacker3.Box{ - makeBoxWithProps(20, 10, 1), // 1 kg - makeBoxWithProps(20, 10, 19), // 19 kg - }, - } - - require.True(t, boxpacker3.BalancedPackingGoal(resA, resB), - "BalancedPacking should prefer equal weights") -} - -func TestGoals_TieBreaker(t *testing.T) { - t.Parallel() - - // Both use 1 box. - // A is smaller (10L). B is larger (20L). - resA := &boxpacker3.Result{Boxes: []*boxpacker3.Box{makeBoxWithProps(10, 5, 5)}} - resB := &boxpacker3.Result{Boxes: []*boxpacker3.Box{makeBoxWithProps(20, 5, 5)}} - - // MinimizeBoxes should fall back to volume check if counts are equal - require.True(t, boxpacker3.MinimizeBoxesGoal(resA, resB), - "MinimizeBoxes should prefer smaller volume if box counts are equal") -} - -// makeBoxWithProps creates a real Box struct and populates it with an item -// to simulate volume and weight usage for testing goals. -func makeBoxWithProps(volume, itemsVolume, itemsWeight float64) *boxpacker3.Box { - // Create a box with W=volume, H=1, D=1 => Volume = volume - b := boxpacker3.NewBox("mock", volume, 1, 1, 1000) - - // Create an item with matching props to populate the box stats - item := boxpacker3.NewItem("mock-item", itemsVolume, 1, 1, itemsWeight) - - // PutItem updates internal itemsVolume and itemsWeight - b.PutItem(item, boxpacker3.Pivot{}) - - return b -}