Fix one-off hashrate calculation

This commit is contained in:
Ben
2026-09-15 21:54:06 -04:00
parent 5889e3af5b
commit 789b529bd5
2 changed files with 62 additions and 15 deletions
+26 -3
View File
@@ -32,7 +32,30 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64); await statistics.addShares(client, 64);
} }
expect(statistics.hashRate).toBeGreaterThan(0); expect(statistics.hashRate).toBeCloseTo((64 * 4294967296) / 62);
});
it('keeps exactly the configured number of share samples', async () => {
for (let i = 0; i < 31; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000)));
await statistics.addShares(client, 64);
}
expect((statistics as any).submissionCache).toHaveLength(30);
expect((statistics as any).submissionCacheDifficultySum).toBe(30 * 64);
});
it('excludes pre-window work and corrects share-terminated sampling bias', async () => {
for (let i = 0; i < 30; i++) {
jest.setSystemTime(new Date(Date.parse('2026-05-06T12:00:00Z') + (i * 31000)));
await statistics.addShares(client, 64);
}
const elapsedSeconds = 29 * 31;
const expectedUnbiasedDifficulty = 28 * 64;
expect(statistics.hashRate).toBeCloseTo(
(expectedUnbiasedDifficulty * 4294967296) / elapsedSeconds,
);
}); });
it('should not suggest a difficulty change before enough time or shares have passed', () => { it('should not suggest a difficulty change before enough time or shares have passed', () => {
@@ -51,7 +74,7 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64); await statistics.addShares(client, 64);
} }
expect(statistics.getSuggestedDifficulty(64)).toBe(2048); expect(statistics.getSuggestedDifficulty(64)).toBe(1024);
}); });
it('does not retarget when accepted shares have a zero-duration sample window', async () => { it('does not retarget when accepted shares have a zero-duration sample window', async () => {
@@ -76,7 +99,7 @@ describe('StratumV1ClientStatistics', () => {
await statistics.addShares(client, 64); await statistics.addShares(client, 64);
} }
expect(statistics.getSuggestedDifficulty(128)).toBe(16); expect(statistics.getSuggestedDifficulty(128)).toBe(8);
}); });
it('should not suggest a difficulty below the configured minimum', () => { it('should not suggest a difficulty below the configured minimum', () => {
+36 -12
View File
@@ -16,9 +16,9 @@ export class StratumV1ClientStatistics {
} }
public async addShares(_client: ClientEntity, targetDifficulty: number) { public async addShares(_client: ClientEntity, targetDifficulty: number) {
var date = new Date(); const date = new Date();
if (this.submissionCache.length > CACHE_SIZE) { if (this.submissionCache.length >= CACHE_SIZE) {
this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty; this.submissionCacheDifficultySum -= this.submissionCache[0].difficulty;
this.submissionCache.shift(); this.submissionCache.shift();
} }
@@ -28,9 +28,12 @@ export class StratumV1ClientStatistics {
}); });
this.submissionCacheDifficultySum += targetDifficulty; this.submissionCacheDifficultySum += targetDifficulty;
const time = new Date().getTime() - this.submissionCache[0].time.getTime(); const elapsedSeconds = (date.getTime() - this.submissionCache[0].time.getTime()) / 1000;
if(time > 60000 && this.submissionCache.length > 2) { if (elapsedSeconds > 60) {
this.hashRate = (this.submissionCacheDifficultySum * 4294967296) / (time / 1000); const difficultyPerSecond = this.getDifficultyPerSecond(elapsedSeconds);
if (difficultyPerSecond != null) {
this.hashRate = difficultyPerSecond * 4294967296;
}
} }
} }
@@ -46,17 +49,12 @@ export class StratumV1ClientStatistics {
} }
} }
const sum = this.submissionCache.reduce((pre, cur) => {
pre += cur.difficulty;
return pre;
}, 0);
const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000; const diffSeconds = (this.submissionCache[this.submissionCache.length - 1].time.getTime() - this.submissionCache[0].time.getTime()) / 1000;
if (!Number.isFinite(diffSeconds) || diffSeconds <= 0) { const difficultyPerSecond = this.getDifficultyPerSecond(diffSeconds);
if (difficultyPerSecond == null) {
return null; return null;
} }
const difficultyPerSecond = sum / diffSeconds;
const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds; const targetDifficulty = difficultyPerSecond * this.targetSubmitShareEveryNSeconds;
if (!Number.isFinite(targetDifficulty) || targetDifficulty <= 0) { if (!Number.isFinite(targetDifficulty) || targetDifficulty <= 0) {
return null; return null;
@@ -69,6 +67,32 @@ export class StratumV1ClientStatistics {
return null; return null;
} }
/**
* Estimate work rate from a share-terminated sample window.
*
* A cache of N shares contains N - 1 observed inter-share intervals. The
* first share's work predates the window and must not be counted. Because
* the window closes on a share arrival, the reciprocal elapsed time also
* has the usual finite-sample Poisson bias; multiplying by
* (intervalCount - 1) / intervalCount removes it.
*/
private getDifficultyPerSecond(elapsedSeconds: number): number | null {
const sampleCount = this.submissionCache.length;
if (sampleCount <= 2 || !Number.isFinite(elapsedSeconds) || elapsedSeconds <= 0) {
return null;
}
const intervalCount = sampleCount - 1;
const observedDifficulty = this.submissionCacheDifficultySum
- this.submissionCache[0].difficulty;
const unbiasedDifficulty = observedDifficulty * (intervalCount - 1) / intervalCount;
const difficultyPerSecond = unbiasedDifficulty / elapsedSeconds;
return Number.isFinite(difficultyPerSecond) && difficultyPerSecond > 0
? difficultyPerSecond
: null;
}
private nearestPowerOfTwo(val: number): number { private nearestPowerOfTwo(val: number): number {
if (!Number.isFinite(val) || val <= 0) { if (!Number.isFinite(val) || val <= 0) {
return null; return null;