mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-13 04:51:55 -04:00
feat(engine): score nesting jobs by per-sheet stock cost
This commit is contained in:
1 parent
60fb009329
commit
5dbf887b68
19 files changed
+413
-92
No files matched your search
@@ -9,9 +9,9 @@ public class NestJobCostTests
|
||||
public static IEnumerable<object[]> EdgeCases()
|
||||
{
|
||||
foreach (var rate in new[] { 0.0, 0.5, 1.0 })
|
||||
foreach (var edge in new[] { "bottom", "top", "left", "right" })
|
||||
foreach (var quadrant in new[] { 1, 2, 3, 4 })
|
||||
yield return new object[] { rate, edge, quadrant };
|
||||
foreach (var edge in new[] { "bottom", "top", "left", "right" })
|
||||
foreach (var quadrant in new[] { 1, 2, 3, 4 })
|
||||
yield return new object[] { rate, edge, quadrant };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -84,7 +84,14 @@ public class NestJobCostTests
|
||||
TestDrawingFactory.Rectangle(4, 3)), 5);
|
||||
var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options);
|
||||
foreach (var sheet in result.Plates)
|
||||
Assert.Equal(LegacyNestJobCost.EstimateNetArea(cleanJob, sheet), NestJobCost.NetSheetArea(job, sheet));
|
||||
{
|
||||
var area = LegacyNestJobCost.EstimateNetArea(cleanJob, sheet);
|
||||
Assert.Equal(area, NestJobCost.NetSheetArea(job, sheet));
|
||||
var priced = new NestPlateStock(sheet.Stock.Id, sheet.Stock.Size, sheet.Stock.Quantity,
|
||||
sheet.Stock.PartSpacing, sheet.Stock.EdgeSpacing, sheet.Stock.Quadrant, 37);
|
||||
var pricedSheet = new NestJobPlateResult(sheet.PlateIndex, priced, sheet.Placements);
|
||||
Assert.Equal(37 * (area / sheet.Stock.Area), NestJobCost.NetSheetCost(job, pricedSheet));
|
||||
}
|
||||
Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job));
|
||||
Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000,
|
||||
NestJobCost.Evaluate(job, result));
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class StockCostTests
|
||||
{
|
||||
public static IEnumerable<object[]> Engines()
|
||||
{
|
||||
foreach (var engine in new[] { "Rectangles", "Irregular", "StockLadder", "Default", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" })
|
||||
foreach (var factor in new[] { 1e-12, 1.0, 100.0 })
|
||||
yield return new object[] { engine, factor };
|
||||
}
|
||||
|
||||
private static NestPlateStock Stock(string id, double cost, int quantity = 2, double size = 10) =>
|
||||
new(id, new Size(size, size), quantity, 0.1, default, 1, cost);
|
||||
|
||||
private static NestJob Job(params NestPlateStock[] stocks) => new(
|
||||
new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1) }, stocks);
|
||||
|
||||
[Fact]
|
||||
public void ValidationRejectsForgedStockPrice()
|
||||
{
|
||||
var job = Job(Stock("offered", 10));
|
||||
var forged = Stock("offered", 1);
|
||||
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, forged, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }) },
|
||||
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||
Assert.NotEmpty(NestLayoutCheck.Violations(job, result));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnavailablePriceDoesNotChangeAreaModeOrLegacyPenalty()
|
||||
{
|
||||
var job = Job(new NestPlateStock("available", new Size(10, 10)), Stock("unavailable", 10000, 0));
|
||||
Assert.False(NestJobCost.UsesExplicitCosts(job));
|
||||
Assert.Equal(100, NestJobCost.UnplacedPartPenalty(job));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedStrategyUsesSalvageOnlyForPricedStock()
|
||||
{
|
||||
var poses = new[] { new NestJobPlacement("part", 0, 0, 0, 0) };
|
||||
var candidate = new PlateCandidate(poses);
|
||||
var priced = Job(Stock("small", 10, size: 5), Stock("large", 12, size: 10));
|
||||
var options = new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1);
|
||||
var pricedJob = new NestJob(priced.Parts, priced.Plates, options);
|
||||
Assert.True(new NestJobCandidateComparer(pricedJob).Compare(candidate, priced.Plates[1], 1,
|
||||
candidate, priced.Plates[0], 0) > 0);
|
||||
var unpriced = new NestJob(priced.Parts, new[] { new NestPlateStock("small", new Size(5, 5)),
|
||||
new NestPlateStock("large", new Size(10, 10)) }, options);
|
||||
Assert.True(new NestJobCandidateComparer(unpriced).Compare(candidate, unpriced.Plates[0], 0,
|
||||
candidate, unpriced.Plates[1], 1) > 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 20, 0)]
|
||||
[InlineData(0.5, 14, 6)]
|
||||
public void SummarySeparatesGrossCreditAndNet(double rate, double net, double credit)
|
||||
{
|
||||
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(4, 4));
|
||||
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
|
||||
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, new[] { stock },
|
||||
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 6)));
|
||||
Assert.Empty(result.Violations);
|
||||
var summary = NestCostSummary.FromAccepted(result);
|
||||
Assert.Equal("supplied-cost", summary.Basis);
|
||||
Assert.Equal(20, summary.GrossTotal);
|
||||
Assert.Equal(net, summary.NetScore, 10);
|
||||
Assert.Equal(credit, summary.SalvageCredit, 10);
|
||||
Assert.Equal(1, Assert.Single(summary.Stock).Used);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Engines))]
|
||||
public void PricesReverseSelectionWithoutChangingFulfillment(string engine, double factor)
|
||||
{
|
||||
foreach (var cheap in new[] { "small", "large" })
|
||||
{
|
||||
var job = Job(Stock("small", (cheap == "small" ? 1 : 10) * factor),
|
||||
Stock("large", (cheap == "large" ? 1 : 10) * factor, size: 12));
|
||||
var result = NestingEngineRegistry.Create(engine).Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Empty(NestLayoutCheck.Violations(job, result));
|
||||
Assert.Equal(cheap, Assert.Single(result.Plates).StockId);
|
||||
Assert.Equal(factor, NestJobCost.Evaluate(job, result));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Rectangles")]
|
||||
[InlineData("Irregular")]
|
||||
[InlineData("StockLadder")]
|
||||
[InlineData("Default")]
|
||||
[InlineData("Fill")]
|
||||
public void SameSizeStockRetainsIndependentFiniteInventory(string engine)
|
||||
{
|
||||
var stocks = new[] { Stock("expensive", 10, 2, 5), Stock("cheap", 1, 1, 5) };
|
||||
var job = new NestJob(new[] { new NestJobPart("part",
|
||||
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 3) }, stocks);
|
||||
var result = NestingEngineRegistry.Create(engine).Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Empty(NestLayoutCheck.Violations(job, result));
|
||||
Assert.Equal("cheap", result.Plates[0].StockId);
|
||||
Assert.Single(result.Plates.Where(p => p.StockId == "cheap"));
|
||||
Assert.Equal(21, NestJobCost.Evaluate(job, result));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
public void ExplicitCostsMustBeFiniteAndPositive(double cost) =>
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Stock("bad", cost));
|
||||
|
||||
[Fact]
|
||||
public void MixedModeRejectsOnlyAvailableStock()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => Job(Stock("priced", 2), new NestPlateStock("missing", new Size(10, 10))));
|
||||
var job = Job(Stock("priced", 2), new NestPlateStock("unavailable", new Size(10, 10), 0));
|
||||
NestJobValidator.Validate(job);
|
||||
Assert.Equal(2, NestJobCost.UnplacedPartPenalty(job));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 5, 20)]
|
||||
[InlineData(0.5, 0, 20)]
|
||||
[InlineData(0.5, 7, 20)]
|
||||
[InlineData(0.5, 6, 14)]
|
||||
public void PricesCreditSalvageProportionally(double rate, double minimum, double expected)
|
||||
{
|
||||
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
|
||||
var job = new NestJob(Job(stock).Parts, new[] { stock }, new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum));
|
||||
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
|
||||
Assert.Equal(expected, NestJobCost.NetSheetCost(job, sheet), 10);
|
||||
Assert.Equal(expected, NestJobCost.NetSheetCost(job.Options, stock, new Box(0, 0, 4, 4)), 10);
|
||||
Assert.Equal(expected * 5, NestJobCost.NetSheetArea(job, sheet), 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AggregateOverflowFailsClearly()
|
||||
{
|
||||
var stock = Stock("huge", double.MaxValue);
|
||||
var job = new NestJob(new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 2) }, new[] { stock });
|
||||
var builder = new NestJobResultBuilder(job);
|
||||
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
|
||||
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
|
||||
Assert.Throws<OverflowException>(() => NestJobCost.Evaluate(job, builder.Build(NestJobStopReason.Completed)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuilderMapsLegacyZeroAndSnapshotsPositiveCosts()
|
||||
{
|
||||
var option = new PlateOption { Width = 10, Length = 10 };
|
||||
Assert.Null(Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option })).Cost);
|
||||
option.Cost = 12;
|
||||
var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option }));
|
||||
option.Cost = 50;
|
||||
Assert.Equal(12, stock.Cost);
|
||||
Assert.Throws<ArgumentException>(() => NestStockBuilder.FromTemplate(new Plate(10, 10),
|
||||
new[] { option, new PlateOption { Width = 12, Length = 12 } }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
public sealed record NestStockCost(string StockId, int Used, double? Cost, double GrossSubtotal);
|
||||
|
||||
/// <summary>Costs of validated physical sheets, excluding unplaced-demand penalties.</summary>
|
||||
public sealed record NestCostSummary(string Basis, IReadOnlyList<NestStockCost> Stock,
|
||||
double GrossTotal, double SalvageCredit, double NetScore)
|
||||
{
|
||||
public static NestCostSummary FromAccepted(NestPipelineResult result)
|
||||
{
|
||||
if (!result.CanKeep || !result.IsValid)
|
||||
throw new ArgumentException("Cost reporting requires a validated proposal.", nameof(result));
|
||||
var stock = result.Job.Plates.Select(s =>
|
||||
{
|
||||
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
|
||||
return new NestStockCost(s.Id, used, s.Cost,
|
||||
used == 0 ? 0 : NestJobCost.RequireFinite(used * NestJobCost.GrossSheetCost(s)));
|
||||
}).ToArray();
|
||||
var gross = NestJobCost.RequireFinite(stock.Sum(s => s.GrossSubtotal));
|
||||
var net = NestJobCost.RequireFinite(result.Plates.Sum(sheet =>
|
||||
{
|
||||
if (sheet.Parts.Count == 0 || result.Job.Options.SalvageRate == 0
|
||||
|| result.Job.Options.MinimumSalvageDimension <= 0)
|
||||
return NestJobCost.GrossSheetCost(sheet.Stock);
|
||||
var bounds = sheet.Parts.Select(NestLayoutCheck.MaterialBounds).ToArray();
|
||||
var left = bounds.Min(b => b.Left);
|
||||
var bottom = bounds.Min(b => b.Bottom);
|
||||
return NestJobCost.NetSheetCost(result.Job.Options, sheet.Stock,
|
||||
new Box(left, bottom, bounds.Max(b => b.Right) - left, bounds.Max(b => b.Top) - bottom));
|
||||
}));
|
||||
return new NestCostSummary(NestJobCost.UsesExplicitCosts(result.Job)
|
||||
? "supplied-cost" : "area", stock, gross, NestJobCost.RequireFinite(gross - net), net);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public sealed class NestJob
|
||||
Parts = Own(parts);
|
||||
Plates = Own(plates);
|
||||
Options = options ?? new NestJobOptions();
|
||||
NestJobValidator.ValidateStockCosts(Plates);
|
||||
if (
|
||||
Parts.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Parts.Count
|
||||
|| Plates.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != Plates.Count
|
||||
|
||||
@@ -4,10 +4,13 @@ using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>Ranks independent plate trials: priority fulfillment, sheet area, placement envelope, then input order.</summary>
|
||||
/// <summary>Ranks independent plate trials: priority fulfillment, sheet cost, placement envelope, then input order.</summary>
|
||||
public sealed class NestJobCandidateComparer
|
||||
{
|
||||
private readonly IReadOnlyList<NestJobPart> parts;
|
||||
private readonly NestJob job;
|
||||
|
||||
public NestJobCandidateComparer(NestJob job) : this(job.Parts) => this.job = job;
|
||||
|
||||
public NestJobCandidateComparer(IReadOnlyList<NestJobPart> parts)
|
||||
{
|
||||
@@ -36,7 +39,7 @@ public sealed class NestJobCandidateComparer
|
||||
return leftCount.CompareTo(rightCount);
|
||||
}
|
||||
|
||||
var area = Area(rightStock).CompareTo(Area(leftStock));
|
||||
var area = Score(right, rightStock).CompareTo(Score(left, leftStock));
|
||||
if (area != 0)
|
||||
return area;
|
||||
|
||||
@@ -52,7 +55,10 @@ public sealed class NestJobCandidateComparer
|
||||
parts.First(part => part.Id == placement.PartId).Priority == priority
|
||||
);
|
||||
|
||||
private static double Area(NestPlateStock stock) => stock.Size.Width * stock.Size.Length;
|
||||
private double Score(PlateCandidate candidate, NestPlateStock stock) =>
|
||||
job != null && stock.Cost.HasValue
|
||||
? NestJobCost.NetSheetCost(job, new NestJobPlateResult(0, stock, candidate.Placements))
|
||||
: NestJobCost.GrossSheetCost(stock);
|
||||
|
||||
private static double Envelope(PlateCandidate candidate)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>Benchmark scoring primitives. Lower costs represent less sheet consumption.</summary>
|
||||
/// <summary>Generic per-sheet scoring; unspecified costs retain legacy area scores.</summary>
|
||||
public static class NestJobCost
|
||||
{
|
||||
/// <summary>
|
||||
@@ -66,16 +67,35 @@ public static class NestJobCost
|
||||
return area - options.SalvageRate * salvage;
|
||||
}
|
||||
|
||||
/// <summary>Largest offered sheet area, charged by the benchmark per unplaced part.</summary>
|
||||
public static bool UsesExplicitCosts(NestJob job) =>
|
||||
job.Plates.Any(s => s.Quantity != 0 && s.Cost.HasValue);
|
||||
|
||||
public static double RequireFinite(double value) => double.IsFinite(value) ? value
|
||||
: throw new OverflowException("Stock cost arithmetic exceeded the finite scoring range.");
|
||||
|
||||
public static double GrossSheetCost(NestPlateStock stock) => RequireFinite(stock.Cost ?? stock.Area);
|
||||
|
||||
public static double NetSheetCost(NestJob job, NestJobPlateResult sheet) =>
|
||||
PriceArea(sheet.Stock, NetSheetArea(job, sheet));
|
||||
|
||||
public static double NetSheetCost(NestJobOptions options, NestPlateStock stock, Box envelope) =>
|
||||
PriceArea(stock, NetSheetArea(options, stock, envelope));
|
||||
|
||||
private static double PriceArea(NestPlateStock stock, double area) =>
|
||||
RequireFinite(stock.Cost is double cost ? cost * (area / stock.Area) : area);
|
||||
|
||||
/// <summary>Largest offered sheet score, charged per unplaced part.</summary>
|
||||
public static double UnplacedPartPenalty(NestJob job) =>
|
||||
job.Plates.Count == 0 ? 0 : job.Plates.Max(stock => stock.Size.Width * stock.Size.Length);
|
||||
job.Plates.Count == 0 ? 0 : UsesExplicitCosts(job)
|
||||
? job.Plates.Where(s => s.Quantity != 0).Max(GrossSheetCost)
|
||||
: job.Plates.Max(s => RequireFinite(s.Area));
|
||||
|
||||
/// <summary>
|
||||
/// Sum of net sheet areas plus unplaced quantity times the largest offered sheet area.
|
||||
/// Sum of net sheet costs plus unplaced quantity times the largest offered sheet score.
|
||||
/// Matches the benchmark for a valid run; this method does not validate the result.
|
||||
/// </summary>
|
||||
public static double Evaluate(NestJob job, NestJobResult result) =>
|
||||
result.Plates.Sum(sheet => NetSheetArea(job, sheet))
|
||||
RequireFinite(result.Plates.Sum(sheet => NetSheetCost(job, sheet))
|
||||
+ System.Math.Max(0, job.Parts.Sum(part => part.Quantity)
|
||||
- result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job);
|
||||
- result.Plates.Sum(sheet => sheet.Placements.Count)) * UnplacedPartPenalty(job));
|
||||
}
|
||||
@@ -38,7 +38,7 @@ public sealed class NestJobRunner : INestingEngine
|
||||
var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal);
|
||||
var parts = job.Parts.ToDictionary(part => part.Id, StringComparer.Ordinal);
|
||||
var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal);
|
||||
var comparer = new NestJobCandidateComparer(job.Parts);
|
||||
var comparer = new NestJobCandidateComparer(job);
|
||||
var nester =
|
||||
job.Parts.Count == 0
|
||||
? null
|
||||
|
||||
@@ -10,11 +10,13 @@ public static class NestJobValidator
|
||||
public static void Validate(NestJob job)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
ValidateStockCosts(job.Plates);
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
var edges = stock.EdgeSpacing;
|
||||
if (
|
||||
!Positive(stock.Size.Width)
|
||||
!Positive(stock.Area)
|
||||
|| !Positive(stock.Size.Width)
|
||||
|| !Positive(stock.Size.Length)
|
||||
|| !Nonnegative(stock.PartSpacing)
|
||||
|| !Nonnegative(edges.Left)
|
||||
@@ -61,6 +63,14 @@ public static class NestJobValidator
|
||||
}
|
||||
}
|
||||
|
||||
public static void ValidateStockCosts(IEnumerable<NestPlateStock> stocks)
|
||||
{
|
||||
var available = stocks.Where(s => s.Quantity != 0).ToArray();
|
||||
if (available.Any(s => s.Cost.HasValue) && available.Any(s => !s.Cost.HasValue))
|
||||
throw new ArgumentException("Specify a positive cost for every available stock row or omit all costs. Missing: "
|
||||
+ string.Join(", ", available.Where(s => !s.Cost.HasValue).Select(s => s.Id)));
|
||||
}
|
||||
|
||||
internal static void ValidateCandidate(
|
||||
PlateCandidate candidate,
|
||||
NestPlateStock stock,
|
||||
|
||||
@@ -231,7 +231,8 @@ public static class NestLayoutCheck
|
||||
private static bool SameSettings(NestPlateStock expected, NestPlateStock actual) =>
|
||||
ReferenceEquals(expected, actual)
|
||||
|| (
|
||||
expected.Size.Equals(actual.Size)
|
||||
expected.Cost == actual.Cost
|
||||
&& expected.Size.Equals(actual.Size)
|
||||
&& expected.PartSpacing.IsEqualTo(actual.PartSpacing)
|
||||
&& expected.EdgeSpacing.Left.IsEqualTo(actual.EdgeSpacing.Left)
|
||||
&& expected.EdgeSpacing.Right.IsEqualTo(actual.EdgeSpacing.Right)
|
||||
|
||||
@@ -131,6 +131,8 @@ public static class NestPipeline
|
||||
);
|
||||
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep);
|
||||
var validationTime = clock.Elapsed;
|
||||
if (canKeep && violations.Count == 0)
|
||||
_ = NestJobCost.Evaluate(job, raw);
|
||||
|
||||
var plates = canKeep
|
||||
? raw.Plates.Select(sheet => new ProposedPlate(
|
||||
|
||||
@@ -16,7 +16,14 @@ public sealed class NestPlateStock
|
||||
Spacing edgeSpacing = default,
|
||||
int quadrant = 1
|
||||
)
|
||||
: this(id, size, quantity, partSpacing, edgeSpacing, quadrant, null) { }
|
||||
|
||||
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
|
||||
Spacing edgeSpacing, int quadrant, double? cost)
|
||||
{
|
||||
if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0))
|
||||
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
|
||||
Cost = cost;
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
if (quantity < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(quantity));
|
||||
@@ -36,6 +43,8 @@ public sealed class NestPlateStock
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
|
||||
public double? Cost { get; }
|
||||
public string Id { get; }
|
||||
public Size Size { get; }
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ public static class NestStockBuilder
|
||||
quantityPerOption,
|
||||
template.PartSpacing,
|
||||
template.EdgeSpacing,
|
||||
template.Quadrant
|
||||
template.Quadrant,
|
||||
option.Cost == 0 ? null : option.Cost
|
||||
));
|
||||
}
|
||||
NestJobValidator.ValidateStockCosts(stock);
|
||||
return stock.AsReadOnly();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements
|
||||
/// must reproduce exactly the removed demand and reduce net sheet area; inventory is transactional.
|
||||
/// must reproduce exactly the removed demand and reduce net sheet cost; inventory is transactional.
|
||||
/// This is a deterministic heuristic, not an optimality or geometric impossibility proof.
|
||||
/// </summary>
|
||||
public sealed class StockLadderNestingEngine : INestingEngine
|
||||
@@ -41,12 +41,12 @@ public sealed class StockLadderNestingEngine : INestingEngine
|
||||
|
||||
// Probe actual validated single-part placements, not bounding-box fit assertions.
|
||||
foreach (var part in job.Parts)
|
||||
foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
|
||||
{
|
||||
var probe = Trial(stock, new[] { WithQuantity(part, 1) });
|
||||
if (probe.Placements.Count != 0)
|
||||
feasible[part.Id].Add(stock.Id);
|
||||
}
|
||||
foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
|
||||
{
|
||||
var probe = Trial(stock, new[] { WithQuantity(part, 1) });
|
||||
if (probe.Placements.Count != 0)
|
||||
feasible[part.Id].Add(stock.Id);
|
||||
}
|
||||
var ordered = job
|
||||
.Parts.OrderBy(p => p.Priority)
|
||||
.ThenBy(p => feasible[p.Id].Count)
|
||||
@@ -97,8 +97,8 @@ public sealed class StockLadderNestingEngine : INestingEngine
|
||||
// Initial construction only: material area, never raw part counts. Repacking below
|
||||
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
|
||||
var value =
|
||||
NestJobCost.NetSheetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]);
|
||||
if (value < score - 1e-9)
|
||||
NestJobCost.RequireFinite(NestJobCost.NetSheetCost(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]));
|
||||
if (value < score - (stock.Cost.HasValue ? 0 : 1e-9))
|
||||
{
|
||||
winner = sheet;
|
||||
score = value;
|
||||
@@ -182,55 +182,55 @@ public sealed class StockLadderNestingEngine : INestingEngine
|
||||
var changed = false;
|
||||
// Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree.
|
||||
for (var index = 0; index < sheets.Count; index++)
|
||||
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--)
|
||||
{
|
||||
var old = sheets.Skip(index).Take(count).ToList();
|
||||
var demand = old.SelectMany(s => s.Placements)
|
||||
.GroupBy(p => p.PartId)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
var baseline = old.Sum(s => NestJobCost.NetSheetArea(job, s));
|
||||
NestJobPlateResult replacement = null;
|
||||
foreach (var stock in job.Plates)
|
||||
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var returned = old.Count(s => s.StockId == stock.Id);
|
||||
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit)
|
||||
continue;
|
||||
// Even the maximum possible salvage credit cannot beat the incumbent.
|
||||
var lowerBound =
|
||||
stock.Size.Width * stock.Size.Length * (1 - job.Options.SalvageRate);
|
||||
if (lowerBound >= baseline - 1e-9)
|
||||
continue;
|
||||
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id)))
|
||||
continue;
|
||||
var candidate = Trial(
|
||||
stock,
|
||||
ordered
|
||||
.Where(p => demand.ContainsKey(p.Id))
|
||||
.Select(p => WithQuantity(p, demand[p.Id]))
|
||||
);
|
||||
var actual = candidate
|
||||
.Placements.GroupBy(p => p.PartId)
|
||||
var old = sheets.Skip(index).Take(count).ToList();
|
||||
var demand = old.SelectMany(s => s.Placements)
|
||||
.GroupBy(p => p.PartId)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value))
|
||||
var baseline = NestJobCost.RequireFinite(old.Sum(s => NestJobCost.NetSheetCost(job, s)));
|
||||
NestJobPlateResult replacement = null;
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var returned = old.Count(s => s.StockId == stock.Id);
|
||||
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit)
|
||||
continue;
|
||||
// Even the maximum possible salvage credit cannot beat the incumbent.
|
||||
var lowerBound =
|
||||
NestJobCost.GrossSheetCost(stock) * (1 - job.Options.SalvageRate);
|
||||
if (lowerBound >= baseline - (stock.Cost.HasValue ? 0 : 1e-9))
|
||||
continue;
|
||||
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id)))
|
||||
continue;
|
||||
var candidate = Trial(
|
||||
stock,
|
||||
ordered
|
||||
.Where(p => demand.ContainsKey(p.Id))
|
||||
.Select(p => WithQuantity(p, demand[p.Id]))
|
||||
);
|
||||
var actual = candidate
|
||||
.Placements.GroupBy(p => p.PartId)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value))
|
||||
continue;
|
||||
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
|
||||
var cost = NestJobCost.NetSheetCost(job, trial);
|
||||
if (cost >= baseline - (stock.Cost.HasValue ? 0 : 1e-9))
|
||||
continue;
|
||||
baseline = cost;
|
||||
replacement = trial;
|
||||
}
|
||||
if (replacement == null)
|
||||
continue;
|
||||
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
|
||||
var cost = NestJobCost.NetSheetArea(job, trial);
|
||||
if (cost >= baseline - 1e-9)
|
||||
continue;
|
||||
baseline = cost;
|
||||
replacement = trial;
|
||||
// No accounting changes until the entire equivalent-demand candidate is valid.
|
||||
foreach (var sheet in old)
|
||||
used[sheet.StockId]--;
|
||||
used[replacement.StockId]++;
|
||||
sheets.RemoveRange(index, count);
|
||||
sheets.Insert(index, replacement);
|
||||
changed = true;
|
||||
}
|
||||
if (replacement == null)
|
||||
continue;
|
||||
// No accounting changes until the entire equivalent-demand candidate is valid.
|
||||
foreach (var sheet in old)
|
||||
used[sheet.StockId]--;
|
||||
used[replacement.StockId]++;
|
||||
sheets.RemoveRange(index, count);
|
||||
sheets.Insert(index, replacement);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public sealed class DefaultNestingEngine : INestingEngine
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order)
|
||||
private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order, bool Priced)
|
||||
{
|
||||
public static Scored Of(NestJob job, NestJobResult result, int order)
|
||||
{
|
||||
@@ -109,7 +109,7 @@ public sealed class DefaultNestingEngine : INestingEngine
|
||||
var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed);
|
||||
// Cost needs every placement to name a known part, which only a valid result guarantees.
|
||||
var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity;
|
||||
return new Scored(result, valid, unplaced, cost, order);
|
||||
return new Scored(result, valid, unplaced, cost, order, NestJobCost.UsesExplicitCosts(job));
|
||||
}
|
||||
|
||||
public bool IsBetterThan(Scored other)
|
||||
@@ -118,6 +118,8 @@ public sealed class DefaultNestingEngine : INestingEngine
|
||||
return Valid;
|
||||
if (Unplaced != other.Unplaced)
|
||||
return Unplaced < other.Unplaced;
|
||||
if (Priced && Cost != other.Cost)
|
||||
return Cost < other.Cost;
|
||||
var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost)));
|
||||
if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
|
||||
return Cost < other.Cost;
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places
|
||||
/// either the largest part that fills a gap behind the packing front, or the part that advances
|
||||
/// the front least per unit of area covered. Across sheets, every available stock size is
|
||||
/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the
|
||||
/// trial-packed and the one with the lowest estimated whole-job cost (its own net cost plus the
|
||||
/// remaining demand at the best efficiency seen) is committed. A handful of deterministic
|
||||
/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins.
|
||||
///
|
||||
@@ -120,7 +120,7 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
var reason = run.Reason;
|
||||
if (unplaced > 0 && reason == NestJobStopReason.Completed)
|
||||
reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold.
|
||||
return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason);
|
||||
return new Plan(run.Sheets, NestJobCost.RequireFinite(run.Net + unplaced * Penalty), unplaced, reason, NestJobCost.UsesExplicitCosts(job));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -138,7 +138,7 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
var prefix = sheets.Take(sheets.Count - k).ToList();
|
||||
var tail = sheets.Skip(sheets.Count - k).ToList();
|
||||
var tailParts = tail.Sum(s => s.Parts.Count);
|
||||
var tailNet = tail.Sum(s => NetArea(job.Options, s));
|
||||
var tailNet = tail.Sum(s => NetCost(job.Options, s));
|
||||
var tailDemand = new int[types.Count];
|
||||
foreach (var part in tail.SelectMany(s => s.Parts))
|
||||
tailDemand[part.Orientation.TypeIndex]++;
|
||||
@@ -148,7 +148,7 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null;
|
||||
|
||||
Run? bestRun = null;
|
||||
var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet);
|
||||
var bestNet = tailNet - (NestJobCost.UsesExplicitCosts(job) ? 0 : 1e-9 * System.Math.Max(1, tailNet));
|
||||
foreach (var (axis, beta) in Variants)
|
||||
foreach (var first in job.Plates)
|
||||
{
|
||||
@@ -245,7 +245,7 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
|
||||
var fill = packer.Fill(remaining, token);
|
||||
if (fill.Parts.Count > 0)
|
||||
trials.Add((fill, NetArea(job.Options, fill)));
|
||||
trials.Add((fill, NetCost(job.Options, fill)));
|
||||
}
|
||||
|
||||
if (trials.Count == 0)
|
||||
@@ -260,14 +260,14 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
var remainingArea = types.Sum(t => remaining[t.Index] * t.Area);
|
||||
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12));
|
||||
var chosen = trials
|
||||
.Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio))
|
||||
.Select((t, order) => (t.Fill, t.Net, order, Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio)))
|
||||
.OrderBy(t => t.Estimate)
|
||||
.ThenByDescending(t => t.Fill.Parts.Count)
|
||||
.ThenBy(t => t.order)
|
||||
.First();
|
||||
|
||||
sheets.Add(chosen.Fill);
|
||||
net += chosen.Net;
|
||||
net = NestJobCost.RequireFinite(net + chosen.Net);
|
||||
used[chosen.Fill.Stock.Id]++;
|
||||
foreach (var part in chosen.Fill.Parts)
|
||||
remaining[part.Orientation.TypeIndex]--;
|
||||
@@ -289,21 +289,23 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
return builder.Build(plan.Reason);
|
||||
}
|
||||
|
||||
private static double NetArea(NestJobOptions options, SheetFill fill)
|
||||
private static double NetCost(NestJobOptions options, SheetFill fill)
|
||||
{
|
||||
if (fill.Parts.Count == 0) return fill.Stock.Area;
|
||||
if (fill.Parts.Count == 0) return NestJobCost.GrossSheetCost(fill.Stock);
|
||||
var left = fill.Parts.Min(p => p.Left);
|
||||
var bottom = fill.Parts.Min(p => p.Bottom);
|
||||
return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
|
||||
return NestJobCost.NetSheetCost(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
|
||||
fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom));
|
||||
}
|
||||
|
||||
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
|
||||
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason, bool Priced)
|
||||
{
|
||||
public bool IsBetterThan(Plan other)
|
||||
{
|
||||
if (Unplaced != other.Unplaced)
|
||||
return Unplaced < other.Unplaced;
|
||||
if (Priced && Cost != other.Cost)
|
||||
return Cost < other.Cost;
|
||||
var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost));
|
||||
if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
|
||||
return Cost < other.Cost;
|
||||
|
||||
@@ -18,8 +18,8 @@ namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
///
|
||||
/// Sheet by sheet, each available stock is packed under several free-space scoring rules and
|
||||
/// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with
|
||||
/// the lowest estimated whole-job cost wins: its salvage-credited net area (NestJobCost) plus the
|
||||
/// remaining demand priced at the best net-area-per-part-area ratio seen among the candidates.
|
||||
/// the lowest estimated whole-job cost wins: its salvage-credited net cost (NestJobCost) plus the
|
||||
/// remaining demand priced at the best net-cost-per-part-area ratio seen among the candidates.
|
||||
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
|
||||
/// </summary>
|
||||
public sealed class RectanglesNestingEngine : INestingEngine
|
||||
@@ -78,7 +78,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
|
||||
{
|
||||
var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token);
|
||||
if (plan.Parts.Count > 0)
|
||||
trials.Add((plan, NetArea(job, plan)));
|
||||
trials.Add((plan, NetCost(job, plan)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
|
||||
|
||||
/// <summary>
|
||||
/// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the
|
||||
/// best net-area-per-material ratio any candidate achieved, so a sheet that finishes the job
|
||||
/// best net-cost-per-material ratio any candidate achieved, so a sheet that finishes the job
|
||||
/// competes fairly with a denser partial one. Ties: more material placed, then enumeration order.
|
||||
/// </summary>
|
||||
private static SheetPlan Choose(
|
||||
@@ -111,7 +111,7 @@ public sealed class RectanglesNestingEngine : INestingEngine
|
||||
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12));
|
||||
return trials
|
||||
.Select((t, order) => (t.Plan, order,
|
||||
Estimate: t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio))
|
||||
Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)))
|
||||
.OrderBy(t => PriorityDebt(types, remaining, t.Plan))
|
||||
.ThenBy(t => t.Estimate)
|
||||
.ThenByDescending(t => t.Plan.MaterialArea)
|
||||
@@ -135,10 +135,10 @@ public sealed class RectanglesNestingEngine : INestingEngine
|
||||
return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
|
||||
}
|
||||
|
||||
private static double NetArea(NestJob job, SheetPlan plan) =>
|
||||
private static double NetCost(NestJob job, SheetPlan plan) =>
|
||||
plan.Envelope is { } envelope
|
||||
? NestJobCost.NetSheetArea(job.Options, plan.Stock, envelope)
|
||||
: plan.Stock.Area;
|
||||
? NestJobCost.NetSheetCost(job.Options, plan.Stock, envelope)
|
||||
: NestJobCost.GrossSheetCost(plan.Stock);
|
||||
|
||||
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.IO.Tests;
|
||||
|
||||
public class StockCostPersistenceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(12.5)]
|
||||
public void ExistingOptionsRoundTripWithoutChangingMetadata(double cost)
|
||||
{
|
||||
var nest = new Nest("priced options")
|
||||
{
|
||||
Customer = "customer",
|
||||
Notes = "notes",
|
||||
Units = Units.Inches,
|
||||
SalvageRate = 0.25,
|
||||
PlateOptions = [new PlateOption { Width = 48, Length = 96, Cost = cost }],
|
||||
};
|
||||
using var stream = new MemoryStream();
|
||||
Assert.True(new NestWriter(nest).Write(stream));
|
||||
stream.Position = 0;
|
||||
var reopened = new NestReader(stream).Read();
|
||||
Assert.Equal(cost, Assert.Single(reopened.PlateOptions).Cost);
|
||||
Assert.Equal(nest.Customer, reopened.Customer);
|
||||
Assert.Equal(nest.Notes, reopened.Notes);
|
||||
Assert.Equal(nest.SalvageRate, reopened.SalvageRate);
|
||||
var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(48, 96), reopened.PlateOptions));
|
||||
Assert.Equal(cost == 0 ? (double?)null : cost, stock.Cost);
|
||||
}
|
||||
}
|
||||
@@ -84,3 +84,10 @@ the existing layers; this change does not alter the strategy or either post.
|
||||
Future hardening: make numeric G-code serialization/parsing culture-invariant
|
||||
as a coordinated compatibility change; do not change just one side. Program
|
||||
content deduplication is optional and must not change per-part ownership.
|
||||
|
||||
## Stock-option prices
|
||||
|
||||
The existing `plateOptions` entries retain width, length and `cost`; no new `.nest`
|
||||
format version is required. Legacy zero cost means unspecified, not a free sheet.
|
||||
Positive costs are generic per-sheet values in caller-consistent units. Solving
|
||||
requires all available options to be priced or all to be unspecified.
|
||||
@@ -120,3 +120,19 @@ executable. A plug-in implements `INestingEngine` with a public parameterless co
|
||||
registers under its CLR type name. A plug-in whose name matches a built-in engine, or a renamed
|
||||
engine's old name, is skipped: a leftover `OpenNest.Engine.Opus55.dll` cannot shadow Irregular.
|
||||
Leftover Gpt6Astra or Qwen38FlashNext DLLs still load as ordinary plug-ins until deleted.
|
||||
|
||||
## Cost basis and heuristic limits
|
||||
|
||||
`NestPlateStock.Cost` is nullable, immutable and per physical sheet. Every available
|
||||
stock must have a finite positive cost, or all must omit it. Omitted costs preserve
|
||||
area scoring. The original constructor and area-valued `NetSheetArea` and obsolete
|
||||
`EstimateNetArea` APIs retain their units. `NetSheetCost` applies proportional salvage
|
||||
to explicit costs; geometric area still controls fit, utilization and material ordering.
|
||||
|
||||
Rectangles uses net cost in sheet trials and remaining-material estimates. Irregular
|
||||
uses it in look-ahead, strategy comparison and equivalent-demand tail replacement.
|
||||
StockLadder ranks net cost per placed material area and uses total net cost for
|
||||
consolidation. Default compares valid equally fulfilled results by generic job score.
|
||||
Fixed Fill/Strip/remnant strategies rank priority fulfillment first, then net cost
|
||||
for priced stock (gross area for legacy unpriced stock), then existing stable ties.
|
||||
None guarantees globally optimal purchasing or explores every combination of patterns.
|
||||
Reference in new issue
Block a user