diff --git a/OpenNest.Tests/CuttingPlanning/ProblemNestRegressionTests.cs b/OpenNest.Tests/CuttingPlanning/ProblemNestRegressionTests.cs
index 23f4a46c..003e858b 100644
--- a/OpenNest.Tests/CuttingPlanning/ProblemNestRegressionTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/ProblemNestRegressionTests.cs
@@ -3,6 +3,7 @@ using System.Text.RegularExpressions;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Data;
using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Engine.Sequencing;
using OpenNest.IO;
using Xunit.Abstractions;
@@ -10,15 +11,19 @@ namespace OpenNest.Tests.CuttingPlanning;
///
/// Plans every nest on a dedicated problem-nest OpenNest server and fails when a plate's outcome is
-/// worse than its recorded baseline. Opt-in and private: the nests and their baselines stay on the
-/// server, never in the repository. See docs/problem-nests.md.
+/// worse than its recorded baseline. A nest with a cut-off baseline is planned a second time with
+/// its applied lead-ins and cut-offs cleared and default scrap cut-offs added. Opt-in and private:
+/// the nests and their baselines stay on the server, never in the repository. See docs/problem-nests.md.
///
[Trait("Category", "ProblemNests")]
public partial class ProblemNestRegressionTests(ITestOutputHelper output)
{
internal const string ServerVariable = "OPENNEST_PROBLEM_NESTS_SERVER";
internal const string TimeoutVariable = "OPENNEST_PROBLEM_NESTS_TIMEOUT";
+ internal const string CutOffsVariable = "OPENNEST_PROBLEM_NESTS_CUTOFFS";
internal const string Timeout = "Timeout";
+ internal const string PlanningLine = "planning:";
+ internal const string CutOffPlanningLine = "planning with cut-offs:";
// A corpus is a handful of nests; more means the variable names a production archive.
internal const int MaximumNests = 200;
@@ -38,39 +43,72 @@ public partial class ProblemNestRegressionTests(ITestOutputHelper output)
{
var bytes = await repository.GetFileAsync(record.Id)
?? throw new InvalidOperationException($"'{record.Name}' has no file.");
- using var stream = new MemoryStream(bytes);
- var nest = new NestReader(stream).Read();
var baseline = Baseline(record.Comments);
- var observed = new SortedDictionary();
- for (var index = 0; index < nest.Plates.Count; index++)
- {
- var plate = nest.Plates[index];
- if (plate.Parts.Count == 0)
- continue;
- var number = index + 1;
- var watch = Stopwatch.StartNew();
- var (outcome, planned) = Plan(plate, number, limit);
- observed[number] = outcome;
- var expected = baseline.GetValueOrDefault(number);
- output.WriteLine($"{record.Name} plate {number}: {outcome} (baseline {expected ?? "none"}), "
- + $"{planned?.Result.Expansions ?? 0} expansions, {watch.Elapsed.TotalSeconds:0.0} s");
- if (expected == null)
- continue;
- if (Rank(outcome) < Rank(expected))
- failures.Add($"{record.Name} plate {number}: {outcome}, worse than baseline {expected}. "
- + string.Join(" | ", planned?.Result.Findings.Take(3).Select(f => f.Message) ?? []));
- else if (outcome != expected)
- output.WriteLine($" changed from {expected}; update the baseline if this is intended.");
- }
- var line = "planning: " + string.Join(" ", observed.Select(p => $"{p.Key}={p.Value}"));
+ var observed = PlanAll(record.Name, bytes, false, baseline, limit, failures);
if (baseline.Count == 0)
- failures.Add($"{record.Name} has no baseline. Add this line to its comments: {line}");
- else if (baseline.Keys.Except(observed.Keys).Any() || observed.Keys.Except(baseline.Keys).Any())
- failures.Add($"{record.Name}: baseline plates do not match the nest's plates. Observed: {line}");
+ failures.Add($"{record.Name} has no baseline. Add this line to its comments: {Line(PlanningLine, observed)}");
+ if (Environment.GetEnvironmentVariable(CutOffsVariable) == "1")
+ PlanAll(record.Name, bytes, true, [], limit, failures);
+ else if (Baseline(record.Comments, CutOffPlanningLine) is { Count: > 0 } withCutOffs)
+ PlanAll(record.Name, bytes, true, withCutOffs, limit, failures);
}
Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures));
}
+ // Plans each plate with parts and compares it with the baseline; plate number to outcome.
+ private SortedDictionary PlanAll(string name, byte[] bytes, bool cutOffs,
+ Dictionary baseline, TimeSpan limit, List failures)
+ {
+ using var stream = new MemoryStream(bytes);
+ var nest = new NestReader(stream).Read();
+ var label = cutOffs ? $"{name} with cut-offs" : name;
+ var observed = new SortedDictionary();
+ for (var index = 0; index < nest.Plates.Count; index++)
+ {
+ var plate = nest.Plates[index];
+ if (plate.Parts.Count == 0)
+ continue;
+ var number = index + 1;
+ var watch = Stopwatch.StartNew();
+ if (cutOffs)
+ AddScrapCutOffs(plate, number, nest.Units);
+ var (outcome, planned) = Plan(plate, number, limit);
+ observed[number] = outcome;
+ var expected = baseline.GetValueOrDefault(number);
+ output.WriteLine($"{label} plate {number}: {outcome} (baseline {expected ?? "none"}), "
+ + $"{planned?.Result.Expansions ?? 0} expansions, {watch.Elapsed.TotalSeconds:0.0} s");
+ if (expected == null)
+ continue;
+ if (Rank(outcome) < Rank(expected))
+ failures.Add($"{label} plate {number}: {outcome}, worse than baseline {expected}. "
+ + string.Join(" | ", planned?.Result.Findings.Take(3).Select(f => f.Message) ?? []));
+ else if (outcome != expected)
+ output.WriteLine($" changed from {expected}; update the baseline if this is intended.");
+ }
+ if (baseline.Count != 0 && (baseline.Keys.Except(observed.Keys).Any() || observed.Keys.Except(baseline.Keys).Any()))
+ failures.Add($"{label}: baseline plates do not match the nest's plates. Observed: "
+ + Line(cutOffs ? CutOffPlanningLine : PlanningLine, observed));
+ return observed;
+ }
+
+ private static string Line(string prefix, SortedDictionary observed) =>
+ prefix + " " + string.Join(" ", observed.Select(p => $"{p.Key}={p.Value}"));
+
+ // As Plan Cutting after "Clear Lead-ins & Cut-offs", with "Add scrap cut-offs" at its defaults.
+ private static void AddScrapCutOffs(Plate plate, int number, Units units)
+ {
+ AppliedCuttingWork.Clear([plate], [number]);
+ var spacing = units == Units.Millimeters ? 889 : 35;
+ var staging = AutomaticCutOffBatch.Stage([plate], new AutomaticCutOffOptions
+ {
+ Spacing = spacing,
+ MinimumStripLength = spacing / 2.0,
+ MinimumTailLength = units == Units.Millimeters ? 304.8 : 12,
+ }, new CutOffSettings());
+ Assert.False(staging.IsBlocked, $"Plate {number}'s scrap cut-offs are blocked.");
+ PlateSequencing.PlaceCutOffs(plate, staging.Plans[0].Definitions);
+ }
+
// Capture and planning share the limit; a plate that never finishes must not hang the run.
private static (string Outcome, CuttingPlanPlateResult Planned) Plan(Plate plate, int number, TimeSpan limit)
{
@@ -101,11 +139,14 @@ public partial class ProblemNestRegressionTests(ITestOutputHelper output)
return page.Items;
}
- /// Plate number to outcome, from a "planning: 1=Ready 2=BestEffort" comment line.
- internal static Dictionary Baseline(string comments)
+ ///
+ /// Plate number to outcome, from a "planning: 1=Ready 2=BestEffort" comment line, or from the
+ /// line starting with .
+ ///
+ internal static Dictionary Baseline(string comments, string prefix = PlanningLine)
{
var line = (comments ?? "").Split('\n').Select(l => l.Trim())
- .FirstOrDefault(l => l.StartsWith("planning:", StringComparison.OrdinalIgnoreCase));
+ .FirstOrDefault(l => l.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
return line == null ? []
: BaselineEntry().Matches(line).ToDictionary(m => int.Parse(m.Groups[1].Value), m => m.Groups[2].Value);
}
@@ -148,6 +189,17 @@ public class ProblemNestBaselineTests
Assert.Empty(ProblemNestRegressionTests.Baseline("no baseline yet"));
}
+ [Fact]
+ public void Baseline_ReadsTheCutOffLineApartFromThePlanningLine()
+ {
+ const string comments = "planning: 1=Ready 2=BestEffort\nplanning with cut-offs: 1=Ready 2=Ready";
+
+ Assert.Equal(new Dictionary { [1] = "Ready", [2] = "BestEffort" },
+ ProblemNestRegressionTests.Baseline(comments));
+ Assert.Equal(new Dictionary { [1] = "Ready", [2] = "Ready" },
+ ProblemNestRegressionTests.Baseline(comments, ProblemNestRegressionTests.CutOffPlanningLine));
+ }
+
[Fact]
public void Rank_OrdersReadyAboveBestEffortAboveBudgetAboveBlockedAboveTimeout()
{
diff --git a/docs/problem-nests.md b/docs/problem-nests.md
index b26da386..50b0cb62 100644
--- a/docs/problem-nests.md
+++ b/docs/problem-nests.md
@@ -29,6 +29,12 @@ baseline, when a nest has no baseline, or when the baseline's plate numbers diff
nest's. A different outcome at the same or a better rank is only reported. When it is
intended, update the baseline.
+A nest whose comments also carry a `planning with cut-offs: 1=Ready 2=BestEffort` line is
+planned a second time, as Plan Cutting does after **Clear Lead-ins & Cut-offs** and **Add scrap
+cut-offs** at their defaults, and each plate is held to that line the same way. Set
+`OPENNEST_PROBLEM_NESTS_CUTOFFS=1` to plan every nest with cut-offs and print the outcomes
+without comparing them, to record a new line.
+
Plates use their own cutting parameters. A plate without them uses line lead-ins of 0.15 at
90 degrees (external and internal) and no lead-outs.