mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-15 15:51:38 -04:00
fix(engine): let clamp recovery move on to another stock with sheets left
Each blocked requirement was tied to the one stock where it needed the fewest removals; once that stock's finite pool ran out the part stayed unplaced even with another offered stock untouched. Recovery now runs in rounds: a blocked part with demand left takes its best removal among the stocks it has not tried that still have sheets (that stock's own minimal run, C11). A variant used in several rounds stays one stock entry. PM bf4438ac-929a-405f-ba26-fcfaa9b1c55e
This commit is contained in:
1 parent
360f58c101
commit
64ebda2f6f
2 files changed
+121
-70
No files matched your search
@@ -307,4 +307,30 @@ public class ClampRemovalRepairTests
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -87,89 +87,109 @@ internal static class ClampRemovalRecovery
|
||||
if (blocked.Count == 0)
|
||||
return null;
|
||||
|
||||
// C11: per blocked part, the fewest clamps (smallest contiguous run, nearest the
|
||||
// origin on ties) whose removal frees an empty sheet of some offered stock. Parts
|
||||
// needing the same removal on the same stock share one variant.
|
||||
var groups = new List<RemovalGroup>();
|
||||
foreach (var part in blocked)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var best = BestRemoval(part, availableStock, job.Options, lanes, budget, token);
|
||||
if (best == null)
|
||||
continue; // Unfit even with every clamp removed: stays unplaced, as today.
|
||||
var group = groups.FirstOrDefault(g => g.Removal.Stock.Id == best.Stock.Id);
|
||||
if (group == null)
|
||||
groups.Add(group = new RemovalGroup(best));
|
||||
group.BlockedIds.Add(part.Id);
|
||||
}
|
||||
if (groups.Count == 0)
|
||||
return null;
|
||||
|
||||
// Phase 2, one removal variant at a time, fewest removed clamps first: each variant
|
||||
// takes its own blocked parts plus any other unmet demand that is not blocked (freed
|
||||
// space may take other parts, C12). A sheet is kept only when it holds a part that
|
||||
// needed exactly this removal (C10/C11 per sheet), and every kept sheet comes out of
|
||||
// its base stock's remaining physical pool (C12: a finite pool is never exceeded).
|
||||
// Phase 2 state. Each kept sheet comes out of its base stock's remaining physical
|
||||
// pool and the plate cap (C12: a finite pool is never exceeded).
|
||||
var unmet = remaining.ToDictionary(x => x.Part.Id, x => x.Unplaced, StringComparer.Ordinal);
|
||||
var blockedIds = new HashSet<string>(groups.SelectMany(g => g.BlockedIds), StringComparer.Ordinal);
|
||||
var blockedIds = new HashSet<string>(blocked.Select(p => p.Id), StringComparer.Ordinal);
|
||||
var pool = availableStock.ToDictionary(
|
||||
s => s.Id,
|
||||
s => s.Quantity is int q ? q - raw.StockUsage.Where(u => u.StockId == s.Id).Sum(u => u.Used) : (int?)null,
|
||||
StringComparer.Ordinal);
|
||||
int? platesLeft = job.Options.MaxPlates is int cap ? cap - raw.Plates.Count : null;
|
||||
|
||||
var tried = blocked.ToDictionary(p => p.Id, _ => new HashSet<string>(StringComparer.Ordinal), StringComparer.Ordinal);
|
||||
var recovered = new List<(NestPlateStock Variant, string BaseId, List<NestJobPlateResult> Sheets)>();
|
||||
foreach (var group in groups.OrderBy(g => g.Removal.RemovedIds.Count))
|
||||
|
||||
// Rounds: each blocked part with demand left takes its best removal among the offered
|
||||
// stocks it has not tried that still have sheets, so a part whose preferred stock runs
|
||||
// out moves on to another stock (with that stock's own minimal removal).
|
||||
for (var round = 0; round < availableStock.Count && platesLeft is not <= 0; round++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var baseId = group.Removal.BaseStockId;
|
||||
var available = pool[baseId];
|
||||
if (available <= 0 || platesLeft <= 0)
|
||||
continue;
|
||||
int? sheetCap = (available, platesLeft) switch
|
||||
{
|
||||
(int a, int p) => System.Math.Min(a, p),
|
||||
(int a, null) => a,
|
||||
(null, int p) => p,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var parts = remaining
|
||||
.Where(x => unmet[x.Part.Id] > 0
|
||||
&& (group.BlockedIds.Contains(x.Part.Id) || !blockedIds.Contains(x.Part.Id)))
|
||||
.Select(x => new NestJobPart(x.Part.Id, x.Part.Geometry, unmet[x.Part.Id], x.Part.Priority, x.Part.Rotation))
|
||||
.ToList();
|
||||
if (!parts.Any(p => group.BlockedIds.Contains(p.Id)))
|
||||
continue;
|
||||
|
||||
var variant = Clone(group.Removal.Stock, group.Removal.Stock.Id, sheetCap, null);
|
||||
var options = new NestJobOptions(job.Options.PlacementStrategy, platesLeft,
|
||||
job.Options.SalvageRate, job.Options.MinimumSalvageDimension);
|
||||
var groupJob = new NestJob(parts, new[] { variant }, options);
|
||||
|
||||
List<NestJobPlateResult> kept = null;
|
||||
foreach (var lane in lanes)
|
||||
// C11: per blocked part, the fewest clamps (smallest contiguous run, nearest the
|
||||
// origin on ties) whose removal frees an empty sheet of one of those stocks.
|
||||
// Parts needing the same removal on the same stock share one variant.
|
||||
var groups = new List<RemovalGroup>();
|
||||
foreach (var part in blocked)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var solved = lane.Solve(groupJob, null, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
kept = solved?.Plates
|
||||
.Where(sheet => sheet.Placements.Any(p => group.BlockedIds.Contains(p.PartId)))
|
||||
.Take(sheetCap ?? int.MaxValue)
|
||||
if (unmet[part.Id] <= 0)
|
||||
continue;
|
||||
var usable = availableStock
|
||||
.Where(s => pool[s.Id] is not <= 0 && !tried[part.Id].Contains(s.Id))
|
||||
.ToList();
|
||||
if (kept is { Count: > 0 })
|
||||
break;
|
||||
if (usable.Count == 0)
|
||||
continue;
|
||||
var best = BestRemoval(part, usable, job.Options, lanes, budget, token);
|
||||
if (best == null)
|
||||
{
|
||||
// Unfit on all of them even with every clamp removed: stays unplaced.
|
||||
tried[part.Id].UnionWith(usable.Select(s => s.Id));
|
||||
continue;
|
||||
}
|
||||
tried[part.Id].Add(best.BaseStockId);
|
||||
var group = groups.FirstOrDefault(g => g.Removal.Stock.Id == best.Stock.Id);
|
||||
if (group == null)
|
||||
groups.Add(group = new RemovalGroup(best));
|
||||
group.BlockedIds.Add(part.Id);
|
||||
}
|
||||
if (kept is not { Count: > 0 })
|
||||
continue;
|
||||
if (groups.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var placement in kept.SelectMany(sheet => sheet.Placements))
|
||||
unmet[placement.PartId]--;
|
||||
if (pool[baseId] is int left)
|
||||
pool[baseId] = left - kept.Count;
|
||||
if (platesLeft is int plates)
|
||||
platesLeft = plates - kept.Count;
|
||||
recovered.Add((group.Removal.Stock, baseId, kept));
|
||||
// One removal variant at a time, fewest removed clamps first: each variant takes
|
||||
// its own blocked parts plus any other unmet demand that is not blocked (freed
|
||||
// space may take other parts, C12). A sheet is kept only when it holds a part
|
||||
// that needed exactly this removal (C10/C11 per sheet).
|
||||
foreach (var group in groups.OrderBy(g => g.Removal.RemovedIds.Count))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var baseId = group.Removal.BaseStockId;
|
||||
var available = pool[baseId];
|
||||
if (available <= 0 || platesLeft <= 0)
|
||||
continue;
|
||||
int? sheetCap = (available, platesLeft) switch
|
||||
{
|
||||
(int a, int p) => System.Math.Min(a, p),
|
||||
(int a, null) => a,
|
||||
(null, int p) => p,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var parts = remaining
|
||||
.Where(x => unmet[x.Part.Id] > 0
|
||||
&& (group.BlockedIds.Contains(x.Part.Id) || !blockedIds.Contains(x.Part.Id)))
|
||||
.Select(x => new NestJobPart(x.Part.Id, x.Part.Geometry, unmet[x.Part.Id], x.Part.Priority, x.Part.Rotation))
|
||||
.ToList();
|
||||
if (!parts.Any(p => group.BlockedIds.Contains(p.Id)))
|
||||
continue;
|
||||
|
||||
var variant = Clone(group.Removal.Stock, group.Removal.Stock.Id, sheetCap, null);
|
||||
var options = new NestJobOptions(job.Options.PlacementStrategy, platesLeft,
|
||||
job.Options.SalvageRate, job.Options.MinimumSalvageDimension);
|
||||
var groupJob = new NestJob(parts, new[] { variant }, options);
|
||||
|
||||
List<NestJobPlateResult> kept = null;
|
||||
foreach (var lane in lanes)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var solved = lane.Solve(groupJob, null, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
kept = solved?.Plates
|
||||
.Where(sheet => sheet.Placements.Any(p => group.BlockedIds.Contains(p.PartId)))
|
||||
.Take(sheetCap ?? int.MaxValue)
|
||||
.ToList();
|
||||
if (kept is { Count: > 0 })
|
||||
break;
|
||||
}
|
||||
if (kept is not { Count: > 0 })
|
||||
continue;
|
||||
|
||||
foreach (var placement in kept.SelectMany(sheet => sheet.Placements))
|
||||
unmet[placement.PartId]--;
|
||||
if (pool[baseId] is int left)
|
||||
pool[baseId] = left - kept.Count;
|
||||
if (platesLeft is int plates)
|
||||
platesLeft = plates - kept.Count;
|
||||
recovered.Add((group.Removal.Stock, baseId, kept));
|
||||
}
|
||||
}
|
||||
if (recovered.Count == 0)
|
||||
return null;
|
||||
@@ -363,6 +383,11 @@ internal static class ClampRemovalRecovery
|
||||
IReadOnlyList<(NestPlateStock Variant, string BaseId, List<NestJobPlateResult> Sheets)> recovered
|
||||
)
|
||||
{
|
||||
// A variant used in more than one round is one stock entry with all its sheets.
|
||||
recovered = recovered
|
||||
.GroupBy(r => r.Variant.Id, StringComparer.Ordinal)
|
||||
.Select(g => (g.First().Variant, g.First().BaseId, g.SelectMany(r => r.Sheets).ToList()))
|
||||
.ToList();
|
||||
var placedSoFar = raw.Fulfillment.ToDictionary(f => f.PartId, f => f.Placed, StringComparer.Ordinal);
|
||||
var nextPlateIndex = raw.Plates.Count;
|
||||
var reindexed = new List<NestJobPlateResult>();
|
||||
|
||||
Reference in new issue
Block a user