Created
September 2, 2023 05:08
-
-
Save djnugent/57ee73177920bc590d8f50fc8fe6d0ae to your computer and use it in GitHub Desktop.
Time sync implementation in Javascript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Function to generate normally distributed random numbers | |
function normalDistribution(mean, stdDev) { | |
let u = 0, v = 0; | |
while (u === 0) u = Math.random(); | |
while (v === 0) v = Math.random(); | |
return mean + stdDev * Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); | |
} | |
function time_ns() { | |
const nanos = process.hrtime.bigint(); | |
return Number(nanos); | |
} | |
var createRingBuffer = function (length) { | |
/* https://stackoverflow.com/a/4774081 */ | |
var pointer = 0, buffer = []; | |
return { | |
get: function (key) { | |
if (key < 0) { | |
return buffer[pointer + key]; | |
} else if (key === false) { | |
return buffer[pointer - 1]; | |
} else { | |
return buffer[key]; | |
} | |
}, | |
push: function (item) { | |
buffer[pointer] = item; | |
pointer = (pointer + 1) % length; | |
return item; | |
}, | |
prev: function () { | |
var tmp_pointer = (pointer - 1) % length; | |
if (buffer[tmp_pointer]) { | |
pointer = tmp_pointer; | |
return buffer[pointer]; | |
} | |
}, | |
next: function () { | |
if (buffer[pointer]) { | |
pointer = (pointer + 1) % length; | |
return buffer[pointer]; | |
} | |
}, | |
buffer: function () { | |
return buffer; | |
}, | |
}; | |
}; | |
/* | |
Sync to a single time source | |
Use a low pass filter to adjust to the time source | |
Use a proportional controller to monotonicly converge to the time source | |
Terms: | |
true time: the time that the time source is reporting | |
system time: the time that the system is reporting - assumed to be monotonic | |
adjusted time: the system time adjusted to the true time - non-monotonic | |
synced time: the system time adjusted to the true time and monotonic | |
*/ | |
class TimeSync { | |
constructor(alpha = 0.1, dilation_p = 0.3, max_dilation_per_sec = 0.2, jump_on_first_measurement = false) { | |
this.alpha = alpha; // exponential moving average alpha for adjusted time offset | |
this.dilation_p = dilation_p; // proportional gain for sync time tracking adjusted time | |
this.max_dilation_per_sec = max_dilation_per_sec; // max dilation(in seconds) per second | |
this.jump_on_first_measurement = jump_on_first_measurement; // jump the synced time to the adjusted time on the first measurement | |
this.offsets = createRingBuffer(30); | |
this.adjustedTimeOffset_ns = 0; | |
this.syncedTime_ns = null; | |
this.lastSyncUpdate_ns = null; | |
this.lastMeasurement_ns = null; | |
this.lastMeasurementTime_ns = null; | |
this.stdDev = 0; | |
} | |
addMeasurement(trueTime_ns, now_ns = null) { | |
if (now_ns === null) { | |
now_ns = time_ns(); | |
} | |
this.lastMeasurement_ns = trueTime_ns; | |
const offset_ns = trueTime_ns - now_ns; | |
this.offsets.push(offset_ns); | |
// If this is the first measurement, set the adjusted time offset to the measured offset | |
// and jump the synced time to the adjusted time if jump_on_first_measurement is true | |
if (this.lastMeasurementTime_ns === null) { | |
this.adjustedTimeOffset_ns = offset_ns; | |
if (this.jump_on_first_measurement) { | |
this.jump(); | |
} | |
} | |
// Adjust the time offset using a low pass filter | |
this.adjustedTimeOffset_ns = (1 - this.alpha) * this.adjustedTimeOffset_ns + this.alpha * offset_ns; | |
// Calculate the standard deviation of the offsets | |
this.stdDev = Math.sqrt(this.offsets.buffer().reduce((acc, val) => acc + Math.pow(val - this.adjustedTimeOffset_ns, 2), 0) / this.offsets.buffer().length); | |
this.lastMeasurementTime_ns = now_ns; | |
} | |
// Monotonically increasing time - dilates to adjusted time | |
getSyncedTime_ns(now_ns = null) { | |
if (now_ns === null) { | |
now_ns = time_ns(); | |
} | |
const adjustedTime_ns = now_ns + this.adjustedTimeOffset_ns; | |
// return the system time if we have no synced time | |
if (this.lastSyncUpdate_ns === null) { | |
this.lastSyncUpdate_ns = now_ns; | |
this.syncedTime_ns = now_ns; | |
return this.syncedTime_ns; | |
} | |
// Tick the synced time forward | |
const elapsed_ns = now_ns - this.lastSyncUpdate_ns; | |
const syncedTimeNoCorrection_ns = this.syncedTime_ns + elapsed_ns; | |
// Calculate the error between the adjusted time and the synced time | |
// And dilate the synced time to reduce the error | |
const error_ns = adjustedTime_ns - syncedTimeNoCorrection_ns; | |
const max_correction_ns = this.max_dilation_per_sec * elapsed_ns; | |
var correction_ns = (this.dilation_p * error_ns) * (elapsed_ns / 1e9); | |
correction_ns = Math.max(Math.min(correction_ns, max_correction_ns), -max_correction_ns); // Clamp the correction | |
correction_ns = Math.max(correction_ns, -elapsed_ns / 2); // if the correction is negative it cant be more than half the magnitude of the elapsed time | |
if (syncedTimeNoCorrection_ns + correction_ns < this.syncedTime_ns) { | |
throw new Error("Time went backwards!"); | |
} | |
this.syncedTime_ns = syncedTimeNoCorrection_ns + correction_ns; | |
this.lastSyncUpdate_ns = now_ns; | |
return this.syncedTime_ns; | |
} | |
// Time that is adjusted to the true time, converges to true time | |
// No monotonicity guarantee | |
getAdjustedTime_ns(now_ns = null) { | |
if (now_ns === null) { | |
now_ns = time_ns(); | |
} | |
return now_ns + this.adjustedTimeOffset_ns; | |
} | |
// Jump the synced time to the adjusted time | |
jump(now_ns = null) { | |
if (now_ns === null) { | |
now_ns = time_ns(); | |
} | |
this.syncedTime_ns = now_ns + this.adjustedTimeOffset_ns; | |
this.lastSyncUpdate_ns = now_ns; | |
} | |
getStats(now_ns = null) { | |
if (now_ns === null) { | |
now_ns = time_ns(); | |
} | |
const systemTime_ns = now_ns; | |
const adjustedTime_ns = this.getAdjustedTime_ns(now_ns); | |
const syncedTime_ns = this.getSyncedTime_ns(now_ns); | |
const adjustedTimeOffset = this.adjustedTimeOffset_ns / 1e9; | |
const syncedTimeError = (syncedTime_ns - adjustedTime_ns) / 1e9; | |
const ageOfLastMeasurement = this.lastMeasurementTime_ns && (now_ns - this.lastMeasurementTime_ns) / 1e9; | |
return { | |
systemTime_ns: systemTime_ns, | |
adjustedTime_ns: adjustedTime_ns, | |
syncedTime_ns: syncedTime_ns, | |
adjustedTimeOffset: adjustedTimeOffset, | |
syncedTimeError: syncedTimeError, | |
lastMeasurement_ns: this.lastMeasurement_ns, | |
ageOfLastMeasurement: ageOfLastMeasurement, | |
stdDev: this.stdDev / 1e9, | |
}; | |
} | |
} | |
// Create a time synchronizer | |
const alpha = 0.03; | |
const dilation_p = 0.4; | |
const max_dilation_per_sec = 0.2; | |
const jump_on_first_measurement = true | |
const timeSync = new TimeSync(alpha, dilation_p, max_dilation_per_sec, jump_on_first_measurement); | |
// Simulate a time source like GPS | |
setInterval(() => { | |
const systemTime_ns = time_ns(); | |
const trueTime_ns = systemTime_ns + normalDistribution(50 * 1e9, 0.5 * 1e9); // Simulating system time with noise | |
timeSync.addMeasurement(trueTime_ns, systemTime_ns); | |
}, 1000); | |
// Simulate a system that is using the synced time | |
setInterval(() => { | |
console.log("Stats:", timeSync.getStats()); | |
}, 50); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment