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