Files
OpenNest/OpenNest.Engine.Tests/Jobs/ClampRemovalRepairTests.cs
T
aj a1914e147a fix(engine): reuse clamp-removal probes across identical stock
Offering several identical sheets (same size, spacing and clamps) multiplied
every feasibility probe, so eight full-sheet parts on four 48x120 options
with the eight-clamp rail spent the 1000-probe budget and recovered nothing
although each part fits with its clamps removed. A probe's answer is now
reused for the same part, engine and physically identical stock.

Delta review (s3b-delta-review.md) policy gate 3; PM 0e655fdb-a99a-40d8-968a-1863d8068c3e
2026-10-13 18:15:50 -04:00

363 lines
17 KiB
C#

using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Engine.Tests.NestingEngines;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Slice 6 task 6.4: regressions for the independent Slice 3b reviews of last-resort clamp
/// removal (D8/D9, C10-C13). Geometry: a 23"-long, 3"-wide sheet with 1.5 x 0.625 clamps
/// centred at X = 6 and X = 21 (clamp-1 spans X [5.25, 6.75], clamp-2 [20.25, 21.75]); a
/// 3"-tall part always crosses the clamp band, a 14"-wide part fits only with one clamp
/// removed, a 22"-wide part only with both removed.
/// </summary>
public class ClampRemovalRepairTests
{
private static ClampZone[] Zones() => new[]
{
new ClampZone("clamp-1", new Box(5.25, 0, 1.5, 0.625)),
new ClampZone("clamp-2", new Box(20.25, 0, 1.5, 0.625)),
};
private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 });
/// <summary>Stock resolved from the machine rail (as NestStockBuilder builds it).</summary>
private static NestPlateStock Stock(int? quantity = null) =>
new("sheet", new Size(3, 23), quantity, 0, default, 1, null, Zones()) { ClampRail = Rail };
/// <summary>The same zones given explicitly, with no rail behind them.</summary>
private static NestPlateStock ExplicitStock() =>
JobBuilder.Stock("sheet", width: 3, length: 23, clampZones: Zones());
private static NestItem Item(string name, double w, double h, int quantity) =>
new() { Drawing = new Drawing(name, TestDrawingFactory.Rectangle(w, h)), Quantity = quantity };
private static NestPipelineRequest Request(NestPlateStock stock, params NestItem[] items) =>
new("Rectangles", items, new[] { stock });
/// <summary>Delegates the ordinary solve to Rectangles, then fails every later call.</summary>
private sealed class ProbeThrowEngine : INestingEngine
{
public int Calls;
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
if (++Calls > 1)
throw new InvalidOperationException("feasibility probe failed");
return new RectanglesNestingEngine().Solve(job, progress, token);
}
}
/// <summary>Solves with Rectangles and cancels the run on its second call.</summary>
private sealed class CancelOnSecondCallEngine(CancellationTokenSource source) : INestingEngine
{
private int calls;
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
var result = new RectanglesNestingEngine().Solve(job, progress, token);
if (++calls == 2)
source.Cancel();
return result;
}
}
// PM 1db4333f: an optional recovery probe failure must not discard the ordinary result.
[Fact]
public void FailingRecoveryProbeKeepsTheOrdinaryResult()
{
var engine = new ProbeThrowEngine();
var request = Request(Stock(), Item("wide", 14, 3, 1), Item("small", 2, 1, 1));
var result = NestPipeline.Run(engine, "probe-throw", request);
Assert.True(engine.Calls > 1, "recovery never probed");
Assert.True(result.IsValid, string.Join("; ", result.Violations));
// Items map to part ids in request order: part-0 = wide (blocked), part-1 = small.
Assert.Equal(1, Assert.Single(result.Raw.Fulfillment, f => f.PartId == "part-0").Unplaced);
Assert.Equal(0, Assert.Single(result.Raw.Fulfillment, f => f.PartId == "part-1").Unplaced);
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
}
[Fact]
public void CancellationDuringRecoveryStillCancelsTheRun()
{
using var source = new CancellationTokenSource();
var request = Request(Stock(), Item("wide", 14, 3, 1), Item("small", 2, 1, 1));
Assert.ThrowsAny<OperationCanceledException>(() =>
NestPipeline.Run(new CancelOnSecondCallEngine(source), "cancel", request, token: source.Token));
}
// PM 346d46ed: explicit zones have no rail to name a removed clamp or its time.
[Fact]
public void ExplicitZonesWithoutARailAreNeverRemoved()
{
var result = NestPipeline.Run(Request(ExplicitStock(), Item("wide", 14, 3, 1)));
Assert.NotEqual(NestJobStatus.Complete, result.Status);
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
}
[Fact]
public void RailStockRemovalIsNamedOnACommittedPlateWithoutARail()
{
// The stock comes from a rail-following template; the commit target is an empty
// sheet holding the same zones explicitly. The removal must still be recorded.
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
var template = new Plate(new Size(3, 23)) { EdgeSpacing = new Spacing(0, 0, 0, 0) };
template.SetClampRail(Rail);
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(3, 23);
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
var target = nest.CreatePlate();
foreach (var zone in Zones())
target.ClampZones.Add(zone);
Assert.Null(target.ClampRail);
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, NestStockBuilder.SinglePlate(template)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var plate = Assert.Single(NestPipelineCommit.ApplyToEmptyPlates(result, manager));
Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps);
Assert.NotNull(plate.ClampRail);
Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes);
}
// PM 6ded8ac4 (d): the nearest-origin tie-break follows distance along the edge, not
// the order the zones happen to be listed in.
[Fact]
public void TieBreakPrefersTheClampNearestTheOriginWhateverTheZoneOrder()
{
var reversed = new NestPlateStock("sheet", new Size(3, 23), null, 0, default, 1, null,
Zones().Reverse().ToArray())
{ ClampRail = Rail };
var result = NestPipeline.Run(Request(reversed, Item("wide", 14, 3, 1)));
Assert.Equal(NestJobStatus.Complete, result.Status);
var sheet = Assert.Single(result.Raw.Plates);
Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1");
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
}
// PM c5984c6e (C10): one lane's failure to place is not proof the part cannot fit. The
// fixed-rotation L below fits at the origin with its notch over the clamp (an explicit
// witness passes the layout check), but Rectangles packs its 10 x 10 bounding box and
// cannot place it; no clamp may be removed for it.
[Fact]
public void PartThatFitsClampedInSomeLaneNeverRemovesAClamp()
{
var program = new OpenNest.CNC.Program();
program.MoveTo(0, 0);
program.LineTo(2, 0);
program.LineTo(2, 8);
program.LineTo(10, 8);
program.LineTo(10, 10);
program.LineTo(0, 10);
program.LineTo(0, 0);
var rail = new ClampRail(1.5, 0.625, new[] { 5.75 }); // clamp-1 spans X [5, 6.5]
var size = new Size(10, 10);
var stock = new NestPlateStock("sheet", size, null, 0, default, 1, null, rail.ResolveFor(size, 1))
{ ClampRail = rail };
var drawing = new Drawing("notched", program);
var witnessJob = new NestJob(new[] { DrawingJobMapper.FromItem("p", new NestItem { Drawing = drawing, Quantity = 1 }) },
new[] { stock });
var witness = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
Assert.Empty(NestLayoutCheck.Violations(witnessJob, witness)); // fits with the clamp in place
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { stock }));
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
Assert.All(result.Raw.Plates, sheet => Assert.Single(sheet.Stock.ClampZones));
}
private static int RemovedCount(NestJobPlateResult sheet) => 2 - sheet.Stock.ClampZones.Count;
// PM f91e054a (C11): minimality holds per sheet. part-0 (22 wide) needs both clamps off,
// part-1 (14 wide) only clamp-1; the sheet holding only part-1 must keep clamp-2.
[Fact]
public void EachSheetRemovesOnlyTheClampsItsOwnPartsNeed()
{
var result = NestPipeline.Run(Request(Stock(), Item("widest", 22, 3, 1), Item("wide", 14, 3, 1)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Raw.Plates.Count);
foreach (var sheet in result.Raw.Plates)
{
var needsBoth = sheet.Placements.Any(p => p.PartId == "part-0");
Assert.Equal(needsBoth ? 2 : 1, RemovedCount(sheet));
if (!needsBoth)
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
}
}
// PM 245a555f (C12): reduced-clamp sheets come out of the same finite pool.
[Theory]
[InlineData(3, false)] // three blocked parts, one physical sheet
[InlineData(1, true)] // the ordinary part already used the only sheet
public void RemovalNeverInventsSheetsBeyondTheFiniteStock(int wide, bool small)
{
var items = new List<NestItem> { Item("wide", 14, 3, wide) };
if (small)
items.Add(Item("small", 2, 1, 1));
var result = NestPipeline.Run(Request(Stock(quantity: 1), items.ToArray()));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Single(result.Raw.Plates);
Assert.Equal(1, result.Raw.StockUsage.Sum(u => u.Used));
Assert.Equal(1, result.Job.Plates.Sum(s => s.Quantity ?? int.MaxValue));
Assert.All(result.Raw.StockUsage, u => Assert.True(u.Remaining is null or >= 0));
}
/// <summary>Never places anything; counts how often recovery asks it to.</summary>
private sealed class CountingNoFitEngine : INestingEngine
{
public int Calls;
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
Calls++;
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.NoPlacementFound,
Array.Empty<NestJobPlateResult>(),
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)).ToList(),
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)).ToList());
}
}
private static NestPlateStock WideRailStock(string id, ClampRail rail)
{
var size = new Size(3, 23);
return new NestPlateStock(id, size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail };
}
// PM 0e655fdb: parts that cannot fit even with every clamp removed are dismissed by one
// all-clamps-removed probe per stock instead of a search over every clamp run.
[Fact]
public void PartsThatNeverFitAreDismissedWithoutSearchingClampRuns()
{
var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 30, h: 3, count: 1)).ToArray();
var job = new NestJob(parts, stocks);
var engine = new CountingNoFitEngine();
var raw = engine.Solve(job);
engine.Calls = 0;
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
Assert.Null(outcome);
// One feasibility probe per part plus one all-clamps-removed probe per part and stock.
Assert.True(engine.Calls <= 25 + 25 * 4, $"recovery made {engine.Calls} solves");
}
// PM 0e655fdb: the search has a fixed budget; running out keeps the ordinary result.
[Fact]
public void RecoveryStopsAtItsProbeBudget()
{
// 23-wide parts fit only with all 20 clamps off: every smaller run is probed first.
var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 23, h: 3, count: 1)).ToArray();
var job = new NestJob(parts, stocks);
var engine = new CountingNoFitEngine();
var raw = engine.Solve(job);
engine.Calls = 0;
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
Assert.Null(outcome);
Assert.True(engine.Calls <= ClampRemovalRecovery.MaxProbeSolves, $"recovery made {engine.Calls} solves");
}
// PM 60f7a673: the removal is recorded whatever the empty target sheet already holds.
[Theory]
[InlineData("reduced-explicit")] // no rail, already exactly the solved reduced zones
[InlineData("other-rail")] // follows a different rail that cannot recreate them
public void CommittedRemovalIsRecordedOnAnyEmptyTarget(string target)
{
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
var nest = new Nest();
nest.Drawings.Add(drawing);
nest.PlateDefaults.Size = new Size(3, 23);
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
var plate = nest.CreatePlate();
if (target == "reduced-explicit")
plate.ClampZones.Add(Zones()[1]);
else
plate.SetClampRail(new ClampRail(2, 0.625, new[] { 6.0, 21.0 }));
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(Request(Stock(), Item("wide", 14, 3, 1)));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var applied = Assert.Single(NestPipelineCommit.ApplyToEmptyPlates(result, manager));
Assert.Same(plate, applied);
Assert.Same(Rail, applied.ClampRail);
Assert.Equal(new[] { "clamp-1" }, applied.RemovedClamps);
Assert.Equal(new[] { "clamp-2" }, applied.ClampZones.Select(z => z.Id));
Assert.Equal(ClampRail.DefaultRemovalMinutes, applied.ClampRemovalMinutes);
}
// PM bf4438ac: when one offered stock runs out, a blocked part may still use another
// offered stock that has sheets left (each with its own minimal removal).
[Theory]
[InlineData(2, false)] // two blocked copies: one on A, one on B
[InlineData(1, true)] // the ordinary part took A; the blocked part goes to B
public void RecoveryMovesOnToAnotherStockWithSheetsLeft(int wide, bool small)
{
NestPlateStock Sheet(string id, double length)
{
var size = new Size(3, length);
return new NestPlateStock(id, size, 1, 0, default, 1, null, Rail.ResolveFor(size, 1)) { ClampRail = Rail };
}
var items = new List<NestItem> { Item("wide", 14, 3, wide) };
if (small)
items.Add(Item("small", 2, 1, 1));
var request = new NestPipelineRequest("Rectangles", items, new[] { Sheet("A", 23), Sheet("B", 24) },
new NestJobOptions(maxPlates: 2));
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Raw.Plates.Count);
Assert.All(result.Raw.Plates.Where(p => p.StockId.Contains("removed")), p => Assert.Single(p.Stock.ClampZones));
Assert.Equal(2, result.Raw.StockUsage.Sum(u => u.Used));
}
// Delta review policy gate 3: identical offered stock (four 48x120 sheets with the same
// eight-clamp rail) must not multiply the probe count, or a moderate job of full-sheet
// parts runs out of budget although every part fits with its clamps removed.
[Fact]
public void IdenticalStockOptionsShareTheirProbes()
{
var rail = new ClampRail(1.5, 0.625, new[] { 6.0, 21.0, 36.0, 51.0, 66.0, 81.0, 96.0, 111.0 });
var size = new Size(48, 120);
var stocks = Enumerable.Range(0, 4)
.Select(i => new NestPlateStock($"s{i}", size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail })
.ToArray();
var parts = Enumerable.Range(0, 8)
.Select(i => JobBuilder.Rectangle($"full-{i}", w: 120, h: 48, count: 1, x: i * 1e-3))
.ToArray();
var job = new NestJob(parts, stocks);
var engine = new OpenNest.Engine.NestingEngines.Default.DefaultNestingEngine();
var raw = engine.Solve(job);
Assert.Empty(raw.Plates);
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
Assert.NotNull(outcome);
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
Assert.Equal(8, outcome.Result.Plates.Count);
Assert.All(outcome.Result.Plates, sheet => Assert.Empty(sheet.Stock.ClampZones));
}
}