feat: Added AvgSpeed class to handle download speed calculations and statistics.

This commit is contained in:
jarvis2f 2024-12-25 14:27:30 +08:00
parent dd19756efb
commit d47ed3f5fe
4 changed files with 427 additions and 1 deletions

View file

@ -0,0 +1,187 @@
package telegram.files;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class AvgSpeed {
private final int interval;
private final TreeMap<Long, SpeedPoint> speedPoints;
private long maxSpeed;
private long minSpeed;
private final int smoothingWindowSize;
/**
* @param speed Speed since last point
*/
record SpeedPoint(long totalSize, long downloadedSize, long speed) {
}
public AvgSpeed() {
this(15 * 60); // Default interval is 15 minutes
}
public AvgSpeed(int interval) {
this(interval, 5);
}
public AvgSpeed(int interval, int smoothingWindowSize) {
this.interval = interval;
this.speedPoints = new TreeMap<>();
this.maxSpeed = 0;
this.minSpeed = Long.MAX_VALUE;
this.smoothingWindowSize = smoothingWindowSize;
}
/**
* Update download progress
*/
public void update(long totalSize, long downloadedSize, long timestamp) {
// Calculate speed since last point
long speed = calculateSpeed(downloadedSize, timestamp);
// Apply smoothing if we have enough points
if (speedPoints.size() >= smoothingWindowSize) {
speed = smoothSpeed(speed);
}
// Update max/min speeds only for non-zero speeds
if (speed > 0) {
maxSpeed = Math.max(maxSpeed, speed);
minSpeed = Math.min(minSpeed, speed);
}
// Add new speed point
speedPoints.put(timestamp, new SpeedPoint(totalSize, downloadedSize, speed));
// Remove old points outside the interval
long cutoffTime = timestamp - interval * 1000L; // Convert interval to milliseconds
speedPoints.headMap(cutoffTime).clear();
}
private long calculateSpeed(long downloadedSize, long timestamp) {
if (speedPoints.isEmpty()) {
return 0;
}
Map.Entry<Long, SpeedPoint> lastEntry = speedPoints.lastEntry();
long timeDiff = timestamp - lastEntry.getKey();
if (timeDiff <= 0) {
return 0;
}
long bytesDiff = downloadedSize - lastEntry.getValue().downloadedSize;
if (bytesDiff < 0) {
// Handle download restart
bytesDiff = downloadedSize;
}
return (bytesDiff * 1000L) / timeDiff; // Speed in bytes per second
}
private long smoothSpeed(long currentSpeed) {
List<Long> recentSpeeds = speedPoints.values().stream()
.skip(Math.max(0, speedPoints.size() - smoothingWindowSize))
.map(point -> point.speed)
.collect(Collectors.toList());
recentSpeeds.add(currentSpeed);
// Sort speeds to find median
Collections.sort(recentSpeeds);
long median = recentSpeeds.get(recentSpeeds.size() / 2);
// Filter out speeds that deviate too much from median
double threshold = median * 2.0;
List<Long> filteredSpeeds = recentSpeeds.stream()
.filter(speed -> speed <= threshold)
.toList();
// Calculate average of remaining speeds
return (long) filteredSpeeds.stream()
.mapToLong(Long::longValue)
.average()
.orElse(currentSpeed);
}
/**
* Get average speed in bytes per second for last interval
*/
public long getSpeed() {
if (speedPoints.size() < 2) {
return 0;
}
Map.Entry<Long, SpeedPoint> firstEntry = speedPoints.firstEntry();
Map.Entry<Long, SpeedPoint> lastEntry = speedPoints.lastEntry();
long timeDiff = lastEntry.getKey() - firstEntry.getKey();
if (timeDiff <= 0) {
return 0;
}
long bytesDownloaded = lastEntry.getValue().downloadedSize - firstEntry.getValue().downloadedSize;
if (bytesDownloaded < 0) {
bytesDownloaded = lastEntry.getValue().downloadedSize;
}
return (bytesDownloaded * 1000L) / timeDiff;
}
/**
* Get median speed from all recorded points
*/
public long getMedianSpeed() {
if (speedPoints.size() < 2) {
return 0;
}
List<Long> speeds = speedPoints.values().stream()
.map(point -> point.speed)
.filter(speed -> speed > 0)
.sorted()
.toList();
if (speeds.isEmpty()) {
return 0;
}
return speeds.get(speeds.size() / 2);
}
/**
* Get maximum recorded speed
*/
public long getMaxSpeed() {
return maxSpeed;
}
/**
* Get minimum recorded speed
*/
public long getMinSpeed() {
return minSpeed == Long.MAX_VALUE ? 0 : minSpeed;
}
/**
* Get speed statistics summary
*/
public SpeedStats getSpeedStats() {
return new SpeedStats(
interval,
getSpeed(),
getMedianSpeed(),
getMaxSpeed(),
getMinSpeed()
);
}
public record SpeedStats(int interval, long avgSpeed, long medianSpeed, long maxSpeed, long minSpeed) {
}
}

View file

@ -52,6 +52,8 @@ public class TelegramVerticle extends AbstractVerticle {
public TelegramRecord telegramRecord;
private final AvgSpeed avgSpeed = new AvgSpeed();
static {
Client.setLogMessageHandler(0, new LogMessageHandler());
@ -429,6 +431,8 @@ public class TelegramVerticle extends AbstractVerticle {
.put("sentBytes", bytes.v1)
.put("receivedBytes", bytes.v2)
);
jsonObject.put("speedStats", avgSpeed.getSpeedStats());
return jsonObject;
});
}
@ -676,6 +680,7 @@ public class TelegramVerticle extends AbstractVerticle {
private void onFileDownloadsUpdated(TdApi.UpdateFileDownloads updateFileDownloads) {
log.trace("[%s] Receive file downloads update: %s".formatted(getRootId(), updateFileDownloads));
avgSpeed.update(updateFileDownloads.totalSize, updateFileDownloads.downloadedSize, System.currentTimeMillis());
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_DOWNLOAD, updateFileDownloads));
}

View file

@ -0,0 +1,164 @@
package telegram.files;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AvgSpeedTest {
private AvgSpeed avgSpeed;
private static final int TEST_INTERVAL = 100; // 100 seconds for testing
private static final int SMOOTHING_WINDOW = 3;
@BeforeEach
void setUp() {
avgSpeed = new AvgSpeed(TEST_INTERVAL, SMOOTHING_WINDOW);
}
@Test
void testInitialState() {
assertEquals(0, avgSpeed.getSpeed());
assertEquals(0, avgSpeed.getMedianSpeed());
assertEquals(0, avgSpeed.getMaxSpeed());
assertEquals(0, avgSpeed.getMinSpeed());
}
@Test
void testConstantSpeed() {
// Simulate constant download speed of 100 bytes per second
long baseTime = System.currentTimeMillis();
long totalSize = 1000;
avgSpeed.update(totalSize, 0L, baseTime);
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec for 10 seconds
avgSpeed.update(totalSize, 2000L, baseTime + 20000); // 100 bytes/sec for another 10 seconds
AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats();
assertEquals(100, stats.avgSpeed(), 1.0, "Average speed should be 100 bytes/sec");
}
@Test
void testSpeedWithPause() {
long baseTime = System.currentTimeMillis();
long totalSize = 1000;
// Download at 100 bytes/sec
avgSpeed.update(totalSize, 0L, baseTime);
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec
// Pause for 10 seconds
avgSpeed.update(totalSize, 1000L, baseTime + 20000);
// Resume at 100 bytes/sec
avgSpeed.update(totalSize, 2000L, baseTime + 30000);
// Average speed should be: 2000 bytes / 30 seconds = 66.67 bytes/sec
assertEquals(67, avgSpeed.getSpeed(), 1.0,
"Speed should account for pause period");
}
@Test
void testDownloadRestart() {
long baseTime = System.currentTimeMillis();
long totalSize = 1000;
// Initial download
avgSpeed.update(totalSize, 0L, baseTime);
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec
// Restart download
avgSpeed.update(totalSize, 0L, baseTime + 15000);
avgSpeed.update(totalSize, 500L, baseTime + 20000); // 100 bytes/sec after restart
AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats();
assertTrue(stats.maxSpeed() >= 100,
"Max speed should capture the highest speed segment");
}
@Test
void testSpeedFluctuation() {
long baseTime = System.currentTimeMillis();
long totalSize = 1000;
avgSpeed.update(totalSize, 0L, baseTime);
avgSpeed.update(totalSize, 500L, baseTime + 5000); // 100 bytes/sec
avgSpeed.update(totalSize, 2500L, baseTime + 10000); // 400 bytes/sec
avgSpeed.update(totalSize, 3000L, baseTime + 15000); // 100 bytes/sec
AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats();
assertTrue(stats.maxSpeed() >= 300,
"Max speed should capture high speed period");
assertTrue(stats.minSpeed() <= 100,
"Min speed should capture low speed period");
}
@Test
void testDataSmoothing() {
long baseTime = System.currentTimeMillis();
long totalSize = 1000;
avgSpeed.update(totalSize, 0L, baseTime);
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec
avgSpeed.update(totalSize, 2000L, baseTime + 20000); // 100 bytes/sec
avgSpeed.update(totalSize, 7000L, baseTime + 30000); // 500 bytes/sec (spike)
avgSpeed.update(totalSize, 8000L, baseTime + 40000); // 100 bytes/sec
AvgSpeed.SpeedStats stats = avgSpeed.getSpeedStats();
assertTrue(stats.avgSpeed() < 300,
"Smoothed speed should be less than the spike value");
}
@Test
void testOldDataRemoval() {
long baseTime = System.currentTimeMillis();
long totalSize = 1000;
// Add initial points
avgSpeed.update(totalSize, 0L, baseTime);
avgSpeed.update(totalSize, 1000L, baseTime + 10000); // 100 bytes/sec
// Add point beyond interval
// Note: Need to move forward by interval milliseconds plus a buffer
avgSpeed.update(totalSize, 2000L, baseTime + (TEST_INTERVAL * 1000L) + 20000);
long speed = avgSpeed.getSpeed();
assertTrue(speed < 50,
"Speed should be very low when most points are outside the interval");
}
@Test
void testMedianCalculation() {
long baseTime = System.currentTimeMillis();
long totalSize = 1000;
// Create a sequence of very distinct speeds for clear median
avgSpeed.update(totalSize, 0L, baseTime);
avgSpeed.update(totalSize, 1000L, baseTime + 5000); // 200 bytes/sec
avgSpeed.update(totalSize, 1500L, baseTime + 10000); // 100 bytes/sec
avgSpeed.update(totalSize, 3500L, baseTime + 15000); // 400 bytes/sec
long medianSpeed = avgSpeed.getMedianSpeed();
assertTrue(medianSpeed >= 100 && medianSpeed <= 400,
"Median speed should be between the lowest and highest speeds");
}
@Test
void testExtremeCases() {
long baseTime = System.currentTimeMillis();
// Test with zero time difference
avgSpeed.update(1000L, 0L, baseTime);
avgSpeed.update(1000L, 100L, baseTime);
assertEquals(0, avgSpeed.getSpeed(),
"Speed should be 0 when time difference is 0");
// Test with large downloads
avgSpeed.update(1000000L, 0L, baseTime + 1000);
avgSpeed.update(1000000L, 500000L, baseTime + 6000); // 100,000 bytes/sec
assertTrue(avgSpeed.getSpeed() > 0,
"Speed should be calculated correctly with large numbers");
}
}

View file

@ -2,12 +2,16 @@ import React from "react";
import useSWR from "swr";
import {
AlertTriangle,
ArrowDown,
ArrowUp,
CheckCircle,
Clock,
CloudDownload,
Download,
File,
FileText,
Image,
LineChart,
LoaderPinwheel,
Music,
Network,
@ -16,11 +20,11 @@ import {
Video,
} from "lucide-react";
import { request, telegramApi, type TelegramApiArg } from "@/lib/api";
import prettyBytes from "pretty-bytes";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@/components/ui/button";
import useSWRMutation from "swr/mutation";
import type { TelegramApiResult } from "@/lib/types"; // Define a fetcher function to handle the API request
import prettyBytes from "pretty-bytes";
// Interface defining the structure of the data returned from the API
interface StatisticsData {
@ -38,6 +42,13 @@ interface StatisticsData {
sentBytes: number;
receivedBytes: number;
};
speedStats: {
interval: number;
avgSpeed: number;
maxSpeed: number;
medianSpeed: number;
minSpeed: number;
};
}
// Props interface for the component, expecting a telegramId as input
@ -124,6 +135,37 @@ const FileStatistics: React.FC<FileStatisticsProps> = ({ telegramId }) => {
},
];
const avgStatFields = [
{
label: "Avg",
value: prettyBytes(data.speedStats.avgSpeed, { bits: true }) + "/s",
icon: PauseCircle,
color: "text-blue-500",
bgColor: "bg-blue-100",
},
{
label: "Max",
value: prettyBytes(data.speedStats.maxSpeed, { bits: true }) + "/s",
icon: ArrowUp,
color: "text-green-500",
bgColor: "bg-green-100",
},
{
label: "Median",
value: prettyBytes(data.speedStats.medianSpeed, { bits: true }) + "/s",
icon: LineChart,
color: "text-purple-500",
bgColor: "bg-purple-100",
},
{
label: "Min",
value: prettyBytes(data.speedStats.minSpeed, { bits: true }) + "/s",
icon: ArrowDown,
color: "text-red-500",
bgColor: "bg-red-100",
},
];
return (
<div className="space-y-6 rounded-lg bg-gray-50 p-2 md:p-6">
<div className="flex-1 rounded-lg bg-white p-4 shadow-md">
@ -182,6 +224,34 @@ const FileStatistics: React.FC<FileStatisticsProps> = ({ telegramId }) => {
</div>
</div>
<div className="flex-1 rounded-lg bg-white p-4 shadow-md">
<div className="flex items-center space-x-3 border-gray-200">
<h3 className="text-md flex items-center space-x-2 font-semibold text-gray-700">
<Clock className="h-5 w-5 text-yellow-500" />
<span>Speed Statistics</span>
<span className="text-sm text-gray-500 font-medium">({data.speedStats.interval / 60} minute interval)</span>
</h3>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
{avgStatFields.map((stat, index) => (
<div
key={index}
className="flex flex-col space-y-3 rounded-xl border border-gray-100 p-4 transition-colors hover:border-gray-200"
>
<div className="flex items-center space-x-2">
<div className={`rounded-lg p-2 ${stat.bgColor}`}>
<stat.icon className={`h-5 w-5 ${stat.color}`} />
</div>
<span className="text-sm font-medium text-gray-500">
{stat.label}
</span>
</div>
<div className="text-lg font-semibold">{stat.value}</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<div className="flex-1 rounded-lg bg-white p-4 shadow-md md:col-span-2">
<h3 className="text-md flex items-center space-x-2 font-semibold text-gray-700">