Files
MoShiro/backend/src/MoShiro.Backend.Analysis/Engine/TestEvaluator.cs
T
justme 2e146d4da7
ci/woodpecker/push/main-amd64-build Pipeline was successful
refactor!: rename the project from Monitor to MoShiro [CI]
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
2026-10-07 02:03:25 +08:00

254 lines
9.0 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>导出值:与持续指标同一套 id,另有 <c>hy2.*</c> 等测试专用的值。</summary>
public Dictionary<string, double> Metrics { get; } = new(StringComparer.Ordinal);
/// <summary>逐步或逐秒的曲线,例如码率–丢包曲线。</summary>
public Dictionary<string, double[]> Series { get; } = new(StringComparer.Ordinal);
public List<TestStepResult> Steps { get; } = [];
public List<string> Caveats { get; } = [];
}
/// <summary>
/// 测试结果的计算:所有阶段的块到达之后,按方案算出指标和曲线。
/// udp_train 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。
/// </summary>
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<RawChunk> 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<RawChunk> chunks, TestResult result)
{
var buckets = new SortedDictionary<long, MetricSet>();
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<RawChunk> chunks, TestResult result)
{
var steps = new List<TestStepResult>();
var perSecond = new SortedDictionary<long, double>();
int index = 0;
// 一个阶段(一档码率)可能被切成好几个块(一个块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块):
// 阶梯里的一步是一个阶段,不是一个块——同一阶段的块先合并,再算丢包、抖动和实际吞吐。
foreach (IGrouping<int, RawChunk> 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<long, MetricSet>();
WindowEvaluator.Default.Evaluate(chunk, 24L * 3600 * 1000, buckets, new HashSet<string> { "train.loss", "train.jitter", "train.rate" });
foreach (MetricSet set in buckets.Values)
{
merged.Merge(set);
}
bytes += ReceivedBytes(chunk.Find("pkt")!);
var seconds = new SortedDictionary<long, MetricSet>();
WindowEvaluator.Default.Evaluate(chunk, 1000, seconds, new HashSet<string> { "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;
/// <summary>
/// 发送端的时间戳(Agent 时钟)在 UTC 上的跨度:所有块里第一个包到最后一个包,再补上一个平均间隔(n 个包有 n−1 个间隔)。
/// 没有时间戳就返回 null。
/// </summary>
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;
}
}