feat(benchmark): build jobs from DXF manifests and run solves in parallel
Benchmark jobs could only come from .nest files. A JSON manifest now lists DXF files with quantities (plus sheet sizes, spacing, edge spacing, quadrant and per-part allowRotation), imported through CadImporter. DXF paths resolve relative to the manifest; sheet sizes are required from the manifest or --sheet-sizes and are read in the DXFs' own units. Folder scans pick up *.nest and *.manifest.json, and invalid manifests fail loudly. BenchmarkRunner now runs (job x engine) solves concurrently, capped by --parallel N (CLI default 3; --parallel 1 is sequential). Results are written by index so report order is unchanged. Concurrent solves compete for cores, so Time(ms) is only clean at --parallel 1; the run prints a note when N > 1. Also fixes --output for manifest jobs, which tried to read the manifest as a .nest to copy metadata from. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
using OpenNest.Benchmark;
|
||||
|
||||
namespace OpenNest.Tests.Benchmark;
|
||||
|
||||
public sealed class BenchmarkRunnerTests : IDisposable
|
||||
{
|
||||
private static readonly string SourceDxf = Path.Combine(
|
||||
"Bending",
|
||||
"TestData",
|
||||
"4526 A14 PT11.dxf"
|
||||
);
|
||||
|
||||
private readonly string _dir = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"opennest-runner-" + Guid.NewGuid().ToString("N")
|
||||
);
|
||||
|
||||
public BenchmarkRunnerTests()
|
||||
{
|
||||
Directory.CreateDirectory(_dir);
|
||||
File.Copy(SourceDxf, Path.Combine(_dir, "part.dxf"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_dir, recursive: true);
|
||||
}
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
private List<BenchmarkJob> LoadJob()
|
||||
{
|
||||
var manifest = Path.Combine(_dir, "job.json");
|
||||
File.WriteAllText(
|
||||
manifest,
|
||||
"""{ "sheetSizes": ["48x96"], "parts": [ { "dxf": "part.dxf", "quantity": 2 } ] }"""
|
||||
);
|
||||
return JobLoader.Load(manifest);
|
||||
}
|
||||
|
||||
private static List<NestingEngineInfo> FakeEngines(
|
||||
ConcurrencyProbe probe,
|
||||
params int[] delaysMs
|
||||
) =>
|
||||
delaysMs
|
||||
.Select(
|
||||
(delay, i) =>
|
||||
new NestingEngineInfo(
|
||||
$"Engine{i}",
|
||||
"test double",
|
||||
() => new SleepingEngine(probe, delay)
|
||||
)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
[Fact]
|
||||
public void Run_NeverExceedsMaxParallelism_ButReachesIt()
|
||||
{
|
||||
var probe = new ConcurrencyProbe();
|
||||
|
||||
var results = BenchmarkRunner.Run(
|
||||
LoadJob(),
|
||||
FakeEngines(probe, 200, 200, 200, 200, 200, 200),
|
||||
maxParallelism: 2
|
||||
);
|
||||
|
||||
Assert.Equal(6, results.Count);
|
||||
Assert.Equal(2, probe.Max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_WithMaxParallelismOne_RunsOneSolveAtATime()
|
||||
{
|
||||
var probe = new ConcurrencyProbe();
|
||||
|
||||
BenchmarkRunner.Run(LoadJob(), FakeEngines(probe, 50, 50, 50, 50), maxParallelism: 1);
|
||||
|
||||
Assert.Equal(1, probe.Max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_KeepsResultsInJobThenEngineOrder_RegardlessOfCompletionOrder()
|
||||
{
|
||||
var probe = new ConcurrencyProbe();
|
||||
|
||||
// The first engine finishes last, so completion order differs from input order.
|
||||
var results = BenchmarkRunner.Run(
|
||||
LoadJob(),
|
||||
FakeEngines(probe, 300, 10, 10, 10),
|
||||
maxParallelism: 3
|
||||
);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "Engine0", "Engine1", "Engine2", "Engine3" },
|
||||
results.Select(r => r.EngineName)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_WithOutputDirectory_WritesManifestJobsWithoutNeedingASourceNest()
|
||||
{
|
||||
var probe = new ConcurrencyProbe();
|
||||
var output = Path.Combine(_dir, "out");
|
||||
|
||||
var results = BenchmarkRunner.Run(
|
||||
LoadJob(),
|
||||
FakeEngines(probe, 0),
|
||||
outputDirectory: output
|
||||
);
|
||||
|
||||
var result = Assert.Single(results);
|
||||
Assert.Null(result.Error);
|
||||
Assert.True(result.Valid);
|
||||
Assert.True(File.Exists(Path.Combine(output, "job-Engine0.nest")));
|
||||
Assert.True(File.Exists(Path.Combine(output, "job-Engine0.json")));
|
||||
}
|
||||
|
||||
private sealed class ConcurrencyProbe
|
||||
{
|
||||
private int _current;
|
||||
private int _max;
|
||||
|
||||
public int Max => Volatile.Read(ref _max);
|
||||
|
||||
public void Enter()
|
||||
{
|
||||
var now = Interlocked.Increment(ref _current);
|
||||
int seen;
|
||||
while ((seen = Volatile.Read(ref _max)) < now)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _max, now, seen) == seen)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void Exit() => Interlocked.Decrement(ref _current);
|
||||
}
|
||||
|
||||
/// <summary>Places nothing after sleeping, recording how many solves overlap.</summary>
|
||||
private sealed class SleepingEngine(ConcurrencyProbe probe, int delayMs) : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
probe.Enter();
|
||||
try
|
||||
{
|
||||
Thread.Sleep(delayMs);
|
||||
return new NestJobResult(
|
||||
NestJobStatus.Incomplete,
|
||||
NestJobStopReason.NoPlacementFound,
|
||||
Array.Empty<NestJobPlateResult>(),
|
||||
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)),
|
||||
job.Plates.Select(s => new StockUsage(s.Id, 0, null))
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
probe.Exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Benchmark;
|
||||
|
||||
public sealed class JobLoaderManifestTests : IDisposable
|
||||
{
|
||||
private static readonly string SourceDxf = Path.Combine(
|
||||
"Bending",
|
||||
"TestData",
|
||||
"4526 A14 PT11.dxf"
|
||||
);
|
||||
|
||||
private readonly string _dir = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"opennest-manifest-" + Guid.NewGuid().ToString("N")
|
||||
);
|
||||
|
||||
public JobLoaderManifestTests()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_dir, "parts"));
|
||||
File.Copy(SourceDxf, Path.Combine(_dir, "parts", "bracket.dxf"));
|
||||
File.Copy(SourceDxf, Path.Combine(_dir, "parts", "plate.dxf"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_dir, recursive: true);
|
||||
}
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
private string WriteManifest(string name, string json)
|
||||
{
|
||||
var path = Path.Combine(_dir, name);
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
|
||||
private const string ValidManifest = """
|
||||
{
|
||||
"sheetSizes": ["48x96", "60x120"],
|
||||
"spacing": 0.25,
|
||||
"edgeSpacing": 0.5,
|
||||
"quadrant": 2,
|
||||
"parts": [
|
||||
{ "dxf": "parts/bracket.dxf", "quantity": 12 },
|
||||
{ "dxf": "parts/plate.dxf", "quantity": 4 }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void Manifest_ImportsDxfsWithQuantitiesAndJobSettings()
|
||||
{
|
||||
var path = WriteManifest("bench.json", ValidManifest);
|
||||
|
||||
var job = Assert.Single(JobLoader.Load(path));
|
||||
|
||||
Assert.Equal("bench", job.Name);
|
||||
Assert.Equal(new[] { 12, 4 }, job.Requests.Select(r => r.Quantity));
|
||||
Assert.Equal(16, job.TotalRequestedQuantity);
|
||||
Assert.All(job.Requests, r => Assert.NotEmpty(r.Drawing.Program.Codes));
|
||||
Assert.Equal(new[] { new Size(48, 96), new Size(60, 120) }, job.CandidateSizes.ToArray());
|
||||
Assert.Equal(0.25, job.PartSpacing);
|
||||
Assert.Equal(0.5, job.EdgeSpacing.Left);
|
||||
Assert.Equal(0.5, job.EdgeSpacing.Top);
|
||||
Assert.Equal(2, job.Quadrant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manifest_DxfPathsResolveRelativeToTheManifestNotTheWorkingDirectory()
|
||||
{
|
||||
var path = WriteManifest("bench.json", ValidManifest);
|
||||
var elsewhere = Directory.GetCurrentDirectory();
|
||||
Assert.NotEqual(_dir, elsewhere);
|
||||
|
||||
var job = Assert.Single(JobLoader.Load(path));
|
||||
|
||||
Assert.Equal(2, job.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overrides_ReplaceManifestSheetSizesAndSpacing()
|
||||
{
|
||||
var path = WriteManifest("bench.json", ValidManifest);
|
||||
|
||||
var job = Assert.Single(JobLoader.Load(path, new[] { new Size(10, 20) }, 0.75));
|
||||
|
||||
Assert.Equal(new[] { new Size(10, 20) }, job.CandidateSizes.ToArray());
|
||||
Assert.Equal(0.75, job.PartSpacing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SheetSizesFromOverrideAreEnoughWhenManifestOmitsThem()
|
||||
{
|
||||
var path = WriteManifest(
|
||||
"bench.json",
|
||||
"""{ "parts": [ { "dxf": "parts/bracket.dxf", "quantity": 1 } ] }"""
|
||||
);
|
||||
|
||||
var job = Assert.Single(JobLoader.Load(path, new[] { new Size(48, 96) }));
|
||||
|
||||
Assert.Equal(new[] { new Size(48, 96) }, job.CandidateSizes.ToArray());
|
||||
Assert.Equal(0, job.PartSpacing);
|
||||
Assert.Equal(1, job.Quadrant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoSheetSizesAnywhere_ThrowsAndMentionsSheetSizes()
|
||||
{
|
||||
var path = WriteManifest(
|
||||
"bench.json",
|
||||
"""{ "parts": [ { "dxf": "parts/bracket.dxf", "quantity": 1 } ] }"""
|
||||
);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => JobLoader.Load(path));
|
||||
|
||||
Assert.Contains("sheet size", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnparseableSheetSize_ThrowsNamingTheValue()
|
||||
{
|
||||
var path = WriteManifest(
|
||||
"bench.json",
|
||||
"""{ "sheetSizes": ["huge"], "parts": [ { "dxf": "parts/bracket.dxf", "quantity": 1 } ] }"""
|
||||
);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => JobLoader.Load(path));
|
||||
|
||||
Assert.Contains("huge", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingDxf_ThrowsNamingTheEntry()
|
||||
{
|
||||
var path = WriteManifest(
|
||||
"bench.json",
|
||||
"""{ "sheetSizes": ["48x96"], "parts": [ { "dxf": "parts/nope.dxf", "quantity": 1 } ] }"""
|
||||
);
|
||||
|
||||
var ex = Assert.Throws<FileNotFoundException>(() => JobLoader.Load(path));
|
||||
|
||||
Assert.Contains("nope.dxf", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-3)]
|
||||
public void NonPositiveQuantity_ThrowsNamingTheEntry(int quantity)
|
||||
{
|
||||
var path = WriteManifest(
|
||||
"bench.json",
|
||||
$$"""{ "sheetSizes": ["48x96"], "parts": [ { "dxf": "parts/bracket.dxf", "quantity": {{quantity}} } ] }"""
|
||||
);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => JobLoader.Load(path));
|
||||
|
||||
Assert.Contains("bracket.dxf", ex.Message);
|
||||
Assert.Contains("quantity", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyPartsList_Throws()
|
||||
{
|
||||
var path = WriteManifest("bench.json", """{ "sheetSizes": ["48x96"], "parts": [] }""");
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => JobLoader.Load(path));
|
||||
|
||||
Assert.Contains("parts", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowRotationFalse_LocksThatPartsRotationInTheNestJob()
|
||||
{
|
||||
var path = WriteManifest(
|
||||
"bench.json",
|
||||
"""
|
||||
{
|
||||
"sheetSizes": ["48x96"],
|
||||
"parts": [
|
||||
{ "dxf": "parts/bracket.dxf", "quantity": 2 },
|
||||
{ "dxf": "parts/plate.dxf", "quantity": 2, "allowRotation": false }
|
||||
]
|
||||
}
|
||||
"""
|
||||
);
|
||||
|
||||
var job = Assert.Single(JobLoader.Load(path));
|
||||
var nestJob = job.BuildNestJob(maxPlates: 4);
|
||||
|
||||
Assert.Equal(RotationPolicyKind.Automatic, nestJob.Parts[0].Rotation.Kind);
|
||||
|
||||
// The legacy lock (step 2π, start = end = 0) reaches the engine as a single-angle sweep at 0.
|
||||
var locked = nestJob.Parts[1].Rotation;
|
||||
Assert.Equal(RotationPolicyKind.BoundedSweep, locked.Kind);
|
||||
Assert.Equal(0, locked.Start);
|
||||
Assert.Equal(0, locked.End);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Folder_LoadsManifestSuffixedFilesAndIgnoresOtherJson()
|
||||
{
|
||||
WriteManifest("a.manifest.json", ValidManifest);
|
||||
WriteManifest("b.manifest.json", ValidManifest);
|
||||
WriteManifest("results.json", """{ "not": "a manifest" }""");
|
||||
|
||||
var jobs = JobLoader.Load(_dir);
|
||||
|
||||
Assert.Equal(new[] { "a.manifest", "b.manifest" }, jobs.Select(j => j.Name));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user