123 lines
4.3 KiB
C#
123 lines
4.3 KiB
C#
using System;
|
|
|
|
namespace T3.Core.Stats;
|
|
|
|
/// <summary>
|
|
/// Derives a single 0..1 score and letter grade (A+ .. F) from the distribution
|
|
/// of frame times in a <see cref="RollingMetric"/>.
|
|
///
|
|
/// Approach:
|
|
/// 1. Detect the target frame time by snapping the window median to the nearest
|
|
/// common refresh period (1/144, 1/120, 1/75, 1/60). Adapts to high-refresh displays.
|
|
/// 2. Use P99 vs. target to measure tail latency.
|
|
/// 3. Penalise the fraction of samples > 2x target (drops > ~33 ms @ 60 Hz).
|
|
/// </summary>
|
|
public static class FrameTimeGrader
|
|
{
|
|
public readonly struct Result
|
|
{
|
|
public readonly float Score; // 0..1
|
|
public readonly string Letter; // "A+", "A", ... "F"
|
|
public readonly float TargetMs; // detected target frame time
|
|
public readonly float P50Ms;
|
|
public readonly float P95Ms;
|
|
public readonly float P99Ms;
|
|
public readonly float FractionOver2x;
|
|
|
|
public Result(float score, string letter, float targetMs, float p50, float p95, float p99, float fracOver2x)
|
|
{
|
|
Score = score; Letter = letter; TargetMs = targetMs;
|
|
P50Ms = p50; P95Ms = p95; P99Ms = p99; FractionOver2x = fracOver2x;
|
|
}
|
|
}
|
|
|
|
private static readonly float[] _commonTargetsMs =
|
|
{
|
|
1000f / 144f, 1000f / 120f, 1000f / 75f, 1000f / 60f, 1000f / 30f,
|
|
};
|
|
|
|
public static Result Grade(RollingMetric frameTimes)
|
|
{
|
|
if (frameTimes.Count == 0)
|
|
return new Result(0f, "-", 1000f / 60f, 0f, 0f, 0f, 0f);
|
|
|
|
var p50 = Percentile(frameTimes, 0.50f);
|
|
var p95 = Percentile(frameTimes, 0.95f);
|
|
var p99 = Percentile(frameTimes, 0.99f);
|
|
var targetMs = SnapToCommonTarget(p50);
|
|
var fracOver2x = FractionOver(frameTimes, 2f * targetMs);
|
|
|
|
// Tail-latency component: P99 within [target, 4*target] → 1..0.
|
|
var tail = 1f - (p99 - targetMs) / (targetMs * 3f);
|
|
if (tail < 0f) tail = 0f; else if (tail > 1f) tail = 1f;
|
|
|
|
// Drop penalty: every 1% of samples > 2x target removes 2 points.
|
|
var score = tail - fracOver2x * 2f;
|
|
if (score < 0f) score = 0f; else if (score > 1f) score = 1f;
|
|
|
|
return new Result(score, ScoreToLetter(score, targetMs, p50), targetMs, p50, p95, p99, fracOver2x);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Percentile computed directly from the histogram bucket counts.
|
|
/// Returns the lower edge of the containing bucket — precision is bucket-bound, which is fine for grading.
|
|
/// </summary>
|
|
private static float Percentile(RollingMetric h, float p)
|
|
{
|
|
var target = (int)MathF.Ceiling(p * h.Count);
|
|
if (target < 1) target = 1;
|
|
|
|
var running = 0;
|
|
var slots = h.Slots;
|
|
for (var i = 0; i < slots.Length; i++)
|
|
{
|
|
running += slots[i].CountRecent;
|
|
if (running >= target)
|
|
return slots[i].ValueRangeMin;
|
|
}
|
|
return slots[^1].ValueRangeMin;
|
|
}
|
|
|
|
private static float FractionOver(RollingMetric h, float thresholdMs)
|
|
{
|
|
var slots = h.Slots;
|
|
var over = 0;
|
|
for (var i = 0; i < slots.Length; i++)
|
|
{
|
|
if (slots[i].ValueRangeMin >= thresholdMs)
|
|
over += slots[i].CountRecent;
|
|
}
|
|
return (float)over / h.Count;
|
|
}
|
|
|
|
private static float SnapToCommonTarget(float medianMs)
|
|
{
|
|
// Snap upward: we prefer to credit high-refresh displays rather than grade them against 60 Hz.
|
|
var best = _commonTargetsMs[^1];
|
|
var bestDist = float.PositiveInfinity;
|
|
for (var i = 0; i < _commonTargetsMs.Length; i++)
|
|
{
|
|
var t = _commonTargetsMs[i];
|
|
var d = MathF.Abs(medianMs - t);
|
|
if (d < bestDist) { bestDist = d; best = t; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
private static string ScoreToLetter(float score, float targetMs, float p50Ms)
|
|
{
|
|
// Bonus A+ only when essentially everything hits the target and the target is 60 Hz or faster.
|
|
if (score >= 0.97f && targetMs <= 1000f / 60f + 0.5f && p50Ms <= targetMs + 0.5f)
|
|
return "A+";
|
|
if (score >= 0.85f)
|
|
return "A";
|
|
if (score >= 0.70f)
|
|
return "B";
|
|
if (score >= 0.50f)
|
|
return "C";
|
|
if (score >= 0.30f)
|
|
return "D";
|
|
return "F";
|
|
}
|
|
}
|