From 610b9dafcbc1d9cee7b1a51839671e347fbd85a5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 25 Oct 2026 18:19:56 -0400 Subject: [PATCH] fix(autonest): mark ledger substitutes against the best rank matched An acceptance rule may list sheet grades first, so at plate thickness its best match can be rank 3 (A36 for HR). Auto Nest marked every row above rank 1 a substitute; it now ranks rows against the best rule rank ML matched at the nest's thickness, as ML labels them, so A36 is preferred and only A572 is a substitute. Co-Authored-By: Claude Opus 5.5 --- .../MaterialLedger/LedgerStockMapper.cs | 17 +++++++++-- OpenNest.Tests/Data/LedgerStockMapperTests.cs | 29 +++++++++++++++++++ docs/automatic-nesting.md | 9 ++++-- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs b/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs index 5454053e..8fe3469f 100644 --- a/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs +++ b/OpenNest.Data/MaterialLedger/LedgerStockMapper.cs @@ -30,8 +30,15 @@ public sealed record LedgerStockRow( /// The catalog grade and finish the row is cut from, e.g. "A1011 P&O"; null when the ledger sends neither. public string? Label { get; init; } - /// True for stock ML accepts in place of the preferred material (rank above 1). - public bool IsSubstitute => Rank > 1; + /// + /// The best rule rank among the specs ML matched at this thickness: the preferred material + /// for this request. A rule may list sheet grades first, so at plate thickness its best match + /// can be rank 3 and is still not a substitute. + /// + public int PreferredRank { get; init; } = 1; + + /// True for stock ML accepts in place of the preferred material (a rule rank above ). + public bool IsSubstitute => Rank > PreferredRank; } /// The rows offered to Auto Nest and the counts of ledger items left out. @@ -156,6 +163,11 @@ public static class LedgerStockMapper } } + // ML labels substitutes against the best-ranked spec it matched, not against rank 1; + // an exact mapping (rank 0) overrides the rule and never makes rule stock a substitute. + var ruleRanks = specById.Values.Where(s => s.Rank >= 1).Select(s => s.Rank!.Value) + .Concat(entries.Where(e => e.Rank >= 1).Select(e => e.Rank)).ToList(); + var preferredRank = ruleRanks.Count == 0 ? 1 : ruleRanks.Min(); var rows = new List(entries.Count); // ML already sends rank order; the stable sort keeps ledger order within a rank. foreach (var entry in entries.OrderBy(e => e.Rank)) @@ -175,6 +187,7 @@ public static class LedgerStockMapper { ItemLocations = itemLocations, Rank = entry.Rank, + PreferredRank = preferredRank, Label = entry.Label, }); } diff --git a/OpenNest.Tests/Data/LedgerStockMapperTests.cs b/OpenNest.Tests/Data/LedgerStockMapperTests.cs index 406171d1..d57d7315 100644 --- a/OpenNest.Tests/Data/LedgerStockMapperTests.cs +++ b/OpenNest.Tests/Data/LedgerStockMapperTests.cs @@ -87,6 +87,35 @@ public class LedgerStockMapperTests Assert.Equal(new[] { false, false, false, true, true }, mapping.Rows.Select(r => r.IsSubstitute)); } + // Steel/HR lists A1011 grades first; at 3/8 only its A36 (rank 3) and A572 (rank 4) entries + // match, so A36 is the preferred plate and only A572 is a substitute, as ML labels them. + [Fact] + public void SubstitutesAreRankedAgainstTheBestRankMatchedAtThisThickness() + { + var mapping = LedgerStockMapper.Map(new[] + { + Item("A36-1", LedgerItemKinds.FullSheet, 60, 120, spec: 30, rank: 3), + Item("A36-2", LedgerItemKinds.Drop, 20, 70, spec: 30, rank: 3), + Item("GR50-1", LedgerItemKinds.FullSheet, 60, 120, spec: 31, rank: 4), + }, Units.Inches, specs: new[] { Spec(30, 3, "A36", null), Spec(31, 4, "A572 GR50", null) }); + + Assert.Equal(new[] { false, false, true }, mapping.Rows.Select(r => r.IsSubstitute)); + Assert.Equal(1, mapping.SubstituteRows); + Assert.All(mapping.Rows, r => Assert.Equal(3, r.PreferredRank)); + } + + // The preferred spec may have nothing on the rack: it still sets the bar for substitutes. + [Fact] + public void AnOutOfStockPreferredSpecStillMakesLowerRanksSubstitutes() + { + var mapping = LedgerStockMapper.Map(new[] + { + Item("PO-1", LedgerItemKinds.FullSheet, 48, 96, spec: 41, rank: 2), + }, Units.Inches, specs: new[] { Spec(40, 1, "A1011", "HR"), Spec(41, 2, "A1011", "HRPO") }); + + Assert.True(Assert.Single(mapping.Rows).IsSubstitute); + } + [Fact] public void AnOlderLedgerWithoutRanksOrGradesIsRankOneAndUnlabeled() { diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index e7059e22..c0a6c0ff 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -277,8 +277,11 @@ and the thickness converted to inches (rounded to 4 places). ML resolves its own OpenNest material mapping, and OpenNest keeps no copy of it. OpenNest's grade is a shop condition such as HR or P&O; ML's acceptance rule for the material and grade decides which catalog grades and finishes may fill it, ranked by preference. Rank 0 is an exact mapping -override, rank 1 the rule's preferred entry, and higher ranks are substitutes, which only -ever upgrade (P&O may fill HR, never the reverse). Specs carry `rank`, `materialFamily`, +override and rank 1 the rule's first entry. The best rule rank ML matched at the nest's +thickness is the preferred material, and higher ranks are substitutes, which only ever +upgrade (P&O may fill HR, never the reverse). A rule that lists sheet grades first can +match only its plate entries at plate thickness: there A36 at rank 3 is preferred and +A572 at rank 4 the substitute, as ML labels them. Specs carry `rank`, `materialFamily`, `grade` and `finish`; items carry `rank` and arrive in rank, location code, item code order. An older ledger that sends none of these still loads: its stock counts as rank 1, unlabeled. The button is enabled when @@ -309,7 +312,7 @@ separate `NestJob` input. `OpenNest.Engine` has no ledger reference. **Grid.** A load replaces earlier ledger rows and keeps hand-typed rows. A ledger row's Source shows its label, or "Ledger" when the ledger sends no grade or finish, with -"(irr.)" for an irregular drop. A substitute (rank above 1) reads, for example, +"(irr.)" for an irregular drop. A substitute (a rank above the preferred one) reads, for example, "A1011 P&O for HR" in amber, and its tooltip names the substitution and rank ahead of the usual item codes and locations. Substitutes load ticked like other rows; untick **Use** to keep them out of the run. Ledger rows' size and cost are read-only. You can lower a ledger