This project was inspired by the Metronalmost project done by Mike Coats. I found his submission to the Hackaday One Hertz Challenge Contest to be delightfully irritating and oddly interesting. I particularly liked the idea that you could use a mapping of values across a distribution as the basis for skewing the randomness to accuracy over time. It also demanded that I create something to 'fix' it.

Project Goals

How Does It Work?

Thankfully, we are constantly being bombarded by RFI that gives us nanosecond accurate time and date information. There are plenty of options for GPS disciplined oscillators, but a GPS disciplined metronome would be absurd. So let's make one...

A GPS module can pick up the satellite signals that will give us the current date and time. Using this, we can set the time on the metronome controller accurately as a basis for our clock signal. Many of the GPS modules can also provide a highly accurate PPS (Pulse Per Second) signal that could be used to very accurately drive the metronome. That is beside the point...

What we can do is use the GPS messages and the PPS signal to determine when any given minute starts. This is the basis for the data that we want send to the clock. We can now create a 60 bit IRIG-H timecode, in the style of radio station WWVB, which can be sent to to any receiver that requires synchronization. Just to demonstrate the this is not just some wibbly wobbly, timey wimey whimsy, we can throw in things like Daylight Savings Time, error correction, and date and still have some bits left over for flavor.

Sending the Signal

Drawing the rest of the owl will require that we find a way to use the almost once per second ticks of the metronome as a carrier for the 60 bits of data we need to send each minute. But first, let's talk about how we propagate the signal. In the interest of time, I'm just going to hard wire a connection between the metronome and the clock. The signal will be indicated by a HIGH output for the same duration of time of each tick of the metronome. The clock can measure the duration of each pulse to determine what is being sent. Future versions for the project will include as many signaling options as possible so we can share our time with as many devices as we can dream up.

So now that we know the date and time (thanks, GPS!), we have to figure out how to get our time code modulated over the tell-tale heartbeat of the metronome. Since we're never ticking at exactly once per second, we can break the ticks into less than one second and more than one second. With that, we can manipulate each sequence of 60 ticks such that we can send our time code. There are a few problems with this that we need to solve...

With all that on the table, we can create our sixty bit time code, use our distribution mapping to create sixty less than/greater than one second pulses, trigger the servo for the metronome, and send our signal across the wire. Here are some details on the WWVB Timecode format.

The clock itself is as straightforward as the rest of this project. We receive the signal, detect the data frame, decode the data, and set the clock. Everything is amazingly complete, consistent, and well documented across the ESP32, MicroPython, and CYD (Cheap Yellow Display) ecosystem so what could possibly go wrong?

There are Problems

What time do we start?

If we don't put our time code in some kind of data frame, it will just look like a series of ones and zeroes and can not be interpreted. To get around this, the time code uses a third signal as a MARK between segments of data. A full 60 bit frame contains a total of seven MARKs. It starts with a MARK and ends with a MARK. There is also a MARK every 10 seconds, which makes it horribly inconvenient for encoding and decoding the signal because you have to account for MARK in the middle of some bytes you are trying to read for a given data point.

This seems a little off...and it's getting worse...

The second issue is that one of the project constraints indicates that the swinging of the metronome should average out to one beat per second over time. Because there are reserved bits that are always ZERO and with the nature of the BCD number encodings with dropped bits, there will be a skew to one side of our greater than/less than one second ticks.

There are also Solutions

Building the Data Frame

Following the lead from WWVB's time code, a data frame starts and ends with a MARK, signaling the receiver that a new data frame is starting. Adding a MARK signal is easy enough. We know that a zero is going to be less than 1.0 seconds and a one is going to be greater than 1.0 seconds. Setting min/max values for zero and one bits allows us to set a pulse duration for mark bits that is less than the lowest zero or higher than the highest one. Since these pulses are going to be on the extreme edges of our distribution, they will skew our results over the 60 bit code. There are seven mark bits in each data frame, so this can be significant. This should probably be fixed at some point. Perhaps manipulating the values could correct some of the overall bit skew.

Mapping the Distribution

Disclaimer: I am not good at statistics, probabilities or math. Most of this work is probably incorrect. I would love for people to riff on this and come up with some better mapping techniques.

Zero, one, and mark pulse durations are selected randomly from a mapped distribution file.  This distribution mapping can be created any way you'd like. If you want them to be as close to 1.0 seconds as possible, tighten it up. If you want it to be as far away as possible, you can do that too. Some sample code has been provided to test some randomly generated mappings.

Adjusting for Bit Skew

Each sixty bit time code will have it's own skew toward zero or one values. To adjust for this, we keep track of what has been sent and how far away our average pulses are from 1.0 second. This allows us to pick better random numbers and adjust them in a way that gets us closer to 1.0 on average.

To generate a series of mappings and pick the 'best' one, you can use generate_mappings.py. This will create a series of 'test.csv' mapping files and copy the best one to 'test_best.csv'. You can replace the mappings.csv file with any mapping of your choosing.

cd mappings
python generate_mappings.py

Sample Output

Generating mapping with 100 entries, mean 0.8500000000000002, 85.00000000000001% above mean
Writing mapping to 'test.csv'

Generated 100 entries in the mapping.
Length: 100
Mean: 0.76
Percentage above mean: 81.00%
Testing mapping with 999 iterations
Total Time:  59008.47 Average Time: 59.07 / 999 iterations
Test 256, Mean: 0.85, Above: 0.85, Avg Time: 59.07

Total Tests: 256, Best Mean: 0.45, Best Above: 0.70, Best Avg Time: 60.55

It would be an interesting experiment to apply some machine learning or AI models to create an optimal algorithm for generating the pulse durations.

Conclusions

We took the time and date from a GPS signal and used a metronome to synchronize a clock.

Things I wish I would have done

Things I wish I would NOT have done

Things I'll probably still do

Sample Output

Controller

Here is an example of the serial output of the controller

Sending 0: 0.8187668 us
Time: 2025-8-19  7:33:49.0  skew == -7.78231
Sending bit 58: 0
Sending 0: 0.7615986 us
Time: 2025-8-19  7:33:49.0  skew == -8.020714
Sending bit 59: 2
Sending 2: 0.3 us
Time: 2025-8-19  7:33:49.0  skew == -9.020714
Satellite Data:    In Use: 11, Visible: [30, 1, 2, 3, 24, 22, 6, 46, 48],  Used: [14, 17, 22, 1, 46, 30, 19, 2, 6, 3, 24]    Fix: 3, HDOP: 0.84, VDOP: 1.07, PDOP: 1.36    Latitude: 42° 45.22403' N, Longitude: 85° 47.90342' W, Altitude: 209.1    Speed: 0.25002 km/h, Course: 0.0, GEOID Height: -34.4
Date: (19, 8, 25), Time: [7, 33, 49.0]
UTC Timestamp: [7, 33, 49.0], UTC Offset: 0
Time: 2025-8-19  7:33:49.0
DEBUG: Day 2025-8-19 == 001000110001 == 001000110001
DEBUG: _set_days_bits(2025-8-19)
Current datetime: None
Day of the year: 231
DEBUG: Day 2025-8-19 == 001000110001 == 001000110001
Sending Timecode: 201100011200000011120010000112000100101200000001020101000002
Sending bit 0: 2
Sending 2: 0.3 us
Time: 2025-8-19  7:33:49.0  skew == -1
Sending bit 1: 0
Sending 0: 0.88125 us
Time: 2025-8-19  7:34:8.0  skew == -1.11875

Clock

Here is an example of the serial output of the clock

[57] Data[57]: 0  (818791 us)
Data: 2025-08-19 07:31:00 M01100010M000000111M001000011M000100101M000000010M_________M (!update/part)
[58] Data[58]: 0  (761626 us)
Data: 2025-08-19 07:31:00 M01100010M000000111M001000011M000100101M000000010M_________M (!update/part)
[59] Data[59]: MARK (300040 us)
Found M data frame marker
Updating screen...
Data: 2025-08-19 07:31:00 M01100010M000000111M001000011M000100101M000000010M010100000M (update)
Drawing screen...
[60] Data[00]: MARK (300047 us)
Found MM data frame marker
Ticks: 0
[01] Data[01]: 0  (881273 us)
Updating screen...
Data: 2025-08-19 07:32:00 M01100010M000000111M001000011M000100101M000000010M010100000M (update)
Drawing screen...
[02] Data[02]: 1  (1139376 us)
Data: 2025-08-19 07:32:00 M01100010M000000111M001000011M000100101M000000010M010100000M (!update)
[03] Data[03]: 1  (1052432 us)