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:
@@ -20,28 +20,40 @@ namespace OpenNest.Benchmark
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An immutable specification for one benchmark job: a set of drawings/quantities
|
||||
/// to be nested onto a plate of a given size. Every engine under test gets a fresh
|
||||
/// Plate and NestItem list built from this spec via CreatePlate()/CreateItems(),
|
||||
/// so one engine's run can never leak mutated state into another's.
|
||||
/// An immutable specification for one benchmark job: the full set of
|
||||
/// drawings/quantities that must be nested, and the pool of sheet sizes the
|
||||
/// engine may draw from while doing it. A single run may use several
|
||||
/// 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>
|
||||
public class BenchmarkJob
|
||||
{
|
||||
public string SourceFile { get; init; }
|
||||
public string SheetSizeLabel { get; init; }
|
||||
public Size PlateSize { get; init; }
|
||||
public List<Size> CandidateSizes { get; init; }
|
||||
public Spacing EdgeSpacing { get; init; }
|
||||
public double PartSpacing { get; init; }
|
||||
public int Quadrant { 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 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,
|
||||
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()
|
||||
{
|
||||
return Requests.Select(r => new NestItem
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
@@ -8,13 +7,22 @@ using System.Threading;
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs every candidate engine against every job. Each (job, engine) pair gets
|
||||
/// its own freshly-built Plate and NestItem list (via BenchmarkJob.CreatePlate/
|
||||
/// CreateItems), so no engine can see another's mutated state and no job can
|
||||
/// leak partial state into the next run of the same engine.
|
||||
/// Runs every candidate engine against every job. A job may need several
|
||||
/// plates to place everything it asks for; this drives that loop itself,
|
||||
/// since NestEngineBase.Nest() fills exactly one already-sized plate and
|
||||
/// 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>
|
||||
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)
|
||||
{
|
||||
var results = new List<JobResult>(jobs.Count * engines.Count);
|
||||
@@ -32,22 +40,58 @@ namespace OpenNest.Benchmark
|
||||
|
||||
private static JobResult RunOne(BenchmarkJob job, NestEngineInfo engineInfo)
|
||||
{
|
||||
var plate = job.CreatePlate();
|
||||
var items = job.CreateItems();
|
||||
var template = job.CreateTemplatePlate();
|
||||
var options = job.BuildPlateOptions();
|
||||
var remaining = job.CreateItems();
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
|
||||
var plateRuns = new List<(Plate Plate, List<Part> Parts)>();
|
||||
string engineError = null;
|
||||
var sw = Stopwatch.StartNew();
|
||||
List<Part> parts;
|
||||
|
||||
try
|
||||
{
|
||||
var engine = engineInfo.Factory(plate);
|
||||
parts = engine.Nest(items, null, CancellationToken.None) ?? new List<Part>();
|
||||
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 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)
|
||||
{
|
||||
sw.Stop();
|
||||
engineError = $"{ex.GetType().Name}: {ex.Message}";
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
if (engineError != null)
|
||||
{
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = engineInfo.Name,
|
||||
@@ -55,19 +99,19 @@ namespace OpenNest.Benchmark
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
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);
|
||||
|
||||
// Matches Plate.Utilization(): full sheet area, not just the cuttable
|
||||
// work area, since that's what the material actually costs.
|
||||
var plateArea = plate.Area();
|
||||
var placedArea = validation.Valid ? parts.Sum(p => p.BaseDrawing.Area) : 0;
|
||||
var usedBox = parts.Count > 0 ? parts.GetBoundingBox() : Box.Empty;
|
||||
var sizeBreakdown = plateRuns
|
||||
.GroupBy(pr => pr.Plate.Size.ToString(1))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
return new JobResult
|
||||
{
|
||||
@@ -75,13 +119,33 @@ namespace OpenNest.Benchmark
|
||||
JobName = job.Name,
|
||||
Valid = validation.Valid,
|
||||
Violations = validation.Violations,
|
||||
PartsPlaced = parts.Count,
|
||||
PartsPlaced = totalPlaced,
|
||||
PartsRequested = requested,
|
||||
PlacedArea = placedArea,
|
||||
PlateArea = plateArea,
|
||||
UsedBoundingBoxArea = usedBox.Width * usedBox.Length,
|
||||
PlatesUsed = plateRuns.Count,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ using System.Linq;
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on any
|
||||
/// valid .nest file, using whatever drawings/quantities/plate settings it contains.
|
||||
/// Optionally sweeps a fixed list of sheet sizes instead of the sizes embedded
|
||||
/// in the file, so the same drawing set can be benchmarked across a standard
|
||||
/// sheet-size lineup.
|
||||
/// Builds BenchmarkJobs from .nest files on disk. Fully generic: works on
|
||||
/// any valid .nest file, using whatever drawings/quantities/plate settings
|
||||
/// it contains. One job per file, carrying the full pool of candidate
|
||||
/// sheet sizes the engine may use across the whole nest - by default the
|
||||
/// distinct sizes already present in that file, or a fixed override list
|
||||
/// (e.g. a standard sheet-size lineup) applied to every file.
|
||||
/// </summary>
|
||||
public static class JobLoader
|
||||
{
|
||||
@@ -46,22 +47,18 @@ namespace OpenNest.Benchmark
|
||||
|
||||
var template = ResolvePlateTemplate(nest);
|
||||
var sizes = sheetSizeOverrides != null && sheetSizeOverrides.Count > 0
|
||||
? sheetSizeOverrides
|
||||
? sheetSizeOverrides.ToList()
|
||||
: ResolveSheetSizes(nest);
|
||||
|
||||
foreach (var size in sizes)
|
||||
jobs.Add(new BenchmarkJob
|
||||
{
|
||||
jobs.Add(new BenchmarkJob
|
||||
{
|
||||
SourceFile = file,
|
||||
SheetSizeLabel = size.ToString(1),
|
||||
PlateSize = size,
|
||||
EdgeSpacing = template.EdgeSpacing,
|
||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
Requests = requests,
|
||||
});
|
||||
}
|
||||
SourceFile = file,
|
||||
CandidateSizes = sizes,
|
||||
EdgeSpacing = template.EdgeSpacing,
|
||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
Requests = requests,
|
||||
});
|
||||
}
|
||||
|
||||
return jobs;
|
||||
|
||||
@@ -3,8 +3,11 @@ using System.Collections.Generic;
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Outcome of running one engine against one job. An invalid or crashed run
|
||||
/// always scores zero utilization for that job, per the benchmark rules.
|
||||
/// Outcome of running one engine against one job. A job may span several
|
||||
/// 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>
|
||||
public class JobResult
|
||||
{
|
||||
@@ -17,11 +20,16 @@ namespace OpenNest.Benchmark
|
||||
public int PartsRequested { get; init; }
|
||||
public double PlacedArea { 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 bool Crashed => Error != null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,52 +13,41 @@ namespace OpenNest.Benchmark
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a placed layout against the benchmark rules: every part must lie
|
||||
/// within the plate's work area, every pair of parts must be at least
|
||||
/// PartSpacing apart, and no drawing may have more parts placed than requested.
|
||||
/// 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.
|
||||
/// Validates a (possibly multi-plate) placed layout against the benchmark
|
||||
/// rules: on every plate, every part must lie within that plate's work
|
||||
/// area and every pair of parts must be at least PartSpacing apart; across
|
||||
/// all plates combined, no drawing may have more parts placed than
|
||||
/// requested (the quantity limit is a property of the whole order, not of
|
||||
/// 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>
|
||||
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 allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
||||
|
||||
if (parts == null || parts.Count == 0)
|
||||
if (allParts.Count == 0)
|
||||
return result;
|
||||
|
||||
ValidateQuantities(parts, job, result);
|
||||
ValidateBounds(parts, plate, result);
|
||||
ValidateAreaBudget(parts, plate, result);
|
||||
ValidateSpacing(parts, plate.PartSpacing, result);
|
||||
ValidateQuantities(allParts, job, result);
|
||||
|
||||
foreach (var (plate, parts) in plateRuns)
|
||||
{
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
|
||||
ValidateBounds(parts, plate, result);
|
||||
ValidateAreaBudget(parts, plate, result);
|
||||
ValidateSpacing(parts, plate.PartSpacing, 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)
|
||||
{
|
||||
var allowed = job.Requests.ToDictionary(r => r.Drawing.Id, r => r.Quantity);
|
||||
@@ -77,7 +66,7 @@ namespace OpenNest.Benchmark
|
||||
if (placed > max)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var worldPolygons = new Polygon[parts.Count];
|
||||
|
||||
@@ -57,6 +57,13 @@ static class BenchmarkConsole
|
||||
}
|
||||
|
||||
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))}");
|
||||
|
||||
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();
|
||||
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("utilization first, then (if everything requested was placed) a smaller used");
|
||||
Console.Error.WriteLine("bounding box as the tie-break. An invalid layout (out of bounds, overlapping,");
|
||||
Console.Error.WriteLine("or over-quantity) scores zero for that job.");
|
||||
Console.Error.WriteLine("For each .nest file, every drawing with quantity > 0 is nested (mixed together),");
|
||||
Console.Error.WriteLine("once per registered engine. This is a full nest, not a single fixed-size plate:");
|
||||
Console.Error.WriteLine("as many plates as needed are created, one at a time, each sized by picking the");
|
||||
Console.Error.WriteLine("smallest candidate sheet size that fits the largest still-unplaced drawing -");
|
||||
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("Usage:");
|
||||
Console.Error.WriteLine(" OpenNest.Benchmark <file.nest | folder> [options]");
|
||||
Console.Error.WriteLine();
|
||||
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(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
|
||||
Console.Error.WriteLine(" --csv <path> Write a flat CSV of all results");
|
||||
|
||||
@@ -9,9 +9,10 @@ namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Console + CSV reporting for benchmark results. Ranking rule per job:
|
||||
/// valid beats invalid; higher utilization wins; if utilization ties and both
|
||||
/// engines fully placed every requested part, the smaller used-bounding-box
|
||||
/// (more compact remnant) wins. Ties beyond that are a shared win.
|
||||
/// valid beats invalid; higher aggregate utilization wins; if utilization
|
||||
/// ties and both engines fully placed every requested part, fewer plates
|
||||
/// used wins (the multi-plate analogue of "smaller remnant" - both are
|
||||
/// proxies for wasting less material). Ties beyond that are a shared win.
|
||||
/// </summary>
|
||||
public static class Report
|
||||
{
|
||||
@@ -27,7 +28,7 @@ namespace OpenNest.Benchmark
|
||||
var ranked = jobGroup.OrderBy(r => r, Comparer<JobResult>.Create(Compare)).ToList();
|
||||
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)
|
||||
{
|
||||
@@ -36,10 +37,10 @@ namespace OpenNest.Benchmark
|
||||
var status = r.Crashed ? "CRASH" : r.Valid ? "ok" : "INVALID";
|
||||
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
|
||||
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));
|
||||
|
||||
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(),
|
||||
Valid = g.Count(r => r.Valid),
|
||||
Crashed = g.Count(r => r.Crashed),
|
||||
FullyPlaced = g.Count(r => r.FullyPlaced),
|
||||
TotalUtilization = g.Sum(r => r.Utilization),
|
||||
TotalPlates = g.Sum(r => r.PlatesUsed),
|
||||
TotalTimeMs = g.Sum(r => r.ElapsedMs),
|
||||
})
|
||||
.OrderByDescending(e => e.TotalUtilization)
|
||||
@@ -65,35 +68,43 @@ namespace OpenNest.Benchmark
|
||||
|
||||
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)
|
||||
{
|
||||
var avgUtil = e.Jobs > 0 ? e.TotalUtilization / e.Jobs * 100 : 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
|
||||
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.Utilization.ToString("F4", CultureInfo.InvariantCulture),
|
||||
r.UsedBoundingBoxArea.ToString("F2", CultureInfo.InvariantCulture),
|
||||
r.PlatesUsed, Csv(SizeSummary(r.SizeBreakdown)),
|
||||
r.ElapsedMs, Csv(notes)));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
@@ -124,7 +135,7 @@ namespace OpenNest.Benchmark
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (a.Valid != b.Valid)
|
||||
@@ -138,13 +149,8 @@ namespace OpenNest.Benchmark
|
||||
if (System.Math.Abs(utilDiff) > Epsilon)
|
||||
return utilDiff > 0 ? 1 : -1;
|
||||
|
||||
if (a.FullyPlaced && b.FullyPlaced)
|
||||
{
|
||||
var bboxDiff = a.UsedBoundingBoxArea - b.UsedBoundingBoxArea;
|
||||
|
||||
if (System.Math.Abs(bboxDiff) > Epsilon)
|
||||
return bboxDiff > 0 ? 1 : -1;
|
||||
}
|
||||
if (a.FullyPlaced && b.FullyPlaced && a.PlatesUsed != b.PlatesUsed)
|
||||
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user