using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenNest.Benchmark
{
///
/// Console + CSV reporting for benchmark results. Ranking rule per job:
/// valid beats invalid; placing every requested part beats not; then lower
/// JobResult.Cost wins - salvage-credited sheet area consumed plus a
/// largest-sheet penalty per unplaced part, so dropping awkward parts can
/// never buy a better score; then fewer plates. Ties beyond that are a
/// shared win. Across jobs, costs and areas are summed (not averaged), so
/// a big job weighs more than a three-part one.
///
public static class Report
{
private const double Epsilon = 1e-6;
public static void PrintDetailed(List results)
{
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
Console.WriteLine();
Console.WriteLine($"=== {jobGroup.Key} ===");
var ranked = jobGroup.OrderBy(r => r, Comparer.Create(Compare)).ToList();
var best = ranked.Count > 0 ? ranked[0] : null;
Console.WriteLine(
$"{"Engine", -16} {"Result", -9} {"Parts", -10} {"Util%", -7} {"Net%", -7} {"Cost", -12} {"Plates", -18} {"Time(ms)", -9} Notes"
);
foreach (var r in ranked)
{
var isWinner = best != null && Compare(r, best) == 0 && r.Valid;
var marker = isWinner ? "*" : " ";
var status =
r.Crashed ? "CRASH"
: r.Valid ? "ok"
: "INVALID";
var partsCol = $"{r.PartsPlaced}/{r.PartsRequested}";
var utilCol = r.Valid ? $"{r.Utilization * 100:F1}" : "-";
var netCol = r.Valid ? $"{r.NetUtilization * 100:F1}" : "-";
var platesCol =
r.PlatesUsed > 0 ? $"{r.PlatesUsed} ({SizeSummary(r.SizeBreakdown)})" : "-";
var notes = r.Crashed ? r.Error : string.Join("; ", r.Violations.Take(2));
Console.WriteLine(
$"{marker}{r.EngineName, -15} {status, -9} {partsCol, -10} {utilCol, -7} {netCol, -7} {r.Cost, -12:F1} {platesCol, -18} {r.ElapsedMs, -9} {notes}"
);
}
}
}
public static void PrintSummary(List results)
{
Console.WriteLine();
Console.WriteLine("=== Summary ===");
var byEngine = results
.GroupBy(r => r.EngineName)
.Select(g => new
{
Engine = g.Key,
Jobs = g.Count(),
Valid = g.Count(r => r.Valid),
Crashed = g.Count(r => r.Crashed),
FullyPlaced = g.Count(r => r.FullyPlaced),
Unplaced = g.Sum(r => r.PartsUnplaced),
PlacedArea = g.Where(r => r.Valid).Sum(r => r.PlacedArea),
PlateArea = g.Where(r => r.Valid).Sum(r => r.PlateArea),
NetSheetArea = g.Where(r => r.Valid).Sum(r => r.NetSheetArea),
TotalCost = g.Sum(r => r.Cost),
TotalPlates = g.Sum(r => r.PlatesUsed),
TotalTimeMs = g.Sum(r => r.ElapsedMs),
})
.OrderBy(e => e.TotalCost)
.ToList();
var wins = CountWins(results);
// Util% and Net% are area-weighted over valid runs (sum placed / sum
// sheet), not a mean of per-job percentages. TotalCost sums across
// jobs, so it is only meaningful when every job uses the same units.
Console.WriteLine(
$"{"Engine", -16} {"Jobs", -6} {"Valid", -7} {"Complete", -9} {"Unplaced", -9} {"Wins", -6} {"Util%", -7} {"Net%", -7} {"TotalCost", -14} {"Plates", -8} {"TotalTime(ms)", -14}"
);
foreach (var e in byEngine)
{
var util = e.PlateArea > 0 ? e.PlacedArea / e.PlateArea * 100 : 0;
var netUtil = e.NetSheetArea > 0 ? e.PlacedArea / e.NetSheetArea * 100 : 0;
var winCount = wins.TryGetValue(e.Engine, out var w) ? w : 0;
Console.WriteLine(
$"{e.Engine, -16} {e.Jobs, -6} {e.Valid, -7} {e.FullyPlaced, -9} {e.Unplaced, -9} {winCount, -6} {util, -7:F1} {netUtil, -7:F1} {e.TotalCost, -14:F1} {e.TotalPlates, -8} {e.TotalTimeMs, -14}"
);
}
}
public static void WriteCsv(string path, List results)
{
var sb = new StringBuilder();
sb.AppendLine(
"Job,Engine,Valid,Crashed,FullyPlaced,PartsPlaced,PartsRequested,Utilization,NetUtilization,PlateArea,NetSheetArea,Cost,PlatesUsed,SizeBreakdown,ElapsedMs,Notes"
);
foreach (var r in results)
{
var notes = r.Crashed ? r.Error : string.Join(" | ", r.Violations);
sb.AppendLine(
string.Join(
",",
Csv(r.JobName),
Csv(r.EngineName),
r.Valid,
r.Crashed,
r.FullyPlaced,
r.PartsPlaced,
r.PartsRequested,
r.Utilization.ToString("F4", CultureInfo.InvariantCulture),
r.NetUtilization.ToString("F4", CultureInfo.InvariantCulture),
r.PlateArea.ToString("F2", CultureInfo.InvariantCulture),
r.NetSheetArea.ToString("F2", CultureInfo.InvariantCulture),
r.Cost.ToString("F2", CultureInfo.InvariantCulture),
r.PlatesUsed,
Csv(SizeSummary(r.SizeBreakdown)),
r.ElapsedMs,
Csv(notes)
)
);
}
File.WriteAllText(path, sb.ToString());
}
private static string SizeSummary(Dictionary breakdown)
{
if (breakdown == null || breakdown.Count == 0)
return "-";
return string.Join("; ", breakdown.Select(kv => $"{kv.Key}×{kv.Value}"));
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return $"\"{value.Replace("\"", "\"\"")}\"";
return value;
}
private static Dictionary CountWins(List results)
{
var wins = new Dictionary();
foreach (var jobGroup in results.GroupBy(r => r.JobName))
{
var ranked = jobGroup.OrderBy(r => r, Comparer.Create(Compare)).ToList();
if (ranked.Count == 0 || !ranked[0].Valid)
continue;
foreach (var r in ranked.TakeWhile(r => Compare(r, ranked[0]) == 0))
wins[r.EngineName] = wins.GetValueOrDefault(r.EngineName) + 1;
}
return wins;
}
/// Lower sorts first (better). Valid beats invalid, complete beats
/// incomplete, then lower cost (relative tolerance, since costs are areas
/// in whatever units the job uses), then fewer plates.
public static int Compare(JobResult a, JobResult b)
{
if (a.Valid != b.Valid)
return a.Valid ? -1 : 1;
if (!a.Valid)
return 0;
if (a.FullyPlaced != b.FullyPlaced)
return a.FullyPlaced ? -1 : 1;
var costDiff = a.Cost - b.Cost;
var scale = System.Math.Max(1, System.Math.Max(a.Cost, b.Cost));
if (System.Math.Abs(costDiff) > Epsilon * scale)
return costDiff > 0 ? 1 : -1;
if (a.PlatesUsed != b.PlatesUsed)
return a.PlatesUsed > b.PlatesUsed ? 1 : -1;
return 0;
}
}
}