Skip to content

Commit 9223ece

Browse files
committed
Add function for computing package trend
1 parent 13099b7 commit 9223ece

File tree

2 files changed

+203
-0
lines changed

2 files changed

+203
-0
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
const minAvgDailyDownloadsThreshold = 1000;
6+
const analysisWindowDays = 30;
7+
const weightAge = 0.25;
8+
const weightGrowthRate = 0.75;
9+
10+
/// Calculates the relative daily growth rate of a package's downloads.
11+
///
12+
/// Given a list with total daily downloads ([totalDownloads]), where the most
13+
/// recent day's data is at index 0, this function analyzes the downloads trend
14+
/// over the last ([analysisWindowDays]) days to determine how fast a package is
15+
/// growing relative to its own current download volume.
16+
///
17+
/// Returns -double.maxFinite:
18+
/// - If the [totalDownloads] list has fewer data points than
19+
/// [analysisWindowDays].
20+
/// - If the average daily downloads over the [analysisWindowDays] period are
21+
/// less than [minAvgDailyDownloadsThreshold].
22+
///
23+
/// Otherwise, it returns the calculated relative growth rate. A positive value
24+
/// indicates an upward trend in downloads, while a negative value indicates a
25+
/// downward trend. The magnitude represents the growth (or decline) rate
26+
/// normalized by the average daily downloads, allowing for comparison across
27+
/// packages of different popularity. For example, a slope of +10 downloads/day
28+
/// is more significant for a package with 100 average daily downloads (10%
29+
/// relative growth) than for a package with 10000 average daily downloads (0.1%
30+
/// relative growth).
31+
double computeRelativeGrowthRate(List<int> totalDownloads) {
32+
if (totalDownloads.length < analysisWindowDays) {
33+
// insufficient data points.
34+
return -double.maxFinite;
35+
}
36+
37+
final recentDownloads = totalDownloads.sublist(0, analysisWindowDays);
38+
39+
final averageRecentDownloads =
40+
recentDownloads.reduce((prev, element) => prev + element) /
41+
recentDownloads.length;
42+
43+
if (averageRecentDownloads < minAvgDailyDownloadsThreshold) {
44+
// Package does not meet the minimum average download threshold.
45+
return -double.maxFinite;
46+
}
47+
// We reverse the recentDownloads list for regression, since the first entry
48+
// is the newest point in time. By reversing, we pass the data in
49+
// chronological order.
50+
final growthRate =
51+
calculateLinearRegressionSlope(recentDownloads.reversed.toList());
52+
53+
// Normalize slope by average downloads to represent relative growth.
54+
// This measures how much the download count is growing relative to its
55+
// current volume.
56+
return growthRate / averageRecentDownloads;
57+
}
58+
59+
/// Computes the slope of the best-fit line for a given list of data points
60+
/// [yValues] using the method of least squares (linear regression).
61+
///
62+
/// The function assumes that the [yValues] are equally spaced in time and are
63+
/// provided in chronological order
64+
///
65+
/// The slope `b` is calculated using the formula: `b = (N * sum(xy) - sum(x) *
66+
/// sum(y)) / (N * sum(x^2) - (sum(x))^2)` where `N` is the number of data
67+
/// points.
68+
///
69+
/// Returns `0.0` if the slope cannot be determined reliably (e.g., if there are
70+
/// fewer than 2 data points, or if the denominator in the slope formula is
71+
/// effectively zero).
72+
double calculateLinearRegressionSlope(List<num> yValues) {
73+
double sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
74+
final n = yValues.length;
75+
76+
// Slope is undefined or 0 for fewer than 2 points.
77+
if (n < 2) {
78+
return 0.0;
79+
}
80+
81+
for (int x = 0; x < n; x++) {
82+
final y = yValues[x];
83+
sumX += x;
84+
sumY += y;
85+
sumXY += x * y;
86+
sumXX += x * x;
87+
}
88+
89+
final double denominator = (n * sumXX - sumX * sumX);
90+
91+
// If the denominator is very close to zero, the slope is unstable/undefined.
92+
if (denominator.abs() < 1e-9) {
93+
return 0.0;
94+
}
95+
return (n * sumXY - sumX * sumY) / denominator;
96+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:pub_dev/service/download_counts/package_trends.dart';
6+
import 'package:test/test.dart';
7+
8+
void main() {
9+
group('calculateLinearRegressionSlope', () {
10+
test('correctly calculates slope for chronological data', () {
11+
expect(calculateLinearRegressionSlope([10.0, 20.0, 30.0]), 10.0);
12+
expect(calculateLinearRegressionSlope([30.0, 20.0, 10.0]), -10.0);
13+
expect(calculateLinearRegressionSlope([10.0, 10.0, 10.0]), 0);
14+
});
15+
16+
test('return 0.0 if denominator is very small', () {
17+
expect(calculateLinearRegressionSlope([]), 0.0);
18+
expect(calculateLinearRegressionSlope([10.0]), 0.0);
19+
});
20+
});
21+
22+
group('computeRelativeGrowthRate', () {
23+
test('returns -double.maxFinite for insufficient data', () {
24+
final downloads = List<int>.generate(analysisWindowDays - 1, (i) => 1500);
25+
expect(computeRelativeGrowthRate(downloads), -double.maxFinite);
26+
});
27+
28+
test('returns -double.maxFinite for average downloads below threshold', () {
29+
final downloads = List<int>.generate(
30+
analysisWindowDays, (i) => minAvgDailyDownloadsThreshold - 1);
31+
expect(computeRelativeGrowthRate(downloads), -double.maxFinite);
32+
});
33+
34+
test('returns 0.0 for stable downloads meeting threshold', () {
35+
final downloads = List<int>.generate(analysisWindowDays, (i) => 2000);
36+
expect(computeRelativeGrowthRate(downloads), 0.0);
37+
});
38+
39+
test('calculates positive relative growth rate for positive trend', () {
40+
// Input list (newest first): [1645, 1635, ..., 1355] (30 values)
41+
// Average = 1500 for the first 30 values. Slope: 10.
42+
final downloads =
43+
List<int>.generate(analysisWindowDays * 2, (i) => 1645 - (i * 10));
44+
final expectedRate = 10.0 / 1500.0;
45+
expect(computeRelativeGrowthRate(downloads), expectedRate);
46+
});
47+
48+
test('calculates negative relative growth rate for negative trend', () {
49+
// Input list (newest first): [1355, 1365, ..., 1645]
50+
// Average = 1500. Slope: -10.
51+
final downloads =
52+
List<int>.generate(analysisWindowDays, (i) => 1355 + (i * 10));
53+
final expectedRate = -10.0 / 1500.0;
54+
expect(computeRelativeGrowthRate(downloads), expectedRate);
55+
});
56+
57+
test(
58+
'calculates positive relative growth for data barely meeting threshold',
59+
() {
60+
// Input list (newest first): [1016, 1015, ..., 987]
61+
// Average: 1001.5. Slope: 1.
62+
final downloads =
63+
List<int>.generate(analysisWindowDays, (i) => 1016 - i * 1);
64+
final expectedRate = 1.0 / 1001.5;
65+
expect(computeRelativeGrowthRate(downloads), closeTo(expectedRate, 1e-9));
66+
});
67+
68+
test('should handle fluctuating data with a slight positive overall trend',
69+
() {
70+
// Newest first. Average 1135.
71+
final downloads = <int>[
72+
1300,
73+
1250,
74+
1280,
75+
1230,
76+
1260,
77+
1210,
78+
1240,
79+
1190,
80+
1220,
81+
1170,
82+
1200,
83+
1150,
84+
1180,
85+
1130,
86+
1160,
87+
1110,
88+
1140,
89+
1090,
90+
1120,
91+
1070,
92+
1100,
93+
1050,
94+
1080,
95+
1030,
96+
1060,
97+
1010,
98+
1040,
99+
990,
100+
1020,
101+
970
102+
];
103+
final expectedRate = 683250.0 / 67425.0 / 1135.0;
104+
expect(computeRelativeGrowthRate(downloads), expectedRate);
105+
});
106+
});
107+
}

0 commit comments

Comments
 (0)