fix(benchmark): use reference-based drawing identity in NestValidator, fix duplicate-sheet-size crash, document Engines/ plugin contract
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ namespace OpenNest.Benchmark
|
|||||||
private const int MaxPlates = 40;
|
private const int MaxPlates = 40;
|
||||||
|
|
||||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines)
|
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines)
|
||||||
{
|
{
|
||||||
@@ -40,14 +40,14 @@ namespace OpenNest.Benchmark
|
|||||||
|
|
||||||
private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo)
|
private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo)
|
||||||
{
|
{
|
||||||
var nestJob = job.BuildNestJob(MaxPlates);
|
|
||||||
var requested = job.TotalRequestedQuantity;
|
var requested = job.TotalRequestedQuantity;
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var nestJob = job.BuildNestJob(MaxPlates);
|
||||||
var engine = engineInfo.Factory();
|
var engine = engineInfo.Factory();
|
||||||
using var cts = new CancellationTokenSource(Timeout);
|
using var cts = new CancellationTokenSource(SolveTimeout);
|
||||||
var jobResult = engine.Solve(nestJob, null, cts.Token);
|
var jobResult = engine.Solve(nestJob, null, cts.Token);
|
||||||
|
|
||||||
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
|
||||||
@@ -55,7 +55,12 @@ namespace OpenNest.Benchmark
|
|||||||
.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
|
.Select(plate => (Plate: plate, Parts: plate.Parts.ToList()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var validation = NestValidator.Validate(plateRuns, job);
|
var requirements = job.Requests.ToDictionary<DrawingRequest, Drawing, (string Name, int Quantity)>(
|
||||||
|
r => materialized.DrawingsByPartId[r.Drawing.Id.ToString()],
|
||||||
|
r => (r.Drawing.Name, r.Quantity),
|
||||||
|
ReferenceEqualityComparer.Instance);
|
||||||
|
|
||||||
|
var validation = NestValidator.Validate(plateRuns, requirements);
|
||||||
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
|
var totalPlaced = plateRuns.Sum(pr => pr.Parts.Count);
|
||||||
var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0;
|
var placedArea = validation.Valid ? plateRuns.Sum(pr => pr.Parts.Sum(p => p.BaseDrawing.Area)) : 0;
|
||||||
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
|
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
|
||||||
@@ -92,7 +97,7 @@ namespace OpenNest.Benchmark
|
|||||||
Valid = false,
|
Valid = false,
|
||||||
PartsRequested = requested,
|
PartsRequested = requested,
|
||||||
ElapsedMs = sw.ElapsedMilliseconds,
|
ElapsedMs = sw.ElapsedMilliseconds,
|
||||||
Error = $"Timed out after {Timeout.TotalMinutes:F0} minute(s)",
|
Error = $"Timed out after {SolveTimeout.TotalMinutes:F0} minute(s)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ namespace OpenNest.Benchmark
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class NestValidator
|
public static class NestValidator
|
||||||
{
|
{
|
||||||
public static ValidationResult Validate(List<(Plate Plate, List<Part> Parts)> plateRuns, BenchmarkJob job)
|
/// <summary>
|
||||||
|
/// requirements maps each materialized part's BaseDrawing (by reference - materialized
|
||||||
|
/// Drawing instances are freshly reconstructed per NestResultMaterializer.Materialize, so
|
||||||
|
/// identity must never be inferred from Name, which is only incidentally seeded from the
|
||||||
|
/// originating NestJobPart id) to its original quantity limit and display name.
|
||||||
|
/// </summary>
|
||||||
|
public static ValidationResult Validate(List<(Plate Plate, List<Part> Parts)> plateRuns,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements)
|
||||||
{
|
{
|
||||||
var result = new ValidationResult();
|
var result = new ValidationResult();
|
||||||
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
var allParts = plateRuns.SelectMany(pr => pr.Parts).ToList();
|
||||||
@@ -33,38 +40,33 @@ namespace OpenNest.Benchmark
|
|||||||
if (allParts.Count == 0)
|
if (allParts.Count == 0)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
ValidateQuantities(allParts, job, result);
|
ValidateQuantities(allParts, requirements, result);
|
||||||
|
|
||||||
foreach (var (plate, parts) in plateRuns)
|
foreach (var (plate, parts) in plateRuns)
|
||||||
{
|
{
|
||||||
if (parts.Count == 0)
|
if (parts.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
ValidateBounds(parts, plate, result);
|
ValidateBounds(parts, plate, requirements, result);
|
||||||
ValidateAreaBudget(parts, plate, result);
|
ValidateAreaBudget(parts, plate, result);
|
||||||
ValidateSpacing(parts, plate.PartSpacing, result);
|
ValidateSpacing(parts, plate.PartSpacing, requirements, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateQuantities(List<Part> parts, BenchmarkJob job, ValidationResult result)
|
private static void ValidateQuantities(List<Part> parts,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
||||||
{
|
{
|
||||||
// Materialized parts reference freshly reconstructed Drawing objects (NestResultMaterializer
|
|
||||||
// rebuilds them via DrawingJobMapper.CreateDrawing, which sets the materialized Drawing's Name
|
|
||||||
// to the originating NestJobPart id - a fresh, unrelated Drawing.Id gets auto-generated instead).
|
|
||||||
// BuildNestJob sets each NestJobPart's id to the original Drawing.Id.ToString(), so that string -
|
|
||||||
// materialized as BaseDrawing.Name - is the stable identity across the materialization boundary.
|
|
||||||
var allowed = job.Requests.ToDictionary(r => r.Drawing.Id.ToString(), r => (r.Quantity, r.Drawing.Name));
|
|
||||||
var placedCounts = parts
|
var placedCounts = parts
|
||||||
.GroupBy(p => p.BaseDrawing.Name)
|
.GroupBy<Part, Drawing>(p => p.BaseDrawing, ReferenceEqualityComparer.Instance)
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
foreach (var (partId, placed) in placedCounts)
|
foreach (var (drawing, placed) in placedCounts)
|
||||||
{
|
{
|
||||||
if (!allowed.TryGetValue(partId, out var requirement))
|
if (!requirements.TryGetValue(drawing, out var requirement))
|
||||||
{
|
{
|
||||||
result.Violations.Add($"Placed drawing id={partId} which was not requested for this job");
|
result.Violations.Add($"Placed drawing '{drawing.Name}' which was not requested for this job");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +78,8 @@ namespace OpenNest.Benchmark
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateBounds(List<Part> parts, Plate plate, ValidationResult result)
|
private static void ValidateBounds(List<Part> parts, Plate plate,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
||||||
{
|
{
|
||||||
var workArea = plate.WorkArea();
|
var workArea = plate.WorkArea();
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@ 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 " +
|
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area " +
|
||||||
$"of a {plate.Size} plate");
|
$"of a {plate.Size} plate");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +124,8 @@ namespace OpenNest.Benchmark
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateSpacing(List<Part> parts, double spacing, ValidationResult result)
|
private static void ValidateSpacing(List<Part> parts, double spacing,
|
||||||
|
IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements, ValidationResult result)
|
||||||
{
|
{
|
||||||
var worldPolygons = new Polygon[parts.Count];
|
var worldPolygons = new Polygon[parts.Count];
|
||||||
var inflatedPolygons = new Polygon[parts.Count];
|
var inflatedPolygons = new Polygon[parts.Count];
|
||||||
@@ -145,12 +149,18 @@ namespace OpenNest.Benchmark
|
|||||||
if (Collision.HasOverlap(inflatedPolygons[i], worldPolygons[j]))
|
if (Collision.HasOverlap(inflatedPolygons[i], worldPolygons[j]))
|
||||||
{
|
{
|
||||||
result.Violations.Add(
|
result.Violations.Add(
|
||||||
$"'{parts[i].BaseDrawing.Name}' and '{parts[j].BaseDrawing.Name}' are closer than the required spacing ({spacing:F3})");
|
$"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Friendly name for a violation message, falling back to the materialized
|
||||||
|
/// Drawing's own Name (the raw partId string) if this part wasn't in requirements at all -
|
||||||
|
/// that mismatch is already reported by ValidateQuantities, so this is display-only.</summary>
|
||||||
|
private static string DisplayName(Part part, IReadOnlyDictionary<Drawing, (string Name, int Quantity)> requirements) =>
|
||||||
|
requirements.TryGetValue(part.BaseDrawing, out var requirement) ? requirement.Name : part.BaseDrawing.Name;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts a part's perimeter as a world-space polygon, optionally inflated
|
/// Extracts a part's perimeter as a world-space polygon, optionally inflated
|
||||||
/// outward by the given spacing, mirroring Part.Intersects' own geometry
|
/// outward by the given spacing, mirroring Part.Intersects' own geometry
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ static class BenchmarkConsole
|
|||||||
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
|
Console.Error.WriteLine($"Warning: could not parse sheet size '{token}', skipping");
|
||||||
}
|
}
|
||||||
|
|
||||||
return sizes;
|
return sizes.Distinct().ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void PrintUsage()
|
private static void PrintUsage()
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using OpenNest.CNC;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ dotnet run --project OpenNest.Benchmark/OpenNest.Benchmark.csproj -- job.nest \
|
|||||||
|
|
||||||
An engine's layout is rejected (scoring zero for that job) if any part falls outside the work area, any two parts are closer than the required spacing, or a drawing gets more parts placed than requested. A run that doesn't finish within its time budget also scores zero, as a timeout.
|
An engine's layout is rejected (scoring zero for that job) if any part falls outside the work area, any two parts are closer than the required spacing, or a drawing gets more parts placed than requested. A run that doesn't finish within its time budget also scores zero, as a timeout.
|
||||||
|
|
||||||
|
Custom competitor engines can be added by dropping a DLL implementing `INestingEngine` with a public parameterless constructor into the `Engines/` directory next to the benchmark executable; each one is registered under its own CLR type name. This is a separate plugin contract from the desktop app's `NestEngineRegistry`/`NestEngineBase` (which requires a `(Plate)` constructor) — a `NestEngineBase` plugin dropped into the benchmark's `Engines/` folder is silently skipped, since the benchmark only ever solves whole jobs.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user