diff --git a/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs b/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs new file mode 100644 index 00000000..4eff8077 --- /dev/null +++ b/OpenNest.Data/MaterialLedger/ReleaseLedgerStock.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using OpenNest.Geometry; + +namespace OpenNest.Data.MaterialLedger; + +/// +/// The MaterialLedger stock one released nest is offered: the rows Auto Nest's ledger load +/// would tick (substitutes included). An empty offer nests on the plate defaults alone, and +/// says why the ledger was not used. +/// +public sealed record ReleaseLedgerOffer(IReadOnlyList Rows, string? NotUsed) +{ + public static ReleaseLedgerOffer None(string reason) => + new(Array.Empty(), reason); + + /// Why codes on other nests were not left out of the rows, or null. + public string? OtherNestsUnchecked { get; init; } + + /// + /// The rows as engine stock: finite, under their ledger:{n} ids, no costs. + /// + public IReadOnlyList Options => Rows + .Select(row => new PlateOption + { + Width = row.Size.Width, + Length = row.Size.Length, + Quantity = row.Quantity, + Id = row.Id, + }) + .ToList(); +} + +/// +/// MaterialLedger stock for one Make Nests run. Each nest is offered the free sheets and +/// drops of its material, grade and thickness, minus codes on other open server nests (read +/// once per run) and codes nests earlier in the same run took: those are not on the server +/// until the release uploads them. Nothing here throws for an unusable ledger; the offer +/// says so. +/// +public sealed class ReleaseLedgerStock +{ + /// Reads the free stock for a material, grade and thickness in inches. + public delegate Task StockReader(string material, string grade, + double thicknessIn, CancellationToken cancellationToken); + + private readonly StockReader _readStock; + private readonly Func> _readCodesInUse; + private readonly HashSet _taken = new(StringComparer.Ordinal); + private LedgerCodesInUse? _codesInUse; + + public ReleaseLedgerStock(StockReader readStock, + Func> readCodesInUse) + { + _readStock = readStock ?? throw new ArgumentNullException(nameof(readStock)); + _readCodesInUse = readCodesInUse + ?? throw new ArgumentNullException(nameof(readCodesInUse)); + } + + /// The codes nests of this run have taken so far. + public IReadOnlyCollection Taken => _taken; + + /// + /// The stock to offer , whose drawings carry their release + /// quantities. + /// + public async Task OfferAsync(Nest nest, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(nest); + var material = nest.Material?.Name?.Trim() ?? ""; + if (material.Length == 0 || !(nest.Thickness > 0)) + return ReleaseLedgerOffer.None("the nest has no material or thickness"); + + var grade = nest.Material?.Grade?.Trim() ?? ""; + var thicknessIn = LedgerStockMapper.ThicknessInches(nest.Thickness, nest.Units); + var result = await _readStock(material, grade, thicknessIn, cancellationToken); + switch (result) + { + case LedgerStockResult.Ok ok: + var mapping = LedgerStockMapper.Map(ok.Items, nest.Units, PartEnvelopes(nest), + ok.Specs); + _codesInUse ??= await _readCodesInUse(cancellationToken); + if (_codesInUse.IsKnown) + mapping = LedgerCodeFilter.Exclude(mapping, _codesInUse.Codes); + var rows = LedgerCodeFilter.Exclude(mapping.Rows, _taken, out _); + if (rows.Count == 0) + return ReleaseLedgerOffer.None( + $"no free stock of {Describe(nest, material, grade)}"); + return new ReleaseLedgerOffer(rows, null) + { + OtherNestsUnchecked = _codesInUse.Unavailable, + }; + case LedgerStockResult.Unmapped: + return ReleaseLedgerOffer.None($"no mapping for {Describe(nest, material, grade)}"); + case LedgerStockResult.Unauthorized: + return ReleaseLedgerOffer.None("it rejected the API key (Tools > Options)"); + case LedgerStockResult.Unreachable unreachable: + return ReleaseLedgerOffer.None($"unreachable: {unreachable.Reason}"); + default: + return ReleaseLedgerOffer.None("not set up on this PC (Tools > Options)"); + } + } + + /// + /// Gives the plates the release committed on 's rows their item + /// codes, as Auto Nest does, keeps those codes from later nests of this run, and returns + /// the note for the release: how many sheets came from the ledger, or why it was not used. + /// + /// + /// Each committed plate with the engine stock id it was solved on. + /// + public string Assign(Nest nest, ReleaseLedgerOffer offer, + IReadOnlyList<(Plate Plate, string StockId)> committed) + { + ArgumentNullException.ThrowIfNull(nest); + ArgumentNullException.ThrowIfNull(offer); + ArgumentNullException.ThrowIfNull(committed); + if (offer.NotUsed != null) + return $"MaterialLedger not used: {offer.NotUsed}."; + + LedgerStockAssignment.Assign(nest.Plates, committed, offer.Rows); + var sheets = 0; + foreach (var (plate, stockId) in committed) + { + _taken.UnionWith(plate.StockItemCodes); + if (LedgerStockMapper.IsLedgerId(stockId)) + sheets += plate.Quantity; + } + + var note = sheets == 0 + ? "No MaterialLedger stock used." + : string.Format(CultureInfo.CurrentCulture, "{0} sheet{1} from MaterialLedger stock.", + sheets, sheets == 1 ? "" : "s"); + return offer.OtherNestsUnchecked == null + ? note + : $"{note} Codes on other nests were not checked: {offer.OtherNestsUnchecked}."; + } + + private static List PartEnvelopes(Nest nest) + { + var envelopes = new List(); + var drawings = nest.Drawings + .Where(d => !d.IsCutOff && d.Quantity.Required > 0 && d.Program != null); + foreach (var drawing in drawings) + { + var box = drawing.Program.BoundingBox(); + if (double.IsFinite(box.Width) && double.IsFinite(box.Length) + && box.Width > 0 && box.Length > 0) + envelopes.Add(new Size(box.Width, box.Length)); + } + return envelopes; + } + + private static string Describe(Nest nest, string material, string grade) + { + var unit = nest.Units == Units.Millimeters ? "mm" : "in"; + return FormattableString.Invariant( + $"{material}{(grade.Length == 0 ? "" : " " + grade)} {nest.Thickness:G} {unit}"); + } +} diff --git a/OpenNest.Engine/Jobs/NestRenest.cs b/OpenNest.Engine/Jobs/NestRenest.cs index 616a1635..aaf72214 100644 --- a/OpenNest.Engine/Jobs/NestRenest.cs +++ b/OpenNest.Engine/Jobs/NestRenest.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; +using System.Threading.Tasks; namespace OpenNest.Engine.Jobs; @@ -16,7 +17,14 @@ public sealed record NestRenestResult( double Utilization, IReadOnlyList Unplaced, string Note -); +) +{ + /// + /// Each committed plate with the engine stock id it was solved on, in commit order. + /// + public IReadOnlyList<(Plate Plate, string StockId)> Committed { get; init; } = + Array.Empty<(Plate Plate, string StockId)>(); +} /// /// Auto Nest without the dialog, for a nest no one is looking at yet (a released drawing @@ -25,8 +33,8 @@ public sealed record NestRenestResult( /// . A layout is committed only when it is valid and keepable; /// otherwise the nest is left without plates for the operator to run Auto Nest. Copies the /// layout leaves unplaced stay as remaining quantity. The caller owns the nest throughout. -/// A caller showing the nest uses and to solve -/// off its thread and commit on it. +/// A caller showing the nest uses (or +/// and ) to solve off its thread and commit on it. /// public static class NestRenest { @@ -48,7 +56,17 @@ public static class NestRenest /// Smallest offcut credited when the nest offers plate-size options, in the nest's units. /// public static NestRenestResult Run(Nest nest, INestingEngine engine, string engineName, - double minimumSalvageDimension = 0, IProgress progress = null, CancellationToken token = default) + double minimumSalvageDimension = 0, IProgress progress = null, + CancellationToken token = default) => + Run(nest, engine, engineName, null, minimumSalvageDimension, progress, token); + + /// + /// Sheets offered before the plate defaults (see ); null or + /// empty nests on the plate defaults alone. + /// + public static NestRenestResult Run(Nest nest, INestingEngine engine, string engineName, + IReadOnlyList firstStock, double minimumSalvageDimension = 0, + IProgress progress = null, CancellationToken token = default) { ArgumentNullException.ThrowIfNull(nest); ArgumentNullException.ThrowIfNull(engine); @@ -56,14 +74,66 @@ public static class NestRenest nest.Plates.Clear(); nest.UpdateDrawingQuantities(); - var request = CreateRequest(nest, engineName, minimumSalvageDimension); - if (request == null) - return new NestRenestResult(false, 0, 0, Array.Empty(), NothingToNestNote); + // Solved synchronously: the returned task has already completed. + return RunStagedAsync(nest, engineName, firstStock, minimumSalvageDimension, + request => Task.FromResult( + NestPipeline.Run(engine, engineName, request, progress, token)), + kept => NestPipelineCommit.ApplyToPlates(kept, nest.CreatePlate, token: token), + token) + .GetAwaiter().GetResult(); + } - var result = NestPipeline.Run(engine, engineName, request, progress, token); - // Engines that ignore cancellation still have their proposal discarded. + /// + /// Nests every drawing's remaining quantity of , which has no parts + /// placed yet, in up to two kept layouts: first on alone + /// (finite sheets such as MaterialLedger stock, so they are used before anything else), + /// then whatever is left on the plate defaults and plate-size options. A stage whose layout + /// is not valid and keepable commits nothing, so its copies fall to the next stage or stay + /// remaining. runs a request; attaches a + /// kept result's sheets to the nest on the nest's own thread and returns them. + /// + public static async Task RunStagedAsync(Nest nest, string engineName, + IReadOnlyList firstStock, double minimumSalvageDimension, + Func> solve, + Func> commit, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(nest); + ArgumentNullException.ThrowIfNull(solve); + ArgumentNullException.ThrowIfNull(commit); token.ThrowIfCancellationRequested(); - return Keep(result, kept => NestPipelineCommit.ApplyToPlates(kept, nest.CreatePlate, token: token)); + + var applied = new List(); + var committed = new List<(Plate Plate, string StockId)>(); + IReadOnlyList unplaced = Array.Empty(); + var anyRequest = false; + var stages = firstStock is { Count: > 0 } + ? new[] { firstStock, null } + : new IReadOnlyList[] { null }; + foreach (var stock in stages) + { + var request = CreateRequest(nest, engineName, minimumSalvageDimension, stock, + remainingOnly: true); + if (request == null) + break; + anyRequest = true; + var result = await solve(request); + // Engines that ignore cancellation still have their proposal discarded. + token.ThrowIfCancellationRequested(); + unplaced = result.Unplaced; + if (!IsKeepable(result)) + continue; + // Attaching parts counts them as nested, so the next stage asks only for the rest. + var plates = commit(result); + committed.AddRange(StockIds(result, plates)); + applied.AddRange(plates); + } + + if (!anyRequest) + return new NestRenestResult(false, 0, 0, Array.Empty(), + NothingToNestNote); + if (applied.Count == 0) + return new NestRenestResult(false, 0, 0, unplaced, NotKeptNote); + return Summarize(applied, unplaced) with { Committed = committed }; } /// @@ -71,15 +141,23 @@ public static class NestRenest /// sheets from its plate defaults and plate-size options; null when nothing has a quantity. /// The nest is not changed, so a caller showing it can solve this off its thread. /// - public static NestPipelineRequest CreateRequest(Nest nest, string engineName, double minimumSalvageDimension = 0) + public static NestPipelineRequest CreateRequest(Nest nest, string engineName, + double minimumSalvageDimension = 0) => + CreateRequest(nest, engineName, minimumSalvageDimension, null, remainingOnly: false); + + // stock: offered instead of the nest's plate-size options; remainingOnly: copies already + // placed are not asked for again. + private static NestPipelineRequest CreateRequest(Nest nest, string engineName, + double minimumSalvageDimension, IReadOnlyList stock, bool remainingOnly) { ArgumentNullException.ThrowIfNull(nest); + int Wanted(Drawing d) => remainingOnly ? d.Quantity.Remaining : d.Quantity.Required; var items = nest.Drawings - .Where(d => !d.IsCutOff && d.Quantity.Required > 0) + .Where(d => !d.IsCutOff && Wanted(d) > 0) .Select(d => new NestItem { Drawing = d, - Quantity = d.Quantity.Required, + Quantity = Wanted(d), Priority = d.Priority, RotationStart = d.Constraints.StartAngle, RotationEnd = d.Constraints.EndAngle, @@ -89,7 +167,7 @@ public static class NestRenest if (items.Count == 0) return null; - var options = nest.PlateOptions; + var options = stock ?? nest.PlateOptions; return new NestPipelineRequest(engineName, items, NestStockBuilder.FromTemplate(nest.PlateDefaults.CreateNew(), options), new NestJobOptions(maxPlates: MaxPlates, @@ -106,14 +184,32 @@ public static class NestRenest { ArgumentNullException.ThrowIfNull(result); ArgumentNullException.ThrowIfNull(commit); - if (!result.IsValid || !result.CanKeep || result.Plates.All(p => p.Parts.Count == 0)) + if (!IsKeepable(result)) return new NestRenestResult(false, 0, 0, result.Unplaced, NotKeptNote); var applied = commit(result); + return Summarize(applied, result.Unplaced) with { Committed = StockIds(result, applied) }; + } + + private static List<(Plate Plate, string StockId)> StockIds(NestPipelineResult result, + IReadOnlyList applied) => + NestPipelineCommit.PairWithStock(result, applied) + .Select(p => (p.Plate, p.Stock.Id)) + .ToList(); + + private static bool IsKeepable(NestPipelineResult result) => + result.IsValid && result.CanKeep && result.Plates.Any(p => p.Parts.Count > 0); + + private static NestRenestResult Summarize(IReadOnlyList applied, + IReadOnlyList unplaced) + { var sheets = applied.Sum(p => p.Quantity); var area = applied.Sum(p => p.Area() * p.Quantity); - var utilization = area > 0 ? applied.Sum(p => p.Utilization() * p.Area() * p.Quantity) / area : 0; - return new NestRenestResult(true, sheets, utilization, result.Unplaced, Note(sheets, utilization, result.Unplaced)); + var utilization = area > 0 + ? applied.Sum(p => p.Utilization() * p.Area() * p.Quantity) / area + : 0; + return new NestRenestResult(true, sheets, utilization, unplaced, + Note(sheets, utilization, unplaced)); } private static string Note(int sheets, double utilization, IReadOnlyList unplaced) diff --git a/OpenNest.Tests/Data/ReleaseLedgerStockTests.cs b/OpenNest.Tests/Data/ReleaseLedgerStockTests.cs new file mode 100644 index 00000000..dd981f92 --- /dev/null +++ b/OpenNest.Tests/Data/ReleaseLedgerStockTests.cs @@ -0,0 +1,173 @@ +using OpenNest.Data; +using OpenNest.Data.MaterialLedger; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.Shapes; + +namespace OpenNest.Tests.Data; + +public class ReleaseLedgerStockTests +{ + private const string Material = "Synthetic Steel"; + + private static Nest NestOf(double partSize, int quantity, string grade = "SYN-1") + { + var nest = new Nest("N26-0001") + { + Material = new Material(Material, grade), + Thickness = 0.25, + }; + nest.PlateDefaults.Size = new Size(48, 96); + var drawing = new RectangleShape { Name = "SYN-A", Length = partSize, Width = partSize } + .GetDrawing(); + drawing.Quantity.Required = quantity; + nest.Drawings.Add(drawing); + return nest; + } + + private static LedgerStockItem Sheet(string code, decimal width, decimal length) => + new(code, 1, LedgerItemKinds.FullSheet, width, length, 1, LedgerItemShapes.Rectangular, + "R1", "Rack 1", null, null, DateTime.UnixEpoch); + + private static LedgerStockResult Stock(params LedgerStockItem[] items) => + new LedgerStockResult.Ok(Array.Empty(), items); + + private sealed class FakeLedger + { + public LedgerStockResult Stock { get; init; } = Stock(); + public LedgerCodesInUse InUse { get; init; } = + LedgerCodesInUse.Known(Array.Empty()); + public List<(string Material, string Grade, double Thickness)> StockReads { get; } = new(); + public int InUseReads { get; private set; } + + public ReleaseLedgerStock Create() => new( + (material, grade, thickness, _) => + { + StockReads.Add((material, grade, thickness)); + return Task.FromResult(Stock); + }, + _ => + { + InUseReads++; + return Task.FromResult(InUse); + }); + } + + private static async Task<(NestRenestResult Renest, string Note)> Release( + ReleaseLedgerStock ledger, Nest nest) + { + var offer = await ledger.OfferAsync(nest, CancellationToken.None); + var renest = NestRenest.Run(nest, NestingEngineRegistry.Create("Rectangles"), "Rectangles", + offer.Options); + return (renest, ledger.Assign(nest, offer, renest.Committed)); + } + + [Fact] + public async Task LedgerStockIsUsedFirst_EvenWhenTheDefaultSheetWouldBeSmaller() + { + // One 60x120 ledger sheet holds both parts; a single pass scored by area would take + // the smaller 48x96 Nest Defaults sheet instead. + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) }; + var nest = NestOf(20, 2); + + var (renest, note) = await Release(ledger.Create(), nest); + + Assert.True(renest.Nested, renest.Note); + var plate = Assert.Single(nest.Plates); + Assert.Equal(new Size(60, 120), plate.Size); + Assert.Equal(["S-1"], plate.StockItemCodes); + Assert.Equal(0, nest.Drawings.First().Quantity.Remaining); + Assert.Equal("1 sheet from MaterialLedger stock.", note); + Assert.Equal((Material, "SYN-1", 0.25), Assert.Single(ledger.StockReads)); + } + + [Fact] + public async Task CopiesTheLedgerCannotHold_TopUpOnTheNestDefaultsSheet_WithoutACode() + { + // Two free 40x40 sheets each hold one 30x30 part; the third copy needs a default sheet. + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 40, 40), Sheet("S-2", 40, 40)) }; + var nest = NestOf(30, 3); + + var (renest, note) = await Release(ledger.Create(), nest); + + Assert.True(renest.Nested, renest.Note); + Assert.Empty(renest.Unplaced); + Assert.Equal(0, nest.Drawings.First().Quantity.Remaining); + var coded = nest.Plates.Where(p => p.StockItemCodes.Count > 0).ToList(); + Assert.Equal(["S-1", "S-2"], coded.SelectMany(p => p.StockItemCodes).Order()); + Assert.All(coded, p => Assert.Equal(new Size(40, 40), p.Size)); + var topUp = Assert.Single(nest.Plates.Where(p => p.StockItemCodes.Count == 0)); + Assert.Equal(new Size(48, 96), topUp.Size); + Assert.Equal(3, renest.Sheets); + Assert.Equal("2 sheets from MaterialLedger stock.", note); + } + + [Fact] + public async Task ALaterNestOfTheSameRun_DoesNotGetCodesAnEarlierNestTook() + { + // The release uploads every nest only after all are nested, so the server cannot + // report the first nest's codes yet; a substitute row can list the same sheet twice. + var ledger = new FakeLedger { Stock = Stock(Sheet("S-1", 60, 120)) }; + var run = ledger.Create(); + await Release(run, NestOf(20, 2)); + + var second = NestOf(20, 2, grade: "SYN-2"); + var (renest, note) = await Release(run, second); + + Assert.True(renest.Nested, renest.Note); + var plate = Assert.Single(second.Plates); + Assert.Empty(plate.StockItemCodes); + Assert.Equal(new Size(48, 96), plate.Size); + Assert.Equal("MaterialLedger not used: no free stock of Synthetic Steel SYN-2 0.25 in.", + note); + Assert.Equal(["S-1"], run.Taken); + Assert.Equal(1, ledger.InUseReads); + } + + [Fact] + public async Task CodesOnOtherServerNests_AreLeftOut() + { + var ledger = new FakeLedger + { + Stock = Stock(Sheet("S-1", 60, 120), Sheet("S-2", 60, 120)), + InUse = LedgerCodesInUse.Known( + [new NestStockCodeUse { ItemCode = "S-1", NestName = "N26-0099" }]), + }; + + var offer = await ledger.Create().OfferAsync(NestOf(20, 2), CancellationToken.None); + + Assert.Null(offer.NotUsed); + Assert.Equal(["S-2"], Assert.Single(offer.Rows).ItemCodes); + } + + [Fact] + public async Task AnUnreachableLedger_NestsOnTheNestDefaultsSheet_AndSaysSo() + { + var ledger = new FakeLedger { Stock = new LedgerStockResult.Unreachable("timed out") }; + var nest = NestOf(20, 2); + + var (renest, note) = await Release(ledger.Create(), nest); + + Assert.True(renest.Nested, renest.Note); + var plate = Assert.Single(nest.Plates); + Assert.Equal(new Size(48, 96), plate.Size); + Assert.Empty(plate.StockItemCodes); + Assert.Equal("MaterialLedger not used: unreachable: timed out.", note); + Assert.Equal(0, ledger.InUseReads); + } + + [Fact] + public async Task UncheckedOtherNests_AreNamedInTheNote() + { + var ledger = new FakeLedger + { + Stock = Stock(Sheet("S-1", 60, 120)), + InUse = LedgerCodesInUse.Unknown("the nest server did not answer"), + }; + + var (_, note) = await Release(ledger.Create(), NestOf(20, 2)); + + Assert.Equal("1 sheet from MaterialLedger stock. Codes on other nests were not checked: " + + "the nest server did not answer.", note); + } +}