32 KiB
ML Angle Pruning Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Instrument the nesting engine to collect per-angle results during training runs, store them in the database, and add a Description field to the progress window for real-time visibility.
Architecture: The engine gains a ForceFullAngleSweep flag and an AngleResults collection populated during FindBestFill. BruteForceResult passes these through. The training project stores them in a new AngleResults SQLite table. The WinForms progress form gains a description row. ONNX inference (AnglePredictor) is a separate future task — it requires trained model data that doesn't exist yet.
Tech Stack: C# / .NET 8, EF Core SQLite, WinForms
Spec: docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md
Chunk 1: Engine Instrumentation
Task 1: Add AngleResult class and Description to NestProgress
Files:
-
Modify:
OpenNest.Engine/NestProgress.cs -
Step 1: Add
AngleResultclass andDescriptionproperty
Add to OpenNest.Engine/NestProgress.cs, after the PhaseResult class:
public class AngleResult
{
public double AngleDeg { get; set; }
public NestDirection Direction { get; set; }
public int PartCount { get; set; }
}
Add to NestProgress:
public string Description { get; set; }
- Step 2: Build to verify
Run: dotnet build OpenNest.Engine
Expected: Build succeeded
- Step 3: Commit
git add OpenNest.Engine/NestProgress.cs
git commit -m "feat(engine): add AngleResult class and Description to NestProgress"
Task 2: Add ForceFullAngleSweep and AngleResults to NestEngine
Files:
-
Modify:
OpenNest.Engine/NestEngine.cs -
Step 1: Add properties to
NestEngine
Add after the existing PhaseResults property (line 29):
public bool ForceFullAngleSweep { get; set; }
public List<AngleResult> AngleResults { get; } = new();
- Step 2: Clear
AngleResultsat the start ofFill
In Fill(NestItem item, Box workArea, IProgress<NestProgress> progress, CancellationToken token) (line 52), add AngleResults.Clear(); right after PhaseResults.Clear(); (line 55).
- Step 3: Force full angle sweep when flag is set
In FindBestFill(NestItem item, Box workArea, IProgress<NestProgress> progress, CancellationToken token) (line 163), after the existing narrow-work-area angle expansion block (lines 182-190), add a second block:
if (ForceFullAngleSweep)
{
var step = Angle.ToRadians(5);
for (var a = 0.0; a < System.Math.PI; a += step)
{
if (!angles.Any(existing => existing.IsEqualTo(a)))
angles.Add(a);
}
}
Also apply the same pattern to the non-progress overload FindBestFill(NestItem item, Box workArea) (line 84) — add the same ForceFullAngleSweep block after line 115.
- Step 4: Collect per-angle results in the progress overload
In FindBestFill (progress overload, line 163), inside the Parallel.ForEach over angles (lines 209-221), replace the parallel body to also collect angle results. Use a ConcurrentBag<AngleResult> alongside the existing linearBag:
Before the Parallel.ForEach (after line 207), add:
var angleBag = new System.Collections.Concurrent.ConcurrentBag<AngleResult>();
Inside the parallel body, after computing h and v, add:
var angleDeg = Angle.ToDegrees(angle);
if (h != null && h.Count > 0)
angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count });
if (v != null && v.Count > 0)
angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count });
After the Parallel.ForEach completes and linearSw.Stop() (line 222), add:
AngleResults.AddRange(angleBag);
- Step 5: Add
ForceFullAngleSweepto non-progress overload (angle sweep only, no result collection)
The non-progress FindBestFill at line 84 is only called from TryStripRefill for sub-strip fills. Do NOT collect angle results there — sub-strip data would contaminate the main plate's results. Only add the ForceFullAngleSweep block (same as Step 3) after line 115.
Do NOT add AngleResults.Clear() to Fill(NestItem, Box) at line 41 — it delegates to the progress overload at line 52 which already clears AngleResults.
- Step 6: Report per-angle progress descriptions
In the progress overload's Parallel.ForEach body, after computing h and v for an angle, report progress with a description. Since we're inside a parallel loop, use a simple approach — report after computing each angle:
var bestDir = (h?.Count ?? 0) >= (v?.Count ?? 0) ? "H" : "V";
var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0);
progress?.Report(new NestProgress
{
Phase = NestPhase.Linear,
PlateNumber = PlateNumber,
Description = $"Linear: {angleDeg:F0}\u00b0 {bestDir} - {bestCount} parts"
});
- Step 7: Report per-candidate progress in Pairs phase
In FillWithPairs(NestItem item, Box workArea, CancellationToken token) (line 409), inside the Parallel.For body (line 424), add progress reporting. Since FillWithPairs doesn't have access to the progress parameter, this requires adding a progress parameter.
Change the signature of the cancellation-token overload at line 409 to:
private List<Part> FillWithPairs(NestItem item, Box workArea, CancellationToken token, IProgress<NestProgress> progress = null)
Update the call site at line 194 (FindBestFill progress overload) to pass progress:
var pairResult = FillWithPairs(item, workArea, token, progress);
Inside the Parallel.For body (line 424), after computing filled, add:
progress?.Report(new NestProgress
{
Phase = NestPhase.Pairs,
PlateNumber = PlateNumber,
Description = $"Pairs: candidate {i + 1}/{candidates.Count} - {filled?.Count ?? 0} parts"
});
- Step 8: Build to verify
Run: dotnet build OpenNest.Engine
Expected: Build succeeded
- Step 9: Commit
git add OpenNest.Engine/NestEngine.cs
git commit -m "feat(engine): add ForceFullAngleSweep flag and per-angle result collection"
Task 3: Add AngleResults to BruteForceResult and BruteForceRunner
Files:
-
Modify:
OpenNest.Engine/ML/BruteForceRunner.cs -
Step 1: Add
AngleResultsproperty toBruteForceResult
Add to BruteForceResult class (after ThirdPlaceTimeMs):
public List<AngleResult> AngleResults { get; set; } = new();
- Step 2: Populate
AngleResultsinBruteForceRunner.Run
In the return new BruteForceResult block (line 47), add:
AngleResults = engine.AngleResults.ToList(),
- Step 3: Build to verify
Run: dotnet build OpenNest.Engine
Expected: Build succeeded
- Step 4: Commit
git add OpenNest.Engine/ML/BruteForceRunner.cs
git commit -m "feat(engine): pass per-angle results through BruteForceResult"
Chunk 2: Training Database & Runner
Task 4: Add TrainingAngleResult EF Core entity
Files:
-
Create:
OpenNest.Training/Data/TrainingAngleResult.cs -
Modify:
OpenNest.Training/Data/TrainingDbContext.cs -
Step 1: Create the entity class
Create OpenNest.Training/Data/TrainingAngleResult.cs:
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OpenNest.Training.Data
{
[Table("AngleResults")]
public class TrainingAngleResult
{
[Key]
public long Id { get; set; }
public long RunId { get; set; }
public double AngleDeg { get; set; }
public string Direction { get; set; }
public int PartCount { get; set; }
[ForeignKey(nameof(RunId))]
public TrainingRun Run { get; set; }
}
}
- Step 2: Add navigation property to
TrainingRun
In OpenNest.Training/Data/TrainingRun.cs, add at the end of the class (after the Part navigation property):
public List<TrainingAngleResult> AngleResults { get; set; } = new();
Add using System.Collections.Generic; to the top if not already present.
- Step 3: Register
DbSetand configure inTrainingDbContext
In OpenNest.Training/Data/TrainingDbContext.cs:
Add DbSet:
public DbSet<TrainingAngleResult> AngleResults { get; set; }
Add configuration in OnModelCreating:
modelBuilder.Entity<TrainingAngleResult>(e =>
{
e.HasIndex(a => a.RunId).HasDatabaseName("idx_angleresults_runid");
e.HasOne(a => a.Run)
.WithMany(r => r.AngleResults)
.HasForeignKey(a => a.RunId);
});
- Step 4: Build to verify
Run: dotnet build OpenNest.Training
Expected: Build succeeded
- Step 5: Commit
git add OpenNest.Training/Data/TrainingAngleResult.cs OpenNest.Training/Data/TrainingRun.cs OpenNest.Training/Data/TrainingDbContext.cs
git commit -m "feat(training): add TrainingAngleResult entity and DbSet"
Task 5: Extend TrainingDatabase for angle result storage and migration
Files:
-
Modify:
OpenNest.Training/TrainingDatabase.cs -
Step 1: Create
AngleResultstable inMigrateSchema
Add to the end of the MigrateSchema method, after the existing column migration loop:
try
{
_db.Database.ExecuteSqlRaw(@"
CREATE TABLE IF NOT EXISTS AngleResults (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
RunId INTEGER NOT NULL,
AngleDeg REAL NOT NULL,
Direction TEXT NOT NULL,
PartCount INTEGER NOT NULL,
FOREIGN KEY (RunId) REFERENCES Runs(Id)
)");
_db.Database.ExecuteSqlRaw(
"CREATE INDEX IF NOT EXISTS idx_angleresults_runid ON AngleResults (RunId)");
}
catch
{
// Table already exists or other non-fatal issue.
}
- Step 2: Extend
AddRunto accept and batch-insert angle results
Change the AddRun signature to accept angle results:
public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath, List<AngleResult> angleResults = null)
Add using OpenNest; at the top if not already present (for AngleResult type).
After _db.Runs.Add(run); and before _db.SaveChanges();, add:
if (angleResults != null && angleResults.Count > 0)
{
foreach (var ar in angleResults)
{
_db.AngleResults.Add(new Data.TrainingAngleResult
{
Run = run,
AngleDeg = ar.AngleDeg,
Direction = ar.Direction.ToString(),
PartCount = ar.PartCount
});
}
}
The single SaveChanges() call will batch-insert both the run and all angle results in one transaction.
- Step 3: Build to verify
Run: dotnet build OpenNest.Training
Expected: Build succeeded
- Step 4: Commit
git add OpenNest.Training/TrainingDatabase.cs
git commit -m "feat(training): add AngleResults table migration and batch insert"
Task 6: Wire up training runner to collect angle data
Files:
-
Modify:
OpenNest.Training/Program.cs -
Step 1: Set
ForceFullAngleSweepon the engine
In Program.cs, inside the foreach (var size in sheetSuite) loop, after creating the BruteForceRunner.Run call (line 203), we need to change the approach. Currently BruteForceRunner.Run creates the engine internally. We need to modify BruteForceRunner.Run to accept the ForceFullAngleSweep flag.
Actually, looking at the code, BruteForceRunner.Run creates a NestEngine internally (line 29 of BruteForceRunner.cs). The cleanest approach: add an overload or optional parameter.
In OpenNest.Engine/ML/BruteForceRunner.cs, change the Run method signature to:
public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false)
And set it on the engine after creation:
var engine = new NestEngine(plate);
engine.ForceFullAngleSweep = forceFullAngleSweep;
- Step 2: Pass
forceFullAngleSweep = truefrom the training runner
In OpenNest.Training/Program.cs, change the BruteForceRunner.Run call (line 203) to:
var result = BruteForceRunner.Run(drawing, runPlate, forceFullAngleSweep: true);
- Step 3: Pass angle results to
AddRun
Change the db.AddRun call (line 266) to:
db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath, result.AngleResults);
- Step 4: Add angle result count to console output
In the console output line (line 223), append angle result count. Change:
Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}]");
To:
Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}] angles={result.AngleResults.Count}");
- Step 5: Build to verify
Run: dotnet build OpenNest.Training
Expected: Build succeeded
- Step 6: Commit
git add OpenNest.Engine/ML/BruteForceRunner.cs OpenNest.Training/Program.cs
git commit -m "feat(training): enable forced full angle sweep and store per-angle results"
Chunk 3: Progress Window Enhancement
Task 7: Add Description row to the progress form
Files:
-
Modify:
OpenNest/Forms/NestProgressForm.Designer.cs -
Modify:
OpenNest/Forms/NestProgressForm.cs -
Step 1: Add description label and value controls in Designer
In NestProgressForm.Designer.cs, add field declarations alongside the existing ones (after elapsedValue on line 231):
private System.Windows.Forms.Label descriptionLabel;
private System.Windows.Forms.Label descriptionValue;
In InitializeComponent(), add control creation (after the elapsedValue creation, around line 43):
this.descriptionLabel = new System.Windows.Forms.Label();
this.descriptionValue = new System.Windows.Forms.Label();
Add the description row to the table. Exact changes:
- Line 71: Change
this.table.RowCount = 6;tothis.table.RowCount = 7; - After line 77 (last
RowStyles.Add), add:this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - After line 66 (elapsedValue table.Controls.Add), add the description controls to the table
- Line 197: Change
this.ClientSize = new System.Drawing.Size(264, 207);tothis.ClientSize = new System.Drawing.Size(264, 230);(taller to fit the new row)
Add table controls (after the elapsed row controls):
this.table.Controls.Add(this.descriptionLabel, 0, 6);
this.table.Controls.Add(this.descriptionValue, 1, 6);
Configure the labels (in the label configuration section, after elapsedValue config):
// descriptionLabel
this.descriptionLabel.AutoSize = true;
this.descriptionLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold);
this.descriptionLabel.Margin = new System.Windows.Forms.Padding(4);
this.descriptionLabel.Name = "descriptionLabel";
this.descriptionLabel.Text = "Detail:";
// descriptionValue
this.descriptionValue.AutoSize = true;
this.descriptionValue.Margin = new System.Windows.Forms.Padding(4);
this.descriptionValue.Name = "descriptionValue";
this.descriptionValue.Text = "\u2014";
Add field declarations after elapsedValue (line 230), before stopButton (line 231):
- Step 2: Display
DescriptioninUpdateProgress
In NestProgressForm.cs, in the UpdateProgress method (line 30), add after the existing updates:
if (!string.IsNullOrEmpty(progress.Description))
descriptionValue.Text = progress.Description;
- Step 3: Build to verify
Run: dotnet build OpenNest.sln
Expected: Build succeeded
- Step 4: Commit
git add OpenNest/Forms/NestProgressForm.cs OpenNest/Forms/NestProgressForm.Designer.cs
git commit -m "feat(ui): add description row to nest progress form"
Chunk 4: ONNX Inference Scaffolding
Task 8: Add Microsoft.ML.OnnxRuntime NuGet package
Files:
-
Modify:
OpenNest.Engine/OpenNest.Engine.csproj -
Step 1: Add the package reference
Add to OpenNest.Engine/OpenNest.Engine.csproj inside the existing <ItemGroup> (or create a new one for packages):
<ItemGroup>
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.17.3" />
</ItemGroup>
- Step 2: Restore and build
Run: dotnet restore OpenNest.Engine && dotnet build OpenNest.Engine
Expected: Build succeeded
- Step 3: Commit
git add OpenNest.Engine/OpenNest.Engine.csproj
git commit -m "chore(engine): add Microsoft.ML.OnnxRuntime package"
Task 9: Create AnglePredictor with ONNX inference
Files:
-
Create:
OpenNest.Engine/ML/AnglePredictor.cs -
Step 1: Create the predictor class
Create OpenNest.Engine/ML/AnglePredictor.cs:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenNest.Math;
namespace OpenNest.Engine.ML
{
public static class AnglePredictor
{
private static InferenceSession _session;
private static bool _loadAttempted;
private static readonly object _lock = new();
public static List<double> PredictAngles(
PartFeatures features, double sheetWidth, double sheetHeight,
double threshold = 0.3)
{
var session = GetSession();
if (session == null)
return null;
try
{
var input = new float[11];
input[0] = (float)features.Area;
input[1] = (float)features.Convexity;
input[2] = (float)features.AspectRatio;
input[3] = (float)features.BoundingBoxFill;
input[4] = (float)features.Circularity;
input[5] = (float)features.PerimeterToAreaRatio;
input[6] = features.VertexCount;
input[7] = (float)sheetWidth;
input[8] = (float)sheetHeight;
input[9] = (float)(sheetWidth / (sheetHeight > 0 ? sheetHeight : 1.0));
input[10] = (float)(features.Area / (sheetWidth * sheetHeight));
var tensor = new DenseTensor<float>(input, new[] { 1, 11 });
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("features", tensor)
};
using var results = session.Run(inputs);
var probabilities = results.First().AsEnumerable<float>().ToArray();
var angles = new List<(double angleDeg, float prob)>();
for (var i = 0; i < 36 && i < probabilities.Length; i++)
{
if (probabilities[i] >= threshold)
angles.Add((i * 5.0, probabilities[i]));
}
// Minimum 3 angles — take top by probability if fewer pass threshold.
if (angles.Count < 3)
{
angles = probabilities
.Select((p, i) => (angleDeg: i * 5.0, prob: p))
.OrderByDescending(x => x.prob)
.Take(3)
.ToList();
}
// Always include 0 and 90 as safety fallback.
var result = angles.Select(a => Angle.ToRadians(a.angleDeg)).ToList();
if (!result.Any(a => a.IsEqualTo(0)))
result.Add(0);
if (!result.Any(a => a.IsEqualTo(Angle.HalfPI)))
result.Add(Angle.HalfPI);
return result;
}
catch (Exception ex)
{
Debug.WriteLine($"[AnglePredictor] Inference failed: {ex.Message}");
return null;
}
}
private static InferenceSession GetSession()
{
if (_loadAttempted)
return _session;
lock (_lock)
{
if (_loadAttempted)
return _session;
_loadAttempted = true;
try
{
var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location);
var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx");
if (!File.Exists(modelPath))
{
Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}");
return null;
}
_session = new InferenceSession(modelPath);
Debug.WriteLine("[AnglePredictor] Model loaded successfully");
}
catch (Exception ex)
{
Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}");
}
return _session;
}
}
}
}
- Step 2: Build to verify
Run: dotnet build OpenNest.Engine
Expected: Build succeeded (model file doesn't exist yet — that's expected, GetSession returns null gracefully)
- Step 3: Commit
git add OpenNest.Engine/ML/AnglePredictor.cs
git commit -m "feat(engine): add AnglePredictor ONNX inference class"
Task 10: Wire AnglePredictor into NestEngine.FindBestFill
Files:
-
Modify:
OpenNest.Engine/NestEngine.cs -
Step 1: Add ML angle prediction to the progress overload
In FindBestFill (progress overload), after the narrow-work-area angle expansion block and after the ForceFullAngleSweep block, add ML prediction logic. This replaces the full sweep when the model is available:
// When the work area triggers a full sweep (and we're not forcing it for training),
// try ML angle prediction to reduce the sweep.
if (!ForceFullAngleSweep && angles.Count > 2)
{
var features = FeatureExtractor.Extract(item.Drawing);
if (features != null)
{
var predicted = AnglePredictor.PredictAngles(
features, workArea.Width, workArea.Length);
if (predicted != null)
{
// Use predicted angles, but always keep bestRotation and bestRotation + 90.
var mlAngles = new List<double>(predicted);
if (!mlAngles.Any(a => a.IsEqualTo(bestRotation)))
mlAngles.Add(bestRotation);
if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI)))
mlAngles.Add(bestRotation + Angle.HalfPI);
Debug.WriteLine($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted");
angles = mlAngles;
}
}
}
Add using OpenNest.Engine.ML; at the top of the file if not already present.
- Step 2: Apply the same pattern to the non-progress overload
Add the identical ML prediction block to FindBestFill(NestItem item, Box workArea) after its ForceFullAngleSweep block.
- Step 3: Build the full solution
Run: dotnet build OpenNest.sln
Expected: Build succeeded
- Step 4: Commit
git add OpenNest.Engine/NestEngine.cs
git commit -m "feat(engine): integrate AnglePredictor into FindBestFill angle selection"
Chunk 5: Training Notebook Scaffolding
Task 11: Create Python training notebook and requirements
Files:
-
Create:
OpenNest.Training/notebooks/requirements.txt -
Create:
OpenNest.Training/notebooks/train_angle_model.ipynb -
Step 1: Create requirements.txt
Create OpenNest.Training/notebooks/requirements.txt:
pandas>=2.0
scikit-learn>=1.3
xgboost>=2.0
onnxmltools>=1.12
skl2onnx>=1.16
matplotlib>=3.7
jupyter>=1.0
- Step 2: Create training notebook skeleton
Create OpenNest.Training/notebooks/train_angle_model.ipynb as a Jupyter notebook with the following cells:
Cell 1 (markdown):
# Angle Prediction Model Training
Trains an XGBoost multi-label classifier to predict which rotation angles are competitive for a given part geometry and sheet size.
**Input:** SQLite database from OpenNest.Training data collection runs
**Output:** `angle_predictor.onnx` model file for `OpenNest.Engine/Models/`
Cell 2 (code): imports and setup
import sqlite3
import pandas as pd
import numpy as np
from pathlib import Path
DB_PATH = "../OpenNestTraining.db" # Adjust to your database location
OUTPUT_PATH = "../../OpenNest.Engine/Models/angle_predictor.onnx"
COMPETITIVE_THRESHOLD = 0.95 # Angle is "competitive" if >= 95% of best
Cell 3 (code): data extraction
# Extract training data from SQLite
conn = sqlite3.connect(DB_PATH)
query = """
SELECT
p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity,
p.PerimeterToAreaRatio, p.VertexCount,
r.SheetWidth, r.SheetHeight, r.Id as RunId,
a.AngleDeg, a.Direction, a.PartCount
FROM AngleResults a
JOIN Runs r ON a.RunId = r.Id
JOIN Parts p ON r.PartId = p.Id
WHERE a.PartCount > 0
"""
df = pd.read_sql_query(query, conn)
conn.close()
print(f"Loaded {len(df)} angle result rows")
print(f"Unique runs: {df['RunId'].nunique()}")
print(f"Angle range: {df['AngleDeg'].min()}-{df['AngleDeg'].max()}")
Cell 4 (code): label generation
# For each run, find best PartCount (max of H and V per angle),
# then label angles within 95% of best as positive.
# Best count per angle per run (max of H and V)
angle_best = df.groupby(['RunId', 'AngleDeg'])['PartCount'].max().reset_index()
angle_best.columns = ['RunId', 'AngleDeg', 'BestCount']
# Best count per run (overall best angle)
run_best = angle_best.groupby('RunId')['BestCount'].max().reset_index()
run_best.columns = ['RunId', 'RunBest']
# Merge and compute labels
labels = angle_best.merge(run_best, on='RunId')
labels['IsCompetitive'] = (labels['BestCount'] >= labels['RunBest'] * COMPETITIVE_THRESHOLD).astype(int)
# Pivot to 36-column binary label matrix
label_matrix = labels.pivot_table(
index='RunId', columns='AngleDeg', values='IsCompetitive', fill_value=0
)
# Ensure all 36 angle columns exist (0, 5, 10, ..., 175)
all_angles = [i * 5 for i in range(36)]
for a in all_angles:
if a not in label_matrix.columns:
label_matrix[a] = 0
label_matrix = label_matrix[all_angles]
print(f"Label matrix: {label_matrix.shape}")
print(f"Average competitive angles per run: {label_matrix.sum(axis=1).mean():.1f}")
Cell 5 (code): feature engineering
# Build feature matrix — one row per run
features_query = """
SELECT DISTINCT
r.Id as RunId, p.FileName,
p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity,
p.PerimeterToAreaRatio, p.VertexCount,
r.SheetWidth, r.SheetHeight
FROM Runs r
JOIN Parts p ON r.PartId = p.Id
WHERE r.Id IN ({})
""".format(','.join(str(x) for x in label_matrix.index))
conn = sqlite3.connect(DB_PATH)
features_df = pd.read_sql_query(features_query, conn)
conn.close()
features_df = features_df.set_index('RunId')
# Derived features
features_df['SheetAspectRatio'] = features_df['SheetWidth'] / features_df['SheetHeight']
features_df['PartToSheetAreaRatio'] = features_df['Area'] / (features_df['SheetWidth'] * features_df['SheetHeight'])
# Filter outliers (title blocks, etc.)
mask = (features_df['BBFill'] >= 0.01) & (features_df['Area'] > 0.1)
print(f"Filtering: {(~mask).sum()} outlier runs removed")
features_df = features_df[mask]
label_matrix = label_matrix.loc[features_df.index]
feature_cols = ['Area', 'Convexity', 'AspectRatio', 'BBFill', 'Circularity',
'PerimeterToAreaRatio', 'VertexCount',
'SheetWidth', 'SheetHeight', 'SheetAspectRatio', 'PartToSheetAreaRatio']
X = features_df[feature_cols].values
y = label_matrix.values
print(f"Features: {X.shape}, Labels: {y.shape}")
Cell 6 (code): train/test split and training
from sklearn.model_selection import GroupShuffleSplit
from sklearn.multioutput import MultiOutputClassifier
import xgboost as xgb
# Split by part (all sheet sizes for a part stay in the same split)
groups = features_df['FileName']
splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, test_idx = next(splitter.split(X, y, groups))
X_train, X_test = X[train_idx], X[test_idx]
y_train, y_test = y[train_idx], y[test_idx]
print(f"Train: {len(train_idx)}, Test: {len(test_idx)}")
# Train XGBoost multi-label classifier
base_clf = xgb.XGBClassifier(
n_estimators=200,
max_depth=6,
learning_rate=0.1,
use_label_encoder=False,
eval_metric='logloss',
random_state=42
)
clf = MultiOutputClassifier(base_clf, n_jobs=-1)
clf.fit(X_train, y_train)
print("Training complete")
Cell 7 (code): evaluation
from sklearn.metrics import recall_score, precision_score
import matplotlib.pyplot as plt
y_pred = clf.predict(X_test)
y_prob = np.array([est.predict_proba(X_test)[:, 1] for est in clf.estimators_]).T
# Per-angle metrics
recalls = []
precisions = []
for i in range(36):
if y_test[:, i].sum() > 0:
recalls.append(recall_score(y_test[:, i], y_pred[:, i], zero_division=0))
precisions.append(precision_score(y_test[:, i], y_pred[:, i], zero_division=0))
print(f"Mean recall: {np.mean(recalls):.3f}")
print(f"Mean precision: {np.mean(precisions):.3f}")
# Average angles predicted per run
avg_predicted = y_pred.sum(axis=1).mean()
print(f"Avg angles predicted per run: {avg_predicted:.1f}")
# Plot
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
axes[0].bar(range(len(recalls)), recalls)
axes[0].set_title('Recall per Angle Bin')
axes[0].set_xlabel('Angle (5-deg bins)')
axes[0].axhline(y=0.95, color='r', linestyle='--', label='Target 95%')
axes[0].legend()
axes[1].bar(range(len(precisions)), precisions)
axes[1].set_title('Precision per Angle Bin')
axes[1].set_xlabel('Angle (5-deg bins)')
axes[1].axhline(y=0.60, color='r', linestyle='--', label='Target 60%')
axes[1].legend()
plt.tight_layout()
plt.show()
Cell 8 (code): export to ONNX
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
from pathlib import Path
initial_type = [('features', FloatTensorType([None, 11]))]
onnx_model = convert_sklearn(clf, initial_types=initial_type)
output_path = Path(OUTPUT_PATH)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'wb') as f:
f.write(onnx_model.SerializeToString())
print(f"Model saved to {output_path} ({output_path.stat().st_size / 1024:.0f} KB)")
- Step 3: Create the
Modelsdirectory placeholder
Run: mkdir -p OpenNest.Engine/Models
Create OpenNest.Engine/Models/.gitkeep (empty file to track the directory).
- Step 4: Commit
git add OpenNest.Training/notebooks/ OpenNest.Engine/Models/.gitkeep
git commit -m "feat(training): add training notebook skeleton and requirements"
Summary
| Chunk | Tasks | Purpose |
|---|---|---|
| 1 | 1-3 | Engine instrumentation: AngleResult, ForceFullAngleSweep, per-angle collection |
| 2 | 4-6 | Training DB: AngleResults table, migration, runner wiring |
| 3 | 7 | Progress window: Description display |
| 4 | 8-10 | ONNX inference: AnglePredictor class, NuGet package, FindBestFill integration |
| 5 | 11 | Python notebook: training pipeline skeleton |
Dependency order: Chunks 1-2 must be sequential (2 depends on 1). Chunks 3, 4, 5 are independent of each other and can be done in parallel after Chunk 1.
After this plan: Run training data collection with --force-sweep (the existing training runner + new angle collection). Once data exists, run the notebook to train and export the ONNX model. The engine will automatically use it once angle_predictor.onnx is placed in the Models/ directory.