My test is accurate to about 10 µs on the Pi 4 - way sharper than NTP. (I'd need an oscilloscope to characterize the test time error better than that....)).
I'm interrested in your measurement methodology - are you reading the GPS pulses with a pin on the PI? Is your code called via pin change interrupt, or running in a busy loop?
Also, is both the GPS monitoring code and the NTP/internal timer code running on the same core?
0. RTL: Ultibo Pascal RTL (32 bit presently, 64 bit v. imminent (I'm told)).
1. Pi 4.
2. GPS
- UART @ 921600 bps (not that it matters much - if it were at the default 38400 bps it would work just as well)
- 1 PPS pulse programmed (by setting to the GPS via UART) for TOS on rising edge.
- The GPS is "told" to make measurements at 1 Hz.
- The GPS is told to send the NAV-PVT message at the rate of 1 per measurement. (in this case 1 Hz)
- NAV-PVT contains, amongst many things, the number of ms since the beginning of the GPS week (00:00:00.000000000 of Sunday morning, UTC).
- The GPS is told to send its LeapSecond value since the inception of GPS (1980 ish). Current Leap second is 18.
GPS manufacturer (ublox) states the 1 PPS error as ±30 ns RMS, ±60 ns (99%)
Antenna (active) is on the roof with a good view of the sky. At 1 Hz, the GPS tracks about 30 satellites (GPS, Galileo, Beidou and *&^%&*^%). For UTC, the time is off of the US GPS system - but the over determined position is from all sources (and SBAS) which helps the time be more accurate (ns level - not very germane here. I've also programmed the GPS for a 76ns delay for the RF cable (15.24m), but again, not really germane to these results).
3. GPIO
Interrupt programmed as follows. This was with a lot of help from big G at Ultibo. It's a FIQ (fastest) and is the fastest possible interrupt for users of the RTL.
There are some h/w details I don't fully understand, but, at least since the Pi4, interrupts can be finely "caught" on a pin-by-pin basis and dispatched to an interrupt handler. That is handled by the RTL. I've used this interrupt at up to 2000 Hz with no issues (but it's 1 Hz for the NTP checking program).
Procedure GPS_Pulse_Interrupt (P:pointer; PI, TI:Longword); // H/W interrupt trapped by GPIO unit with callback to here.
BEGIN
GPS_PPS_InterruptTime := ClockGetTotal; // On Pi4: 1/54000000s resolution
Inc(GPS_PPS_InterruptCount);
END;
Procedure Config_PPS_Interrupt;
CONST
PPS_intline = GPIO_PIN_4;
BEGIN
If Interrupt_PPS = nil then
begin
GPS_PPS_InterruptCount := 0; //this is an int64
Interrupt_PPS := GPIODeviceFindByDescription('BCM2838 GPIO');
if Interrupt_PPS = nil then begin ConsoleWindowWriteln(Con1, 'Set up GPS pulse interrupt - received NIL pointer'); exit end;
GPS_Int_Result := GPIODeviceInputEvent(Interrupt_PPS, PPS_intline,
GPIO_TRIGGER_RISING, GPIO_EVENT_FLAG_REPEAT or GPIO_EVENT_FLAG_INTERRUPT,
INFINITE, @GPS_Pulse_Interrupt, nil);
end;
END;
Method:
1. Notice in the interrupt that there is "GPS_PPS_InterruptTime". This tells me the time-since-start-of-the-computer of the top of the UTC second to sub µs level. Not sure "how" sub µsec as I don't have a scope to set up measurements.
.. this is number of ticks since the computer started.
.. each tick has a resolution of 1/54,000,000s. (about 18.5 ns).
What "time"?
A. System time: When NTP is active on the computer the computer system time is set by the NTP function in the Services unit. Thus, "Now" and "ClockGetTime" return the system time set by NTP. This program doesn't need to "do" anything with respect to that. (But does have a function to load a list of NTP sources and use them via a client function within the Services package). Or simply set the default NTP to a particular service and re-start.
B: GPSTime. Pretty trivial to take the GPS time and set the system time and that can be done in a few lines of code. Of course if NTP is running, when it polls and updates the system time there is no "notice given". So for this program GPS time is always kept separate from system time so that comparison can be made.
Just using a UART one can set their system time to well less than 1 second away from UTC. Depending on the maker of the device, the offset from top-of-second (TOS) will vary. To know when that "was" the 1 PPS pulse can be used to move the result into the µsec (or even 100 nsec) realm. An interrupt is the best way to do this but more complicated. OTOH, if one wanted to "watch" the pulse once per 5 minutes or 1 per hour, the end result would be very sharp. Some smart programming would have the "watch" period begin close to TOS. Note that there's also the possibility to characterize the drift rate of the computer's clock and that in turn can be used to decide on the polling rate. Once per day would suffice for many uses.
2. Elsewhere in the code is the stuff that reads in messages from the GPS receiver, identifies the message and passes to the correct decoder.
3. One of those decoders is the NAV-PVT decode thread. It has various "states" and begins with initialization states which (amongst other things):
- on receipt of the first valid nav message (state -1).
.... sends a request to the GPS to send the Leap Second message
.... waits in the next state (-2) (could take 2 or 3 seconds) for the GPS to reach at least a 2D fix _and_
,,,,,for the LeapSecondValid flag (set by the LeapSecond decoder thread).
.... computes the beginning of the week in the RTL's time frame reference, to wit: 100ns ticks since 1601-01-01. MINUS: the GPS LeapSecond.
THIS IS THE KEY: That value represents
exactly the beginning of the GPS week in the RTL's time system of 100ns ticks since 1601-01-01.
NOTICE:
a) The "real time" clock of the system is 1/10,000,000s (100ns) ticks origin 0 at 1601-01-01::00:00:00.000...
b) The "measurement" ticks are 1/54,000,000s (~18.5ns) since the computer started (h/w level tick counter of the Pi 4).
4. Meanwhile, the 1 PPS (from GPS PPS pin) interrupt "stamps" the Pi's H/W "tick counts" into GPS_PPS_InterruptTime. That is precisely when it happened to a resolution of ~18.5ns and to an accuracy somewhat better than 1 µsec.
The GPS NAV message also contains an error estimate for the time. This is usually around 35ns or so. I don't know if this applies to the 1PPS or not, but it is negligible for these tests. (The doc says: "Time accuracy estimate (UTC)" w/o further elaboration).
5. At 1 Hz, the GPS message comes in about 30 - 45 ms after the interrupt. This varies for too many reasons to get into, but it's in that range.
6. Once the message is in, and the ToWms value extracted from the message ("when the interrupt happened"), then the number of 100ns ticks since the beginning of 1601-01-01 can be computed (note, at 1 Hz, the ToWMS portion of the nav time is always exactly 0 as it represents "top of the second". The message arrives 30 - 45ms later, however).
Point of compareSample both the RTL "Now" [
ClockGetTime] (which is set to 100ns ticks since 1601-01-01 - and with NTP running represents NTP time(UTC)) and the [
ClockGetTotal] (ticks since "start of computer @ 1/54,000,000s per tick). It doesn't matter "when" we sample these values,
but they must be close together. Since pre-emption is possible, we'll do this inside a
2 1 µsec gate.
(Per Rt results it's usually around 35 and has never gone past 84 ticks or ~1.5 µs in a passed gate. (It's tempting to narrow the gate to 1 µs and I'll try that today and add a counter for times the loop has to repeat. Update: the 1 µs gate has been running for an
hour week. It had to repeat
at least once a few times until I removed the checking code. Theoretically improves the result by some tiny amount (½ µs? Not sure.)).
While the gate (Repeat .. Until) is 1 µs "wide", the time between NOWCGT := ... and NOWCGTot is much smaller - and the "real" time difference beteen the 2 at the "point of compare" is less than ½ µs. (And when Ultibo finally releases as 64 bit, probably less than ¼ µs).
(Also removed a "Rt := NowCGTot - Rt" before the Until test, saves about 5 ARM instructions. Will improve moreso with 64 bit).
Repeat //in case preempted between samples.
Rt:= ClockGetTotal; //Gate ref: 18.5 ns resolution
NowCGT := ClockGetTime; //sample (100 ns resolution) <<<< Is = NTP time but in the 100ns ticks since 1601-01-01 format
NowCGTot := ClockGetTotal; //sample ( 18.5 ns resolution)
Until (NowCGTot - Rt) < 54; //gate for close sampling : 1 µsec window.
The above
can be improved by disabling interrupts but care needs to be taken to ensure the system time has been set to some "real" value before the call to ClockGetTime (some deeper issue with potential deadlock if the time has not yet been set). I take care to do so with the GPS time before I start the thread that contains this code. So, with that caveat in mind, replace the above section with:
Mask := SaveIRQFIQ; //Disable IRQ/FIQ and return the previous state
NowCGT := ClockGetTime; //sample (100 ns resolution) <<<< Is = NTP time but in the 100ns ticks since 1601-01-01 format
NowCGTot := ClockGetTotal; //sample ( 18.5 ns resolution)
RestoreIRQFIQ (Mask); //Restore the previous state of IRQ/FIQ
Where Mask is a variable of type TIRQFIQMask . This is, to me, more satisfactory as driving the machine goes than the "gate" Repeat/Until loop above. But the prior works quite well and there is little time cost to it. The latter is slightly less friendly, but as long as the time is correctly set earlier in the run, then it is no issue. Further of course, the difference in time between the two samples is guaranteed to be very close to the bottom end of a µsec rather than
probably close to the bottom of a µs. I'd venture that in 64 bit code it would be sub µsec which will be very useful on other projects. Is this "bare metal"? No. But the coating is very thin....
So, onward ....
.. compute the exact time of the interrupt since 1601-01-01 in 100 ns ticks. ToWms is "Top of week milliseconds" -- ms since sun morning UTC. X 10000 = 100 ns ticks.
Function GPSTimeInCGT (ToWms:longword):int64; inline;
BEGIN
Result := WeekTickRef + int64(Towms) * 10000;
END;
Then add the number of 100ns ticks between the interrupt and the moment of comparison ("sampling "instant"") - and convert from 1/54,000,000 (~18.5ns) ticks to 1/10,000,000 (100ns) ticks:
NowGPS := GPSTimeInCGT(ToWms) + ((NowCGTot - GPS_PPS_InterruptTime) * 10 ) div 54;
Then we can compute the error between the NTP and GPS as simply:
Where dCG is in 100 ns tick units. +result == NTP "lateness".
In summary
it comes down to three "chunks" of time.--
A really big chunk of 100ns ticks since 1601-01-01 to the beginning of the GPS week - computed once per program execution (and updated if ToWMS < Previous_ToWMs) by adding 7 days of 100ns ticks to the WeekTickRef value. This happens once per week.
--
a chunk of 100ns ticks since top-of-the GPS week until the instant of the interrupt.
--
a tiny chunk of 100ns ticks since the interrupt until instant of sampling the "Now" (in NTP 100ns ticks) v the GPS (in 100ns ticks).
As to core run location.The NTP tests use some of my NAV code which is divided:
..serial GPS input thread: Core 0.
..message dispatcher: Core 1. ___ Frankly the message dispatcher and serial input can be on the same core with no issues at all.
..decoders: Core 2 ____ these could also run on the same core as the above.
..EXCEPT NAV-PVT messages: thread is on Core 3. it's a big (92 byte) message (as far as GPS goes) with about 35 parameters (I use about a dozen) and in the Nav app is at 25 messages per second. A lot of stuff happens with it.
..NTP code: no idea what core it's on. The Ultibo thread thrower is pretty well balanced in my experience. NTP does not "cycle" often. It's a slow machine that goes to the web (typically) once every 5 minutes. The process is not all that intensive.
Edit: typos, clarification and 1 PPS error range per manuf.
Edit: 1 PPS error
Edit: added stuff about core/NTP.
Edit: 2022-09-03 - added some details about time references (clarification) and fixed some typos.
Edit: 2022-09-05 - more massage...
Edit: 2022-09-07 - finicky detail around the sampling gate.
Edit: 2022-09-09 - alternate sampling scheme that avoids using a gate