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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 18:20:11 -04:00
1 parent 079dfaf9ea
commit 610b9dafcb
3 files changed
+50 -5

No files matched your search

@@ -30,8 +30,15 @@ public sealed record LedgerStockRow(
/// <summary>The catalog grade and finish the row is cut from, e.g. "A1011 P&amp;O"; null when the ledger sends neither.</summary>
public string? Label { get; init; }
/// <summary>True for stock ML accepts in place of the preferred material (rank above 1).</summary>
public bool IsSubstitute => Rank > 1;
/// <summary>
/// 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.
/// </summary>
public int PreferredRank { get; init; } = 1;
/// <summary>True for stock ML accepts in place of the preferred material (a rule rank above <see cref="PreferredRank"/>).</summary>
public bool IsSubstitute => Rank > PreferredRank;
}
/// <summary>The rows offered to Auto Nest and the counts of ledger items left out.</summary>
@@ -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<LedgerStockRow>(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,
});
}
@@ -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()
{
+6 -3
View File
@@ -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