diff --git a/CODEOWNERS b/CODEOWNERS index 19f392a5b..af4e8f8a3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -16,6 +16,8 @@ /software/glasgow/database/xilinx/xc9500xl.py @wanda-phi /software/glasgow/database/xilinx/xpla3.py @wanda-phi +/software/glasgow/applet/internal/calibrate_clock @i-infra + /software/glasgow/applet/servo/ @tpwrules /software/glasgow/applet/interface/spi_flashrom/ @neuschaefer diff --git a/docs/manual/src/applets/index.rst b/docs/manual/src/applets/index.rst index daa283a36..191f38ad0 100644 --- a/docs/manual/src/applets/index.rst +++ b/docs/manual/src/applets/index.rst @@ -17,4 +17,5 @@ Applet index sensor/index bridge/index audio/index + measure/index internal/index diff --git a/docs/manual/src/applets/measure/calibrate_clock.rst b/docs/manual/src/applets/measure/calibrate_clock.rst new file mode 100644 index 000000000..d358f047c --- /dev/null +++ b/docs/manual/src/applets/measure/calibrate_clock.rst @@ -0,0 +1,18 @@ +``calibrate-clock`` +=================== + +CLI reference +------------- + +.. _applet.measure.calibrate_clock: + +.. autoprogram:: glasgow.applet.measure.calibrate_clock:CalibrateClockApplet._get_argparser_for_sphinx("calibrate-clock") + :prog: glasgow run calibrate-clock + + +API reference +------------- + +.. module:: glasgow.applet.measure.calibrate_clock + +.. autoclass:: CalibrateClockInterface diff --git a/docs/manual/src/applets/measure/index.rst b/docs/manual/src/applets/measure/index.rst new file mode 100644 index 000000000..ec2a942a3 --- /dev/null +++ b/docs/manual/src/applets/measure/index.rst @@ -0,0 +1,11 @@ +.. _applet.measure: + +Measure +======= + +.. automodule:: glasgow.applet.measure + +.. toctree:: + :maxdepth: 2 + + calibrate_clock diff --git a/software/glasgow/applet/measure/__init__.py b/software/glasgow/applet/measure/__init__.py new file mode 100644 index 000000000..cab42129d --- /dev/null +++ b/software/glasgow/applet/measure/__init__.py @@ -0,0 +1 @@ +"""The ``measure`` applets aid in the measurement of signals or the properties of devices.""" diff --git a/software/glasgow/applet/measure/calibrate_clock/__init__.py b/software/glasgow/applet/measure/calibrate_clock/__init__.py new file mode 100644 index 000000000..2084205a9 --- /dev/null +++ b/software/glasgow/applet/measure/calibrate_clock/__init__.py @@ -0,0 +1,327 @@ +import logging +import struct +from amaranth import * +from amaranth.lib import io, wiring, stream +from amaranth.lib.wiring import Out +from amaranth.lib.cdc import FFSynchronizer + +from glasgow.abstract import AbstractAssembly, GlasgowPin +from glasgow.applet import GlasgowAppletError, GlasgowAppletV2 + + +__all__ = ["CalibrateClockInterface"] + + +# Maximum gate time in seconds. Longer = more resolution but slower updates. +MAX_GATE_TIME_SEC = 10 + + +class CalibrateClockComponent(wiring.Component): + """Frequency counter gateware using free-running counters. + + All three counters (ref, sys, ext) run continuously. Every + ``ref_edges_per_gate`` reference edges the current counter values are + snapshotted and streamed out. Software subtracts consecutive snapshots + to obtain the counts for each gate window, eliminating dead-time and + first-window phase error. + + Reports 12 bytes per snapshot over o_stream (byte-at-a-time): + bytes [0:4] - ref_count (uint32 LE): cumulative reference edges + bytes [4:8] - sys_count (uint32 LE): cumulative system clock cycles + bytes [8:12] - ext_count (uint32 LE): cumulative external pin edges + """ + + o_stream: Out(stream.Signature(8)) + + def __init__(self, *, ref_port: io.PortLike, ref_edges_per_gate: int, + ext_port: io.PortLike | None = None): + self._ref_port = ref_port + self._ext_port = ext_port + self._ref_edges_per_gate = ref_edges_per_gate + super().__init__() + + def elaborate(self, platform): + m = Module() + + # --- Reference input: rising-edge detector --- + m.submodules.ref_buf = ref_buf = io.Buffer("i", self._ref_port) + ref_sync = Signal() + ref_sync_r = Signal() + m.submodules.ref_cdc = FFSynchronizer(ref_buf.i, ref_sync) + m.d.sync += ref_sync_r.eq(ref_sync) + ref_edge = Signal() + m.d.comb += ref_edge.eq(ref_sync & ~ref_sync_r) + + # --- External pin: rising-edge detector (optional) --- + ext_edge = Signal() + if self._ext_port is not None: + m.submodules.ext_buf = ext_buf = io.Buffer("i", self._ext_port) + ext_sync = Signal() + ext_sync_r = Signal() + m.submodules.ext_cdc = FFSynchronizer(ext_buf.i, ext_sync) + m.d.sync += ext_sync_r.eq(ext_sync) + m.d.comb += ext_edge.eq(ext_sync & ~ext_sync_r) + + # --- Free-running counters (never reset) --- + ref_count = Signal(32) + sys_count = Signal(32) + ext_count = Signal(32) + + m.d.sync += sys_count.eq(sys_count + 1) + with m.If(ref_edge): + m.d.sync += ref_count.eq(ref_count + 1) + with m.If(ext_edge): + m.d.sync += ext_count.eq(ext_count + 1) + + # --- Snapshot registers --- + snap_ref = Signal(32) + snap_sys = Signal(32) + snap_ext = Signal(32) + + # Gate window trigger: snapshot counters every N reference edges + gate_count = Signal(32) + send_trigger = Signal() + + with m.If(ref_edge): + with m.If(gate_count >= (self._ref_edges_per_gate - 1)): + m.d.sync += [ + gate_count.eq(0), + snap_ref.eq(ref_count), + snap_sys.eq(sys_count), + snap_ext.eq(ext_count), + ] + m.d.comb += send_trigger.eq(1) + with m.Else(): + m.d.sync += gate_count.eq(gate_count + 1) + + # --- Byte-at-a-time output --- + byte_idx = Signal(range(12)) + all_snaps = Cat(snap_ref, snap_sys, snap_ext) + m.d.comb += self.o_stream.payload.eq(all_snaps.word_select(byte_idx, 8)) + + with m.FSM(): + with m.State("IDLE"): + with m.If(send_trigger): + m.next = "SEND" + + with m.State("SEND"): + m.d.comb += self.o_stream.valid.eq(1) + with m.If(self.o_stream.ready): + with m.If(byte_idx == 11): + m.d.sync += byte_idx.eq(0) + m.next = "IDLE" + with m.Else(): + m.d.sync += byte_idx.eq(byte_idx + 1) + + return m + + +class CalibrateClockInterface: + """Software interface for the clock calibration applet.""" + + def __init__(self, logger: logging.Logger, assembly: AbstractAssembly, *, + ref_pin: GlasgowPin, + ref_freq: float, + gate_time_sec: float = MAX_GATE_TIME_SEC, + nominal_sys_clk: float = 48e6, + initial_ppm: float = 0.0, + ext_pin: GlasgowPin | None = None, + ext_freq: float | None = None): + self._logger = logger + self._level = logging.DEBUG if self._logger.name == __name__ else logging.TRACE + self._ref_freq = ref_freq + self._ext_freq = ext_freq + self._has_ext = ext_pin is not None + + # Apply initial PPM correction to the nominal system clock so reported + # errors are relative to the corrected baseline. + self._nominal_sys_clk = nominal_sys_clk * (1 + initial_ppm / 1e6) + + self._ref_edges_per_gate = max(1, int(ref_freq * gate_time_sec)) + + ref_port = assembly.add_port(ref_pin, name="ref") + ext_port = assembly.add_port(ext_pin, name="ext") if ext_pin is not None else None + + component = assembly.add_submodule( + CalibrateClockComponent(ref_port=ref_port, ref_edges_per_gate=self._ref_edges_per_gate, + ext_port=ext_port)) + self._pipe = assembly.add_in_pipe(component.o_stream) + self._prev_snap = None + + def _log(self, message, *args): + self._logger.log(self._level, "calibrate-clock: " + message, *args) + + async def measure(self) -> dict: + """Wait for one gate window and return a result dict. + + The gateware sends cumulative counter snapshots; we subtract + consecutive snapshots to get the counts for each window. The first + snapshot is used only as a baseline and is discarded. + """ + while True: + data = await self._pipe.recv(12) + curr_ref, curr_sys, curr_ext = struct.unpack(" 0: + ext_hz = diff_ext / gate_time + ext_ppm = (ext_hz - self._ext_freq) / self._ext_freq * 1e6 + + return { + "ref_count": diff_ref, + "sys_count": diff_sys, + "ext_count": diff_ext, + "sys_clk_hz": sys_clk_hz, + "sys_ppm": sys_ppm, + "ext_hz": ext_hz, + "ext_ppm": ext_ppm, + "gate_time_sec": gate_time, + } + + +class CalibrateClockApplet(GlasgowAppletV2): + logger = logging.getLogger(__name__) + help = "measure clock accuracy against an external reference" + description = """ + Measure clock accuracy against a stable external reference signal. + + By default measures the internal Glasgow system clock. With ``--ext-pin``, measures + a second clock source (e.g. Si5351A output) against the reference instead. + + Measure system clock vs GPS PPS reference (1 Hz) on pin B1: + + :: + + glasgow run calibrate-clock -V 3.3 --ref-pin B1 --ref-freq 1 + + The reference input expects a signal that crosses the logic threshold cleanly. + A 2 V pk-pk sine centred at 1 V works well with the I/O bank set to 2 V. + + Measure system clock vs Rubidium reference (2^23 Hz) on pin B1: + + :: + + glasgow run calibrate-clock -V 2.0 --ref-pin B1 --ref-freq 8388608 + + Measure Si5351A 10 MHz output on A0 vs same Rb reference on B1: + + :: + + glasgow run calibrate-clock -V 2.0 \\ + --ref-pin B1 --ref-freq 8388608 --ext-pin A0 --ext-freq 10000000 + + Apply a known rough correction to the baseline before measuring: + + :: + + glasgow run calibrate-clock -V 2.0 \\ + --ref-pin B1 --ref-freq 8388608 --ppm -12.5 + """ + required_revision = "C0" + + @classmethod + def add_build_arguments(cls, parser, access): + access.add_voltage_argument(parser) + access.add_pins_argument(parser, "ref_pin", required=True, + help="stable reference clock input pin (e.g. B1 for Rb standard)") + access.add_pins_argument(parser, "ext_pin", required=False, + help="optional external clock to measure (e.g. A0 for Si5351A output); " + "if omitted the system clock is measured") + + parser.add_argument( + "--ref-freq", type=float, required=True, metavar="HZ", + help="exact frequency of the reference clock in Hz (e.g. 8388608 for 2^23 Hz)") + parser.add_argument( + "--ext-freq", type=float, default=None, metavar="HZ", + help="nominal frequency of the external clock in Hz (required with --ext-pin)") + parser.add_argument( + "--nominal-sys-clk", type=float, default=48e6, metavar="HZ", + help="nominal system clock frequency in Hz (default: %(default).0f)") + parser.add_argument( + "--gate-time", type=float, default=MAX_GATE_TIME_SEC, metavar="SEC", + help="gate window duration in seconds; longer gives more resolution " + "(default: %(default)s)") + parser.add_argument( + "--ppm", type=float, default=0.0, metavar="PPM", + help="initial PPM correction applied to the nominal frequency before measuring " + "(default: %(default)s)") + + def build(self, args): + if args.ext_pin is not None and args.ext_freq is None: + raise GlasgowAppletError("--ext-freq is required when --ext-pin is given") + + with self.assembly.add_applet(self): + self.assembly.use_voltage(args.voltage) + self.cal_iface = CalibrateClockInterface( + self.logger, self.assembly, + ref_pin=args.ref_pin, + ref_freq=args.ref_freq, + gate_time_sec=args.gate_time, + nominal_sys_clk=args.nominal_sys_clk, + initial_ppm=args.ppm, + ext_pin=args.ext_pin, + ext_freq=args.ext_freq, + ) + + @classmethod + def add_run_arguments(cls, parser): + parser.add_argument( + "--count", type=int, default=0, metavar="N", + help="number of measurements to take then exit (default: 0 = run forever)") + + async def run(self, args): + gate_sec = self.cal_iface._ref_edges_per_gate / args.ref_freq + measuring = f"ext pin {args.ext_pin} ({args.ext_freq:.0f} Hz)" \ + if args.ext_pin is not None else "system clock" + + self.logger.info("reference: pin %s @ %.6f Hz", args.ref_pin, args.ref_freq) + self.logger.info("measuring: %s", measuring) + self.logger.info("gate time: %.2f s per measurement", gate_sec) + if args.ppm != 0.0: + self.logger.info("initial ppm correction: %+.3f ppm", args.ppm) + self.logger.info("waiting for first measurement window...") + + n = 0 + ppms = [] + while args.count == 0 or n < args.count: + result = await self.cal_iface.measure() + + if args.ext_pin is not None: + ppm = result["ext_ppm"] + self.logger.info( + "ext = %.3f Hz | error = %+.3f ppm | gate = %.3f s", + result["ext_hz"], ppm, result["gate_time_sec"]) + else: + ppm = result["sys_ppm"] + self.logger.info( + "sys_clk = %.3f Hz | error = %+.3f ppm | gate = %.3f s", + result["sys_clk_hz"], ppm, result["gate_time_sec"]) + + ppms.append(ppm) + n += 1 + + if ppms: + avg = sum(ppms) / len(ppms) + self.logger.info("--- average over %d measurements: %+.3f ppm ---", len(ppms), avg) + self.logger.info("use: --clock-ppm %+.3f ", avg) diff --git a/software/glasgow/applet/measure/calibrate_clock/test.py b/software/glasgow/applet/measure/calibrate_clock/test.py new file mode 100644 index 000000000..c080e9567 --- /dev/null +++ b/software/glasgow/applet/measure/calibrate_clock/test.py @@ -0,0 +1,37 @@ +from glasgow.simulation.assembly import SimulationAssembly +from glasgow.applet import GlasgowAppletV2TestCase, synthesis_test, applet_v2_simulation_test +from . import CalibrateClockApplet + + +class CalibrateClockAppletTestCase(GlasgowAppletV2TestCase, applet=CalibrateClockApplet): + @synthesis_test + def test_build(self): + self.assertBuilds(["--ref-pin", "A0", "--ref-freq", "1000", "-V", "3.3"]) + + # Simulation clock is 1 MHz. Use a 1 kHz reference (toggle every 500 ticks) + # and a 1 ms gate time (1 ref edge per gate) so the gate fires after ~1000 ticks. + simulation_args = ["--ref-pin", "A0", "--ref-freq", "1000", "--gate-time", "0.001", + "--nominal-sys-clk", "1e6", "-V", "3.3"] + + def prepare_ref_clock(self, assembly: SimulationAssembly): + ref_pin = assembly.get_pin("A0") + + async def drive_ref(ctx): + # Toggle ref pin every 500 cycles to produce a 1 kHz signal + # relative to the 1 MHz simulation clock. + while True: + ctx.set(ref_pin.i, 1) + await ctx.tick().repeat(500) + ctx.set(ref_pin.i, 0) + await ctx.tick().repeat(500) + + assembly.add_testbench(drive_ref, background=True) + + @applet_v2_simulation_test(prepare=prepare_ref_clock, args=simulation_args) + async def test_measure_sys_clk(self, applet: CalibrateClockApplet, ctx): + result = await applet.cal_iface.measure() + # In simulation all clocks are exact, so sys_ppm should be 0. + self.assertAlmostEqual(result["sys_ppm"], 0.0, delta=10.0) + self.assertAlmostEqual(result["gate_time_sec"], 0.001, delta=0.01) + self.assertIsNone(result["ext_hz"]) + self.assertIsNone(result["ext_ppm"]) diff --git a/software/pyproject.toml b/software/pyproject.toml index f0738e156..b46358694 100644 --- a/software/pyproject.toml +++ b/software/pyproject.toml @@ -90,6 +90,7 @@ glasgow = "glasgow.cli:run_main" [project.entry-points."glasgow.applet"] selftest = "glasgow.applet.internal.selftest:SelfTestApplet" benchmark = "glasgow.applet.internal.benchmark:BenchmarkApplet" +calibrate-clock = "glasgow.applet.measure.calibrate_clock:CalibrateClockApplet" analyzer = "glasgow.applet.interface.analyzer:AnalyzerApplet" uart = "glasgow.applet.interface.uart:UARTApplet"