194 lines
8.8 KiB
C#
194 lines
8.8 KiB
C#
namespace Monitor.Backend.Analysis;
|
||
|
||
/// <summary>线路基础质量:RTT、丢包、抖动、连续丢包。</summary>
|
||
public sealed class BaseModel : ServiceModelBase
|
||
{
|
||
private static readonly Curve s_rtt = new((30, 100), (80, 80), (150, 50), (300, 10), (500, 0));
|
||
private static readonly Curve s_loss = new((0, 100), (0.5, 90), (1, 75), (3, 40), (10, 0));
|
||
private static readonly Curve s_jitter = new((2, 100), (8, 85), (20, 50), (80, 0));
|
||
private static readonly Curve s_run = new((1, 100), (3, 80), (10, 30), (30, 0));
|
||
|
||
public override string Id => "base";
|
||
|
||
public override string Title => "Line quality";
|
||
|
||
public override int Version => 1;
|
||
|
||
public override IReadOnlyList<string> Needs { get; } = ["rtt.p95", "loss.total", "jitter.down.p95", "jitter.up.p95", "loss.run_max"];
|
||
|
||
public override ModelScore Score(MetricView view) =>
|
||
Combine(
|
||
view,
|
||
[
|
||
Dim(view, "rtt.p95", "RTT p95", 3, s_rtt),
|
||
Dim(view, "loss.total", "Packet loss", 4, s_loss),
|
||
Dim(view, "jitter.down.p95", "Jitter (down)", 1.5, s_jitter),
|
||
Dim(view, "jitter.up.p95", "Jitter (up)", 1.5, s_jitter),
|
||
Dim(view, "loss.run_max", "Longest loss run", 1, s_run),
|
||
],
|
||
estimated: false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Minecraft:TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。
|
||
/// 没有 tcp_echo 数据时,用 udp_echo 的丢包与 RTT 代入简化的 TCP 恢复模型估算,并标记为 estimated。
|
||
/// </summary>
|
||
public sealed class McModel : ServiceModelBase
|
||
{
|
||
private static readonly Curve s_rtt = new((40, 100), (100, 80), (200, 45), (400, 5), (600, 0));
|
||
private static readonly Curve s_stallRate = new((0, 100), (1, 90), (5, 60), (20, 20), (60, 0));
|
||
private static readonly Curve s_stallMax = new((150, 100), (500, 60), (2000, 10), (5000, 0));
|
||
private static readonly Curve s_retrans = new((0.1, 100), (1, 80), (3, 50), (10, 0));
|
||
|
||
public override string Id => "mc";
|
||
|
||
public override string Title => "Minecraft";
|
||
|
||
public override int Version => 1;
|
||
|
||
public override IReadOnlyList<string> Needs { get; } = ["tcp.rtt.p95", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans"];
|
||
|
||
public override ModelScore Score(MetricView view)
|
||
{
|
||
bool hasTcp = view.Has("tcp.rtt.p95") || view.Has("tcp.stall.per_min");
|
||
if (hasTcp)
|
||
{
|
||
return Combine(
|
||
view,
|
||
[
|
||
Dim(view, "tcp.rtt.p95", "TCP RTT p95", 3, s_rtt),
|
||
Dim(view, "tcp.stall.per_min", "Stalls per minute", 3, s_stallRate),
|
||
Dim(view, "tcp.stall_max", "Longest stall", 2, s_stallMax),
|
||
Dim(view, "tcp.retrans", "Retransmissions", 2, s_retrans),
|
||
],
|
||
estimated: false);
|
||
}
|
||
|
||
if (view.Get("loss.total") is not { } lossPct || view.Get("rtt.p95") is not { } rttMs)
|
||
{
|
||
return Combine(view, [], estimated: true);
|
||
}
|
||
|
||
// 一个丢失的包至少造成一次约 max(200 ms, 2·RTT) 的卡顿;约一半能被快速重传恢复。
|
||
const double messagesPerMinute = 1200;
|
||
double stallsPerMin = lossPct / 100 * messagesPerMinute * 0.5;
|
||
double stallMs = lossPct > 0 ? Math.Max(200, 2 * rttMs) * (1 + (view.Get("loss.run_max") ?? 1) / 3) : 0;
|
||
var estimate = new MetricView(new Dictionary<string, double>
|
||
{
|
||
["tcp.rtt.p95"] = rttMs + (lossPct > 0 ? Math.Max(200, 2 * rttMs) * lossPct / 100 * 5 : 0),
|
||
["tcp.stall.per_min"] = stallsPerMin,
|
||
["tcp.stall_max"] = stallMs,
|
||
["tcp.retrans"] = lossPct,
|
||
})
|
||
{
|
||
MeasuredOnServicePort = view.MeasuredOnServicePort,
|
||
};
|
||
return Combine(
|
||
estimate,
|
||
[
|
||
Dim(estimate, "tcp.rtt.p95", "TCP RTT p95 (estimated)", 3, s_rtt),
|
||
Dim(estimate, "tcp.stall.per_min", "Stalls per minute (estimated)", 3, s_stallRate),
|
||
Dim(estimate, "tcp.stall_max", "Longest stall (estimated)", 2, s_stallMax),
|
||
Dim(estimate, "tcp.retrans", "Retransmissions (estimated)", 2, s_retrans),
|
||
],
|
||
estimated: true,
|
||
"estimated_from_udp");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Hysteria2(Brutal 固定码率):有 hy2_ladder 测试结果时,用可持续码率、有效吞吐和限速开始的位置评分;
|
||
/// 没有测试时,只能用持续的 udp_echo 趋势粗略估算,并标记为 estimated。
|
||
/// </summary>
|
||
public sealed class Hy2Model : ServiceModelBase
|
||
{
|
||
private static readonly Curve s_sustainable = new((0, 0), (10, 30), (25, 60), (50, 80), (100, 100));
|
||
private static readonly Curve s_qosOnset = new((10, 20), (25, 50), (50, 75), (100, 95));
|
||
private static readonly Curve s_loss = new((0, 100), (1, 85), (3, 60), (8, 20), (15, 0));
|
||
private static readonly Curve s_jitter = new((3, 100), (10, 80), (30, 40), (100, 0));
|
||
private static readonly Curve s_rtt = new((40, 100), (120, 80), (250, 40), (500, 0));
|
||
|
||
public override string Id => "hy2";
|
||
|
||
public override string Title => "Hysteria2";
|
||
|
||
public override int Version => 1;
|
||
|
||
public override IReadOnlyList<string> Needs { get; } = ["hy2.sustainable_mbps", "hy2.effective_mbps", "hy2.qos_onset_mbps", "loss.total", "jitter.down.p95", "rtt.p95"];
|
||
|
||
public override ModelScore Score(MetricView view)
|
||
{
|
||
var dimensions = new List<ModelDimension>();
|
||
bool hasTest = view.Has("hy2.sustainable_mbps");
|
||
if (hasTest)
|
||
{
|
||
dimensions.Add(Dim(view, "hy2.sustainable_mbps", "Sustainable rate (loss ≤ 5%)", 4, s_sustainable));
|
||
dimensions.Add(Dim(view, "hy2.effective_mbps", "Effective throughput", 2, s_sustainable));
|
||
dimensions.Add(view.Get("hy2.qos_onset_mbps") is { } onset
|
||
? new ModelDimension("hy2.qos_onset_mbps", "Throttling onset", 2, onset, s_qosOnset.Evaluate(onset))
|
||
: new ModelDimension("hy2.qos_onset_mbps", "Throttling onset", 2, null, 100));
|
||
}
|
||
|
||
dimensions.Add(Dim(view, "loss.total", "Packet loss (trend)", hasTest ? 1.5 : 4, s_loss));
|
||
dimensions.Add(Dim(view, "jitter.down.p95", "Jitter (down)", hasTest ? 0.5 : 1.5, s_jitter));
|
||
dimensions.Add(Dim(view, "rtt.p95", "RTT p95", hasTest ? 0.5 : 1.5, s_rtt));
|
||
return Combine(view, dimensions, estimated: !hasTest, hasTest ? [] : ["no_load_test"]);
|
||
}
|
||
}
|
||
|
||
/// <summary>REALITY(TLS 伪装的 TCP 代理):握手成功率与耗时、长连接稳定性、批量吞吐与重传。</summary>
|
||
public sealed class RealityModel : ServiceModelBase
|
||
{
|
||
private static readonly Curve s_success = new((80, 10), (95, 60), (99, 90), (100, 100));
|
||
private static readonly Curve s_tls = new((150, 100), (400, 80), (1000, 40), (3000, 0));
|
||
private static readonly Curve s_resets = new((0, 100), (1, 80), (4, 40), (12, 0));
|
||
private static readonly Curve s_bulk = new((10, 30), (100, 60), (400, 85), (800, 100));
|
||
private static readonly Curve s_retrans = new((0.1, 100), (1, 80), (3, 50), (10, 0));
|
||
private static readonly Curve s_rtt = new((40, 100), (120, 80), (250, 40), (500, 0));
|
||
|
||
public override string Id => "reality";
|
||
|
||
public override string Title => "REALITY";
|
||
|
||
public override int Version => 1;
|
||
|
||
public override IReadOnlyList<string> Needs { get; } =
|
||
["connect.success", "connect.tls_ms.p95", "hold.resets.per_h", "tcp.bulk.peak.max", "tcp.retrans", "tcp.rtt.p95"];
|
||
|
||
public override ModelScore Score(MetricView view)
|
||
{
|
||
bool hasSession = view.Has("connect.success");
|
||
return Combine(
|
||
view,
|
||
[
|
||
Dim(view, "connect.success", "Connect success", 3, s_success),
|
||
Dim(view, "connect.tls_ms.p95", "TLS handshake p95", 2, s_tls),
|
||
Dim(view, "hold.resets.per_h", "Held connection resets", 2, s_resets),
|
||
Dim(view, "tcp.bulk.peak.max", "Peak bulk throughput", 2, s_bulk),
|
||
Dim(view, "tcp.retrans", "Retransmissions", 1, s_retrans),
|
||
Dim(view, "tcp.rtt.p95", "TCP RTT p95", 1, s_rtt),
|
||
],
|
||
estimated: !hasSession,
|
||
hasSession ? [] : ["no_connect_data"]);
|
||
}
|
||
}
|
||
|
||
public static class ModelRegistry
|
||
{
|
||
public static readonly IReadOnlyList<IServiceModel> All = [new BaseModel(), new McModel(), new Hy2Model(), new RealityModel()];
|
||
|
||
public static IServiceModel? Find(string id) => All.FirstOrDefault(m => m.Id == id);
|
||
|
||
/// <summary>计算全部模型的评分(score.<模型id>)。</summary>
|
||
public static Dictionary<string, ModelScore> ScoreAll(MetricView view)
|
||
{
|
||
var scores = new Dictionary<string, ModelScore>(StringComparer.Ordinal);
|
||
foreach (IServiceModel model in All)
|
||
{
|
||
scores[model.Id] = model.Score(view);
|
||
}
|
||
|
||
return scores;
|
||
}
|
||
}
|