Proof of concept

This commit is contained in:
h3cx committed 2026-09-16 14:52:53 +02:00
commit 64e8da17e7
11 files changed
+690

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
+37
View File
@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
+46
View File
@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html
+15
View File
@@ -0,0 +1,15 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:megaatmega2560]
platform = atmelavr
board = megaatmega2560
framework = arduino
monitor_speed = 115200
+36
View File
@@ -0,0 +1,36 @@
#include <Arduino.h>
#pragma pack(push, 1)
typedef struct {
uint16_t sync;
uint8_t type;
uint32_t r_t;
uint32_t s_t;
} sync_packet;
#pragma pack(pop)
void setup() {
Serial.begin(115200);
}
void loop() {
uint8_t previous = 0;
sync_packet packet{0xBEEF,3,0,0};
while (true) {
if (Serial.available()) {
uint8_t byte;
Serial.readBytes(&byte, 1);
if (previous == 0xEF && byte == 0xBE) {
break;
}
previous = byte;
}
}
uint32_t r_t = micros();
Serial.readBytes(&packet.type, 9);
if (packet.type == 3) {
packet.r_t = r_t;
packet.s_t = micros();
Serial.write((uint8_t*)&packet, sizeof(packet));
}
}
+11
View File
@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
+1
View File
@@ -0,0 +1 @@
3.13
View File
Whitespace-only changes.
+19
View File
@@ -0,0 +1,19 @@
[project]
name = "clock-sync-software"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Hector van der Aa", email = "hector@h3cx.dev" }
]
requires-python = ">=3.13"
dependencies = [
"pyserial>=3.5",
]
[project.scripts]
clock-sync-software = "clock_sync_software:main"
[build-system]
requires = ["uv_build>=0.12.13,<0.13.0"]
build-backend = "uv_build"
@@ -0,0 +1,497 @@
import struct
import time
from dataclasses import dataclass
from serial import Serial
from serial.tools import list_ports
SYNC_PATTERN = 0xBEEF
SYNC_BYTES = struct.pack("<H", SYNC_PATTERN)
BAUDRATE = 115200
SERIAL_TIMEOUT = 1.0
CALIBRATION_DURATION_S = 30.0
VALIDATION_DURATION_S = 60.0
SYNC_INTERVAL_S = 0.05
# Keep the lowest-RTT 25% of calibration samples.
GOOD_SAMPLE_FRACTION = 0.25
@dataclass
class SyncPacket:
sync: int
type: int
r_t: int
s_t: int
FORMAT = "<HBII"
SIZE = struct.calcsize(FORMAT)
def pack(self) -> bytes:
return struct.pack(
self.FORMAT,
self.sync,
self.type,
self.r_t,
self.s_t,
)
@classmethod
def unpack(cls, data: bytes) -> "SyncPacket":
if len(data) != cls.SIZE:
raise ValueError(
f"Invalid SyncPacket size: expected {cls.SIZE}, got {len(data)}"
)
sync, type_, r_t, s_t = struct.unpack(
cls.FORMAT,
data,
)
return cls(
sync=sync,
type=type_,
r_t=r_t,
s_t=s_t,
)
@dataclass
class SyncSample:
arduino_mid_ns: float
pc_mid_ns: float
rtt_ns: int
@property
def observed_offset_ns(self) -> float:
return self.pc_mid_ns - self.arduino_mid_ns
def fit_clock(
samples: list[SyncSample],
) -> tuple[float, float]:
"""
Fit:
pc_ns = scale * arduino_ns + offset_ns
Since both sides are in nanoseconds, the ideal scale is 1.0.
"""
if len(samples) < 2:
raise ValueError("At least two samples are required")
xs = [sample.arduino_mid_ns for sample in samples]
ys = [sample.pc_mid_ns for sample in samples]
x_mean = sum(xs) / len(xs)
y_mean = sum(ys) / len(ys)
numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys))
denominator = sum((x - x_mean) ** 2 for x in xs)
if denominator == 0:
raise ValueError("Cannot fit clock: all Arduino timestamps are equal")
scale = numerator / denominator
offset_ns = y_mean - scale * x_mean
return scale, offset_ns
def arduino_ns_to_monotonic_ns(
arduino_ns: float,
scale: float,
offset_ns: float,
) -> int:
return round(scale * arduino_ns + offset_ns)
def arduino_us_to_monotonic_ns(
arduino_us: int,
scale: float,
offset_ns: float,
) -> int:
return arduino_ns_to_monotonic_ns(
arduino_us * 1_000,
scale,
offset_ns,
)
def start_sync(ser: Serial) -> None:
previous: bytes | None = None
while True:
byte = ser.read(1)
if not byte:
raise TimeoutError("Timed out waiting for sync pattern")
if previous == b"\xef" and byte == b"\xbe":
return
previous = byte
def read_exactly(
ser: Serial,
size: int,
) -> bytes:
data = bytearray()
while len(data) < size:
chunk = ser.read(size - len(data))
if not chunk:
raise TimeoutError(
f"Timed out reading packet: received {len(data)}/{size} bytes"
)
data.extend(chunk)
return bytes(data)
def choose_serial_port() -> str:
ports = list(list_ports.comports())
if not ports:
raise RuntimeError("No serial ports found")
for index, port in enumerate(ports):
print(f"{index}: {port.device} {port.description}")
while True:
try:
choice = int(input("Choose a port to connect: "))
if 0 <= choice < len(ports):
selected_port = ports[choice].device
print(f"Selected port: {selected_port}")
return selected_port
except ValueError:
pass
print("Unknown port. Try again.")
def get_sync_sample(
ser: Serial,
request_packet: bytes,
) -> SyncSample:
#
# Bracket the serial write.
#
tbw = time.monotonic_ns()
ser.write(request_packet)
ser.flush()
taw = time.monotonic_ns()
#
# Wait for response packet.
#
start_sync(ser)
payload = read_exactly(
ser,
SyncPacket.SIZE - len(SYNC_BYTES),
)
tar = time.monotonic_ns()
reply = SyncPacket.unpack(SYNC_BYTES + payload)
#
# PC timestamps.
#
# Estimate request-transmit time as midpoint
# around write()/flush().
#
t1 = (tbw + taw) // 2
#
# Arduino receive/transmit timestamps.
# Convert micros() -> ns.
#
t2 = reply.r_t * 1_000
t3 = reply.s_t * 1_000
#
# PC receive timestamp.
#
t4 = tar
#
# Midpoints give us an approximate
# Arduino<->PC correspondence.
#
arduino_mid_ns = (t2 + t3) / 2
pc_mid_ns = (t1 + t4) / 2
#
# Corrected transport RTT:
#
# total PC round trip
# minus Arduino processing time.
#
arduino_processing_ns = t3 - t2
rtt_ns = (t4 - t1) - arduino_processing_ns
return SyncSample(
arduino_mid_ns=arduino_mid_ns,
pc_mid_ns=pc_mid_ns,
rtt_ns=rtt_ns,
)
def calibrate(
ser: Serial,
request_packet: bytes,
) -> tuple[
float,
float,
list[SyncSample],
]:
print()
print(f"Calibrating clocks for {CALIBRATION_DURATION_S:.0f} seconds...")
samples: list[SyncSample] = []
start_ns = time.monotonic_ns()
duration_ns = int(CALIBRATION_DURATION_S * 1_000_000_000)
while time.monotonic_ns() - start_ns < duration_ns:
try:
sample = get_sync_sample(
ser,
request_packet,
)
samples.append(sample)
elapsed_s = (time.monotonic_ns() - start_ns) / 1_000_000_000
offset_ns = sample.pc_mid_ns - sample.arduino_mid_ns
print(
f"\r"
f"{elapsed_s:5.1f}/"
f"{CALIBRATION_DURATION_S:.0f} s"
f" | offset: "
f"{offset_ns / 1_000_000:+.3f} ms"
f" | RTT: "
f"{sample.rtt_ns / 1_000_000:.3f} ms",
end="",
flush=True,
)
except TimeoutError as exc:
print(f"\nSync timeout: {exc}")
time.sleep(SYNC_INTERVAL_S)
print()
if len(samples) < 2:
raise RuntimeError("Not enough valid synchronization samples")
#
# Select lowest-latency samples.
#
sorted_samples = sorted(
samples,
key=lambda sample: sample.rtt_ns,
)
keep_count = max(
2,
int(len(sorted_samples) * GOOD_SAMPLE_FRACTION),
)
good_samples = sorted_samples[:keep_count]
scale, offset_ns = fit_clock(good_samples)
return (
scale,
offset_ns,
good_samples,
)
def validate(
ser: Serial,
request_packet: bytes,
scale: float,
offset_ns: float,
) -> None:
print()
print(f"Running {VALIDATION_DURATION_S:.0f}-second validation test...")
errors_ns: list[float] = []
rtts_ns: list[int] = []
start_ns = time.monotonic_ns()
duration_ns = int(VALIDATION_DURATION_S * 1_000_000_000)
while time.monotonic_ns() - start_ns < duration_ns:
try:
sample = get_sync_sample(
ser,
request_packet,
)
predicted_pc_ns = scale * sample.arduino_mid_ns + offset_ns
error_ns = sample.pc_mid_ns - predicted_pc_ns
errors_ns.append(error_ns)
rtts_ns.append(sample.rtt_ns)
elapsed_s = (time.monotonic_ns() - start_ns) / 1_000_000_000
print(
f"\r"
f"{elapsed_s:5.1f}/"
f"{VALIDATION_DURATION_S:.0f} s"
f" | error: "
f"{error_ns / 1_000_000:+.3f} ms"
f" | RTT: "
f"{sample.rtt_ns / 1_000_000:.3f} ms",
end="",
flush=True,
)
except TimeoutError as exc:
print(f"\nSync timeout: {exc}")
time.sleep(SYNC_INTERVAL_S)
print()
if not errors_ns:
raise RuntimeError("No valid validation samples")
absolute_errors_ns = [abs(error) for error in errors_ns]
max_abs_error_ns = max(absolute_errors_ns)
mean_abs_error_ns = sum(absolute_errors_ns) / len(absolute_errors_ns)
mean_signed_error_ns = sum(errors_ns) / len(errors_ns)
rms_error_ns = (sum(error * error for error in errors_ns) / len(errors_ns)) ** 0.5
min_error_ns = min(errors_ns)
max_error_ns = max(errors_ns)
print()
print("Validation results")
print("------------------")
print(f"Samples: {len(errors_ns)}")
print()
print(f"Maximum abs error: {max_abs_error_ns / 1_000_000:.6f} ms")
print(f"Mean abs error: {mean_abs_error_ns / 1_000_000:.6f} ms")
print(f"RMS error: {rms_error_ns / 1_000_000:.6f} ms")
print(f"Mean signed error: {mean_signed_error_ns / 1_000_000:+.6f} ms")
print(f"Minimum error: {min_error_ns / 1_000_000:+.6f} ms")
print(f"Maximum error: {max_error_ns / 1_000_000:+.6f} ms")
print()
print(f"Best validation RTT: {min(rtts_ns) / 1_000_000:.6f} ms")
def main() -> None:
try:
selected_port = choose_serial_port()
except KeyboardInterrupt:
print()
return
request_packet = SyncPacket(
sync=SYNC_PATTERN,
type=3,
r_t=0,
s_t=0,
).pack()
with Serial(
selected_port,
baudrate=BAUDRATE,
timeout=SERIAL_TIMEOUT,
) as ser:
print("Waiting 5 seconds before synchronization...")
time.sleep(5)
#
# Remove startup noise / stale bytes.
#
ser.reset_input_buffer()
scale, offset_ns, good_samples = calibrate(
ser,
request_packet,
)
drift_ppm = (scale - 1.0) * 1_000_000
best_rtt_ns = min(sample.rtt_ns for sample in good_samples)
worst_good_rtt_ns = max(sample.rtt_ns for sample in good_samples)
print()
print("Clock synchronization complete")
print("--------------------------------")
print(f"Calibration samples used: {len(good_samples)}")
print(f"Best RTT: {best_rtt_ns / 1_000_000:.6f} ms")
print(f"Calibration RTT cutoff: {worst_good_rtt_ns / 1_000_000:.6f} ms")
print()
print(f"Scale: {scale:.12f}")
print(f"Drift: {drift_ppm:+.3f} ppm")
print(f"Offset: {offset_ns:.0f} ns")
print(f"Offset: {offset_ns / 1_000_000:.6f} ms")
print()
print("Clock equation:")
print(f"PC_ns = {scale:.12f} * Arduino_ns {offset_ns:+.0f}")
print()
print("For an Arduino micros() timestamp:")
print(f"PC_ns = {scale:.12f} * (Arduino_us * 1000) {offset_ns:+.0f}")
#
# Now test the model without changing it.
#
validate(
ser,
request_packet,
scale,
offset_ns,
)
if __name__ == "__main__":
main()
+23
View File
@@ -0,0 +1,23 @@
version = 1
revision = 3
requires-python = ">=3.13"
[[package]]
name = "clock-sync-software"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "pyserial" },
]
[package.metadata]
requires-dist = [{ name = "pyserial", specifier = ">=3.5" }]
[[package]]
name = "pyserial"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
]