using System.Text.Json;
using MoShiro.Protocol;
namespace MoShiro.Backend.Analysis;
public sealed class TestStepResult
{
public int Index { get; set; }
public double TargetMbps { get; set; }
public double LossPct { get; set; }
public double? JitterP95Ms { get; set; }
public double DeliveredMbps { get; set; }
}
public sealed class TestResult
{
/// 导出值:与持续指标同一套 id,另有 hy2.* 等测试专用的值。
public Dictionary Metrics { get; } = new(StringComparer.Ordinal);
/// 逐步或逐秒的曲线,例如码率–丢包曲线。
public Dictionary Series { get; } = new(StringComparer.Ordinal);
public List Steps { get; } = [];
public List Caveats { get; } = [];
}
///
/// 测试结果的计算:所有阶段的块到达之后,按方案算出指标和曲线。
/// udp_train 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。
///
public static class TestEvaluator
{
public const double SustainableLossPct = 5;
public const double SustainableLossLoosePct = 10;
public const double QosShiftPct = 5;
public static TestResult Evaluate(string plan, IReadOnlyList chunks)
{
var result = new TestResult();
EvaluateMetrics(chunks, result);
if (chunks.Any(c => c.Header.Kind == Kinds.UdpTrain.Id))
{
EvaluateLadder(chunks, result);
}
if (chunks.Any(c => c.Header.Partial))
{
result.Caveats.Add("partial_data");
}
if (chunks.Any(c => c.Header.Port is not (0 or 443)))
{
result.Caveats.Add("port_differs_from_service");
}
_ = plan;
return result;
}
private static void EvaluateMetrics(IReadOnlyList chunks, TestResult result)
{
var buckets = new SortedDictionary();
long span = 24L * 3600 * 1000;
foreach (RawChunk chunk in chunks)
{
WindowEvaluator.Default.Evaluate(chunk, span, buckets);
}
var merged = new MetricSet();
foreach (MetricSet set in buckets.Values)
{
merged.Merge(set);
}
double seconds = chunks.Sum(c => c.Header.DurationMs) / 1000.0;
foreach ((MetricDef def, MetricExport export) in MetricRegistry.AllExports())
{
if (merged.TryGet(def.Id, out Accumulator acc) && MetricDef.Export(acc, export, seconds) is { } value)
{
result.Metrics[export.Id] = value;
}
}
}
private static void EvaluateLadder(IReadOnlyList chunks, TestResult result)
{
var steps = new List();
var perSecond = new SortedDictionary();
int index = 0;
// 一个阶段(一档码率)可能被切成好几个块(一个块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块):
// 阶梯里的一步是一个阶段,不是一个块——同一阶段的块先合并,再算丢包、抖动和实际吞吐。
foreach (IGrouping phase in chunks.Where(c => c.Header.Kind == Kinds.UdpTrain.Id).GroupBy(c => c.Header.Phase ?? 0).OrderBy(g => g.Key))
{
RawChunk[] parts = [.. phase.Where(c => c.Find("pkt") is not null).OrderBy(c => c.Header.StartUtcMs)];
if (parts.Length == 0)
{
continue;
}
var merged = new MetricSet();
long bytes = 0;
foreach (RawChunk chunk in parts)
{
var buckets = new SortedDictionary();
WindowEvaluator.Default.Evaluate(chunk, 24L * 3600 * 1000, buckets, new HashSet { "train.loss", "train.jitter", "train.rate" });
foreach (MetricSet set in buckets.Values)
{
merged.Merge(set);
}
bytes += ReceivedBytes(chunk.Find("pkt")!);
var seconds = new SortedDictionary();
WindowEvaluator.Default.Evaluate(chunk, 1000, seconds, new HashSet { "train.rate" });
foreach ((long start, MetricSet set) in seconds)
{
if (set.TryGet("train.rate", out Accumulator rate) && rate is HistogramAcc rh && rh.Mean is { } mean)
{
perSecond[start] = mean;
}
}
}
var step = new TestStepResult
{
Index = index++,
TargetMbps = TargetMbps(parts[0].Header),
};
if (merged.TryGet("train.loss", out Accumulator loss) && loss is RatioAcc ratio)
{
step.LossPct = ratio.Percent ?? 0;
}
if (merged.TryGet("train.jitter", out Accumulator jitter) && jitter is HistogramAcc jh)
{
step.JitterP95Ms = jh.Quantile(0.95);
}
// 阶段的时长:从发送时间戳算(块头里的时长是 Probe 填的,每个块可能都带着整个阶段的时长,不能相加也不能取并集)
double spanSeconds = SendSpanSeconds(parts) ?? ((parts.Max(c => c.Header.StartUtcMs + c.Header.DurationMs) - parts.Min(c => c.Header.StartUtcMs)) / 1000.0);
step.DeliveredMbps = spanSeconds > 0 ? bytes * 8 / spanSeconds / 1_000_000 : 0;
steps.Add(step);
}
if (steps.Count == 0)
{
return;
}
result.Steps.AddRange(steps);
result.Series["step_mbps"] = [.. steps.Select(s => s.TargetMbps)];
result.Series["loss_pct"] = [.. steps.Select(s => s.LossPct)];
result.Series["delivered_mbps"] = [.. steps.Select(s => s.DeliveredMbps)];
result.Series["jitter_p95_ms"] = [.. steps.Select(s => s.JitterP95Ms ?? double.NaN)];
TestStepResult? sustainable = steps.Where(s => s.LossPct <= SustainableLossPct).MaxBy(s => s.TargetMbps);
TestStepResult? loose = steps.Where(s => s.LossPct <= SustainableLossLoosePct).MaxBy(s => s.TargetMbps);
result.Metrics["hy2.sustainable_mbps"] = sustainable?.TargetMbps ?? 0;
result.Metrics["hy2.sustainable10_mbps"] = loose?.TargetMbps ?? 0;
result.Metrics["hy2.effective_mbps"] = steps.Max(s => s.DeliveredMbps);
// 限速开始:丢包率随码率阶梯出现显著跳变的那一档。
int onset = ChangePoint.FirstJump([.. steps.Select(s => s.LossPct)], QosShiftPct);
if (onset >= 0)
{
result.Metrics["hy2.qos_onset_mbps"] = steps[onset].TargetMbps;
}
if (perSecond.Count >= 6)
{
double[] rates = [.. perSecond.Values];
double peak = rates.Max();
int second = ChangePoint.Detect(rates, Math.Max(1, peak * 0.2));
if (second >= 0 && rates[second..].Average() < rates[..second].Average())
{
result.Metrics["hy2.qos_onset_s"] = second;
}
result.Series["delivered_mbps_per_s"] = rates;
}
}
private static double TargetMbps(RawChunkHeader header) =>
header.Params.TryGetValue("mbps", out JsonElement v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : 0;
///
/// 发送端的时间戳(Agent 时钟)在 UTC 上的跨度:所有块里第一个包到最后一个包,再补上一个平均间隔(n 个包有 n−1 个间隔)。
/// 没有时间戳就返回 null。
///
private static double? SendSpanSeconds(RawChunk[] parts)
{
long min = long.MaxValue;
long max = long.MinValue;
long count = 0;
foreach (RawChunk chunk in parts)
{
RawStream? pkt = chunk.Find("pkt");
RawColumn? send = pkt?.Find("t_send");
if (pkt is null || send is null)
{
continue;
}
ChunkClock clock = chunk.Header.Clock;
for (int i = 0; i < pkt.Count; i++)
{
if (!send.IsValid(i))
{
continue;
}
long utcUs = (clock.UtcMs * 1000) + (send.Values[i] - clock.AgentMonoUs);
min = Math.Min(min, utcUs);
max = Math.Max(max, utcUs);
count++;
}
}
if (count < 2 || max <= min)
{
return null;
}
return (max - min) / 1_000_000.0 * count / (count - 1);
}
private static long ReceivedBytes(RawStream pkt)
{
RawColumn? recv = pkt.Find("t_recv");
RawColumn? size = pkt.Find("size");
if (recv is null || size is null)
{
return 0;
}
long bytes = 0;
for (int i = 0; i < pkt.Count; i++)
{
if (recv.IsValid(i))
{
bytes += size.Values[i];
}
}
return bytes;
}
}