LectroTIC-4 — 4-Channel Pulse Timestamper
The LectroTIC-4 will be available for purchase Summer 2026! Check back then, or send us a note to be notified when it ships.
Contents
- Overview
- Connecting to a host via USB
- Basic usage
- Indicator LEDs
- Serial input
- Command interface
- Binary format
- tsctl: a one-file Python utility
- Example applications
- Updating the firmware
- Comparison to similar instruments
- Specifications
- Schematics, Software, and Other Sundries
Overview
The LectroTIC-4 records the precise moment a pulse arrives on any of its four inputs and sends a stream of timestamps out over USB. Resolution is 4 nanoseconds, and the absolute accuracy is whatever your reference oscillator is: a rubidium standard, a GPS-disciplined oscillator, or any other 10 MHz source you trust.
It is zero-dead-time: the timescale never stops, restarts, or loses precision while the device is running. Every pulse you capture sits on the same continuous nanosecond-resolution timeline (the timeline starts at zero at power-up and counts forward indefinitely), whether the pulse arrived a microsecond or a year after the previous one.
The four channels are also coherent: they all sample against the same clock, on hardware counters started in lockstep, so a timestamp on CH 0 and a timestamp on CH 2 can be subtracted directly to get the true time difference between the two pulses; there is no per-channel clock that could drift. That makes the device suitable for long unattended runs where you need to know not just when each pulse occurred but also how each pulse relates to every other pulse seen across all four channels.
Each channel can be independently configured for rising-edge, falling-edge, or both-edge capture (use both edges to time a pulse’s width directly) and for an optional integer divider that reports only every Nth pulse, useful for downsampling busy inputs or matching a reference channel’s rate. These settings are available through a small SCPI command set on the same USB connection, and can be saved to the device so they survive a power cycle.
It is also high throughput. The on-device buffer captures pulses as little as 4 ns apart on a single channel for the first 2,048 pulses, and as little as 100 ns apart for the next 14,336 (16,384 timestamps total before the host must drain the buffer), orders of magnitude faster than the burst rates most laboratory time-interval counters can sustain. Once the buffer is in steady state the host becomes the bottleneck: the default ASCII output streams at roughly 25,000 timestamps per second, and an optional compact binary mode (selectable with a single SCPI command) pushes that to about 100,000 per second, near the practical ceiling of a USB Full-Speed link.
Beyond its pulse channels, the LectroTIC-4 timestamps text: an auxiliary serial input stamps line-oriented serial data onto the same timeline. Feed it the NMEA stream from a GPS receiver and every sentence arrives time-tagged; pair that with the receiver’s 1PPS pulse on a regular channel and the device’s zero-based timeline gains an absolute UTC anchor. Or feed it the debug log of the device under test and every printed message lands on the same timeline as the pulses that device produced. See Serial input.
It is also designed to be easy to use: the device connects over USB and enumerates as a standard virtual serial port on Linux, macOS, and Windows. On most hosts there’s nothing to install (no special driver, no companion app, no SDK), and the device emits plain ASCII text, one timestamp per line, that you can read with any terminal program or pipe directly into a script. The defaults (rising edge, divider 1, all four channels active) are sensible enough that no configuration is required for first-time use; the SCPI commands are there if and when you want them. And once you’ve dialed in a configuration you like, a single command saves it as the new power-on default, so the instrument always boots exactly the way you want it.
Connecting to a host via USB
The LectroTIC-4 enumerates as a standard USB CDC ACM (Communications Device Class) virtual serial port, the same class used by countless USB-to-serial cables, dev boards, and embedded instruments. There’s no Lectrobox-specific driver to install: every modern operating system already ships the host-side CDC driver, and the device shows up as a plain old serial port. Any program that can open one can read the timestamp stream.
The platform-specific sections below cover where the port appears, what to install (if anything), and which free, open-source terminal programs are good defaults.
-
Linux
The Linux kernel binds the built-in
cdc_acmdriver to the LectroTIC-4 automatically. The device appears as/dev/ttyACM0(or/dev/ttyACM1, etc., if you have other CDC devices already attached). No driver installation needed. Useful terminal programs:grabserial(pip install grabserial) — Python command-line tool that logs serial output with a host-side timestamp on each line; great for capture-to-file. Run asgrabserial -d /dev/ttyACM0.pyserial-miniterm /dev/ttyACM0(comes with thepyserialpackage).picocom— a small, friendly terminal:picocom /dev/ttyACM0.minicom— older but common and capable.screen /dev/ttyACM0— built into most distros.cat /dev/ttyACM0— see the line-discipline note below first.
Linux opens new serial ports in canonical mode, which echoes incoming characters back out to the device. That’s fine for an interactive shell, but it can corrupt the timestamp stream. Programs designed for serial work (
grabserial,pyserial-miniterm,picocom,minicom,screen, and our owntsctl.py) reconfigure the port automatically and need no setup. Simpler tools likecatdo not, so before using them, run:stty -F /dev/ttyACM0 raw -echoTo make this permanent, drop the following into
/etc/udev/rules.d/60-lectrotic.rulesand runsudo udevadm control --reload:SUBSYSTEM=="tty", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="71c4", \ RUN+="/usr/bin/stty -F /dev/%k raw -echo", \ SYMLINK+="lectrotic-4"The
SYMLINKline also gives you a stable/dev/lectrotic-4device node so you don’t have to guess whether your LectroTIC-4 came up as/dev/ttyACM0or/dev/ttyACM1. -
macOS
macOS loads its built-in CDC ACM driver automatically. The device appears as
/dev/cu.usbmodem*(the wildcard is a long serial-number-derived suffix). No driver installation needed. Useful terminal programs:grabserial(pip3 install grabserial) — Python command-line tool that logs serial output with a host-side timestamp on each line. Run asgrabserial -d /dev/cu.usbmodem*.pyserial-miniterm /dev/cu.usbmodem*(pip3 install pyserial).picocomandminicomvia Homebrew:brew install picocom minicom.screen /dev/cu.usbmodem*— built in.cat /dev/cu.usbmodem*— see the line-discipline note below first.
macOS opens new serial ports in canonical mode, which echoes incoming characters back out to the device. That’s fine for an interactive shell, but it can corrupt the timestamp stream. The dedicated serial programs above (
grabserial,pyserial-miniterm,picocom,minicom,screen, and our owntsctl.py) reconfigure the port automatically and need no setup. Simpler tools likecatdo not, so before using them, run:stty -f /dev/cu.usbmodem* raw -echo -
Windows
The LectroTIC-4 is a standard USB CDC (serial) device, and the driver Windows needs (
usbser.sys) is built into every version of Windows from XP through 11, but only newer versions of Windows bind it to the device automatically. Windows 10 (version 1607 / Anniversary Update, August 2016) and later does so on plug-in: the LectroTIC-4 appears in Device Manager under “Ports (COM & LPT)” as a new COM port and you can skip ahead to the terminal programs. Windows XP, Vista, 7, 8, and 8.1 don’t auto-bind the driver, so you have to tell Windows which built-in driver to use via a one-time INF install:- Download lectrotic4.inf.
- Right-click the downloaded
lectrotic4.infand select Install. - Accept the unsigned-driver warning if prompted (the INF routes to the OS-supplied driver; no third-party kernel code is involved).
- Unplug and replug the LectroTIC-4. It appears in Device Manager under “Ports (COM & LPT)”.
Once the device shows up as a COM port (look in Device Manager under “Ports (COM & LPT)” to find the COM number, e.g.
COM5), pick a terminal program:- PuTTY — pick “Serial,” type the COM port name (e.g.
COM5), click Open. - Tera Term — open-source terminal with a clean serial-port dialog.
- If you have Python installed
(python.org),
pip install pyserial grabserialthen runpyserial-miniterm COM5orgrabserial -d COM5from a Command Prompt or PowerShell window.
Basic usage
- Connect a 10 MHz reference clock (rubidium, GPS-disciplined, or any other stable source) to the 10 MHz In input.
- Plug the device into a USB port on your computer. The 10 MHz indicator LED begins flashing once the reference clock is locked. The reference clock is checked once at boot, so if you change or reconnect the source after the device is already powered, press the RESET button to make it re-lock; no need to unplug USB.
- Open the virtual serial port the device created:
/dev/ttyACM*on Linux,/dev/cu.usbmodem*on macOS, or a new COM port on Windows. - Connect signals to any of the four SMA inputs (CH 0 … CH 3) and timestamps will stream to your terminal program over USB.
Pulse requirements
By default each channel timestamps the rising edge of each pulse: the
moment the input goes from LOW to HIGH. The polarity is selectable per
channel via the SCPI command INPut<n>:SLOPe POSitive|NEGative|BOTH (see
Command interface below). Falling-edge mode
captures HIGH→LOW transitions instead; both-edge mode captures both,
which lets you measure pulse widths directly after sorting the host-side
records by timestamp. Internally each edge direction has its own
hardware capture channel, and the slope setting selects which are
armed; every reported timestamp carries the polarity that was latched
in hardware at capture time (the +/- column in text mode, a
dedicated bit in binary). The device preserves time order within each
(channel, polarity) sub-stream, but it does not merge sub-streams into
one globally sorted stream: records from different channels, or rising
versus falling records of one channel, can arrive out of time order
(see Text output format for a concrete example
and the host-side sorting rule). The minimum reliable pulse
width is 4 ns
in any mode; anything narrower may be missed. Slow edges are tolerated
thanks to the input’s ~250 mV of Schmitt-trigger hysteresis, but
extremely slow ramps (below a few mV/ns) may produce duplicate triggers
as the signal crosses the threshold band.
The input registers as low for voltages below ~1.0 V and as high for voltages above ~2.3 V, so a standard 3.3 V or 5 V CMOS-level pulse train works directly. Each input is protected by a 220 Ω series resistor and a BAT54S clamp to the 3.3 V rail and ground, which absorbs continuous inputs up to 12 V and brief overshoots well beyond that without damage.
Text output format
The device sends a stream of plain ASCII text terminated by a single \n
(LF) per line. No carriage returns. Output looks like this:
# Starting LectroTIC-4, version 0.17.0-0b23191c
0 5293.585203496 +
0 5293.587201024 -
2 5293.601004112 +
0 5293.589198608 +
3 5293.590112304 -
1 5293.601100008 +
Each timestamp line is <channel> <seconds>.<nanoseconds> <polarity>.
The seconds counter starts at zero when the device powers up and counts
forward indefinitely (it doesn’t wrap until ~136 years). Channel numbers
are 0 through 3. The polarity column is + for a rising edge (LOW→HIGH)
and - for a falling edge (HIGH→LOW).
If the serial input is enabled, received lines appear
in the stream with an S prefix instead of a channel number:
S 5293.601188352 $GPRMC,001225,A,2832.1834,N,08101.0536,W,12,25,251211,1.2,E,A*03
The format is S <seconds>.<nanoseconds> <data>, where the timestamp
marks the moment the line completed on the same timeline as the pulse
timestamps.
The stream is not globally sorted by time. Look closely at the
example above: the channel-0 timestamp ending in .589198608 appears
after the channel-2 timestamp ending in .601004112, even though it
is earlier. The device guarantees time order only within each (channel,
polarity) sub-stream; different sub-streams are drained in batches, so
their lines interleave in arrival order, not time order. If your
analysis needs one global timeline, sort the records by timestamp
host-side. All the example applications do
this where it matters, so they are also reference implementations of
the pattern.
Any line that starts with # is a status message rather than a timestamp:
# Starting LectroTIC-4, version <release>-<git-hash>— printed once after USB enumeration and the reference clock locks (e.g.0.17.0-0b23191c).<release>is a human-assigned, monotonically increasing firmware version you can compare against (e.g. require>= 2.0);<git-hash>pins the exact build it was cut from.# FATAL: External oscillator failure. Connect a 10MHz source and press reset.— printed once if the reference clock fails to start at boot, or drops out mid-run. The device stops emitting timestamps until reset.# serial: <N> lines dropped— printed when serial-input lines arrived faster than the USB stream could drain them; whole lines are dropped and counted, never truncated or corrupted.# ch<N>: <X> overcaptures, <Y> buf overflows— printed when a channel’s pulse rate exceeded what the device could handle. The two counters are distinct failure modes. Overcaptures count pulses that arrived closer together than the minimum event interval, so a new capture stomped on the previous one before it could be recorded. Buf overflows count pulses dropped because an internal buffer (the per-channel capture buffer or the 16,384-timestamp ring) filled faster than it could be drained. To eliminate overcaptures, space your pulses further apart; to eliminate buf overflows, reduce the average rate or split your work into bursts of ≤ 16,384 pulses with quiet periods between to let the buffer drain.
Don’t parse the ASCII stream into a 64-bit float
It’s tempting to read each line and do float(line.split()[1]). Don’t,
at least not for long runs. An IEEE-754 double (float64, what Python’s
float, numpy.float64, and pandas.read_csv give you by default) has
a 53-bit significand, so it can represent distinct values only up to
\(2^{53}\) nanoseconds. The seconds counter starts at zero at power-up,
so after about 104 days (\(2^{53}\) ns \(\approx 9.0 \times
10^{15}\)) adjacent nanosecond timestamps start rounding to the same
double, and the error grows from there: roughly 2 ns at 104 days, 4 ns
at 208 days, doubling each time the run length doubles. On a
4 ns-resolution instrument that is silent, cumulative corruption of your
data.
Three ways to avoid it:
- Use the tsctl.py library. Its
iter_records()/read_for()generators hand back each timestamp as separate integersecondsandnanosecondsfields, so there is no significand to overflow no matter how long the run. This is the easiest correct option if you’re working in Python. - Keep seconds and nanoseconds as separate integers. Split each ASCII
line on the
.and parse the two halves asint, then do arithmetic on the pair (or on a single arbitrary-precision Pythonintof total nanoseconds) rather than collapsing to a float. - Parse into an 80-bit extended float.
numpy.longdouble(exposed asnumpy.float128on x86-64) is really the x87 80-bit type, with a 64-bit significand. That pushes the precision-loss horizon out to \(2^{64}\) ns \(\approx\) 584 years, well past the 32-bit seconds counter’s own ~136-year wrap, so in practice it never drops a nanosecond. Note this is not automatic: plainfloat()orpandas.read_csvwithout an explicitdtype=np.longdoublestill hand you the lossy 64-bit double.
If the reference clock fails
The reference clock is checked once at boot and continuously monitored afterward. If the 10 MHz signal disappears or drifts out of range:
- The 10 MHz LED goes dark.
- The device emits the
# FATALline above on USB. - Timestamping halts. No further timestamps appear, even if pulses are arriving on the inputs. The device refuses to operate without a trusted reference, since any timestamps it produced would be wrong.
To recover, restore the 10 MHz signal at the 10 MHz In input and press RESET. There’s no need to unplug USB.
Indicator LEDs
10 MHz
- Dark: reference clock missing or failed. The device will refuse to emit timestamps until you restore the reference and reset.
- Continuous blink: reference clock locked, device is timing.
USB
- Dark: nothing on the host has opened the device’s serial port; this is the state both when the cable is unplugged and when it’s plugged in but no program is reading
- Solid: a host program has opened the port
- Blinking: data is flowing to that host program
CH 0–CH 3 (one per channel)
- Dark: no pulses arriving
- Occasional blink: a pulse has arrived
- Continuous blink: pulses are arriving at >5 Hz
Serial input
Alongside the four pulse channels, the Serial In connector accepts a 3.3 V-logic serial stream (8N1, any baud rate from 300 to 3,000,000) and timestamps lines of text onto the same timeline as the pulse timestamps, interleaved into the same output stream. Things you can do with it:
- Give the timeline an absolute epoch. The device’s timeline starts at zero at power-up; it is precise but not anchored to wall-clock time. Feed a GPS receiver’s NMEA output into the serial input and its 1PPS output into a pulse channel, and every run self-documents its mapping to UTC; see the worked example below.
- Correlate a device’s logs with its signals. Route the debug console of the device under test into the serial input and every log line lands on the same timeline as the pulses the device emitted, so “which edge was that error message reacting to?” becomes a subtraction.
- Tag events from other instruments that announce themselves over serial, without giving up a pulse channel.
The input is line-oriented: one record is emitted per received line. Lines must be terminated: a line feed (LF) is the canonical terminator, and CRLF or a bare CR work too. A line is never emitted until its terminator arrives. Lines can be up to 255 characters long (longer lines are discarded whole) and blank lines are ignored. Each record carries the time the line completed. Serial timestamps have 1.024 µs resolution and are accurate to well under a millisecond. They are stamped in firmware rather than capture hardware, deliberately coarser than the 4 ns pulse path but far finer than any serial line rate.
The serial input is off by default. Enable it by setting the baud rate and turning it on:
SERial:BAUD 9600
SERial:STATe ON
(or, with tsctl, just tsctl.py serial 9600). The setting is not
persisted: a power cycle or *RST returns the input to off / 115200.
In text mode, received lines appear in the stream as
S <seconds>.<nanoseconds> <data> lines (see
Text output format); in binary mode they are
carried as SERIAL_LINE records (see Binary format).
If lines arrive faster than USB drains, whole lines are dropped and
counted (# serial: <N> lines dropped in text, a SERIAL_LOST record
in binary). Drops are never silent, and a delivered line is never
truncated.
Example: absolute UTC time from GPS
Connect a GPS receiver’s 1PPS output to CH 0 and its NMEA serial output to Serial In, then enable the serial input at the receiver’s baud rate (typically 9,600). The stream now contains both the PPS edges and the sentences that name them:
0 5293.417306520 +
S 5293.599422464 $GPZDA,163205.00,21,07,2026,00,00*6A
0 5294.417306524 +
S 5294.599187968 $GPZDA,163206.00,21,07,2026,00,00*6A
Each PPS rising edge marks a UTC top-of-second with the full 4 ns capture resolution, and the sentence that follows tells you which second it was (GPS receivers emit the sentence a few tens to hundreds of milliseconds after the PPS edge it describes). The device’s timeline starts at an arbitrary moment, whenever it powered up, so the PPS lands at an equally arbitrary point within each device-time second; only its second-to-second spacing is meaningful. One subtraction yields the offset between device time and UTC (here, device time 5293.417306520 = 16:32:05.000000000 UTC), and applying that offset converts any other timestamp in the run, on any channel, to absolute UTC.
The accuracy of that conversion is set by your equipment, not by the LectroTIC-4. A GPS receiver’s PPS carries jitter and systematic offset of its own, ranging from tens of nanoseconds for a dedicated timing receiver to far more for a navigation module; averaging the offset across many PPS captures reduces the jitter but not the systematic part. And unless the 10 MHz reference is itself disciplined to GPS, its frequency error makes the device-to-UTC offset drift over the course of a run, so derive the offset from each second’s own PPS anchor rather than measuring it once. With a timing-grade receiver and a disciplined reference the mapping can be good to well under a microsecond; with modest equipment, plan on microsecond-class. Log both for the duration of a run and the mapping is continuously self-verifying.
Command interface
The LectroTIC-4 accepts SCPI-style commands on the same USB CDC port it
streams timestamps over. Sending commands is strictly optional: out of
the box, the device starts streaming on plug-in with all four channels in
rising-edge capture mode and no divider, and you never need to send
anything to use it. Commands are only there for users who want to change
those defaults: invert a channel’s edge polarity, set a divider, suppress
streaming during a synchronous query session, or switch the output stream
to a compact binary format that roughly quadruples the sustainable
record rate (see FORMat:DATA below).
Send a single command line (terminated with \n); the device responds
with silence for setters, one line of data for queries, or latches an
error that you can read back later. Commands are case-insensitive and
accept either the standard SCPI long form or the abbreviated short form
(the capital letters in each keyword name).
Standard IEEE 488.2 commands:
| Command | Effect |
|---|---|
*IDN? |
Identity string: Lectrobox,LectroTIC-4,<serial>,<release>-<git-hash> (e.g. Lectrobox,LectroTIC-4,LT4-0030003A3334510537303334,0.17.0-0b23191c). <serial> is the unit’s factory-unique serial number (a LT4- product tag followed by 24 hex digits), the same value the device reports as its USB serial number, so it’s unique and stable per unit. <release> is a monotonically increasing firmware version for ordered comparisons; <git-hash> is the exact build. |
*RST |
Reset all channels to defaults (rising-edge capture, divider 1), return the wire format to TEXT, re-enable streaming, disable the serial input (baud back to 115200), and save those defaults: a full factory reset that also clears any previously saved configuration. |
*CLS |
Clear the latched error. |
SYSTem:ERRor? (SYST:ERR?) |
Read and clear the most recent error. Returns 0,"No error" when nothing has gone wrong. |
Per-channel input configuration (channel suffix <n> is 0, 1, 2, or
3; if you omit it the command operates on channel 0):
| Command | Effect |
|---|---|
INPut<n>:SLOPe POSitive|NEGative|BOTH |
Choose rising-edge (default), falling-edge, or both-edge capture. EITHer is accepted as a synonym for BOTH. Both-edge mode timestamps every transition; sort the host-side records by timestamp before pairing rising and falling edges for pulse-width measurements. |
INPut<n>:SLOPe? |
Returns POS, NEG, or BOTH. |
INPut<n>:DIVider <N> |
Report only every Nth pulse on this channel. N=1 reports every pulse (the default). |
INPut<n>:DIVider? |
Returns the current divider as a decimal integer. |
Streaming output:
| Command | Effect |
|---|---|
OUTPut:STATe ON|OFF (OUTP:STAT 1|0) |
Enable (default) or disable continuous timestamp output. With output disabled, hardware capture keeps running and the internal buffer keeps filling, but nothing streams to USB and missed-pulse reports are suppressed. Use this if you need clean SCPI request-response without timestamps interleaving. Re-enable to drain the backlog. |
OUTPut:STATe? |
Returns 1 or 0. |
FORMat[:DATA] TEXT|BINary (FORM:DATA TEXT|BIN) |
Select the wire format of the timestamp stream. TEXT (default) emits one ASCII line per timestamp (<channel> <seconds>.<nanoseconds> <+|->\n); BINary emits a fixed 8-byte record per timestamp, roughly 4× faster on the wire (~100 k records/s vs ~25 k). The diagnostics that text mode prints as # lines are carried as 8-byte records too; see Binary format for the full layout. *RST returns to TEXT. |
FORMat[:DATA]? |
Returns TEXT or BIN. |
OUTPut:CLEar (OUTP:CLE) |
Drop everything currently buffered: the on-device timestamp ring and the in-flight USB TX bytes. Channel configuration (slope, divider, format, stream-enable) is preserved. In text mode the device emits a # output cleared comment line as a sync marker; in binary mode it emits an OUTPUT_CLEARED record. The normal stream then resumes; every record after the marker is one captured strictly after OUTPut:CLEar was processed. Use this when you change a channel’s slope or divider and want to read only post-change captures. |
Serial input (see Serial input):
| Command | Effect |
|---|---|
SERial:BAUD <rate> (SER:BAUD) |
Set the serial input’s baud rate, 300 to 3,000,000. Default 115200. Out-of-range values latch error -222 and leave the setting unchanged. |
SERial:BAUD? |
Returns the current baud rate as a decimal integer. |
SERial:STATe ON|OFF (SER:STAT 1|0) |
Enable or disable the serial input. Default OFF. Not saved by CONFig:SAVE; *RST and power-up return it to OFF. |
SERial:STATe? |
Returns 1 or 0. |
Saving configuration:
| Command | Effect |
|---|---|
CONFig:SAVE (CONF:SAVE) |
Save the current per-channel slope and divider so they’re restored automatically on the next power-up. Without this, settings return to defaults whenever the device is power-cycled. The save pauses capture for a few tens of milliseconds, so run it when the instrument is idle rather than mid-measurement. |
Example session:
*IDN?
Lectrobox,LectroTIC-4,LT4-0030003A3334510537303334,0.17.0-0b23191c
INP1:SLOP NEG
INP2:DIV 100
INP1:SLOP?
NEG
INP2:DIV?
100
*RST
INP1:SLOP?
POS
INP2:DIV?
1
Commands and timestamp data share the stream, so if you send a query while
pulses are flowing, the response line will appear inline with the
timestamps. To get a clean back-and-forth, send OUTPut:STATe OFF first
to pause the timestamp stream, run your queries, then OUTPut:STATe ON
to resume; the device buffers up to 16,384 timestamps internally during
the pause and drains them when streaming resumes.
Binary format
FORMat:DATA BINary switches the stream from ASCII lines to a fixed
8-byte record per event, about 4× the throughput of text
(~100,000 vs ~25,000 records/s), at the cost of having to decode it.
Both FORMat:DATA TEXT and *RST return the stream to text. The
Python tsctl library decodes this for you;
it’s documented here so you can also write a parser in another
language.
Record order on the wire follows the same rule as text mode: time
order is guaranteed only within each (channel, polarity) sub-stream,
and records from different sub-streams interleave in arrival order.
Sort by (seconds, ticks) host-side if you need a global timeline.
Every record is exactly 8 bytes: two little-endian uint32 words,
seconds then tag. One record type carries a variable payload: a
SERIAL_LINE record (below) is immediately followed by its line text
padded out to whole 8-byte groups, so the stream stays 8-byte aligned
at all times. There are no delimiters and no self-framing; the reader
must stay 8-byte aligned. OUTPut:CLEar gives you a known re-sync
point: it flushes everything and emits one OUTPUT_CLEARED record
(below), so a reader that lost alignment can scan for it and resume
cleanly.
The diagrams show each 32-bit word with bit 31 on the left; on the wire each word is those four bytes least-significant first. The record’s structure:
| Bit | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| seconds | seconds | |||||||||||||||||||||||||||||||
| tag | CHN | P | S | 4-ns ticks / message type | ||||||||||||||||||||||||||||
- CHN — bits 31–30: channel, 0–3.
- P — bit 29: edge polarity for timestamp records, 1 = rising (
+), 0 = falling (-). Special-message records keep this bit 0. - S — bit 28: 0 = timestamp, 1 = special message.
- bits 27–0 — for a timestamp, a 4-ns tick count (0–249,999,999); for a special message, the low 8 bits are the message type.
For timestamp records, the top three bits are also a direct channel/edge
index: tag >> 29 == channel * 2 + (polarity == '+').
Timestamp record (S = 0). The tick field counts 4 ns units within
the second (0–249,999,999); nanoseconds = ticks × 4 (0–999,999,996).
seconds is whole seconds since power-up (it counts forward ~136
years before wrapping). The binary equivalent of a text line
<channel> <seconds>.<nanoseconds> <polarity>:
| Bit | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| seconds | seconds since power-up | |||||||||||||||||||||||||||||||
| tag | CHN | P | 0 | 4-ns ticks (0–249,999,999) | ||||||||||||||||||||||||||||
Special message (S = 1). tag bits 7–0 are the message type,
bit 29 is 0, and the seconds word is its payload. These carry the same
information text mode prints as # lines, in-band so a binary reader
never has to switch back to TEXT:
| Type | Name | Channel | Payload (seconds word) |
|---|---|---|---|
| 0 | OUTPUT_CLEARED |
0 | all-zero |
| 1 | PULSES_LOST |
affected channel | counts, below |
| 2 | OSC_FAIL |
0 | all-zero |
| 3 | SERIAL_LINE |
0 | line timestamp, below; followed by the line text |
| 4 | SERIAL_LOST |
0 | count of dropped serial lines |
OUTPUT_CLEARED— the binary equivalent of the# output clearedline:OUTPut:CLEartook effect (ring and in-flight TX dropped). The whole record is the fixed 8 bytes00 00 00 00 00 00 00 10(every field 0, S = 1), and doubles as the stream’s re-sync marker:
| Bit | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| seconds | 0 | |||||||||||||||||||||||||||||||
| tag | 0 | 0 | 1 | 0 | type = 0 | |||||||||||||||||||||||||||
PULSES_LOST— the binary equivalent of# ch<N>: <X> overcaptures, <Y> buf overflows.CHNis the channel that lost edges; thesecondsword carries the two 16-bit counts (each saturates at 65,535):
| Bit | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| seconds | buffer overflows | overcaptures | ||||||||||||||||||||||||||||||
| tag | CHN | 0 | 1 | 0 (reserved) | type = 1 | |||||||||||||||||||||||||||
OSC_FAIL— the binary equivalent of the# FATAL: External oscillator failure …line: the 10 MHz reference failed and the device has halted. Channel 0, zero payload; no records follow until the device is reset:
| Bit | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| seconds | 0 | |||||||||||||||||||||||||||||||
| tag | 0 | 0 | 1 | 0 | type = 2 | |||||||||||||||||||||||||||
SERIAL_LINE— a line arrived on the serial input; the binary equivalent of a text-modeSline. Thesecondsword is the line timestamp’s whole seconds. Because the type field occupies the tag’s low 8 bits, the tick count keeps only its high bits: tag bits 27–8 are bits 27–8 of the 4-ns tick count, making the timestamp’s resolution 256 ticks = 1.024 µs (comfortably finer than the serial path’s sub-millisecond accuracy). Nanoseconds = (tag & 0x0FFFFF00) × 4:
| Bit | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| seconds | seconds since power-up | |||||||||||||||||||||||||||||||
| tag | 0 | 0 | 1 | tick count bits 27–8 (1.024 µs units) | type = 3 | |||||||||||||||||||||||||||
The record is immediately followed by the line’s text, framed as
⌈(1 + len) / 8⌉ zero-padded 8-byte groups: the first payload byte is
the line length len (1–255), the next len bytes are the text
(no terminator), and the remainder of the final group is zero
padding. The next 8-byte record begins immediately after the padding,
so record alignment is preserved throughout.
SERIAL_LOST— the binary equivalent of the# serial: <N> lines droppedline: serial-input lines outran the USB stream. Thesecondsword carries the number of whole lines dropped since the last report:
| Bit | 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| seconds | lines dropped | |||||||||||||||||||||||||||||||
| tag | 0 | 0 | 1 | 0 | type = 4 | |||||||||||||||||||||||||||
(See Text output format for what overcaptures and buffer
overflows mean and how to avoid them.) The one-time boot banner is the
only # line with no binary equivalent; it is text-mode only.
tsctl: a one-file Python utility
Any terminal program is fine for casual use, but if you want a more
convenient way to send SCPI commands and get the higher throughput
that binary mode unlocks,
tsctl.py
is a small, self-contained Python script (the only dependency is
pyserial) that wraps the SCPI
interface and the streaming output behind a friendly command line.
Save the file, chmod +x, and run.
It auto-detects the LectroTIC-4 by USB VID/PID (no --port needed
unless you want to override), and provides subcommands for everything
the device exposes:
tsctl.py idn— read*IDN?tsctl.py reset—*RSTtsctl.py slope <ch> [POS|NEG|BOTH]— set or query slopetsctl.py div <ch> [N]— set or query the dividertsctl.py save— save the current slope/divider so they survive a power cycletsctl.py format [text|binary]— set or query the wire format (persists aftertsctl.pyexits)tsctl.py serial [<baud>|off]— enable the serial input at a baud rate, disable it, or query its statetsctl.py raw '<scpi>'— send any SCPI command verbatimtsctl.py stream— forward the timestamp stream to stdout
The stream subcommand is the most useful one for everyday use. It
puts the device into binary output mode (the high-throughput
8-byte-per-timestamp wire format described above) for the duration of
the stream, then decodes those records back into the same
human-readable <channel> <seconds>.<nanoseconds> <polarity> ASCII lines
you’d see in text mode, so you get the throughput benefit of binary without
changing what your downstream pipeline reads. On exit it restores
whatever wire format the device was in before, so stream (like every
other subcommand except format) leaves the device untouched.
Two extra niceties: tsctl.py configures the serial port for
raw / no-echo operation itself (no stty needed), and on Linux it
works regardless of which /dev/ttyACM* the kernel happened to assign.
Library interface
tsctl.py is also a small Python library: drop it next to your own
script (or pip install directly from the URL), from tsctl import
LectroTIC4, and you have an autodetecting, context-managed handle
to the device with method-style access to every SCPI feature plus
the high-throughput binary stream:
from tsctl import LectroTIC4, Timestamp, PulsesLost
# Autodetects by USB VID/PID; pass port="/dev/ttyACM1" to override.
with LectroTIC4() as tic:
print(tic.idn())
tic.reset()
tic.set_slope(0, "BOTH")
tic.set_divider(0, 100) # one stamp per 100 edges
tic.save() # keep across power cycles
tic.discard_pending() # see only post-config records
for r in tic.read_for(5.0):
if isinstance(r, Timestamp):
print(f"channel {r.channel}: "
f"{r.seconds}.{r.nanoseconds:09d} s "
f"{r.polarity}")
elif isinstance(r, PulsesLost):
print(f"channel {r.channel}: LOST "
f"{r.overcaptures} oc / {r.buf_overflows} ovf")
The library automatically pauses the timestamp stream during every
query so SCPI responses come back uncontaminated. After a configuration
change, call tic.discard_pending() to drop records the device has
already buffered under the previous settings; it sends OUTPut:CLEar,
reads through the device’s # output cleared sync marker for you,
and returns only when every byte that arrives next is post-call. The
next read sees only captures that occurred after the call.
read_for(duration_s) is a generator that yields one of six
namedtuple types, the decoded binary format,
distinguished with isinstance:
Timestamp(channel, seconds, nanoseconds, polarity)— a captured edge.secondsandnanosecondsare integers (whole seconds since boot plus the 0–999,999,999 ns within it), kept separate on purpose so long captures never lose precision to float rounding.polarityis+for rising and-for falling.PulsesLost(channel, overcaptures, buf_overflows)— the device dropped edges on that channel.OutputCleared()— anOUTPut:CLEartook effect.discard_pending()consumes this internally as its sync marker, so you normally only see one if you clear the stream mid-capture yourself.SerialLine(seconds, nanoseconds, text)— a line arrived on the serial input.seconds/nanosecondsare the same integer pair asTimestamp(1.024 µs resolution);textis the line without its terminator.SerialLinesLost(count)— serial-input lines outran the USB stream;countwhole lines were dropped.OscillatorFailure()— the 10 MHz reference failed and the device halted until reset.
Records are yielded in arrival order, which is time order only within
each (channel, polarity) sub-stream; sort by (seconds, nanoseconds)
when your analysis needs a single global timeline (see
Text output format).
The generator exits cleanly after the given wall-clock window, even if
the device is silent, so you can wrap timestamp capture in your own
measurement logic without touching pyserial directly. The
regression test,
deadtime sweep,
and sustained-rate sweep
under src/app/timestamper/test/ are larger working examples of the
same library, and the example applications
below are complete instruments built on it.
Example applications
The timestamp stream is a surprisingly general measurement primitive: many bench instruments reduce to “capture every edge, then do arithmetic.” This section collects the applications we’ve built that way. Each is useful in its own right, and each doubles as a worked example of the tsctl library: the source is short, commented, and shows the intended patterns for auto-ranging dividers, budget-limited gates, and loss-checked capture.
Every app is a single Python file that expects tsctl.py in the same
directory (download both, or clone the repository and run from
src/app/timestamper/util/). The live meters need only
pyserial, autodetect the
LectroTIC-4 the same way tsctl.py does, and share two conventions:
run bare for a live in-place display, or pass --updates N to print N
plain-text readings and exit (the scripting mode used for the sample
output below).
Auto-ranging frequency counter
freq_counter.py
turns the instrument into a four-channel frequency counter. Since every
edge carries a 4 ns timestamp against your reference, frequency is pure
arithmetic: divider * (n - 1) / (t_last - t_first) over a gate,
computed on integer nanoseconds. Accuracy is your reference’s accuracy,
and resolution improves linearly with gate time (a 0.35 s gate resolves
about 10 parts per billion). All four channels are counted
simultaneously.
Auto-ranging is a binary search on each channel’s divider: a gate that
would blow the record-rate budget is abandoned within milliseconds and
the offending channel’s divider doubles; an empty channel halves back
toward divider 1. When all channels go quiet the gate stretches instead,
trading update rate for resolution exactly like a bench counter’s gate
control. Device-reported loss flags the reading OVERRUN.
Run it with no arguments to watch all four channels update in place a
few times per second, or --updates N for scripted use. With four
different sources connected:
ch0 10.00000 MHz [divider 1024]
ch1 1.234568 MHz [divider 128]
ch2 100.0000 kHz [divider 16]
ch3 977.0052 Hz [divider 1]
[gate 0.35 s]
Each line shows the frequency at the resolution the gate actually supports and the divider the auto-ranger settled on.
Frequency-ratio and phase comparator
phase_comparator.py compares two signals directly. Both channels are timestamped by the same 250 MHz counter, so the comparison needs no mixers, no external dividers, and no common trigger: each gate yields both frequencies, their ratio to about a part in 109, and, when the frequencies match, the phase offset between the pulse trains and its drift rate. The phase readout pairs each B record with the nearest A record modulo the period, so it is unaffected by where each channel’s divider lands in the train. For two locked sources the drift reads near zero; for independent sources it reads their fractional frequency difference directly (1 ns/s equals 1 ppb).
By default it compares channel 1 against channel 0; --channels 2 3
selects others. Comparing two outputs of a pulse generator driven from
the same reference, with a deliberate 100 ns offset between them:
A 1.000000 MHz B 1.000000 MHz B/A 1.000000000 dphi +100.0 ns
A 1.000000 MHz B 1.000000 MHz B/A 1.000000000 dphi +100.0 ns drift -0.00 ns/s
A 1.000000 MHz B 1.000000 MHz B/A 1.000000000 dphi +100.0 ns drift -0.00 ns/s
Pulse-width and duty-cycle meter
pulse_width.py
measures pulse shape. Capturing with slope BOTH turns each pulse into
a hardware-latched rising/falling timestamp pair: width is their
difference, period is rise-to-rise, and both come back as exact 4 ns
intervals with no analog threshold or scope cursor involved. Each gate
reports the median width, duty cycle, and repetition rate, plus the
spread of widths across the gate.
Pairing edges requires seeing every edge, so this mode runs undecimated; the meter probes the repetition rate first and refuses inputs too fast for loss-free both-edge capture. Slow signals stretch the gate automatically. Measuring a 10 kHz train with a 22 µs pulse width:
width 22.000 us duty 22.00 % rate 10.00000 kHz [3312 pulses, spread 8 ns]
width 22.000 us duty 22.00 % rate 10.00000 kHz [2605 pulses, spread 8 ns]
The 8 ns spread across thousands of pulses is the instrument’s quantization plus the source’s own jitter, measured pulse by pulse.
Oscillator-stability analyzer
allan.py
computes the overlapping Allan deviation of a periodic input against
your 10 MHz reference. The capture is already a phase record: every
timestamp is a direct time-error sample, so no mixer, dead-time
correction, or heterodyne stage is involved. With a rubidium or
GPS-disciplined reference the result is the device-under-test’s own
stability for averaging times above about 10 ms. The channel divider is
auto-ranged so any input rate fits the budget; the divider sets the
smallest tau and --duration bounds the largest. A stability analysis
is only meaningful loss-free, so any device-reported loss aborts the
run.
allan.py --duration 30 analyzes channel 0 for 30 seconds and prints
an ADEV table:
# 299729 samples over 30.0 s, mean frequency 10.0000000 kHz
#
# tau ADEV terms
100.000 us 3.260e-05 299727
1.000000 ms 3.262e-06 299709
10.000000 ms 3.263e-07 299529
100.000000 ms 3.280e-08 297729
1.000000000 s 3.272e-09 279729
10.000000000 s 3.263e-10 99729
This sample run measured a source driven from the instrument’s own reference, so what you see is the measurement floor: a clean 1/tau line from the 4 ns quantization, reaching parts in 1010 at tau = 10 s. An independent oscillator’s curve flattens out of that floor wherever its own noise takes over.
PPS analyzer
pps_eval.py
is the one offline tool in the set: it post-processes a logged capture
of a 1-pulse-per-second signal (a GPS receiver’s PPS output being the
classic case) rather than reading the device live. Log a capture with
tsctl.py stream > pps.log, then run pps_eval.py pps.log. It fits
the phase and frequency error of the PPS against the instrument’s
timeline, iteratively rejects outliers beyond four standard deviations,
and writes PNG plots of the phase error, raw and rolling-averaged
(--rolling-average sets the window; --plot-raw overlays the
unfiltered points). It needs numpy, pandas, and matplotlib.
$ tsctl.py stream > pps.log # capture for a few minutes, then ^C
$ pps_eval.py pps.log
Channel 0 fit: 185 points (no data for 0), freq error=7.416e-14, b=0.190383384
Wrote pps.log.time.plot.png
The fit line reports the PPS source’s fractional frequency offset relative to your 10 MHz reference across the run, plus the phase intercept; the plot shows the phase error over time. This sample analyzed a pulse generator locked to the instrument’s own reference, so the 7 × 10-14 residual is just the fit’s precision on a coherent pair. Against a real GPS receiver the same numbers show the receiver’s PPS wander and your reference’s frequency error, which is the measurement this tool exists for.
Updating the firmware
LectroTIC-4 firmware can be updated over USB with
dfu-util. Pick the image to
install from the version list at the end of this
section.
Install dfu-util:
- Linux:
apt install dfu-util(or the distro equivalent). - macOS:
brew install dfu-util. - Windows: download the
dfu-utilbinary from its website. The update-mode device also needs the WinUSB driver, which Zadig installs.
With the device connected and not mid-capture, run (Windows: omit
sudo):
sudo dfu-util -d 1209:71c4,0483:df11 -a 0 -s 0x08000000:leave -D lectrotic4-1.0.bin
This switches the device into its USB bootloader, writes the image,
and restarts into the new firmware. Verify the running version with
tsctl.py (see tsctl above):
tsctl.py idn
which reports it in the *IDN? string. If the update is interrupted,
the device remains in its bootloader and is still visible to
dfu-util; re-run the same command.
Firmware versions
- 1.0 — initial firmware release. Coming soon!
Comparison to similar instruments
Most time-interval counters, from the legendary HP 5370A/B (1979, 20 ps single-shot, still the gold standard in the time-nuts community) through the modern Pendulum CNT-91, use analog interpolation: charging a capacitor between the input edge and the next clock tick, digitising the resulting voltage, and reconstructing the sub-clock fraction of the time interval. The HP and Pendulum instruments call this a “vernier interpolator,” the SR620 calls it a time-amplitude converter, the Keysight 53230A calls it a multi-stage time interpolator, and the TAPR TICC delegates to a TI TDC7200 chip that does the same job in silicon. This technique gets ps-class single-shot resolution, but the analog stage takes finite time to charge, digitise, and reset, so those instruments trade sample rate against resolution. The higher the single-shot resolution, the lower the back-to-back capture rate the analog stage can sustain.
We chose the opposite trade-off. The LectroTIC-4 has no analog interpolator at all: it just runs 32-bit hardware counters at 250 MHz, phase-locked in hardware so all four channels share one timeline, and snapshots them on every input edge via DMA. That floors single-shot resolution at one clock tick (4 ns) but lets the device record edges as fast as they arrive, in bursts at the timer’s full hardware rate, with no per-sample dead time. If you need ps-class resolution on a single edge, one of the instruments in the table below is the right tool. If you need to capture every edge of a high-rate train, four channels at once, on a continuous coherent timeline, this is the right tool.
| Instrument | Channels | Single-shot resolution | Minimum sample interval | Sustained rate to host | Internal buffer | Capture model | Interpolation technique | Approx. price (USD) |
|---|---|---|---|---|---|---|---|---|
| Lectrobox LectroTIC-4 | 4 | 4 ns | 4 ns (first 2,048 edges per channel) | 25,000 readings/s ASCII; 100,000 readings/s binary | 16,384 timestamps | Continuous timestamp stream: a 250 MHz hardware timeline runs from boot, no measurement sessions to start/stop | None (pure digital counter) | ~$75 |
| TAPR TICC (open-source kit) | 2 | 60 ps | 833 µs (binary mode, 1,200 readings/s) | 1,200 readings/s | minimal | Continuous timestamp stream: internal timescale runs from boot | TDC7200 IC (silicon vernier) | $249 (kit) |
| HP 5370A/B (1979) | 2 | 20 ps | 125 µs (binary, 800 readings/s) | 800 readings/s binary; 10–20/s formatted | small (HP-IB stream) | Discrete triggered measurements; no shared timescale | Dual vernier interpolator | $300–700 used |
| HP 5335A (1981) | 2 | 1 ns (100 ps with averaging) | 250 ms (4 readings/s NORM mode) | ~4 readings/s NORM | none | Discrete triggered measurements: each reading is a standalone interval, no shared timescale across readings | Vernier interpolator | $100–300 used |
| HP 5371A / 5372A (1989) | 2 | 150 ps (5371A) / 200 ps (5372A) | 100 ns / 75 ns (10 / 13.3 M readings/s in continuous mode) | block transfer over HP-IB | small | Continuous-frequency / time-interval measurement bounded by capture buffer; no global timescale | Multi-stage vernier interpolator | $500–2,000 used |
| Stanford Research SR620 | 2 | 25 ps RMS | 800 µs per reading (1.25 kHz max) | ~1.25 kHz max | small | Discrete triggered measurements; no shared timescale | Time-amplitude converter (charge-cap → ADC) | ~$5,000 new |
| Keysight 53230A | 2 (+ optional RF) | 20 ps | 1 µs (timestamp mode, 1,000,000/s to memory) | 75,000 readings/s to host | 1 M readings | Discrete-by-default; optional continuous timestamp mode for the duration of a measurement session, bounded by buffer | Multi-stage analog interpolator | ~$5,000–7,000 new |
| Pendulum CNT-91 / CNT-91R | 2 (3 on CNT-91R) | 50 ps | 4 µs (timestamp mode, 250,000/s to memory) | 15,000 readings/s block transfer | 750 k stamps (3.5 M with option) | Discrete-by-default; optional continuous-stream measurement session, bounded by buffer | Vernier interpolator | ~$8,000–15,000 new |
| Pendulum CNT-104S | 4 | 7 ps | 50 ns per channel (20 M results/s aggregate, gap-free time-stamping) | block transfer (USB / LAN; not specified per second to host) | very large (4-channel parallel time-stamp memory) | Continuous, gap-free timestamp stream on all 4 channels: Pendulum markets this for phase comparison of 4 atomic clocks | Reciprocal interpolating time-stamping with calibration | ~$10,800 new |
Two instruments above are open-source: the TAPR TICC (designed for the amateur time-and-frequency community by John Ackermann N8UR), and the LectroTIC-4. Everything else is a closed proprietary instrument. Of the proprietary instruments, the HP 5370A/B, 5335A, and 5371A/5372A are out of production but readily available on the secondary market; the SR620, 53230A, CNT-91, and CNT-104S are still in current production from their respective manufacturers.
The Pendulum CNT-104S is the only other instrument in the table with four input channels and gap-free time-stamping on all of them, and Pendulum explicitly markets it for the same use case (phase comparison of four atomic clocks) at roughly 140× the price of the LectroTIC-4. The HP 5371A/5372A are the vintage instruments most likely to come up when time-and-frequency hobbyists want fast, continuous capture at modest single-shot precision; their 10–13 MSa/s continuous mode is the closest entry in the table to the LectroTIC-4’s burst rate, but only on a single channel and only in a measurement session bounded by the instrument’s capture memory.
In short: the eight instruments above are precision laboratory tools for measuring an individual event with picosecond-class accuracy. The LectroTIC-4 is a streaming recorder for measuring every event in a continuous train across four coherent channels, at the resolution ceiling of what an STM32H523’s hardware counter can offer in pure digital silicon.
Specifications
Channels and timing
| Channels | 4 (SMA connectors) |
| Time resolution | 4 ns |
| Minimum pulse width | 4 ns |
| Time accuracy | Limited by your 10 MHz reference. With a rubidium standard, parts in 1011. |
| Dead time | Zero: every captured pulse sits on the same continuous timeline, indefinitely. |
| Minimum interval between pulses (per channel) | 4 ns for the first 2,048 pulses in a burst; 100 ns up to 16,384 pulses; 25,000 pulses/sec sustained in ASCII output mode, 100,000 pulses/sec sustained in binary output mode. |
| Stream ordering | Time-ordered within each (channel, polarity) sub-stream; not globally sorted across sub-streams. Sort host-side for one global timeline. |
| Internal buffer | 16,384 timestamps. If a burst exceeds this, the device drops the surplus and reports the loss as a # ch<N>: ... buf overflows line in the output stream, so you always know if you missed pulses. |
Inputs
| Trigger slope | Rising edge, falling edge, or both edges, selectable per channel via SCPI. Default rising. |
| Per-channel divider | 1 to 4,294,967,295 (32-bit). With divider N, the channel reports only every Nth pulse. Default 1 (every pulse). |
| Input coupling | DC |
| Input impedance | High: chip-side is a CMOS input (essentially open-circuit DC, ~9 pF AC). A 220 Ω series resistor sits between each SMA and the input pin to limit current into the overvoltage clamp; under normal signal levels it does not affect impedance. |
| Maximum continuous input voltage | 12 V. Each input has a 220 Ω series resistor and a BAT54S Schottky clamp to the 3.3 V rail and ground, so brief overshoots and reverse-polarity transients are absorbed without damage. |
| Input threshold | CMOS Schmitt-trigger. Reads as low for inputs below ~1.0 V, high for inputs above ~2.3 V, with ~250 mV of hysteresis around the switching point. |
Serial input
| Signal levels | 3.3 V logic, 8N1, no flow control |
| Baud rate | 300 to 3,000,000, host-selectable (SERial:BAUD) |
| Line length | Up to 255 characters per line (longer lines are discarded); blank lines ignored |
| Line termination | LF, CRLF, or CR; a line is emitted only once its terminator arrives |
| Timestamp resolution | 1.024 µs |
| Timestamp accuracy | Sub-millisecond (marks line completion; stamped in firmware, not capture hardware) |
| Default state | Off. Enable with SERial:STATe ON; not persisted across power cycles. |
Reference clock and host interface
| Reference clock | 10 MHz at the 10 MHz In connector. AC-coupled, so DC offset of the source doesn’t matter. Sine-wave sources (rubidium standards, GPS-disciplined oscillators) work from 0.2 V to 2.2 V peak-to-peak; square-wave / CMOS-output sources work up to 3.3 V peak-to-peak (rail-to-rail). |
| Interface | USB 2.0 Full-Speed (12 Mbps), USB Type-C connector |
| USB VID:PID | 1209:71C4 |
| Host OS support | Enumerates as a CDC virtual serial port on Linux (/dev/ttyACM*), macOS (/dev/cu.usbmodem*), and Windows (a new COM port). No driver install required. |
Physical and environmental
| Power | USB bus-powered (~50 mA) |
| Operating temperature | -40°C to +85°C (industrial range) |
| Dimensions | 100 mm tall × 46 mm wide. Four M3 mounting holes on a 94 mm × 40 mm pattern. |
Schematics, Software, and Other Sundries
The hardware design and firmware are open-source.
- Firmware: src/app/timestamper/ in the RULOS project on GitHub.
- Schematic (Rev A): PDF · KiCad source.
- Board layout (Rev A): PDF
- BOM (Rev A): CSV with reference designators, values, footprints, and LCSC part links.