Decoding radio time signals with RadioClock.jl

Mosè Giordano

UCL

2026-08-14

How do clocks know the time?

Deutsche Bahn railway station clock (source: Commons Wikimedia)

Radio-controlled clocks

  • Cheap clocks, watches, weather stations that are always right: no internet, no GPS, just a small longwave antenna
  • In central Europe they listen to DCF77: Deutschland, Call sign for radio clocks, Frankfurt, at 77.5 kHz
  • Transmitter in Mainflingen (55 km from Mainz!), operated on behalf of PTB, Germany’s national metrology institute
  • On air since 1959, steered by atomic clocks, reaches ~2000 km — most of Europe
  • Similar stations elsewhere: MSF (UK), WWVB (US), JJY (Japan)

One bit per second

  • At the start of every second the carrier amplitude drops briefly:
    • drop for 0.1 s → bit 0
    • drop for 0.2 s → bit 1
  • Second 59: no drop at all — that silence marks the start of the next minute
  • 59 bits per minute ≈ 1 baud: possibly the slowest data stream you rely on daily
  • Each minute transmits the date and time of the following minute (other protocols are different!)

The 60-bit format

Bits Meaning
0 Start of minute, always 0
1–14 Civil warning bits, encrypted weather forecast
15 Call bit (transmitter abnormality)
16 Summer time announcement
17–18 CEST / CET flags
19 Leap second announcement
20 Start of time information, always 1
21–28 Minutes (BCD) + parity
29–35 Hours (BCD) + parity
36–57 Day of month, day of week, month, year in century (BCD)
58 Date parity
59 Not transmitted (minute marker)

Signal amplitude example

Amplitude modulated signal of DCF77 as a function of time (source: Commons Wikimedia)

Signal in receiver modules

Diagram of the DCF77 signal at the output pin of a receiver module (source: Wolles Elektronikkiste)

RadioClock.jl

What it is

  • A pure-Julia decoder and encoder for the DCF77 time code: https://github.com/giordano/RadioClock.jl
  • DCF77Data: the whole 60-bit signal stored in a single UInt64, constructible from an integer or a binary string
  • decode / encode convert between DCF77Data and ZonedDateTime
  • Scope: it works on the bits — pair it with a radio/SDR front end to get them off the air
  • Fast: decoding a signal in < 50ns

Decoding a signal

A minute’s worth of bits, as received on 2025-07-17:

using RadioClock
data = DCF77Data("000000000000000001001000100100000011111010001111001010010010")
RadioClock.decode(DCF77, data)
2025-07-17T20:48:00+02:00

The pretty-printing of DCF77Data gives the answer away, too:

data
Date: 2025-07-17T20:48:00+02:00
Binary representation: 000000000000000001001000100100000011111010001111001010010010

Time zones-aware

  • decode returns a ZonedDateTime, not a DateTime
  • The CET/CEST flags are cross-checked against the real Europe/Berlin transition rules from TimeZones.jl
  • The summer time announcement bit must agree with an actual upcoming transition
  • The year is represented with only two digits: the signal itself is ambiguous, RadioClock.jl assumes 2000–2099

Consistency checks

  • Fixed bits are correct (0, 20, 59)
  • Even parity of minutes, hours, and date fields
  • Exactly one of the CET / CEST flags is set, consistently with the actual DST rules for the decoded instant
  • Transmitted day of week matches the decoded date
corrupted = DCF77Data(data.x  (UInt64(1) << 21))  # noise flipped one bit
RadioClock.decode(DCF77, corrupted)
AssertionError: Minutes data is not consistent with parity check. Input was 0x494f17c09320000
Stacktrace:
 [1] decode(::Type{DCF77}, data::DCF77Data)
   @ RadioClock ~/.julia/packages/RadioClock/cfYJb/src/dcf77.jl:140
 [2] top-level scope
   @ ~/work/2026-08-14-juliacon/2026-08-14-juliacon/radioclock/index.qmd:118

Encoding: the round trip

Generating the signal for a given time:

using TimeZones
talk = ZonedDateTime(2026, 8, 14, 10, 30, tz"Europe/Berlin")
signal = RadioClock.encode(DCF77, talk)
Date: 2026-08-14T10:30:00+02:00
Binary representation: 000000000000000001001000011000000101001010101000100110010000
RadioClock.decode(DCF77, signal) == talk
true

Handy for generating test data

Implementation notes

  • The whole signal is one UInt64: masks and shifts, no arrays, no allocations

  • DCF77 is an empty struct, a subtype of RadioSignal, used purely for dispatch:

    decode(::Type{DCF77}, data::Union{DCF77Data,UInt64,String}) -> ZonedDateTime
    encode(::Type{DCF77}, zdt::ZonedDateTime) -> DCF77Data

    Design easily extensible to other time signals

Live demo time

Conclusions

  • Wishlist: more signals behind the same RadioSignal abstraction
    • MSF (60 kHz, UK), WWVB (60 kHz, US), JJY (40/60 kHz, Japan)
  • Give it a try:
using Pkg
Pkg.add(url = "https://github.com/giordano/RadioClock.jl")

https://github.com/giordano/RadioClock.jl