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>
|
/// <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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
var engine = engineInfo.Factory(plate);
|
while (remaining.Any(i => i.Quantity > 0) && plateRuns.Count < MaxPlates)
|
||||||
parts = engine.Nest(items, null, CancellationToken.None) ?? new List<Part>();
|
{
|
||||||
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
sw.Stop();
|
engineError = $"{ex.GetType().Name}: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +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,22 +47,18 @@ 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,
|
||||||
{
|
CandidateSizes = sizes,
|
||||||
SourceFile = file,
|
EdgeSpacing = template.EdgeSpacing,
|
||||||
SheetSizeLabel = size.ToString(1),
|
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||||
PlateSize = size,
|
Quadrant = template.Quadrant,
|
||||||
EdgeSpacing = template.EdgeSpacing,
|
Requests = requests,
|
||||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
});
|
||||||
Quadrant = template.Quadrant,
|
|
||||||
Requests = requests,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return jobs;
|
return jobs;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
ValidateBounds(parts, plate, result);
|
|
||||||
ValidateAreaBudget(parts, plate, result);
|
foreach (var (plate, parts) in plateRuns)
|
||||||
ValidateSpacing(parts, plate.PartSpacing, result);
|
{
|
||||||
|
if (parts.Count == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
ValidateBounds(parts, plate, result);
|
||||||
|
ValidateAreaBudget(parts, plate, 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];
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user