feat(engine): select from mixed plate inventory
This commit is contained in:
@@ -105,11 +105,12 @@ public class FiniteStockJobTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MixedStockIsNotSilentlyIgnored()
|
public void MixedStockCanBeEvaluated()
|
||||||
{
|
{
|
||||||
var job = Job();
|
var job = Job();
|
||||||
var mixed = new NestJob(job.Parts, job.Plates.Concat(new[] { new NestPlateStock("other", new Size(10, 20), 2) }));
|
var mixed = new NestJob(job.Parts, job.Plates.Concat(new[] { new NestPlateStock("other", new Size(10, 20), 2) }));
|
||||||
Assert.Throws<NotSupportedException>(() => new NestJobRunner(_ => new Nester(One)).Solve(mixed));
|
var result = new NestJobRunner(_ => new Nester(One)).Solve(mixed);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
public class NestJobStockSelectionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void LaterFittingStockWinsWhenFirstStockCannotPlace()
|
||||||
|
{
|
||||||
|
var result = Solve(new[] { Part("p", 1) }, new[] { Stock("small", 10, 10, 1), Stock("large", 20, 20, 1) },
|
||||||
|
request => request.Stock.Id == "large" ? Candidate(request, "p") : Empty());
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal("large", Assert.Single(result.Plates).StockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExhaustedLargeStockIsNotRecreatedWhileSmallerStockServesSmallParts()
|
||||||
|
{
|
||||||
|
var result = Solve(new[] { Part("large", 1, 0), Part("small", 2, 1) },
|
||||||
|
new[] { Stock("large", 20, 20, 1), Stock("small", 10, 10, 2) }, request => request.Stock.Id switch
|
||||||
|
{
|
||||||
|
"large" when request.Parts.Any(part => part.Id == "large") => Candidate(request, "large"),
|
||||||
|
"small" when request.Parts.Any(part => part.Id == "small") => Candidate(request, "small"),
|
||||||
|
_ => Empty()
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "large", "small", "small" }, result.Plates.Select(plate => plate.StockId));
|
||||||
|
Assert.Collection(result.StockUsage,
|
||||||
|
usage => Assert.Equal(new StockUsage("large", 1, 0), usage),
|
||||||
|
usage => Assert.Equal(new StockUsage("small", 2, 0), usage));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EqualDimensionsWithDifferentStockIdsRemainIndependent()
|
||||||
|
{
|
||||||
|
var result = Solve(new[] { Part("p", 2) }, new[] { Stock("first", 10, 10, 1), Stock("second", 10, 10, 1) },
|
||||||
|
request => Candidate(request, "p"));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "first", "second" }, result.Plates.Select(plate => plate.StockId));
|
||||||
|
Assert.Equal(new[] { new StockUsage("first", 1, 0), new StockUsage("second", 1, 0) }, result.StockUsage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LosingTrialsDoNotConsumeStockPartsOrDrawingCounters()
|
||||||
|
{
|
||||||
|
var calls = new List<(string Stock, int Quantity)>();
|
||||||
|
var result = Solve(new[] { Part("p", 2) }, new[] { Stock("wide", 20, 20, 2), Stock("narrow", 10, 10, 2) }, request =>
|
||||||
|
{
|
||||||
|
calls.Add((request.Stock.Id, request.Parts.Single().Quantity));
|
||||||
|
return request.Stock.Id == "wide" ? Candidate(request, "p", 0, 100) : Candidate(request, "p", 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "narrow", "narrow" }, result.Plates.Select(plate => plate.StockId));
|
||||||
|
Assert.Equal(new[] { ("wide", 2), ("narrow", 2), ("wide", 1), ("narrow", 1) }, calls);
|
||||||
|
Assert.Equal(new StockUsage("wide", 0, 2), result.StockUsage[0]);
|
||||||
|
Assert.Equal(new StockUsage("narrow", 2, 0), result.StockUsage[1]);
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.Plates.SelectMany(plate => plate.Placements).Select(placement => placement.InstanceIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CandidatePriorityAreaEnvelopeAndInputOrderAreComparedInDocumentedOrder()
|
||||||
|
{
|
||||||
|
var priority = Solve(new[] { Part("high", 1, 0), Part("low", 1, 1) }, new[] { Stock("a", 10, 10, 1), Stock("b", 10, 10, 1) },
|
||||||
|
request => request.Stock.Id == "a" ? Candidate(request, "low") : Candidate(request, "high"));
|
||||||
|
var area = Solve(new[] { Part("p", 1) }, new[] { Stock("large", 20, 20, 1), Stock("small", 10, 10, 1) },
|
||||||
|
request => Candidate(request, "p"));
|
||||||
|
var envelope = Solve(new[] { Part("p", 2) }, new[] { Stock("a", 10, 10, 1), Stock("b", 10, 10, 1) },
|
||||||
|
request => request.Stock.Id == "a" ? CandidatePair("p", 0, 10) : CandidatePair("p", 0, 1));
|
||||||
|
var inputOrder = Solve(new[] { Part("p", 1) }, new[] { Stock("first", 10, 10, 1), Stock("second", 10, 10, 1) },
|
||||||
|
request => Candidate(request, "p"));
|
||||||
|
|
||||||
|
Assert.Equal("b", priority.Plates[0].StockId);
|
||||||
|
Assert.Equal("small", area.Plates[0].StockId);
|
||||||
|
Assert.Equal("b", envelope.Plates[0].StockId);
|
||||||
|
Assert.Equal("first", inputOrder.Plates[0].StockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnlimitedStockStopsWhenDemandIsFulfilledAndPlateLimitLeavesLeftovers()
|
||||||
|
{
|
||||||
|
var unlimited = Solve(new[] { Part("p", 2) }, new[] { Stock("u", 10, 10, null) }, request => Candidate(request, "p"));
|
||||||
|
var limited = Solve(new[] { Part("p", 3) }, new[] { Stock("u", 10, 10, null) }, request => Candidate(request, "p"), new NestJobOptions(maxPlates: 2));
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStopReason.Completed, unlimited.StopReason);
|
||||||
|
Assert.Equal(2, unlimited.Plates.Count);
|
||||||
|
Assert.Equal(NestJobStopReason.PlateLimitReached, limited.StopReason);
|
||||||
|
Assert.Equal(new PartFulfillment("p", 3, 2, 1), Assert.Single(limited.Fulfillment));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestJobResult Solve(IEnumerable<NestJobPart> parts, IEnumerable<NestPlateStock> stock,
|
||||||
|
Func<PlatePlacementRequest, PlateCandidate> place, NestJobOptions? options = null) =>
|
||||||
|
new NestJobRunner(_ => new Nester(place)).Solve(new NestJob(parts, stock, options));
|
||||||
|
|
||||||
|
private static NestJobPart Part(string id, int quantity, int priority = 0) =>
|
||||||
|
new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), quantity, priority);
|
||||||
|
|
||||||
|
private static NestPlateStock Stock(string id, double width, double length, int? quantity) =>
|
||||||
|
new(id, new Size(width, length), quantity);
|
||||||
|
|
||||||
|
private static PlateCandidate Candidate(PlatePlacementRequest request, string id, double firstX = 0, double secondX = 0)
|
||||||
|
{
|
||||||
|
return new PlateCandidate(new[] { new NestJobPlacement(id, 0, firstX, 0, 0) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlateCandidate CandidatePair(string id, double first, double second) => new(new[]
|
||||||
|
{
|
||||||
|
new NestJobPlacement(id, 0, first, first, 0),
|
||||||
|
new NestJobPlacement(id, 1, second, second, 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
private static PlateCandidate Empty() => new(Array.Empty<NestJobPlacement>());
|
||||||
|
|
||||||
|
private sealed class Nester(Func<PlatePlacementRequest, PlateCandidate> place) : IPlateNester
|
||||||
|
{
|
||||||
|
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default) => place(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>Ranks independent plate trials: priority fulfillment, sheet area, placement envelope, then input order.</summary>
|
||||||
|
public sealed class NestJobCandidateComparer
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<NestJobPart> parts;
|
||||||
|
|
||||||
|
public NestJobCandidateComparer(IReadOnlyList<NestJobPart> parts)
|
||||||
|
{
|
||||||
|
this.parts = parts ?? throw new ArgumentNullException(nameof(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns positive when the left trial is preferred.</summary>
|
||||||
|
public int Compare(PlateCandidate left, NestPlateStock leftStock, int leftIndex,
|
||||||
|
PlateCandidate right, NestPlateStock rightStock, int rightIndex)
|
||||||
|
{
|
||||||
|
var priorities = parts.Select(part => part.Priority).Distinct().OrderBy(priority => priority);
|
||||||
|
foreach (var priority in priorities)
|
||||||
|
{
|
||||||
|
var leftCount = Count(left, priority);
|
||||||
|
var rightCount = Count(right, priority);
|
||||||
|
if (leftCount != rightCount) return leftCount.CompareTo(rightCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
var area = Area(rightStock).CompareTo(Area(leftStock));
|
||||||
|
if (area != 0) return area;
|
||||||
|
|
||||||
|
var envelope = Envelope(right).CompareTo(Envelope(left));
|
||||||
|
if (envelope != 0) return envelope;
|
||||||
|
|
||||||
|
return rightIndex.CompareTo(leftIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int Count(PlateCandidate candidate, int priority) => candidate.Placements.Count(placement =>
|
||||||
|
parts.First(part => part.Id == placement.PartId).Priority == priority);
|
||||||
|
|
||||||
|
private static double Area(NestPlateStock stock) => stock.Size.Width * stock.Size.Length;
|
||||||
|
|
||||||
|
private static double Envelope(PlateCandidate candidate)
|
||||||
|
{
|
||||||
|
if (candidate.Placements.Count == 0) return 0;
|
||||||
|
var xs = candidate.Placements.Select(placement => placement.X);
|
||||||
|
var ys = candidate.Placements.Select(placement => placement.Y);
|
||||||
|
return (xs.Max() - xs.Min()) * (ys.Max() - ys.Min());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ using System.Threading;
|
|||||||
namespace OpenNest;
|
namespace OpenNest;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Single-stock physical-sheet allocation. Candidate accounting is validated before commit;
|
/// Physical-sheet allocation. Every available stock entry is tried independently and only the selected
|
||||||
/// full geometry, clearance, and rotation-policy validation is not implemented yet.
|
/// candidate changes demand or inventory accounting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NestJobRunner : INestingEngine
|
public sealed class NestJobRunner : INestingEngine
|
||||||
{
|
{
|
||||||
@@ -27,56 +27,72 @@ public sealed class NestJobRunner : INestingEngine
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
NestJobValidator.Validate(job);
|
NestJobValidator.Validate(job);
|
||||||
var plates = new List<NestJobPlateResult>();
|
var plates = new List<NestJobPlateResult>();
|
||||||
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
|
var remaining = job.Parts.ToDictionary(part => part.Id, part => part.Quantity, StringComparer.Ordinal);
|
||||||
var placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal);
|
var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal);
|
||||||
|
var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal);
|
||||||
|
var comparer = new NestJobCandidateComparer(job.Parts);
|
||||||
|
var nester = job.Parts.Count == 0 ? null : plateNesterFactory(job.Options.PlacementStrategy) ??
|
||||||
|
throw new NotSupportedException($"Unknown placement strategy: {job.Options.PlacementStrategy}.");
|
||||||
var reason = NestJobStopReason.Completed;
|
var reason = NestJobStopReason.Completed;
|
||||||
if (job.Parts.Count != 0)
|
while (remaining.Values.Any(count => count > 0))
|
||||||
{
|
{
|
||||||
var nester = plateNesterFactory(job.Options.PlacementStrategy) ??
|
token.ThrowIfCancellationRequested();
|
||||||
throw new NotSupportedException($"Unknown placement strategy: {job.Options.PlacementStrategy}.");
|
if (job.Options.MaxPlates <= plates.Count)
|
||||||
var stock = job.Plates.SingleOrDefault();
|
|
||||||
while (remaining.Values.Any(count => count > 0))
|
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
reason = NestJobStopReason.PlateLimitReached;
|
||||||
if (stock == null || stock.Quantity <= plates.Count)
|
break;
|
||||||
{
|
}
|
||||||
reason = NestJobStopReason.StockExhausted;
|
|
||||||
break;
|
CandidateTrial winner = null;
|
||||||
}
|
var hasAvailableStock = false;
|
||||||
if (job.Options.MaxPlates <= plates.Count)
|
for (var index = 0; index < job.Plates.Count; index++)
|
||||||
{
|
{
|
||||||
reason = NestJobStopReason.PlateLimitReached;
|
var stock = job.Plates[index];
|
||||||
break;
|
if (stock.Quantity is int quantity && used[stock.Id] >= quantity) continue;
|
||||||
}
|
hasAvailableStock = true;
|
||||||
var request = new PlatePlacementRequest(stock, job.Parts.Where(p => remaining[p.Id] > 0)
|
var request = new PlatePlacementRequest(stock, job.Parts.Where(part => remaining[part.Id] > 0)
|
||||||
.Select(p => new NestJobPart(p.Id, p.Geometry, remaining[p.Id], p.Priority, p.Rotation)));
|
.Select(part => new NestJobPart(part.Id, part.Geometry, remaining[part.Id], part.Priority, part.Rotation)));
|
||||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id,
|
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id,
|
||||||
plates.Count, plates.Count, placed.Values.Sum()));
|
plates.Count, plates.Count, placed.Values.Sum()));
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
// Do not forward legacy/candidate progress as committed production.
|
|
||||||
var candidate = nester.Place(request, token: token);
|
var candidate = nester.Place(request, token: token);
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
NestJobValidator.ValidateCandidate(candidate, remaining);
|
NestJobValidator.ValidateCandidate(candidate, remaining);
|
||||||
if (candidate.Placements.Count == 0)
|
var trial = new CandidateTrial(candidate, stock, index);
|
||||||
{
|
if (winner == null || comparer.Compare(trial.Candidate, trial.Stock, trial.StockIndex,
|
||||||
reason = NestJobStopReason.NoPlacementFound;
|
winner.Candidate, winner.Stock, winner.StockIndex) > 0)
|
||||||
break;
|
winner = trial;
|
||||||
}
|
|
||||||
var committed = new List<NestJobPlacement>();
|
|
||||||
foreach (var pose in candidate.Placements)
|
|
||||||
{
|
|
||||||
committed.Add(pose with { InstanceIndex = placed[pose.PartId]++ });
|
|
||||||
remaining[pose.PartId]--;
|
|
||||||
}
|
|
||||||
plates.Add(new NestJobPlateResult(plates.Count, stock, committed));
|
|
||||||
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, stock.Id,
|
|
||||||
plates.Count - 1, plates.Count, placed.Values.Sum()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasAvailableStock)
|
||||||
|
{
|
||||||
|
reason = NestJobStopReason.StockExhausted;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (winner.Candidate.Placements.Count == 0)
|
||||||
|
{
|
||||||
|
reason = NestJobStopReason.NoPlacementFound;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var committed = new List<NestJobPlacement>();
|
||||||
|
foreach (var pose in winner.Candidate.Placements)
|
||||||
|
{
|
||||||
|
committed.Add(pose with { InstanceIndex = placed[pose.PartId]++ });
|
||||||
|
remaining[pose.PartId]--;
|
||||||
|
}
|
||||||
|
used[winner.Stock.Id]++;
|
||||||
|
plates.Add(new NestJobPlateResult(plates.Count, winner.Stock, committed));
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, winner.Stock.Id,
|
||||||
|
plates.Count - 1, plates.Count, placed.Values.Sum()));
|
||||||
}
|
}
|
||||||
|
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||||
reason, plates, job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, placed[p.Id], remaining[p.Id])),
|
reason, plates, job.Parts.Select(part => new PartFulfillment(part.Id, part.Quantity, placed[part.Id], remaining[part.Id])),
|
||||||
job.Plates.Select(stock => new StockUsage(stock.Id, plates.Count(p => p.StockId == stock.Id),
|
job.Plates.Select(stock => new StockUsage(stock.Id, used[stock.Id],
|
||||||
stock.Quantity - plates.Count(p => p.StockId == stock.Id))));
|
stock.Quantity is int quantity ? quantity - used[stock.Id] : null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record CandidateTrial(PlateCandidate Candidate, NestPlateStock Stock, int StockIndex);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ public static class NestJobValidator
|
|||||||
!double.IsFinite(m.CenterX) || !double.IsFinite(m.CenterY)))
|
!double.IsFinite(m.CenterX) || !double.IsFinite(m.CenterY)))
|
||||||
throw new ArgumentException($"Geometry must contain finite motions: {part.Id}.", nameof(job));
|
throw new ArgumentException($"Geometry must contain finite motions: {part.Id}.", nameof(job));
|
||||||
}
|
}
|
||||||
// Empty jobs do not select or consume stock (including multiple unused stock entries).
|
|
||||||
if (job.Parts.Count != 0 && job.Plates.Count > 1)
|
|
||||||
throw new NotSupportedException("This slice supports one stock entry only; mixed-stock selection is not implemented.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void ValidateCandidate(PlateCandidate candidate, IReadOnlyDictionary<string, int> remaining)
|
internal static void ValidateCandidate(PlateCandidate candidate, IReadOnlyDictionary<string, int> remaining)
|
||||||
|
|||||||
Reference in New Issue
Block a user