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; } }