refactor(benchmark): drive engines through INestingEngine.Solve instead of a hand-rolled multi-plate loop

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-19 08:19:08 -04:00
co-authored by Claude Sonnet 5
parent ae704478af
commit a2dcfc7484
2 changed files with 75 additions and 143 deletions
+12 -42
View File
@@ -41,50 +41,20 @@ namespace OpenNest.Benchmark
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity); public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
/// <summary> /// <summary>
/// A blank plate carrying only the job's spacing/quadrant template. /// Builds the whole-job request this job represents: one NestJobPart per
/// MultiPlateNester.CreatePlate copies these settings onto whichever /// requested drawing, and one NestPlateStock per candidate sheet size
/// size it ultimately picks; its Size is only the fallback used when /// (unlimited quantity - the engine under test decides how many of each
/// nothing in the candidate pool fits, so it's set to the largest /// size it actually uses, and how demand splits across plates). The
/// candidate rather than an arbitrary one. /// engine owns its own multi-plate/size strategy; this harness no
/// longer picks plate sizes on the engine's behalf.
/// </summary> /// </summary>
public Plate CreateTemplatePlate() public NestJob BuildNestJob(int maxPlates)
{ {
var fallbackSize = CandidateSizes var parts = Requests.Select(r =>
.OrderByDescending(s => s.Width * s.Length) DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity));
.FirstOrDefault(); var stock = CandidateSizes.Select(size =>
new NestPlateStock(size.ToString(1), size, null, PartSpacing, EdgeSpacing, Quadrant));
return new Plate(fallbackSize) return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates));
{
EdgeSpacing = EdgeSpacing,
PartSpacing = PartSpacing,
Quadrant = Quadrant,
};
}
/// <summary>
/// The candidate sizes as PlateOptions for MultiPlateNester.CreatePlate.
/// Cost is area-proportional since no real per-size material pricing is
/// available here - this only affects which size is preferred when more
/// than one candidate fits, favoring the smaller/cheaper sheet.
/// </summary>
public List<PlateOption> BuildPlateOptions()
{
return CandidateSizes
.Select(s => new PlateOption { Width = s.Width, Length = s.Length, Cost = s.Width * s.Length })
.ToList();
}
public List<NestItem> CreateItems()
{
return Requests.Select(r => new NestItem
{
Drawing = r.Drawing,
Quantity = r.Quantity,
Priority = r.Priority,
StepAngle = r.StepAngle,
RotationStart = r.RotationStart,
RotationEnd = r.RotationEnd,
}).ToList();
} }
} }
} }
+63 -101
View File
@@ -7,23 +7,23 @@ using System.Threading;
namespace OpenNest.Benchmark namespace OpenNest.Benchmark
{ {
/// <summary> /// <summary>
/// Runs every candidate engine against every job. A job may need several /// Runs every candidate engine against every job. Each engine is a full
/// plates to place everything it asks for; this drives that loop itself, /// INestingEngine: it owns its own plate/size selection and multi-plate
/// since NestEngineBase.Nest() fills exactly one already-sized plate and /// strategy for the whole job, rather than being handed one already-sized
/// has no say in picking its own size. For each plate the loop needs, the /// plate at a time by this harness. A per-run timeout guards against a
/// smallest candidate size that fits the largest still-unplaced drawing is /// runaway or hanging engine — cooperative cancellation, so it reliably
/// chosen via the codebase's own MultiPlateNester.CreatePlate, then the /// stops engines built on NestJobRunner (all four built-ins) but can't
/// engine's Nest() fills that plate with whatever of the remaining items /// forcibly interrupt an engine that never checks its token.
/// fit. This is applied identically to every engine, so no engine gets to
/// (or has to) implement sheet-size selection itself.
/// </summary> /// </summary>
public static class BenchmarkRunner public static class BenchmarkRunner
{ {
/// <summary>Safety cap so a degenerate engine (placing almost nothing /// <summary>Physical-sheet cap passed to every job's NestJobOptions.MaxPlates.</summary>
/// per plate) can't loop indefinitely.</summary>
private const int MaxPlates = 40; private const int MaxPlates = 40;
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestEngineInfo> engines) /// <summary>Wall-clock budget for one engine solving one job.</summary>
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(5);
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines)
{ {
var results = new List<JobResult>(jobs.Count * engines.Count); var results = new List<JobResult>(jobs.Count * engines.Count);
@@ -38,60 +38,53 @@ namespace OpenNest.Benchmark
return results; return results;
} }
private static JobResult RunOne(BenchmarkJob job, NestEngineInfo engineInfo) private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo)
{ {
var template = job.CreateTemplatePlate(); var nestJob = job.BuildNestJob(MaxPlates);
var options = job.BuildPlateOptions();
var remaining = job.CreateItems();
var requested = job.TotalRequestedQuantity; var requested = job.TotalRequestedQuantity;
var plateRuns = new List<(Plate Plate, List<Part> Parts)>();
string engineError = null;
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
try try
{ {
while (remaining.Any(i => i.Quantity > 0) && plateRuns.Count < MaxPlates) var engine = engineInfo.Factory();
using var cts = new CancellationTokenSource(Timeout);
var jobResult = engine.Solve(nestJob, null, cts.Token);
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
var plateRuns = materialized.Nest.Plates
.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
.ToList();
var validation = NestValidator.Validate(plateRuns, job);
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0;
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
var sizeBreakdown = plateRuns
.GroupBy(pr => pr.Plate.Size.ToString(1))
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
sw.Stop();
return new JobResult
{ {
var largest = remaining EngineName = engineInfo.Name,
.Where(i => i.Quantity > 0) JobName = job.Name,
.OrderByDescending(i => BoundsArea(i)) Valid = validation.Valid,
.First(); Violations = validation.Violations,
PartsPlaced = totalPlaced,
var plate = MultiPlateNester.CreatePlate(template, options, largest.Drawing.Program.BoundingBox()); PartsRequested = requested,
var engine = engineInfo.Factory(plate); PlacedArea = placedArea,
var itemsClone = CloneItems(remaining); PlateArea = plateArea,
PlatesUsed = plateRuns.Count,
var parts = engine.Nest(itemsClone, null, CancellationToken.None) ?? new List<Part>(); SizeBreakdown = sizeBreakdown,
ElapsedMs = sw.ElapsedMilliseconds,
if (parts.Count == 0) };
{
// Not even the largest available candidate size could fit
// the current largest remaining part - stop here rather
// than loop forever; whatever's left is reported unplaced.
break;
}
plateRuns.Add((plate, parts));
foreach (var item in remaining)
{
var placed = parts.Count(p => p.BaseDrawing.Id == item.Drawing.Id);
if (placed > 0)
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
}
} }
catch (Exception ex) catch (OperationCanceledException)
{
engineError = $"{ex.GetType().Name}: {ex.Message}";
}
sw.Stop();
if (engineError != null)
{ {
sw.Stop();
return new JobResult return new JobResult
{ {
EngineName = engineInfo.Name, EngineName = engineInfo.Name,
@@ -99,53 +92,22 @@ namespace OpenNest.Benchmark
Valid = false, Valid = false,
PartsRequested = requested, PartsRequested = requested,
ElapsedMs = sw.ElapsedMilliseconds, ElapsedMs = sw.ElapsedMilliseconds,
Error = engineError, Error = $"Timed out after {Timeout.TotalMinutes:F0} minute(s)",
}; };
} }
catch (Exception ex)
var validation = NestValidator.Validate(plateRuns, job);
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0;
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
var sizeBreakdown = plateRuns
.GroupBy(pr => pr.Plate.Size.ToString(1))
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
return new JobResult
{ {
EngineName = engineInfo.Name, sw.Stop();
JobName = job.Name, return new JobResult
Valid = validation.Valid, {
Violations = validation.Violations, EngineName = engineInfo.Name,
PartsPlaced = totalPlaced, JobName = job.Name,
PartsRequested = requested, Valid = false,
PlacedArea = placedArea, PartsRequested = requested,
PlateArea = plateArea, ElapsedMs = sw.ElapsedMilliseconds,
PlatesUsed = plateRuns.Count, Error = $"{ex.GetType().Name}: {ex.Message}",
SizeBreakdown = sizeBreakdown, };
ElapsedMs = sw.ElapsedMilliseconds, }
};
}
private static double BoundsArea(NestItem item)
{
var bb = item.Drawing.Program.BoundingBox();
return bb.Width * bb.Length;
}
private static List<NestItem> CloneItems(List<NestItem> items)
{
return items.Select(i => new NestItem
{
Drawing = i.Drawing,
Quantity = i.Quantity,
Priority = i.Priority,
StepAngle = i.StepAngle,
RotationStart = i.RotationStart,
RotationEnd = i.RotationEnd,
}).ToList();
} }
} }
} }