namespace Monitor.Backend.Analysis;
/// 线路基础质量:RTT、丢包、抖动、连续丢包。
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 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);
}
///
/// Minecraft:TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。
/// 没有 tcp_echo 数据时,用 udp_echo 的丢包与 RTT 代入简化的 TCP 恢复模型估算,并标记为 estimated。
///
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 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
{
["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");
}
}
///
/// Hysteria2(Brutal 固定码率):有 hy2_ladder 测试结果时,用可持续码率、有效吞吐和限速开始的位置评分;
/// 没有测试时,只能用持续的 udp_echo 趋势粗略估算,并标记为 estimated。
///
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 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();
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"]);
}
}
/// REALITY(TLS 伪装的 TCP 代理):握手成功率与耗时、长连接稳定性、批量吞吐与重传。
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 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 All = [new BaseModel(), new McModel(), new Hy2Model(), new RealityModel()];
public static IServiceModel? Find(string id) => All.FirstOrDefault(m => m.Id == id);
/// 计算全部模型的评分(score.<模型id>)。
public static Dictionary ScoreAll(MetricView view)
{
var scores = new Dictionary(StringComparer.Ordinal);
foreach (IServiceModel model in All)
{
scores[model.Id] = model.Score(view);
}
return scores;
}
}