Rework OpenNest.Benchmark into a full multi-plate, multi-size nest

Previously each job fixed one plate size and ran a single Nest() call,
which doesn't reflect the actual problem: a real job is fulfilled
across however many plates are needed, drawn from a pool of standard
sheet sizes, not forced onto one fixed sheet.

NestEngineBase.Nest() has no way to pick its own plate's size - it
fills whatever Plate it's given - so size selection now lives in the
harness itself, applied identically to every engine:

- BenchmarkJob carries the full candidate size pool (CandidateSizes)
  instead of one fixed PlateSize; one job per file, not one per size.
- BenchmarkRunner drives a loop: while items remain, pick the smallest
  candidate size that fits the largest still-unplaced drawing (reusing
  the codebase's own MultiPlateNester.CreatePlate/FitsBounds), build a
  fresh plate of that size, and run one Nest() call to fill it. Repeat
  until everything is placed, no candidate size fits what's left, or a
  safety cap (40 plates) is hit.
- NestValidator now validates bounds/spacing per plate but the
  quantity cap once globally across all plates, since that limit
  belongs to the whole order, not any one sheet.
- JobResult/Report report PlatesUsed and a per-size breakdown instead
  of a single-plate bounding-box compactness metric; utilization is
  now aggregated across every plate the engine used. Ranking keeps the
  same rule (utilization first), with fewer plates as the tie-break
  when both are fully placed and tied - the natural multi-plate
  analogue of the old single-plate compactness tie-break.

Smoke-tested against the synthetic sample across 5 candidate sizes:
correctly builds one job, picks the smallest fitting size, uses
however many plates each engine needs (1-2 here), and still catches
StripNestEngine's pre-existing out-of-bounds bug.
This commit is contained in:
aj
2026-09-15 21:36:31 -04:00
parent 20da5477b6
commit a9e0f8a1d4
7 changed files with 237 additions and 113 deletions
+34 -9
View File
@@ -20,28 +20,40 @@ namespace OpenNest.Benchmark
} }
/// <summary> /// <summary>
/// An immutable specification for one benchmark job: a set of drawings/quantities /// An immutable specification for one benchmark job: the full set of
/// to be nested onto a plate of a given size. Every engine under test gets a fresh /// drawings/quantities that must be nested, and the pool of sheet sizes the
/// Plate and NestItem list built from this spec via CreatePlate()/CreateItems(), /// engine may draw from while doing it. A single run may use several
/// so one engine's run can never leak mutated state into another's. /// plates - possibly of different sizes - to place everything, the same
/// way a real production job spreads across whatever plates it needs
/// rather than being handed one fixed-size sheet.
/// </summary> /// </summary>
public class BenchmarkJob public class BenchmarkJob
{ {
public string SourceFile { get; init; } public string SourceFile { get; init; }
public string SheetSizeLabel { get; init; } public List<Size> CandidateSizes { get; init; }
public Size PlateSize { get; init; }
public Spacing EdgeSpacing { get; init; } public Spacing EdgeSpacing { get; init; }
public double PartSpacing { get; init; } public double PartSpacing { get; init; }
public int Quadrant { get; init; } public int Quadrant { get; init; }
public List<DrawingRequest> Requests { get; init; } public List<DrawingRequest> Requests { get; init; }
public string Name => $"{Path.GetFileNameWithoutExtension(SourceFile)} [{SheetSizeLabel}]"; public string Name => Path.GetFileNameWithoutExtension(SourceFile);
public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity); public int TotalRequestedQuantity => Requests.Sum(r => r.Quantity);
public Plate CreatePlate() /// <summary>
/// A blank plate carrying only the job's spacing/quadrant template.
/// MultiPlateNester.CreatePlate copies these settings onto whichever
/// size it ultimately picks; its Size is only the fallback used when
/// nothing in the candidate pool fits, so it's set to the largest
/// candidate rather than an arbitrary one.
/// </summary>
public Plate CreateTemplatePlate()
{ {
return new Plate(PlateSize) var fallbackSize = CandidateSizes
.OrderByDescending(s => s.Width * s.Length)
.FirstOrDefault();
return new Plate(fallbackSize)
{ {
EdgeSpacing = EdgeSpacing, EdgeSpacing = EdgeSpacing,
PartSpacing = PartSpacing, PartSpacing = PartSpacing,
@@ -49,6 +61,19 @@ namespace OpenNest.Benchmark
}; };
} }
/// <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() public List<NestItem> CreateItems()
{ {
return Requests.Select(r => new NestItem return Requests.Select(r => new NestItem
+84 -20
View File
@@ -1,4 +1,3 @@
using OpenNest.Geometry;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
@@ -8,13 +7,22 @@ using System.Threading;
namespace OpenNest.Benchmark namespace OpenNest.Benchmark
{ {
/// <summary> /// <summary>
/// Runs every candidate engine against every job. Each (job, engine) pair gets /// Runs every candidate engine against every job. A job may need several
/// its own freshly-built Plate and NestItem list (via BenchmarkJob.CreatePlate/ /// plates to place everything it asks for; this drives that loop itself,
/// CreateItems), so no engine can see another's mutated state and no job can /// since NestEngineBase.Nest() fills exactly one already-sized plate and
/// leak partial state into the next run of the same engine. /// has no say in picking its own size. For each plate the loop needs, the
/// smallest candidate size that fits the largest still-unplaced drawing is
/// chosen via the codebase's own MultiPlateNester.CreatePlate, then the
/// engine's Nest() fills that plate with whatever of the remaining items
/// 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
/// per plate) can't loop indefinitely.</summary>
private const int MaxPlates = 40;
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestEngineInfo> engines) public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestEngineInfo> engines)
{ {
var results = new List<JobResult>(jobs.Count * engines.Count); var results = new List<JobResult>(jobs.Count * engines.Count);
@@ -32,22 +40,58 @@ namespace OpenNest.Benchmark
private static JobResult RunOne(BenchmarkJob job, NestEngineInfo engineInfo) private static JobResult RunOne(BenchmarkJob job, NestEngineInfo engineInfo)
{ {
var plate = job.CreatePlate(); var template = job.CreateTemplatePlate();
var items = job.CreateItems(); 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();
List<Part> parts;
try try
{ {
while (remaining.Any(i => i.Quantity > 0) && plateRuns.Count < MaxPlates)
{
var largest = remaining
.Where(i => i.Quantity > 0)
.OrderByDescending(i => BoundsArea(i))
.First();
var plate = MultiPlateNester.CreatePlate(template, options, largest.Drawing.Program.BoundingBox());
var engine = engineInfo.Factory(plate); var engine = engineInfo.Factory(plate);
parts = engine.Nest(items, null, CancellationToken.None) ?? new List<Part>(); var itemsClone = CloneItems(remaining);
var parts = engine.Nest(itemsClone, null, CancellationToken.None) ?? new List<Part>();
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 (Exception ex)
{ {
engineError = $"{ex.GetType().Name}: {ex.Message}";
}
sw.Stop(); sw.Stop();
if (engineError != null)
{
return new JobResult return new JobResult
{ {
EngineName = engineInfo.Name, EngineName = engineInfo.Name,
@@ -55,19 +99,19 @@ namespace OpenNest.Benchmark
Valid = false, Valid = false,
PartsRequested = requested, PartsRequested = requested,
ElapsedMs = sw.ElapsedMilliseconds, ElapsedMs = sw.ElapsedMilliseconds,
Error = $"{ex.GetType().Name}: {ex.Message}", Error = engineError,
}; };
} }
sw.Stop(); 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 validation = NestValidator.Validate(parts, plate, job); var sizeBreakdown = plateRuns
.GroupBy(pr => pr.Plate.Size.ToString(1))
// Matches Plate.Utilization(): full sheet area, not just the cuttable .OrderByDescending(g => g.Count())
// work area, since that's what the material actually costs. .ToDictionary(g => g.Key, g => g.Count());
var plateArea = plate.Area();
var placedArea = validation.Valid ? parts.Sum(p => p.BaseDrawing.Area) : 0;
var usedBox = parts.Count > 0 ? parts.GetBoundingBox() : Box.Empty;
return new JobResult return new JobResult
{ {
@@ -75,13 +119,33 @@ namespace OpenNest.Benchmark
JobName = job.Name, JobName = job.Name,
Valid = validation.Valid, Valid = validation.Valid,
Violations = validation.Violations, Violations = validation.Violations,
PartsPlaced = parts.Count, PartsPlaced = totalPlaced,
PartsRequested = requested, PartsRequested = requested,
PlacedArea = placedArea, PlacedArea = placedArea,
PlateArea = plateArea, PlateArea = plateArea,
UsedBoundingBoxArea = usedBox.Width * usedBox.Length, PlatesUsed = plateRuns.Count,
SizeBreakdown = sizeBreakdown,
ElapsedMs = sw.ElapsedMilliseconds, 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();
}
} }
} }
+8 -11
View File
@@ -8,11 +8,12 @@ using System.Linq;
namespace OpenNest.Benchmark namespace OpenNest.Benchmark
{ {
/// <summary> /// <summary>
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on any /// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on
/// valid .nest file, using whatever drawings/quantities/plate settings it contains. /// any valid .nest file, using whatever drawings/quantities/plate settings
/// Optionally sweeps a fixed list of sheet sizes instead of the sizes embedded /// it contains. One job per file, carrying the full pool of candidate
/// in the file, so the same drawing set can be benchmarked across a standard /// sheet sizes the engine may use across the whole nest - by default the
/// sheet-size lineup. /// distinct sizes already present in that file, or a fixed override list
/// (e.g. a standard sheet-size lineup) applied to every file.
/// </summary> /// </summary>
public static class JobLoader public static class JobLoader
{ {
@@ -46,23 +47,19 @@ namespace OpenNest.Benchmark
var template = ResolvePlateTemplate(nest); var template = ResolvePlateTemplate(nest);
var sizes = sheetSizeOverrides != null && sheetSizeOverrides.Count > 0 var sizes = sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
? sheetSizeOverrides ? sheetSizeOverrides.ToList()
: ResolveSheetSizes(nest); : ResolveSheetSizes(nest);
foreach (var size in sizes)
{
jobs.Add(new BenchmarkJob jobs.Add(new BenchmarkJob
{ {
SourceFile = file, SourceFile = file,
SheetSizeLabel = size.ToString(1), CandidateSizes = sizes,
PlateSize = size,
EdgeSpacing = template.EdgeSpacing, EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing, PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant, Quadrant = template.Quadrant,
Requests = requests, Requests = requests,
}); });
} }
}
return jobs; return jobs;
} }
+11 -3
View File
@@ -3,8 +3,11 @@ using System.Collections.Generic;
namespace OpenNest.Benchmark namespace OpenNest.Benchmark
{ {
/// <summary> /// <summary>
/// Outcome of running one engine against one job. An invalid or crashed run /// Outcome of running one engine against one job. A job may span several
/// always scores zero utilization for that job, per the benchmark rules. /// plates (PlatesUsed, SizeBreakdown), since the engine may need more than
/// one sheet - possibly of different sizes - to place everything asked of
/// it. An invalid or crashed run always scores zero utilization, per the
/// benchmark rules.
/// </summary> /// </summary>
public class JobResult public class JobResult
{ {
@@ -17,11 +20,16 @@ namespace OpenNest.Benchmark
public int PartsRequested { get; init; } public int PartsRequested { get; init; }
public double PlacedArea { get; init; } public double PlacedArea { get; init; }
public double PlateArea { get; init; } public double PlateArea { get; init; }
public double UsedBoundingBoxArea { get; init; } public int PlatesUsed { get; init; }
public Dictionary<string, int> SizeBreakdown { get; init; } = new();
public long ElapsedMs { get; init; } public long ElapsedMs { get; init; }
public bool Crashed => Error != null; public bool Crashed => Error != null;
public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested; public bool FullyPlaced => Valid && PartsRequested > 0 && PartsPlaced >= PartsRequested;
/// <summary>Aggregate utilization across every plate the engine used:
/// total placed drawing area over total plate area, matching
/// Plate.Utilization()'s per-plate definition summed across the job.</summary>
public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0; public double Utilization => Valid && PlateArea > 0 ? PlacedArea / PlateArea : 0;
} }
} }
+46 -33
View File
@@ -13,52 +13,41 @@ namespace OpenNest.Benchmark
} }
/// <summary> /// <summary>
/// Validates a placed layout against the benchmark rules: every part must lie /// Validates a (possibly multi-plate) placed layout against the benchmark
/// within the plate's work area, every pair of parts must be at least /// rules: on every plate, every part must lie within that plate's work
/// PartSpacing apart, and no drawing may have more parts placed than requested. /// area and every pair of parts must be at least PartSpacing apart; across
/// Geometry checks work on arbitrary (concave, holed) polygons by reusing the /// all plates combined, no drawing may have more parts placed than
/// same world-space extraction Part.Intersects uses internally, so no engine /// requested (the quantity limit is a property of the whole order, not of
/// gets an advantage or penalty from shape complexity. /// any one plate). Geometry checks work on arbitrary (concave, holed)
/// polygons by reusing the same world-space extraction Part.Intersects
/// uses internally, so no engine gets an advantage or penalty from shape
/// complexity.
/// </summary> /// </summary>
public static class NestValidator public static class NestValidator
{ {
public static ValidationResult Validate(List<Part> parts, Plate plate, BenchmarkJob job) public static ValidationResult Validate(List<(Plate Plate, List<Part> Parts)> plateRuns, BenchmarkJob job)
{ {
var result = new ValidationResult(); var result = new ValidationResult();
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
if (parts == null || parts.Count == 0) if (allParts.Count == 0)
return result; return result;
ValidateQuantities(parts, job, result); ValidateQuantities(allParts, job, result);
foreach (var (plate, parts) in plateRuns)
{
if (parts.Count == 0)
continue;
ValidateBounds(parts, plate, result); ValidateBounds(parts, plate, result);
ValidateAreaBudget(parts, plate, result); ValidateAreaBudget(parts, plate, result);
ValidateSpacing(parts, plate.PartSpacing, result); ValidateSpacing(parts, plate.PartSpacing, result);
}
return result; return result;
} }
/// <summary>
/// Hard mathematical backstop: non-overlapping parts confined to the work
/// area can never have a combined area greater than the work area itself.
/// This catches overlap that the polygon-based ValidateSpacing check can
/// miss - Collision.HasOverlap (and Part.Intersects, which uses the same
/// algorithm) has been observed to return false negatives on real,
/// complex production geometry, so this check does not depend on it.
/// </summary>
private static void ValidateAreaBudget(List<Part> parts, Plate plate, ValidationResult result)
{
var workArea = plate.WorkArea();
var budget = workArea.Width * workArea.Length;
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
if (placedArea > budget + Tolerance.Epsilon)
{
result.Violations.Add(
$"Combined placed area ({placedArea:F2}) exceeds the work area ({budget:F2}) - " +
"parts must overlap even though the polygon overlap check did not flag a pair");
}
}
private static void ValidateQuantities(List<Part> parts, BenchmarkJob job, ValidationResult result) private static void ValidateQuantities(List<Part> parts, BenchmarkJob job, ValidationResult result)
{ {
var allowed = job.Requests.ToDictionary(r => r.Drawing.Id, r => r.Quantity); var allowed = job.Requests.ToDictionary(r => r.Drawing.Id, r => r.Quantity);
@@ -77,7 +66,7 @@ namespace OpenNest.Benchmark
if (placed > max) if (placed > max)
{ {
var name = parts.First(p => p.BaseDrawing.Id == drawingId).BaseDrawing.Name; var name = parts.First(p => p.BaseDrawing.Id == drawingId).BaseDrawing.Name;
result.Violations.Add($"'{name}': placed {placed} but only {max} were requested"); result.Violations.Add($"'{name}': placed {placed} across all plates but only {max} were requested");
} }
} }
} }
@@ -98,11 +87,35 @@ namespace OpenNest.Benchmark
if (outLeft || outBottom || outRight || outTop) if (outLeft || outBottom || outRight || outTop)
{ {
result.Violations.Add( result.Violations.Add(
$"'{part.BaseDrawing.Name}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area"); $"'{part.BaseDrawing.Name}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " +
$"of a {plate.Size} plate");
} }
} }
} }
/// <summary>
/// Hard mathematical backstop: non-overlapping parts confined to the
/// work area can never have a combined area greater than the work
/// area itself. This catches overlap that the polygon-based
/// ValidateSpacing check can miss - Collision.HasOverlap (and
/// Part.Intersects, which uses the same algorithm) has been observed
/// to return false negatives on real, complex production geometry, so
/// this check does not depend on it.
/// </summary>
private static void ValidateAreaBudget(List<Part> parts, Plate plate, ValidationResult result)
{
var workArea = plate.WorkArea();
var budget = workArea.Width * workArea.Length;
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
if (placedArea > budget + Tolerance.Epsilon)
{
result.Violations.Add(
$"Combined placed area ({placedArea:F2}) on a {plate.Size} plate exceeds its work area ({budget:F2}) - " +
"parts must overlap even though the polygon overlap check did not flag a pair");
}
}
private static void ValidateSpacing(List<Part> parts, double spacing, ValidationResult result) private static void ValidateSpacing(List<Part> parts, double spacing, ValidationResult result)
{ {
var worldPolygons = new Polygon[parts.Count]; var worldPolygons = new Polygon[parts.Count];
+17 -6
View File
@@ -57,6 +57,13 @@ static class BenchmarkConsole
} }
Console.WriteLine($"Loaded {jobs.Count} job(s) from '{options.InputPath}'"); Console.WriteLine($"Loaded {jobs.Count} job(s) from '{options.InputPath}'");
foreach (var job in jobs)
{
var sizes = string.Join(", ", job.CandidateSizes.Select(s => s.ToString(1)));
Console.WriteLine($" {job.Name}: {job.Requests.Count} drawing(s), {job.TotalRequestedQuantity} part(s) requested, candidate sizes: {sizes}");
}
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}"); Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
var results = BenchmarkRunner.Run(jobs, engines); var results = BenchmarkRunner.Run(jobs, engines);
@@ -133,17 +140,21 @@ static class BenchmarkConsole
{ {
Console.Error.WriteLine("OpenNest.Benchmark - compare registered nesting engines on a set of .nest files"); Console.Error.WriteLine("OpenNest.Benchmark - compare registered nesting engines on a set of .nest files");
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("For each .nest file, every drawing with quantity > 0 is nested (mixed together)"); Console.Error.WriteLine("For each .nest file, every drawing with quantity > 0 is nested (mixed together),");
Console.Error.WriteLine("onto a fresh plate per sheet size, once per registered engine. Scoring: material"); Console.Error.WriteLine("once per registered engine. This is a full nest, not a single fixed-size plate:");
Console.Error.WriteLine("utilization first, then (if everything requested was placed) a smaller used"); Console.Error.WriteLine("as many plates as needed are created, one at a time, each sized by picking the");
Console.Error.WriteLine("bounding box as the tie-break. An invalid layout (out of bounds, overlapping,"); Console.Error.WriteLine("smallest candidate sheet size that fits the largest still-unplaced drawing -");
Console.Error.WriteLine("or over-quantity) scores zero for that job."); Console.Error.WriteLine("applied identically to every engine, since Nest() itself has no say over its");
Console.Error.WriteLine("own plate's size. Scoring: aggregate material utilization across every plate");
Console.Error.WriteLine("used, then (if everything requested was placed) fewer plates as the tie-break.");
Console.Error.WriteLine("An invalid layout (out of bounds, overlapping, or over-quantity) scores zero.");
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Usage:"); Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(" OpenNest.Benchmark <file.nest | folder> [options]"); Console.Error.WriteLine(" OpenNest.Benchmark <file.nest | folder> [options]");
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Options:"); Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --sheet-sizes W1xL1,W2xL2,... Sweep these plate sizes instead of each file's own"); Console.Error.WriteLine(" --sheet-sizes W1xL1,W2xL2,... Candidate sheet-size pool for the whole nest");
Console.Error.WriteLine(" (default: the distinct sizes already in each file)");
Console.Error.WriteLine(" --spacing <value> Override part spacing for every job"); Console.Error.WriteLine(" --spacing <value> Override part spacing for every job");
Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)"); Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
Console.Error.WriteLine(" --csv <path> Write a flat CSV of all results"); Console.Error.WriteLine(" --csv <path> Write a flat CSV of all results");
+25 -19
View File
@@ -9,9 +9,10 @@ namespace OpenNest.Benchmark
{ {
/// <summary> /// <summary>
/// Console + CSV reporting for benchmark results. Ranking rule per job: /// Console + CSV reporting for benchmark results. Ranking rule per job:
/// valid beats invalid; higher utilization wins; if utilization ties and both /// valid beats invalid; higher aggregate utilization wins; if utilization
/// engines fully placed every requested part, the smaller used-bounding-box /// ties and both engines fully placed every requested part, fewer plates
/// (more compact remnant) wins. Ties beyond that are a shared win. /// used wins (the multi-plate analogue of "smaller remnant" - both are
/// proxies for wasting less material). Ties beyond that are a shared win.
/// </summary> /// </summary>
public static class Report public static class Report
{ {
@@ -27,7 +28,7 @@ namespace OpenNest.Benchmark
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList(); var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
var best = ranked.Count > 0 ? ranked[0] : null; var best = ranked.Count > 0 ? ranked[0] : null;
Console.WriteLine($"{"Engine",-16} {"Result",-9} {"Parts",-10} {"Util%",-8} {"Remnant",-12} {"Time(ms)",-9} Notes"); Console.WriteLine($"{"Engine",-16} {"Result",-9} {"Parts",-10} {"Util%",-8} {"Plates",-18} {"Time(ms)",-9} Notes");
foreach (var r in ranked) foreach (var r in ranked)
{ {
@@ -36,10 +37,10 @@ namespace OpenNest.Benchmark
var status = r.Crashed ? "CRASH" : r.Valid ? "ok" : "INVALID"; var status = r.Crashed ? "CRASH" : r.Valid ? "ok" : "INVALID";
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}"; var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-"; var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
var remnantCol = r.Valid ? $"{r.UsedBoundingBoxArea:F0}" : "-"; var platesCol = r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2)); var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
Console.WriteLine($"{marker}{r.EngineName,-15} {status,-9} {partsCol,-10} {utilCol,-8} {remnantCol,-12} {r.ElapsedMs,-9} {notes}"); Console.WriteLine($"{marker}{r.EngineName,-15} {status,-9} {partsCol,-10} {utilCol,-8} {platesCol,-18} {r.ElapsedMs,-9} {notes}");
} }
} }
} }
@@ -57,7 +58,9 @@ namespace OpenNest.Benchmark
Jobs = g.Count(), Jobs = g.Count(),
Valid = g.Count(r => r.Valid), Valid = g.Count(r => r.Valid),
Crashed = g.Count(r => r.Crashed), Crashed = g.Count(r => r.Crashed),
FullyPlaced = g.Count(r => r.FullyPlaced),
TotalUtilization = g.Sum(r => r.Utilization), TotalUtilization = g.Sum(r => r.Utilization),
TotalPlates = g.Sum(r => r.PlatesUsed),
TotalTimeMs = g.Sum(r => r.ElapsedMs), TotalTimeMs = g.Sum(r => r.ElapsedMs),
}) })
.OrderByDescending(e => e.TotalUtilization) .OrderByDescending(e => e.TotalUtilization)
@@ -65,35 +68,43 @@ namespace OpenNest.Benchmark
var wins = CountWins(results); var wins = CountWins(results);
Console.WriteLine($"{"Engine",-16} {"Jobs",-6} {"Valid",-7} {"Crashed",-8} {"Wins",-6} {"AvgUtil%",-10} {"TotalTime(ms)",-14}"); Console.WriteLine($"{"Engine",-16} {"Jobs",-6} {"Valid",-7} {"Complete",-9} {"Wins",-6} {"AvgUtil%",-10} {"Plates",-8} {"TotalTime(ms)",-14}");
foreach (var e in byEngine) foreach (var e in byEngine)
{ {
var avgUtil = e.Jobs > 0 ? e.TotalUtilization / e.Jobs * 100 : 0; var avgUtil = e.Jobs > 0 ? e.TotalUtilization / e.Jobs * 100 : 0;
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0; var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
Console.WriteLine($"{e.Engine,-16} {e.Jobs,-6} {e.Valid,-7} {e.Crashed,-8} {winCount,-6} {avgUtil,-10:F1} {e.TotalTimeMs,-14}"); Console.WriteLine($"{e.Engine,-16} {e.Jobs,-6} {e.Valid,-7} {e.FullyPlaced,-9} {winCount,-6} {avgUtil,-10:F1} {e.TotalPlates,-8} {e.TotalTimeMs,-14}");
} }
} }
public static void WriteCsv(string path, List<JobResult> results) public static void WriteCsv(string path, List<JobResult> results)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("Job,Engine,Valid,Crashed,PartsPlaced,PartsRequested,Utilization,UsedBoundingBoxArea,ElapsedMs,Notes"); sb.AppendLine("Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,PlatesUsed,SizeBreakdown,ElapsedMs,Notes");
foreach (var r in results) foreach (var r in results)
{ {
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations); var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
sb.AppendLine(string.Join(",", sb.AppendLine(string.Join(",",
Csv(r.JobName), Csv(r.EngineName), r.Valid, r.Crashed, Csv(r.JobName), Csv(r.EngineName), r.Valid, r.Crashed, r.FullyPlaced,
r.PartsPlaced, r.PartsRequested, r.PartsPlaced, r.PartsRequested,
r.Utilization.ToString("F4", CultureInfo.InvariantCulture), r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
r.UsedBoundingBoxArea.ToString("F2", CultureInfo.InvariantCulture), r.PlatesUsed, Csv(SizeSummary(r.SizeBreakdown)),
r.ElapsedMs, Csv(notes))); r.ElapsedMs, Csv(notes)));
} }
File.WriteAllText(path, sb.ToString()); File.WriteAllText(path, sb.ToString());
} }
private static string SizeSummary(Dictionary<string, int> breakdown)
{
if (breakdown == null || breakdown.Count == 0)
return "-";
return string.Join("; ", breakdown.Select(kv => $"{kv.Key}×{kv.Value}"));
}
private static string Csv(string value) private static string Csv(string value)
{ {
if (string.IsNullOrEmpty(value)) if (string.IsNullOrEmpty(value))
@@ -124,7 +135,7 @@ namespace OpenNest.Benchmark
} }
/// <summary>Lower sorts first (better). Valid beats invalid, then higher /// <summary>Lower sorts first (better). Valid beats invalid, then higher
/// utilization, then (if both fully placed) smaller used-bounding-box.</summary> /// aggregate utilization, then (if both fully placed) fewer plates used.</summary>
private static int Compare(JobResult a, JobResult b) private static int Compare(JobResult a, JobResult b)
{ {
if (a.Valid != b.Valid) if (a.Valid != b.Valid)
@@ -138,13 +149,8 @@ namespace OpenNest.Benchmark
if (System.Math.Abs(utilDiff) > Epsilon) if (System.Math.Abs(utilDiff) > Epsilon)
return utilDiff > 0 ? 1 : -1; return utilDiff > 0 ? 1 : -1;
if (a.FullyPlaced && b.FullyPlaced) if (a.FullyPlaced && b.FullyPlaced && a.PlatesUsed != b.PlatesUsed)
{ return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
var bboxDiff = a.UsedBoundingBoxArea - b.UsedBoundingBoxArea;
if (System.Math.Abs(bboxDiff) > Epsilon)
return bboxDiff > 0 ? 1 : -1;
}
return 0; return 0;
} }