[verified] add benchmark baseline and rotation fixes
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
@@ -36,6 +36,12 @@ namespace OpenNest.Benchmark
|
||||
public Spacing EdgeSpacing { get; init; }
|
||||
public double PartSpacing { get; init; }
|
||||
public int Quadrant { get; init; }
|
||||
|
||||
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
|
||||
public double SalvageRate { get; init; }
|
||||
|
||||
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
|
||||
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
|
||||
public List<DrawingRequest> Requests { get; init; }
|
||||
|
||||
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
|
||||
@@ -58,8 +64,8 @@ namespace OpenNest.Benchmark
|
||||
/// </summary>
|
||||
public NestJob BuildNestJob(
|
||||
int maxPlates,
|
||||
double salvageRate = 0,
|
||||
double minimumSalvageDimension = 0
|
||||
double? salvageRate = null,
|
||||
double? minimumSalvageDimension = null
|
||||
)
|
||||
{
|
||||
var parts = Requests.Select(r =>
|
||||
@@ -76,7 +82,12 @@ namespace OpenNest.Benchmark
|
||||
return new NestJob(
|
||||
parts,
|
||||
stock,
|
||||
new NestJobOptions("Default", maxPlates, salvageRate, minimumSalvageDimension)
|
||||
new NestJobOptions(
|
||||
"Default",
|
||||
maxPlates,
|
||||
salvageRate ?? SalvageRate,
|
||||
minimumSalvageDimension ?? 0
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ namespace OpenNest.Benchmark
|
||||
public static List<JobResult> Run(
|
||||
List<BenchmarkJob> jobs,
|
||||
IReadOnlyList<NestingEngineInfo> engines,
|
||||
double salvageRate = 0,
|
||||
double minimumSalvageDimension = 0,
|
||||
double? salvageRate = null,
|
||||
double? minimumSalvageDimension = null,
|
||||
string outputDirectory = null,
|
||||
int maxParallelism = 1
|
||||
)
|
||||
@@ -44,6 +44,16 @@ namespace OpenNest.Benchmark
|
||||
MaxDegreeOfParallelism = System.Math.Max(1, maxParallelism),
|
||||
};
|
||||
|
||||
var baselineResults = new JobResult[jobs.Count];
|
||||
Parallel.ForEach(
|
||||
Partitioner.Create(
|
||||
Enumerable.Range(0, jobs.Count),
|
||||
EnumerablePartitionerOptions.NoBuffering
|
||||
),
|
||||
options,
|
||||
i => baselineResults[i] = RunBaseline(jobs[i], salvageRate, minimumSalvageDimension)
|
||||
);
|
||||
|
||||
// NoBuffering hands out one pair at a time: solves run for seconds to minutes,
|
||||
// so chunked partitioning would leave workers idle behind a slow engine.
|
||||
Parallel.ForEach(
|
||||
@@ -63,14 +73,156 @@ namespace OpenNest.Benchmark
|
||||
);
|
||||
|
||||
// Indexed writes keep the report in job-then-engine order whatever finishes first.
|
||||
return results.ToList();
|
||||
var ordered = new List<JobResult>(
|
||||
results.Length + baselineResults.Count(result => result != null)
|
||||
);
|
||||
for (var jobIndex = 0; jobIndex < jobs.Count; jobIndex++)
|
||||
{
|
||||
if (baselineResults[jobIndex] != null)
|
||||
ordered.Add(baselineResults[jobIndex]);
|
||||
var firstResult = jobIndex * engines.Count;
|
||||
for (var engineIndex = 0; engineIndex < engines.Count; engineIndex++)
|
||||
ordered.Add(results[firstResult + engineIndex]);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static JobResult RunBaseline(
|
||||
BenchmarkJob job,
|
||||
double? salvageRate,
|
||||
double? minimumSalvageDimension
|
||||
)
|
||||
{
|
||||
if (job.BaselinePlateRuns == null)
|
||||
return null;
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
try
|
||||
{
|
||||
var requirements = job.Requests.ToDictionary<
|
||||
DrawingRequest,
|
||||
Drawing,
|
||||
(string Name, int Quantity)
|
||||
>(
|
||||
request => request.Drawing,
|
||||
request => (request.Drawing.Name, request.Quantity),
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
var partIds = job.Requests.ToDictionary<DrawingRequest, Drawing, string>(
|
||||
request => request.Drawing,
|
||||
request => request.Drawing.Id.ToString(),
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
var validation = NestValidator.Validate(job.BaselinePlateRuns, requirements);
|
||||
var benchmarkJob = job.BuildNestJob(
|
||||
MaxPlates,
|
||||
salvageRate,
|
||||
minimumSalvageDimension
|
||||
);
|
||||
var instanceIndices = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var plateResults = job
|
||||
.BaselinePlateRuns.Select(
|
||||
(run, index) =>
|
||||
{
|
||||
var stock = new NestPlateStock(
|
||||
$"baseline-{index}",
|
||||
run.Plate.Size,
|
||||
1,
|
||||
run.Plate.PartSpacing,
|
||||
run.Plate.EdgeSpacing,
|
||||
run.Plate.Quadrant
|
||||
);
|
||||
var placements = run
|
||||
.Parts.Select(part =>
|
||||
{
|
||||
var partId = partIds[part.BaseDrawing];
|
||||
instanceIndices.TryGetValue(partId, out var instanceIndex);
|
||||
instanceIndices[partId] = instanceIndex + 1;
|
||||
return new NestJobPlacement(
|
||||
partId,
|
||||
instanceIndex,
|
||||
part.Location.X,
|
||||
part.Location.Y,
|
||||
part.Rotation
|
||||
);
|
||||
})
|
||||
.ToList();
|
||||
return new NestJobPlateResult(index, stock, placements);
|
||||
}
|
||||
)
|
||||
.ToList();
|
||||
var baselineJob = new NestJob(
|
||||
benchmarkJob.Parts,
|
||||
plateResults.Select(result => result.Stock),
|
||||
benchmarkJob.Options
|
||||
);
|
||||
var baselineJobResult = new NestJobResult(
|
||||
NestJobStatus.Complete,
|
||||
NestJobStopReason.Completed,
|
||||
plateResults,
|
||||
Array.Empty<PartFulfillment>(),
|
||||
Array.Empty<StockUsage>()
|
||||
);
|
||||
NestValidator.ValidateAgainstJob(
|
||||
baselineJob,
|
||||
baselineJobResult,
|
||||
job.Requests.ToDictionary(
|
||||
request => request.Drawing.Id.ToString(),
|
||||
request => request.Drawing.Name
|
||||
),
|
||||
validation
|
||||
);
|
||||
|
||||
var plateRuns = job.BaselinePlateRuns;
|
||||
var placedArea = validation.Valid
|
||||
? plateRuns.Sum(run => run.Parts.Sum(part => part.BaseDrawing.Area))
|
||||
: 0;
|
||||
var plateArea = plateRuns.Sum(run => run.Plate.Area());
|
||||
var netSheetArea = validation.Valid
|
||||
? plateResults.Sum(result =>
|
||||
StockLadderNestingEngine.EstimateNetArea(baselineJob, result)
|
||||
)
|
||||
: 0;
|
||||
var sizeBreakdown = plateRuns
|
||||
.GroupBy(run => run.Plate.Size.ToString(1))
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ToDictionary(group => group.Key, group => group.Count());
|
||||
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = "Baseline",
|
||||
JobName = job.Name,
|
||||
Valid = validation.Valid,
|
||||
Violations = validation.Violations,
|
||||
PartsPlaced = plateRuns.Sum(run => run.Parts.Count),
|
||||
PartsRequested = requested,
|
||||
PlacedArea = placedArea,
|
||||
PlateArea = plateArea,
|
||||
NetSheetArea = netSheetArea,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
PlatesUsed = plateRuns.Count,
|
||||
SizeBreakdown = sizeBreakdown,
|
||||
ElapsedMs = 0,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JobResult
|
||||
{
|
||||
EngineName = "Baseline",
|
||||
JobName = job.Name,
|
||||
Valid = false,
|
||||
PartsRequested = requested,
|
||||
UnplacedPartPenalty = job.UnplacedPartPenalty,
|
||||
Error = $"{ex.GetType().Name}: {ex.Message}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static JobResult RunOne(
|
||||
BenchmarkJob job,
|
||||
NestingEngineInfo engineInfo,
|
||||
double salvageRate,
|
||||
double minimumSalvageDimension,
|
||||
double? salvageRate,
|
||||
double? minimumSalvageDimension,
|
||||
string outputDirectory
|
||||
)
|
||||
{
|
||||
@@ -113,7 +265,9 @@ namespace OpenNest.Benchmark
|
||||
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
|
||||
// Salvage credit is recomputed from the job's own geometry, never taken from the engine.
|
||||
var netSheetArea = validation.Valid
|
||||
? jobResult.Plates.Sum(p => StockLadderNestingEngine.EstimateNetArea(nestJob, p))
|
||||
? jobResult.Plates.Sum(p =>
|
||||
StockLadderNestingEngine.EstimateNetArea(nestJob, p)
|
||||
)
|
||||
: 0;
|
||||
|
||||
var sizeBreakdown = plateRuns
|
||||
@@ -138,7 +292,7 @@ namespace OpenNest.Benchmark
|
||||
{
|
||||
materialized.Nest.Name = job.Name;
|
||||
}
|
||||
materialized.Nest.SalvageRate = salvageRate;
|
||||
materialized.Nest.SalvageRate = nestJob.Options.SalvageRate;
|
||||
foreach (var request in job.Requests)
|
||||
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request
|
||||
.Drawing
|
||||
@@ -165,8 +319,8 @@ namespace OpenNest.Benchmark
|
||||
Placed = totalPlaced,
|
||||
SheetArea = plateArea,
|
||||
PlacedArea = placedArea,
|
||||
SalvageRate = salvageRate,
|
||||
MinimumSalvageDimension = minimumSalvageDimension,
|
||||
SalvageRate = nestJob.Options.SalvageRate,
|
||||
MinimumSalvageDimension = nestJob.Options.MinimumSalvageDimension,
|
||||
EstimatedNetArea = netSheetArea,
|
||||
Fulfillment = jobResult.Fulfillment,
|
||||
StockUsage = jobResult.StockUsage,
|
||||
|
||||
@@ -73,6 +73,8 @@ namespace OpenNest.Benchmark
|
||||
EdgeSpacing = template.EdgeSpacing,
|
||||
PartSpacing = partSpacingOverride ?? template.PartSpacing,
|
||||
Quadrant = template.Quadrant,
|
||||
SalvageRate = nest.SalvageRate,
|
||||
BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
|
||||
Requests = requests,
|
||||
}
|
||||
);
|
||||
@@ -156,6 +158,35 @@ namespace OpenNest.Benchmark
|
||||
return requests;
|
||||
}
|
||||
|
||||
private static List<(Plate Plate, List<Part> Parts)> BuildBaselinePlateRuns(
|
||||
Nest nest,
|
||||
double? partSpacingOverride
|
||||
)
|
||||
{
|
||||
var runs = new List<(Plate Plate, List<Part> Parts)>();
|
||||
foreach (var plate in nest.Plates ?? Enumerable.Empty<Plate>())
|
||||
{
|
||||
var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
|
||||
if (parts.Count == 0)
|
||||
continue;
|
||||
var validationPlate = new Plate(new Size(plate.Size.Width, plate.Size.Length))
|
||||
{
|
||||
Quantity = 1,
|
||||
Quadrant = plate.Quadrant,
|
||||
PartSpacing = partSpacingOverride ?? plate.PartSpacing,
|
||||
EdgeSpacing = new Spacing(
|
||||
plate.EdgeSpacing.Left,
|
||||
plate.EdgeSpacing.Bottom,
|
||||
plate.EdgeSpacing.Right,
|
||||
plate.EdgeSpacing.Top
|
||||
),
|
||||
};
|
||||
for (var copy = 0; copy < plate.Quantity; copy++)
|
||||
runs.Add((validationPlate, parts));
|
||||
}
|
||||
return runs.Count > 0 ? runs : null;
|
||||
}
|
||||
|
||||
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
|
||||
Nest nest
|
||||
)
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using OpenNest;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
return BenchmarkConsole.Run(args);
|
||||
|
||||
@@ -96,6 +96,17 @@ static class BenchmarkConsole
|
||||
|
||||
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
|
||||
|
||||
var effectiveSalvageRates = jobs.Select(job => options.SalvageRate ?? job.SalvageRate);
|
||||
if (
|
||||
effectiveSalvageRates.Any(rate => rate > 0)
|
||||
&& (options.MinimumSalvageDimension ?? 0) <= 0
|
||||
)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Warning: salvage credit is disabled because --min-salvage-dimension was not set to a positive value."
|
||||
);
|
||||
}
|
||||
|
||||
var solves = jobs.Count * engines.Count;
|
||||
|
||||
if (options.Parallel > 1 && solves > 1)
|
||||
@@ -294,13 +305,13 @@ static class BenchmarkConsole
|
||||
" --csv <path> Write a flat CSV of all results"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --salvage-rate <0..1> Fraction of the usable offcut credited back in the"
|
||||
" --salvage-rate <0..1> Fraction of eligible offcut area credited (default: saved .nest rate;"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" score (default 0; needs --min-salvage-dimension)"
|
||||
" manifests 0; needs positive --min-salvage-dimension)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --min-salvage-dimension <value> Both offcut dimensions must qualify; 0 disables credit"
|
||||
" --min-salvage-dimension <value> Both offcut dimensions must qualify; positive value enables credit (default 0)"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --output <directory> Save valid layouts as .nest plus detailed JSON reports"
|
||||
@@ -322,8 +333,8 @@ static class BenchmarkConsole
|
||||
public List<string> EngineNames = new();
|
||||
public string CsvPath;
|
||||
public string OutputDirectory;
|
||||
public double SalvageRate;
|
||||
public double MinimumSalvageDimension;
|
||||
public double? SalvageRate;
|
||||
public double? MinimumSalvageDimension;
|
||||
public int Parallel = 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
@@ -101,6 +101,94 @@ public class NestJobGeometryTests
|
||||
Assert.Equal(NestJobStatus.Complete, boundedResult.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyAllow180EquivalentPermitsFlippedCandidate()
|
||||
{
|
||||
var drawing = new Drawing("grain-aligned", TestDrawingFactory.Rectangle(4, 2));
|
||||
drawing.Constraints.StepAngle = System.Math.PI / 2;
|
||||
drawing.Constraints.StartAngle = 0;
|
||||
drawing.Constraints.EndAngle = 0;
|
||||
drawing.Constraints.Allow180Equivalent = true;
|
||||
var job = new NestJob(
|
||||
new[] { DrawingJobMapper.FromDrawing("part", drawing, 1) },
|
||||
new[] { new NestPlateStock("stock", new Size(10, 10), 1) }
|
||||
);
|
||||
|
||||
var result = Solve(job, new NestJobPlacement("part", 0, 4, 2, System.Math.PI));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyItemAllow180EquivalentPermitsFlippedCandidate()
|
||||
{
|
||||
var drawing = new Drawing("grain-aligned", TestDrawingFactory.Rectangle(4, 2));
|
||||
drawing.Constraints.Allow180Equivalent = true;
|
||||
var item = new NestItem
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = 1,
|
||||
StepAngle = System.Math.PI / 2,
|
||||
RotationStart = 0,
|
||||
RotationEnd = 0,
|
||||
};
|
||||
var job = new NestJob(
|
||||
new[] { DrawingJobMapper.FromItem("part", item) },
|
||||
new[] { new NestPlateStock("stock", new Size(10, 10), 1) }
|
||||
);
|
||||
|
||||
var result = Solve(job, new NestJobPlacement("part", 0, 4, 2, System.Math.PI));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BoundedRotationPolicyAcceptsAnEquivalentFullTurn()
|
||||
{
|
||||
var part = new NestJobPart(
|
||||
"part",
|
||||
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 2)),
|
||||
1,
|
||||
rotation: RotationPolicy.BoundedSweep(
|
||||
-System.Math.PI / 2,
|
||||
System.Math.PI / 2,
|
||||
System.Math.PI / 2
|
||||
)
|
||||
);
|
||||
var job = new NestJob(
|
||||
new[] { part },
|
||||
new[] { new NestPlateStock("stock", new Size(10, 10), 1) }
|
||||
);
|
||||
|
||||
var result = Solve(job, new NestJobPlacement("part", 0, 0, 4, 3 * System.Math.PI / 2));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BoundedRotationSpanningFullTurnAcceptsGridAngleAndEquivalent()
|
||||
{
|
||||
var part = new NestJobPart(
|
||||
"part",
|
||||
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 2)),
|
||||
1,
|
||||
rotation: RotationPolicy.BoundedSweep(0, 7, 1)
|
||||
);
|
||||
var job = new NestJob(
|
||||
new[] { part },
|
||||
new[] { new NestPlateStock("stock", new Size(10, 10), 1) }
|
||||
);
|
||||
|
||||
Assert.Equal(
|
||||
NestJobStatus.Complete,
|
||||
Solve(job, new NestJobPlacement("part", 0, 0, 0, 0)).Status
|
||||
);
|
||||
Assert.Equal(
|
||||
NestJobStatus.Complete,
|
||||
Solve(job, new NestJobPlacement("part", 0, 0, 0, System.Math.PI * 2)).Status
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EdgeTouchingIsAllowedAtZeroSpacingAndRejectedAtPositiveSpacing()
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
@@ -169,6 +169,22 @@ public class PlateNesterParityTests
|
||||
Assert.Equal(2, ByPart(result)["arc"].Placed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestrictedRotation_Enumerates180EquivalentFlip()
|
||||
{
|
||||
var anglesMethod = typeof(OrderedPlateNester).GetMethod(
|
||||
"Angles",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static
|
||||
);
|
||||
var policy = RotationPolicy.BoundedSweep(0, 0, System.Math.PI / 2, true);
|
||||
|
||||
var angles = (
|
||||
(IEnumerable<double>)anglesMethod!.Invoke(null, new object[] { policy })!
|
||||
).ToList();
|
||||
|
||||
Assert.Equal(new[] { 0.0, System.Math.PI }, angles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FixedRotation_Respected()
|
||||
{
|
||||
|
||||
@@ -20,7 +20,8 @@ public static class DrawingJobMapper
|
||||
: RotationPolicy.FromLegacy(
|
||||
constraints.StepAngle,
|
||||
constraints.StartAngle,
|
||||
constraints.EndAngle
|
||||
constraints.EndAngle,
|
||||
constraints.Allow180Equivalent
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -34,7 +35,12 @@ public static class DrawingJobMapper
|
||||
PartGeometrySnapshot.FromProgram(item.Drawing.Program),
|
||||
item.Quantity,
|
||||
item.Priority,
|
||||
RotationPolicy.FromLegacy(item.StepAngle, item.RotationStart, item.RotationEnd)
|
||||
RotationPolicy.FromLegacy(
|
||||
item.StepAngle,
|
||||
item.RotationStart,
|
||||
item.RotationEnd,
|
||||
item.Drawing.Constraints?.Allow180Equivalent ?? false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +95,7 @@ public static class DrawingJobMapper
|
||||
StepAngle = LegacyStep(part.Rotation),
|
||||
StartAngle = part.Rotation.Start,
|
||||
EndAngle = part.Rotation.End,
|
||||
Allow180Equivalent = part.Rotation.Allow180Equivalent,
|
||||
};
|
||||
return drawing;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
namespace OpenNest.Engine.Jobs.Placement;
|
||||
|
||||
/// <summary>Constrained-order linear fills in conservative rectangular free regions.
|
||||
@@ -105,6 +105,8 @@ internal sealed class OrderedPlateNester : IPlateNester
|
||||
if (policy.Kind == RotationPolicyKind.Fixed)
|
||||
{
|
||||
yield return policy.Start;
|
||||
if (policy.Allow180Equivalent)
|
||||
yield return policy.Start + System.Math.PI;
|
||||
yield break;
|
||||
}
|
||||
// A bounded deterministic search, not a proof that an unplaced part cannot fit.
|
||||
@@ -125,6 +127,8 @@ internal sealed class OrderedPlateNester : IPlateNester
|
||||
if (angle > policy.End + 1e-9)
|
||||
yield break;
|
||||
yield return angle;
|
||||
if (policy.Allow180Equivalent)
|
||||
yield return angle + System.Math.PI;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,13 @@ public enum RotationPolicyKind
|
||||
/// <summary>Immutable rotation constraints, in radians about the geometry origin.</summary>
|
||||
public sealed class RotationPolicy
|
||||
{
|
||||
private RotationPolicy(RotationPolicyKind kind, double start, double end, double step)
|
||||
private RotationPolicy(
|
||||
RotationPolicyKind kind,
|
||||
double start,
|
||||
double end,
|
||||
double step,
|
||||
bool allow180Equivalent
|
||||
)
|
||||
{
|
||||
if (!double.IsFinite(start) || !double.IsFinite(end) || !double.IsFinite(step))
|
||||
throw new ArgumentException("Angles must be finite.");
|
||||
@@ -20,33 +26,53 @@ public sealed class RotationPolicy
|
||||
Start = start;
|
||||
End = end;
|
||||
Step = step;
|
||||
Allow180Equivalent = allow180Equivalent;
|
||||
}
|
||||
|
||||
public RotationPolicyKind Kind { get; }
|
||||
public double Start { get; }
|
||||
public double End { get; }
|
||||
public double Step { get; }
|
||||
public static RotationPolicy Automatic { get; } = new(RotationPolicyKind.Automatic, 0, 0, 0);
|
||||
|
||||
public static RotationPolicy Fixed(double angle) =>
|
||||
new(RotationPolicyKind.Fixed, angle, angle, 0);
|
||||
/// <summary>Whether an orientation 180° from an allowed angle is also legal.</summary>
|
||||
public bool Allow180Equivalent { get; }
|
||||
public static RotationPolicy Automatic { get; } =
|
||||
new(RotationPolicyKind.Automatic, 0, 0, 0, false);
|
||||
|
||||
public static RotationPolicy BoundedSweep(double start, double end, double step)
|
||||
public static RotationPolicy Fixed(double angle, bool allow180Equivalent = false) =>
|
||||
new(RotationPolicyKind.Fixed, angle, angle, 0, allow180Equivalent);
|
||||
|
||||
public static RotationPolicy BoundedSweep(
|
||||
double start,
|
||||
double end,
|
||||
double step,
|
||||
bool allow180Equivalent = false
|
||||
)
|
||||
{
|
||||
if (step <= 0 || end < start)
|
||||
throw new ArgumentException("Sweep needs a positive step and ordered bounds.");
|
||||
return new RotationPolicy(RotationPolicyKind.BoundedSweep, start, end, step);
|
||||
return new RotationPolicy(
|
||||
RotationPolicyKind.BoundedSweep,
|
||||
start,
|
||||
end,
|
||||
step,
|
||||
allow180Equivalent
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Preserves the legacy zero-step automatic sentinel; zero never means locked rotation.</summary>
|
||||
public static RotationPolicy FromLegacy(
|
||||
double stepAngle,
|
||||
double rotationStart,
|
||||
double rotationEnd
|
||||
) => stepAngle == 0 ? Automatic : BoundedSweep(rotationStart, rotationEnd, stepAngle);
|
||||
double rotationEnd,
|
||||
bool allow180Equivalent = false
|
||||
) =>
|
||||
stepAngle == 0
|
||||
? Automatic
|
||||
: BoundedSweep(rotationStart, rotationEnd, stepAngle, allow180Equivalent);
|
||||
|
||||
/// <summary>True when a placement rotation (radians) satisfies this policy: anything for
|
||||
/// Automatic, the fixed angle modulo a full turn, or an exact step inside a bounded sweep.</summary>
|
||||
/// <summary>True when a placement rotation satisfies this policy. Fixed and bounded
|
||||
/// policies compare orientations modulo full turns; an allowed 180° equivalent is included.</summary>
|
||||
public bool Allows(double rotation)
|
||||
{
|
||||
const double epsilon = 0.0000001;
|
||||
@@ -55,14 +81,33 @@ public sealed class RotationPolicy
|
||||
if (Kind == RotationPolicyKind.Automatic)
|
||||
return true;
|
||||
if (Kind == RotationPolicyKind.Fixed)
|
||||
return AnglesEqual(rotation, Start, epsilon)
|
||||
|| (Allow180Equivalent && AnglesEqual(rotation, Start + System.Math.PI, epsilon));
|
||||
return SweepAllows(rotation, epsilon)
|
||||
|| (Allow180Equivalent && SweepAllows(rotation - System.Math.PI, epsilon));
|
||||
}
|
||||
|
||||
private bool SweepAllows(double rotation, double epsilon)
|
||||
{
|
||||
var fullTurn = System.Math.PI * 2;
|
||||
var firstTurn = System.Math.Ceiling((Start - epsilon - rotation) / fullTurn);
|
||||
var lastTurn = System.Math.Floor((End + epsilon - rotation) / fullTurn);
|
||||
for (var turns = firstTurn; turns <= lastTurn; turns++)
|
||||
{
|
||||
var delta = (rotation - Start) % (System.Math.PI * 2);
|
||||
return System.Math.Abs(delta) <= epsilon
|
||||
|| System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= epsilon;
|
||||
var equivalent = rotation + turns * fullTurn;
|
||||
if (equivalent < Start - epsilon || equivalent > End + epsilon)
|
||||
continue;
|
||||
var steps = (equivalent - Start) / Step;
|
||||
if (System.Math.Abs(steps - System.Math.Round(steps)) <= epsilon)
|
||||
return true;
|
||||
}
|
||||
if (rotation < Start - epsilon || rotation > End + epsilon)
|
||||
return false;
|
||||
var steps = (rotation - Start) / Step;
|
||||
return System.Math.Abs(steps - System.Math.Round(steps)) <= epsilon;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool AnglesEqual(double left, double right, double epsilon)
|
||||
{
|
||||
var delta = (left - right) % (System.Math.PI * 2);
|
||||
return System.Math.Abs(delta) <= epsilon
|
||||
|| System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= epsilon;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Tests.Benchmark;
|
||||
|
||||
@@ -41,6 +43,51 @@ public sealed class BenchmarkRunnerTests : IDisposable
|
||||
return JobLoader.Load(manifest);
|
||||
}
|
||||
|
||||
private string WriteBaselineNest(
|
||||
string name,
|
||||
double salvageRate = 0.5,
|
||||
bool outsideWorkArea = false,
|
||||
int plateQuantity = 1,
|
||||
int requiredQuantity = 1,
|
||||
int partCount = 1,
|
||||
double partSpacing = 0,
|
||||
double partRotation = 0
|
||||
)
|
||||
{
|
||||
var path = Path.Combine(_dir, name + ".nest");
|
||||
var program = new Program();
|
||||
program.MoveTo(0, 0);
|
||||
program.LineTo(2, 0);
|
||||
program.LineTo(2, 2);
|
||||
program.LineTo(0, 2);
|
||||
program.LineTo(0, 0);
|
||||
var drawing = new Drawing("part", program);
|
||||
if (partRotation != 0)
|
||||
{
|
||||
drawing.Constraints.StepAngle = System.Math.PI / 2;
|
||||
drawing.Constraints.StartAngle = 0;
|
||||
drawing.Constraints.EndAngle = 0;
|
||||
}
|
||||
drawing.Quantity.Required = requiredQuantity;
|
||||
var nest = new Nest(name) { SalvageRate = salvageRate };
|
||||
nest.Drawings.Add(drawing);
|
||||
var plate = new Plate(10, 10) { Quantity = plateQuantity, PartSpacing = partSpacing };
|
||||
for (var index = 0; index < partCount; index++)
|
||||
{
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
if (partRotation != 0)
|
||||
part.Rotate(partRotation);
|
||||
part.Location =
|
||||
outsideWorkArea ? new OpenNest.Geometry.Vector(9, 0)
|
||||
: partRotation == 0 ? new OpenNest.Geometry.Vector(index * 2, 0)
|
||||
: new OpenNest.Geometry.Vector(2 + index * 2, 2);
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
nest.Plates.Add(plate);
|
||||
new NestWriter(nest).Write(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static List<NestingEngineInfo> FakeEngines(
|
||||
ConcurrencyProbe probe,
|
||||
params int[] delaysMs
|
||||
@@ -56,6 +103,137 @@ public sealed class BenchmarkRunnerTests : IDisposable
|
||||
)
|
||||
.ToList();
|
||||
|
||||
[Fact]
|
||||
public void NestJobUsesSalvageRateStoredInSourceNest()
|
||||
{
|
||||
var job = Assert.Single(JobLoader.Load(WriteBaselineNest("salvage", salvageRate: 0.5)));
|
||||
|
||||
var nestJob = job.BuildNestJob(maxPlates: 1);
|
||||
|
||||
Assert.Equal(0.5, nestJob.Options.SalvageRate);
|
||||
Assert.Equal(0, nestJob.Options.MinimumSalvageDimension);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_ScoresTheOriginalBaselineLayoutAlongsideEngines()
|
||||
{
|
||||
var results = BenchmarkRunner.Run(
|
||||
JobLoader.Load(WriteBaselineNest("baseline")),
|
||||
FakeEngines(new ConcurrencyProbe(), 0),
|
||||
maxParallelism: 1
|
||||
);
|
||||
|
||||
var baseline = Assert.Single(results.Where(result => result.EngineName == "Baseline"));
|
||||
Assert.True(baseline.Valid);
|
||||
Assert.Equal(1, baseline.PartsPlaced);
|
||||
Assert.Equal(1, baseline.PartsRequested);
|
||||
Assert.Equal(4, baseline.PlacedArea);
|
||||
Assert.Equal(100, baseline.PlateArea);
|
||||
Assert.Equal(1, baseline.PlatesUsed);
|
||||
Assert.Equal(0.04, baseline.Utilization, 6);
|
||||
Assert.Equal(100, baseline.NetSheetArea);
|
||||
Assert.Equal(100, baseline.Cost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_BaselineUsesSourceSalvageRateUnlessCliOverridesIt()
|
||||
{
|
||||
var jobs = JobLoader.Load(WriteBaselineNest("baseline-salvage", salvageRate: 0.5));
|
||||
var sourceRate = BenchmarkRunner.Run(
|
||||
jobs,
|
||||
FakeEngines(new ConcurrencyProbe(), 0),
|
||||
minimumSalvageDimension: 5,
|
||||
maxParallelism: 1
|
||||
);
|
||||
var overriddenRate = BenchmarkRunner.Run(
|
||||
jobs,
|
||||
FakeEngines(new ConcurrencyProbe(), 0),
|
||||
salvageRate: 0,
|
||||
minimumSalvageDimension: 5,
|
||||
maxParallelism: 1
|
||||
);
|
||||
|
||||
Assert.Equal(
|
||||
60,
|
||||
Assert.Single(sourceRate.Where(result => result.EngineName == "Baseline")).NetSheetArea
|
||||
);
|
||||
Assert.Equal(
|
||||
100,
|
||||
Assert
|
||||
.Single(overriddenRate.Where(result => result.EngineName == "Baseline"))
|
||||
.NetSheetArea
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_RejectsBaselineLayoutOutsideRotationConstraint()
|
||||
{
|
||||
var results = BenchmarkRunner.Run(
|
||||
JobLoader.Load(
|
||||
WriteBaselineNest("invalid-baseline-rotation", partRotation: System.Math.PI)
|
||||
),
|
||||
FakeEngines(new ConcurrencyProbe(), 0),
|
||||
maxParallelism: 1
|
||||
);
|
||||
|
||||
var baseline = Assert.Single(results.Where(result => result.EngineName == "Baseline"));
|
||||
Assert.False(baseline.Valid);
|
||||
Assert.Contains(
|
||||
baseline.Violations,
|
||||
violation => violation.Contains("outside its rotation constraint")
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_RejectsAnInvalidBaselineLayout()
|
||||
{
|
||||
var results = BenchmarkRunner.Run(
|
||||
JobLoader.Load(WriteBaselineNest("invalid-baseline", outsideWorkArea: true)),
|
||||
FakeEngines(new ConcurrencyProbe(), 0),
|
||||
maxParallelism: 1
|
||||
);
|
||||
|
||||
var baseline = Assert.Single(results.Where(result => result.EngineName == "Baseline"));
|
||||
Assert.False(baseline.Valid);
|
||||
Assert.Equal(0, baseline.Utilization);
|
||||
Assert.Contains(
|
||||
baseline.Violations,
|
||||
violation => violation.Contains("outside the work area")
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_DoesNotScoreZeroQuantityBaselinePlate()
|
||||
{
|
||||
var results = BenchmarkRunner.Run(
|
||||
JobLoader.Load(WriteBaselineNest("zero-quantity", plateQuantity: 0)),
|
||||
FakeEngines(new ConcurrencyProbe(), 0),
|
||||
maxParallelism: 1
|
||||
);
|
||||
|
||||
Assert.DoesNotContain(results, result => result.EngineName == "Baseline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_AppliesSpacingOverrideToBaseline()
|
||||
{
|
||||
var results = BenchmarkRunner.Run(
|
||||
JobLoader.Load(
|
||||
WriteBaselineNest("spacing-override", requiredQuantity: 2, partCount: 2),
|
||||
partSpacingOverride: 0.25
|
||||
),
|
||||
FakeEngines(new ConcurrencyProbe(), 0),
|
||||
maxParallelism: 1
|
||||
);
|
||||
|
||||
var baseline = Assert.Single(results.Where(result => result.EngineName == "Baseline"));
|
||||
Assert.False(baseline.Valid);
|
||||
Assert.Contains(
|
||||
baseline.Violations,
|
||||
violation => violation.Contains("closer than the required spacing")
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_NeverExceedsMaxParallelism_ButReachesIt()
|
||||
{
|
||||
|
||||
@@ -357,7 +357,11 @@ to `0`, which also disables credit. With both enabled, only the largest qualifyi
|
||||
full-span edge rectangle outside placed bounding boxes plus part spacing is credited,
|
||||
within the usable work area; both dimensions must meet the minimum in job units.
|
||||
Holes/scraps are not credited. No cut-off toolpath, kerf, handling, or future-demand
|
||||
valuation is modeled. Benchmark ranking credits the same estimate (see Benchmarking Nest Engines).
|
||||
valuation is modeled. Benchmark ranking credits the same estimate (see Benchmarking Nest
|
||||
Engines). For a `.nest` benchmark input, the saved `Nest.SalvageRate` is the benchmark
|
||||
default unless `--salvage-rate` overrides it; manifests default to `0`. A positive
|
||||
`--min-salvage-dimension` is still required for any credit, and the console warns when a
|
||||
nonzero rate would otherwise be disabled.
|
||||
|
||||
This is a tested deterministic heuristic baseline, **not an optimal or production-
|
||||
certified solver**. Conservative rectangular free-region hints and linear fills can
|
||||
@@ -370,6 +374,13 @@ Geometry acceptance remains strict, including open marks leaving closed material
|
||||
|
||||
Benchmark export example (use a separate output directory):
|
||||
|
||||
For `.nest` inputs the report includes a `Baseline` row before the engine rows. It validates
|
||||
the saved authored plates (including positive plate repeat quantities) with the same
|
||||
bounds, spacing, quantity, and rotation checks as generated layouts; it is not timed as
|
||||
an engine solve. Its score uses the same salvage-credit and unplaced-part-cost calculation,
|
||||
with `--spacing` and salvage CLI overrides applied without mutating the source. Manifest
|
||||
inputs have no baseline row.
|
||||
|
||||
```bash
|
||||
dotnet run --project OpenNest.Benchmark -- input.nest \
|
||||
--engines StockLadder --sheet-sizes 48x96,48x120,48x144,60x96,60x120,60x144,72x96,72x120,72x144 \
|
||||
|
||||
Reference in New Issue
Block a user