[{"content":" WIP Programmable signal generator with GNSS-checked timing The idea I want to build a programmable function generator for my lab.\nThe basic architecture is:\nSTM32H743ZI → high-speed DAC → analog output\nwith a u-blox GNSS receiver providing a long-term timing reference.\nThe first target is relatively simple: generate a clean sine wave up to at least 10 MHz. The exact output stage and reconstruction filter still need to be designed.\nI chose the STM32H743ZI for a very practical reason: I already have a NUCLEO-H743ZI board sitting on my bench.\nWhat I want it to do The generator should eventually be useful for several different tasks:\nconventional function-generator work; automated measurements; arbitrary waveform generation; signal playback from recorded samples; synchronization experiments; SDR experiments. The Python interface is an important part of the design.\nFor example, together with my programmable Hantek DSO2D10, the generator could automate measurements such as frequency-response sweeps:\nPython / \\ ▼ ▼ Generator DSO2D10 │ │ └─── DUT ──┘ │ ▼ NumPy Instead of manually changing the frequency and reading the oscilloscope, a script could perform the sweep and calculate the amplitude and phase response.\nGNSS timing I also want to experiment with using a u-blox LEA-M8T as a timing reference — but not as the thing that directly clocks the DAC.\nInitially I was planning to have the GNSS timepulse discipline a local high-frequency oscillator in a closed loop. In practice, that\u0026rsquo;s a much harder problem than it sounds: building a clean, low-jitter disciplined oscillator is a project on its own, and getting it wrong would inject more noise into the signal chain than it removes.\nSo the plan changed. The DAC clock is now generated locally, on the STM32 itself:\n4 MHz reference → STM32 clock multiplication → 100 MHz square wave → DAC clock input\nGNSS still has a role, just a smaller one: instead of actively disciplining the clock in real time, the PPS output is used to check the local clock\u0026rsquo;s long-term accuracy — measure the drift, log it, and know how far off the free-running STM32 clock actually is over minutes or hours.\nLEA-M8T ┌───────────┐ PPS ─┤ │ └─────┬─────┘ │ ▼ ┌────────────────┐ │ STM32H743 │ │ │ │ Timer capture │ │ (drift check) │ └────────────────┘ 4 MHz ref ──▶ STM32 clock mult. ──▶ 100 MHz ──▶ AD9744 clock in This is a much simpler architecture than an actively disciplined oscillator, and it\u0026rsquo;s a more honest starting point: get a stable, known-drift clock working first, and only reach for real discipline later if the accuracy turns out not to be good enough.\nPower supply The GNSS receiver will be powered from a Mean Well RS-15-5 AC/DC supply.\nRather than feeding the receiver directly from the 5 V switching supply, I plan to use a 3.3 V LDO between the supply and the LEA-M8T.\nAC mains │ ▼ ┌─────────────┐ │ Mean Well │ │ RS-15-5 │ └──────┬──────┘ │ 5 V ▼ ┌─────────────┐ │ 3.3 V LDO │ └──────┬──────┘ │ ▼ LEA-M8T The LDO provides a clean local supply for the GNSS receiver and adds another stage of filtering and regulation between the switching supply and the timing hardware.\nThe LEA-M8T also has its own internal power regulation, so the external LDO is an additional stage rather than the only regulator in the receiver\u0026rsquo;s power path.\nThe Mean Well\u0026rsquo;s protective earth is treated separately from the signal ground. The PE connection is bonded to the metal enclosure and the protective earth conductor, while the GNSS V− is used as the DC return.\nThe final PCB will also have local decoupling and filtering for the GNSS receiver, STM32 and DAC. The exact grounding and power-distribution scheme will be determined during the hardware design.\nArbitrary waveforms Another reason for building the generator is to have direct control over the samples.\nA waveform generated in Python could be transferred to the STM32 and reproduced through the DAC.\nThat means the instrument could generate much more than a sine wave:\nPython │ ▼ sample table │ ▼ STM32 + DMA │ ▼ DAC │ ▼ analog signal Recorded signals could potentially be replayed in the same way, making the generator useful as a repeatable signal source for testing filters, amplifiers, SDR chains and other hardware.\nThe DAC The main component I need to investigate now is the DAC.\nThe initial requirement is:\n10 MHz sine output\nOne candidate is the AD9744, a 14-bit, 210 MSPS current-output DAC.\nParameter AD9744 Resolution 14 bit Maximum update rate 210 MSPS Target output frequency ≥ 10 MHz Interface CMOS Output Differential current Reference Internal 1.2 V Supply 3.3 V STM32 interface Potentially suitable Arbitrary waveform Suitable AD9744 — one of the DAC candidates for the first prototype The important part is that the DAC itself is only one piece of the signal chain. Clock quality, sample rate, reconstruction filtering, current-to-voltage conversion and PCB layout will ultimately determine how clean the 10 MHz output actually is.\nA 100 MHz sample clock is one possible starting point: at a 10 MHz output frequency this gives ten DAC updates per cycle, while lower output frequencies naturally get more samples per period.\nThe final clock frequency is still to be determined.\nClocking architecture The clocking architecture ended up simpler than I originally planned.\nRather than building a disciplined local oscillator, the STM32 itself generates the DAC clock: a 4 MHz reference is multiplied up to 100 MHz internally and fed out as a square wave directly into the AD9744\u0026rsquo;s clock input. GNSS PPS is used separately, purely to measure how much the STM32 clock drifts over time — not to correct it in real time.\nConceptually:\n4 MHz ref │ ▼ ┌─────────────┐ │ STM32H743 │ │ clock mult. │ └──────┬──────┘ │ 100 MHz square wave ▼ ┌───────────────┐ │ AD9744 │ │ DAC │ └───────┬───────┘ │ ▼ analog OUT GNSS PPS │ ▼ ┌───────────────┐ │ STM32H743 │ │ drift logging │ └───────────────┘ This trades some long-term accuracy for a much simpler and more predictable clock path. If the drift turns out to matter for a given application, an actively disciplined oscillator is still an option later — but it\u0026rsquo;s not a prerequisite for getting the first prototype working.\nNext work out the STM32 → DAC interface; implement the 4 MHz → 100 MHz clock multiplication on the STM32; investigate the LEA-M8T PPS output for drift logging; determine the required DAC clock frequency and jitter budget; design the DAC output stage and reconstruction filter; build the first DAC prototype; connect it to the Hantek DSO2D10 and start automating measurements. For now, this is just the beginning.\nSTM32 + DAC + GNSS-checked clock + Python → a programmable signal source for the lab.\n","permalink":"https://signal-chain.tech/projects/precision_functional_generator/","summary":"\u003chr\u003e\n\u003cdiv class=\"result-callout\"\u003e\n\u003cdiv class=\"callout-number\"\u003eWIP\u003c/div\u003e\n\u003cdiv class=\"callout-label\"\u003eProgrammable signal generator with GNSS-checked timing\u003c/div\u003e\n\u003c/div\u003e\n\u003ch2 id=\"the-idea\"\u003eThe idea\u003c/h2\u003e\n\u003cp\u003eI want to build a programmable function generator for my lab.\u003c/p\u003e\n\u003cp\u003eThe basic architecture is:\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eSTM32H743ZI → high-speed DAC → analog output\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003ewith a u-blox GNSS receiver providing a long-term timing reference.\u003c/p\u003e\n\u003cp\u003eThe first target is relatively simple: generate a clean sine wave up to at least \u003cstrong\u003e10 MHz\u003c/strong\u003e. The exact output stage and reconstruction filter still need to be designed.\u003c/p\u003e\n\u003cp\u003eI chose the STM32H743ZI for a very practical reason: I already have a NUCLEO-H743ZI board sitting on my bench.\u003c/p\u003e","title":"Precision Functional Generator — WIP"},{"content":" A friend from Belarus Alex gave me a Hantek DSO2D10.\nNaturally, the first thing I did was try to make it talk to Python.\nWhat makes this interesting is not just remote control of the instrument, but the possibility of making the oscilloscope part of a programmable measurement system. The scope can be controlled from Python, its built-in signal generator can be operated programmatically, and captured waveform data can be transferred back to the computer for further processing.\nThat makes experiments such as automated frequency-response measurements possible. Instead of manually changing the generator frequency, reading the amplitude and phase from the screen, and writing the results down, a script can perform the whole measurement and produce the resulting data automatically.\nThis is also where a digital oscilloscope becomes particularly interesting for laboratory work. A student can spend less time repeating routine measurements and more time working with the actual signal-processing problem. Once the samples are in Python, they are no longer tied to the oscilloscope\u0026rsquo;s built-in measurements either: FFT, filtering, averaging, spectral analysis, or completely custom processing can be implemented independently. Captured waveforms can also be saved and replayed later as test data.\nSo I wanted to see how far I could get without a vendor-specific software stack and whether the DSO2D10 could be turned into a reasonably open measurement instrument.\nThe setup is fairly simple: Debian 13, the scope connected over USB, and Python talking directly to the Linux USBTMC device at /dev/usbtmc1.\nThere is one slightly annoying detail: at the moment I have to give the device permission manually:\nsudo chmod +x /dev/usbtmc1 Then the scope can be opened directly from Python without a VISA stack:\nfd = os.open(\u0026#34;/dev/usbtmc1\u0026#34;, os.O_RDWR) os.write(fd, b\u0026#34;*IDN?\\n\u0026#34;) response = os.read(fd, 4096) print(response) The first test worked:\nb\u0026#39;undefined, DSO2D10, undefined, 3.0.0(240326.00)\u0026#39; From there I started querying the instrument and experimenting with its waveform interface:\n:ACQuire:POINts? :WAV:FORM? :WAV:SOUR? :WAVeform:DATA:ALL? The interesting part was figuring out what actually comes back from :WAVeform:DATA:ALL?.\nThe DSO2D10 does not simply return one convenient block of samples. The waveform arrives as packets with a 29-byte header containing the packet size, total data size and position of the packet in the complete buffer.\nFor a 4000-point acquisition, the scope returned two packets:\npacket 1: packet size = 128 position = 0 payload = 99 bytes packet 2: packet size = 4029 position = 99 payload = 4000 bytes The first 99 bytes appear to be metadata. The remaining 4000 bytes contain the waveform data.\nI reconstruct the complete buffer using the position field from each packet:\nbuffer = bytearray(total_size) for packet in packets: position = packet[\u0026#34;position\u0026#34;] payload = packet[\u0026#34;payload\u0026#34;] buffer[ position:position + len(payload) ] = payload The complete test script is available in the tools repository.\nThere was also a small surprise here.\nThe waveform was requested using:\n:WAV:FORM WORD so my first assumption was that 4000 points should produce 8000 bytes of waveform data.\nIt doesn\u0026rsquo;t.\nThe instrument returned exactly 4000 bytes:\n4000 points 4000 waveform bytes The raw payload starts with values such as:\n02 02 03 03 02 02 02 03 02 03 ... For now I\u0026rsquo;m treating these as one-byte samples and inspecting the resulting waveform in NumPy rather than assuming that WORD necessarily means a conventional 16-bit sample representation.\nThe raw waveform can then be pulled directly into NumPy:\nsamples = np.frombuffer( waveform_bytes, dtype=np.uint8 ) and plotted with Matplotlib.\nThe reconstructed waveform can be compared directly with the signal shown on the oscilloscope:\nThe two captures correspond to the same acquisition. The important part here is that the waveform is no longer confined to the oscilloscope display — the raw samples are available to the rest of the measurement pipeline.\nThis first capture is still very much an experiment rather than a finished driver. The next things to figure out are the conversion from raw samples to volts, reconstruction of the time axis, the meaning of the metadata, acquisition settings, and eventually continuous capture.\nThe same interface could also be used to automate measurements, collect datasets, perform custom signal processing, and build repeatable measurement procedures around the instrument.\nThe basic path already works:\nHantek → USBTMC → SCPI → Python → NumPy → measurement pipeline.\nNot bad for a gifted oscilloscope.\nMaterials Resource Description test.py Python script for communicating with the Hantek DSO2D10 over USBTMC/SCPI, requesting waveform data, reconstructing the returned packets, extracting the raw waveform, and plotting it with NumPy and Matplotlib. The complete collection of instrument utilities is available in the tools repository.\n","permalink":"https://signal-chain.tech/posts/hantekdso2d10_first_test/","summary":"\u003chr\u003e\n\u003cp\u003e\u003cimg alt=\"Hantek DSO2D10 on the bench\" loading=\"lazy\" src=\"/images/posts/240826/hantekdso2d10.jpg\"\u003e\u003c/p\u003e\n\u003cp\u003eA friend from Belarus \u003ca href=\"https://www.linkedin.com/in/sunnn2000/\"\u003eAlex\u003c/a\u003e gave me a Hantek DSO2D10.\u003c/p\u003e\n\u003cp\u003eNaturally, the first thing I did was try to make it talk to Python.\u003c/p\u003e\n\u003cp\u003eWhat makes this interesting is not just remote control of the instrument, but the possibility of making the oscilloscope part of a programmable measurement system. The scope can be controlled from Python, its built-in signal generator can be operated programmatically, and captured waveform data can be transferred back to the computer for further processing.\u003c/p\u003e","title":"Putting the Hantek DSO2D10 on a Python leash"},{"content":" ≈ 7.3 ns Receiver-to-receiver jitter (NEO-M8T vs ZED-F9T), with the measurement system noise floor removed in quadrature Overview A follow-up to the VLF recorder work — a parallel project at the Institute characterizing PPS (pulse-per-second) timing accuracy across two GNSS receivers, both running in survey-in mode: a u-blox NEO-M8T and a ZED-F9T.\nThe question was straightforward:\nHow precisely can the PPS outputs of two GNSS receivers be compared, and how much of the measured jitter actually comes from the measurement system itself?\nThe idea is simple on paper: capture each receiver\u0026rsquo;s PPS edge on a hardware timer, compare timestamps, and calculate the timing variation.\nIn practice, getting there meant fighting the measurement system itself for a while — a clock tree misconfiguration, and a HardFault buried behind a stale debugger session.\nNone of that shows up as \u0026ldquo;wrong data\u0026rdquo;.\nIt shows up as the board simply not booting.\nOnce the embedded system was working reliably, the experiment became a much simpler problem: build a measurement chain whose own timing uncertainty was small enough that the receivers\u0026rsquo; jitter could actually be observed.\nHardware setup The final measurement system was built around an STM32H743 Nucleo-144 board.\nMCU: STM32H743 Timer: TIM2 Timer clock: 240 MHz Input capture: 3 channels Receiver 1: u-blox NEO-M8T PPS Receiver 2: u-blox ZED-F9T PPS The three input-capture channels were used as follows:\none channel captured the NEO-M8T PPS edge; one channel captured the ZED-F9T PPS edge; the third channel was connected to the same ZED-F9T PPS edge as the second channel. That last part is intentional.\nIt provides a way to measure the timing variation introduced by the measurement system itself.\nNEO-M8T PPS ───────────────\u003e TIM2 input capture CH1 │ ZED-F9T PPS ────────┬──────────\u003e TIM2 input capture CH2 │ └──────────\u003e TIM2 input capture CH3 STM32H743 TIM2 @ 240 MHz The two channels connected to the same physical PPS edge should, ideally, see exactly the same event.\nAny measured difference between them therefore cannot be receiver-to-receiver timing error.\nIt is a measurement of the system\u0026rsquo;s own timing floor.\nWhy measure the system floor? If the two receivers are compared directly, the measured variation contains more than just receiver jitter.\nConceptually:\n\\[ \\sigma_\\mathrm{measured}^2 = \\sigma_\\mathrm{receiver}^2 + \\sigma_\\mathrm{system}^2 \\]The measurement system therefore needs to be characterized separately.\nThat is why two timer channels were connected to the same ZED-F9T PPS output.\nThis gives a direct estimate of the timing uncertainty introduced by the capture system itself.\nWithout that control measurement, a few nanoseconds of measured variation could not be confidently attributed to the receivers.\nMeasurement method The experiment ran for approximately 24 hours, recording one PPS event per second.\nThat gives a long sequence of hardware-captured timestamps rather than relying on software timestamps.\nThe important part is where the timestamp is generated.\nThe PPS edge is captured directly by the timer hardware. The timestamp is therefore latched by the peripheral at the event itself rather than being generated later by code running in the main loop.\nIn other words:\nHardware capture The PPS edge is timestamped by the timer peripheral itself, rather than by software responding to an interrupt. This matters because software execution time and main-loop load should not become part of the timing measurement.\nThe experiment is therefore measuring the timing of the input edges, not the response time of the firmware.\nDebugging the measurement system Before collecting useful data, the measurement system itself had to become trustworthy.\nThe first problems were not subtle timing errors.\nThe board simply would not behave as expected.\nA clock-tree configuration problem prevented the timer from operating with the intended clock, while a HardFault was initially obscured by a stale debugger session.\nThese failures were useful reminders of an important property of measurement systems:\nA broken measurement chain does not necessarily produce obviously broken data.\nIt can also produce nothing at all — or, worse, data that looks reasonable but cannot be trusted.\nFor this experiment, the timer configuration and capture path therefore had to be established before interpreting the statistical results.\nFirmware implementation The measurement chain described above is not just \u0026ldquo;start input capture and read a callback.\u0026rdquo; A few implementation details turned out to matter enough that they\u0026rsquo;re worth documenting alongside the results — the full source is linked at the end of this post.\nCapturing in the ISR, not the HAL callback TIM2_CaptureIRQHandler() reads TIM2-\u0026gt;CCR1 / TIM2-\u0026gt;CCR3 directly out of the timer\u0026rsquo;s status register, instead of going through HAL_TIM_IC_CaptureCallback(). The timer already latches the capture value in hardware the instant the edge arrives — that part is exact regardless of software. What isn\u0026rsquo;t automatically exact is turning a 32-bit hardware counter into a 64-bit timestamp that survives a multi-hour run without wrapping.\nuint32_t ovf = tim2_ovf; if (update_pending) { tim2_ovf = ovf + 1u; } /* Guard against the capture/overflow race on the wrap boundary * (~once every 17.9 s at 240 MHz / 32-bit ARR): if update just fired * and CCRx is small, the capture happened AFTER the wrap, so the * high word must be ovf+1 rather than the pre-update ovf. */ if (got1) { uint32_t use_ovf = (update_pending \u0026amp;\u0026amp; cap1 \u0026lt; OVF_GUARD) ? ovf + 1u : ovf; fifo_push(\u0026amp;fifo1, ((uint64_t)use_ovf \u0026lt;\u0026lt; 32) | cap1); } The 32-bit counter at 240 MHz wraps roughly every 17.9 seconds. Every wrap sets the update flag and increments a software overflow counter. The one case that needs explicit handling is a capture landing right at the wrap boundary — did the edge happen just before the rollover (still belongs to the old overflow count) or just after (belongs to the new one)? OVF_GUARD resolves that ambiguity by checking whether the captured value is suspiciously small at the same instant the update flag is set.\nTwo FIFOs, and why they need to talk to each other Each channel gets its own ring buffer (fifo1, fifo3). That part is unremarkable. What isn\u0026rsquo;t obvious until it happens: if one channel\u0026rsquo;s queue falls behind the other by even a single edge — for instance because the UART transmit in the main loop briefly blocked while a serial terminal wasn\u0026rsquo;t reading fast enough — naively popping \u0026ldquo;the front of each queue\u0026rdquo; now pairs a timestamp from PPS edge N on one channel with edge N+1 on the other. The reported delta doesn\u0026rsquo;t look obviously wrong; it just jumps to roughly one whole PPS period (plus the real jitter, which is now buried under it), and it never self-corrects.\nstatic int align_fifos(void) { for (;;) { uint64_t t1, t3; if (!fifo_peek(\u0026amp;fifo1, \u0026amp;t1)) return 0; if (!fifo_peek(\u0026amp;fifo3, \u0026amp;t3)) return 0; uint64_t newest = (t3 \u0026gt; t1) ? t3 : t1; int aligned = 1; if (newest - t1 \u0026gt; PPS_RESYNC_THRESHOLD_TICKS) { fifo_drop_front(\u0026amp;fifo1); aligned = 0; } if (newest - t3 \u0026gt; PPS_RESYNC_THRESHOLD_TICKS) { fifo_drop_front(\u0026amp;fifo3); aligned = 0; } if (aligned) return 1; } } align_fifos() peeks both queues without consuming them, and discards whichever side is stale by more than half a PPS period before the main loop is allowed to pop a pair. This is the part of the firmware that keeps a 24-hour CSV trustworthy from the first sample to the last, rather than only for the first few seconds before anything in the system has a chance to hiccup.\nThe HardFault, in retrospect Earlier in this post: \u0026ldquo;a HardFault was initially obscured by a stale debugger session.\u0026rdquo; Here\u0026rsquo;s what was actually going on underneath that.\nThe original version of this firmware formatted timestamps with snprintf() and a %llu specifier for the 64-bit values. That\u0026rsquo;s a reasonable thing to reach for — and it\u0026rsquo;s also a landmine on this specific toolchain. STM32CubeIDE links against newlib-nano by default, which is commonly built without long long support in the printf family. A %llu specifier in that configuration doesn\u0026rsquo;t just print the wrong number. It desyncs the internal va_list walk, so every argument after the mismatched one gets read from the wrong stack offset — and when a later %s in the same format string dereferences whatever garbage pointer happened to land there, that\u0026rsquo;s a HardFault that has nothing to do with the timer, the GPIO, or the clock tree. It looks exactly like \u0026ldquo;the board is broken,\u0026rdquo; which is precisely why it was confusing to chase down at first.\nThe fix was to stop using the printf family entirely for this:\nstatic void format_ns(int64_t ticks, char *out, size_t outsz) { char *p = out; if (ticks \u0026lt; 0) { *p++ = \u0026#39;-\u0026#39;; ticks = -ticks; } uint64_t num = (uint64_t)ticks * 25000ULL; uint64_t value_x1000 = (num + 3ULL) / 6ULL; /* round half up */ uint64_t ns_int = value_x1000 / 1000ULL; uint64_t ns_frac = value_x1000 % 1000ULL; p += u64_to_dec(ns_int, p); *p++ = \u0026#39;.\u0026#39;; if (ns_frac \u0026lt; 10) { *p++ = \u0026#39;0\u0026#39;; *p++ = \u0026#39;0\u0026#39;; p += u64_to_dec(ns_frac, p); } else if (ns_frac \u0026lt; 100) { *p++ = \u0026#39;0\u0026#39;; p += u64_to_dec(ns_frac, p); } else { p += u64_to_dec(ns_frac, p); } *p = \u0026#39;\\0\u0026#39;; } Hand-rolling the tick-to-nanosecond conversion (240 MHz → 25/6 ns per tick, done here as integer fixed-point math to avoid pulling in float formatting too) removes the entire class of bug. There\u0026rsquo;s no format string to misinterpret, and the timing of the conversion itself is fully deterministic — which matters slightly less for correctness here and a lot more for not second-guessing whether formatting overhead is contaminating anything downstream.\nInput capture filter sConfigIC.ICFilter = 0x3; This requires roughly six consecutive samples at the timer\u0026rsquo;s sampling clock (~25 ns at 240 MHz) before an edge is accepted, which rejects short glitches without affecting real PPS edges — those rise cleanly and hold far longer than 25 ns. It\u0026rsquo;s applied identically to both channels, so the fixed filter delay is common-mode and cancels out in the CH1-vs-CH3 delta instead of biasing it.\nTimer resolution With TIM2 running at 240 MHz, one timer tick corresponds to:\n\\[ T_\\mathrm{tick} = \\frac{1}{240\\,\\mathrm{MHz}} \\approx 4.167\\,\\mathrm{ns} \\]So the timestamp quantization step is approximately 4.167 ns.\nThis limitation is visible directly in the recorded data.\nThe measured timing differences do not form a perfectly continuous distribution. Instead, the values show discrete steps associated with the timer\u0026rsquo;s finite resolution.\nThis is an important distinction:\nTimer resolution is not the same thing as measurement jitter.\nThe timer defines the granularity with which an edge can be represented. The statistical spread of the captured timestamps is the quantity being characterized.\nResults The 24-hour run produced three useful measurements:\nMeasurement σ Channel-to-channel, same PPS edge (system floor) ≈ 1.6 ns NEO-M8T vs ZED-F9T (raw) ≈ 7.5 ns Receiver-to-receiver jitter, system floor removed ≈ 7.3 ns The first number is the measurement system\u0026rsquo;s own timing floor.\nThe second is what is observed when the two different receivers are compared.\nThe third attempts to isolate the receiver contribution by removing the independently measured system contribution.\nRemoving the measurement-system floor The system-floor measurement was approximately:\n\\[ \\sigma_\\mathrm{system} \\approx 1.6\\,\\mathrm{ns} \\]while the direct NEO-M8T vs ZED-F9T comparison gave:\n\\[ \\sigma_\\mathrm{measured} \\approx 7.5\\,\\mathrm{ns} \\]Assuming the two contributions are independent, their variances add:\n\\[ \\sigma_\\mathrm{measured}^2 = \\sigma_\\mathrm{receiver}^2 + \\sigma_\\mathrm{system}^2 \\]Therefore:\n\\[ \\sigma_\\mathrm{receiver} = \\sqrt{ \\sigma_\\mathrm{measured}^2 - \\sigma_\\mathrm{system}^2 } \\]Substituting the measured values:\n\\[ \\sigma_\\mathrm{receiver} = \\sqrt{ 7.5^2 - 1.6^2 } \\]\\[ \\sigma_\\mathrm{receiver} \\approx 7.33\\,\\mathrm{ns} \\]or approximately:\n≈ 7.3 ns Estimated receiver-to-receiver timing jitter after removing the measured system floor The correction is relatively small because the measurement system floor is much smaller than the observed receiver-to-receiver variation.\nWhat the data actually shows The result is not just a single standard-deviation number.\nThe distribution also contains visible quantization steps corresponding to the approximately 4.167 ns timer resolution.\nThat gives an additional sanity check on the measurement chain.\nThe hardware is behaving like a finite-resolution timestamping system should behave.\nThe measured distribution therefore contains two different things:\nPhysical timing variation from the signals being compared. Quantization introduced by the timer used to timestamp those signals. Keeping those two effects conceptually separate is important when interpreting nanosecond-scale measurements.\nCapture plots Same ZED-F9T PPS edge captured on two channels — measurement system floor NEO-M8T vs ZED-F9T — raw timing comparison over the measurement run The two plots answer different questions.\nThe receiver comparison shows the variation observed between the two independent PPS sources.\nThe same-edge comparison shows how much variation is introduced even when the physical PPS event is identical.\nThat second measurement is what makes the first result interpretable.\nValidation The most useful outcome of the experiment was not simply obtaining a number around 7 ns.\nIt was obtaining a number that is consistent with the expected performance of the receivers.\nThe measurement chain produced:\napproximately 1.6 ns of system-level variation when measuring the same PPS edge; approximately 7.5 ns when comparing the two receivers; approximately 7.3 ns after removing the independently measured system floor. The resulting receiver-to-receiver figure is in line with the relevant receiver specifications.\nThat agreement is the important validation.\nA timing experiment can always produce a plausible-looking distribution.\nThe harder question is whether the measurement chain is actually capable of producing a trustworthy result.\nHere, the independently measured system floor, the observable timer quantization, and the resulting receiver comparison all provide evidence that the measurement chain is behaving consistently with its expected limitations.\nWhat I would change in a second version The experiment also exposed a few limitations of the current setup.\nThe timer resolution is approximately 4.167 ns, so the timestamp representation is already coarse compared with the final jitter number.\nA future version could therefore improve the timestamping resolution and investigate the remaining quantization contribution more directly.\nThe current experiment was also designed primarily to establish a trustworthy comparison between the two receivers.\nA more extensive characterization would separate additional sources of variation and examine how the result changes with different receiver configurations and operating conditions.\nFor the original question, however, the measurement chain was sufficient:\nthe two receivers could be compared, the system floor could be measured independently, and the resulting timing variation landed in the expected nanosecond range.\nTakeaway The interesting part of this experiment was not getting a nanosecond number out of an STM32.\nIt was building a measurement system where that number could actually be trusted.\nThe chain ended up being:\n01 Capture Hardware-latched PPS timestamps on TIM2 02 Characterize Measure the system's own timing floor 03 Compare Measure NEO-M8T vs ZED-F9T 04 Correct Remove the independently measured system contribution 05 Validate Check that the result is consistent with receiver specifications Final result: ≈ 7.3 ns receiver-to-receiver jitter.\nMore importantly: the measurement system had a separately characterized floor of approximately 1.6 ns, and the timer\u0026rsquo;s 4.167 ns quantization was visible in the recorded data.\nThat is what makes the result useful rather than merely plausible.\nCode The full STM32CubeIDE project — ISR capture, the FIFO alignment logic, and the printf-free timestamp formatting — is on GitLab:\npps-timing-firmware →\n","permalink":"https://signal-chain.tech/projects/pps-timing-system/","summary":"\u003cdiv class=\"result-callout\"\u003e\n\u003cdiv class=\"callout-number\"\u003e≈ 7.3 ns\u003c/div\u003e\n\u003cdiv class=\"callout-label\"\u003eReceiver-to-receiver jitter (NEO-M8T vs ZED-F9T), with the measurement system noise floor removed in quadrature\u003c/div\u003e\n\u003c/div\u003e\n\u003ch2 id=\"overview\"\u003eOverview\u003c/h2\u003e\n\u003cp\u003eA follow-up to the VLF recorder work — a parallel project at the Institute characterizing PPS (pulse-per-second) timing accuracy across two GNSS receivers, both running in survey-in mode: a u-blox NEO-M8T and a ZED-F9T.\u003c/p\u003e\n\u003cp\u003eThe question was straightforward:\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003eHow precisely can the PPS outputs of two GNSS receivers be compared, and how much of the measured jitter actually comes from the measurement system itself?\u003c/p\u003e","title":"PPS Timing System: From \"Why Does This Keep Hanging\" to Nanosecond-Level Data"},{"content":" 2 ch Simultaneous time-domain, spectrum, and waterfall monitoring with GPS-disciplined acquisition and unattended recording Overview VLFRecorder is a Windows desktop application for continuous, unattended acquisition and monitoring of Very Low Frequency signals at lightning detection stations.\nThe application grew from a single-channel proof of concept into a dual-channel real-time acquisition system with:\ncontinuous two-channel recording; hardware-disciplined sample timing; GPS/PPS synchronization; real-time time-domain and spectral visualization; scrolling waterfall spectrograms; measurement cursors; GPS-aligned session management; acquisition and connection error logging; automatic post-processing and sferic extraction. The software is used for day-to-day operation of the station\u0026rsquo;s data acquisition hardware, so the primary design constraint is not simply displaying data quickly. The acquisition pipeline must continue receiving samples while visualization, GPS parsing, file I/O, and analysis are running independently.\nThe project therefore sits at the intersection of radio physics, digital signal processing, hardware interfacing, real-time software, and time synchronization.\nThe source code is the property of the Institute of Solar-Terrestrial Physics, Siberian Branch of the Russian Academy of Sciences (ISTP SB RAS), and is not publicly available. This write-up describes the architecture, synchronization strategy, data flow, and engineering decisions behind the application without reproducing any part of the implementation.\nSystem at a glance The complete data path can be viewed as a chain:\nGPS/GNSS → GPSDO/PPS → DAQ hardware → acquisition thread → processing buffers → visualization / recording → post-processing\nThe important property of this architecture is that the host PC is not responsible for defining the primary acquisition timebase.\nThe DAQ receives its sample clock from an external GPS-disciplined oscillator, while PPS provides the synchronization event used to start acquisition. The PC then consumes the resulting stream rather than attempting to generate precise sample timing through software or USB scheduling.\nThis separates two fundamentally different responsibilities:\nhardware determines when samples are acquired; software determines what happens to those samples afterward. That distinction is central to the design.\nEngineering constraints The application was built around several practical constraints imposed by continuous station operation.\nContinuous acquisition The DAQ must be serviced continuously. A slow GUI frame, disk operation, or GPS parser must not be allowed to block the acquisition path.\nDeterministic timing The sample stream must remain tied to an external timing reference rather than the host operating system clock.\nGPS-aware operation Timestamps and session boundaries need to remain associated with GPS time. A stale GPS state should not silently turn into apparently valid recorded data.\nUnattended operation The intended use is continuous station operation rather than short interactive measurements. Startup, session rotation, connection errors, and GPS state therefore need to be handled without constant operator intervention.\nReal-time monitoring without affecting recording An operator should be able to inspect signals, change visualization settings, pause the display, or examine a spectrum without stopping the underlying acquisition and recording pipeline.\nThese constraints shaped the architecture more than the GUI itself.\nStack C++ Dear ImGui — immediate-mode GUI, chosen for a lightweight and responsive interface with minimal overhead on the acquisition workstation FFTW3 — FFT-based spectral analysis for spectrum and waterfall views LUSB API (L-Card) — interface to the L-Card E14-140 USB DAQ module The application is intentionally built as a native desktop program rather than a browser-based monitoring interface. The main reason is that the workstation is part of the acquisition system itself: it communicates directly with the DAQ, receives the GPS data stream, manages continuous binary recording, and provides the operator interface.\nThe ADC: L-Card E14-140 and hardware-level PPS synchronization Acquisition runs through an L-Card E14-140 USB DAQ module.\nThe synchronization strategy deliberately pushes both clocking and triggering into the hardware rather than attempting to reproduce them in software.\nThe module receives two external references.\nSample clock The DAQ sample clock is driven by an external 200 kHz GPS-disciplined oscillator (GPSDO) connected to the module\u0026rsquo;s SYN input.\nThis replaces the module\u0026rsquo;s internal clock as the source of the acquisition timing.\nStart trigger The GPS receiver\u0026rsquo;s PPS (pulse-per-second) signal is connected to the module\u0026rsquo;s INT input.\nThe DAQ is configured to wait for a PPS edge before starting the continuous stream. This is important because a frame-synchronization mode would have a different meaning: it could capture only a small number of words around each PPS pulse rather than provide the continuous sample stream required by the application.\nThe resulting architecture is therefore:\nGPSDO → sample clock\nGPS PPS → acquisition start synchronization\nrather than:\nPC clock → software timing → USB → DAQ\nSample organization The two channels are interleaved in the acquired stream:\nCH0, CH1, CH0, CH1, ...\nAt a combined sample rate of 200 kHz, this corresponds to:\n200,000 individual ADC samples per second; 100,000 CH0/CH1 sample pairs per second; one complete pair every 10 µs. Each acquired sample uses the configured differential input range of ±2.5 V.\nKeeping the channels interleaved in the acquisition stream allows the software to treat each CH0/CH1 pair as a synchronized observation of the two VLF channels.\nWhy the PPS failure mode matters The synchronization logic deliberately treats a missing PPS signal as an acquisition problem rather than merely a timestamp problem.\nIf the expected PPS edge does not arrive within the configured timeout, the acquisition thread:\ntreats the condition as a lost GPS/oscillator reference; cancels the pending I/O operation; cycles the ADC through stop/start; waits for the next valid PPS event; resumes acquisition only after synchronization can be established again. This prevents a particularly dangerous failure mode: the ADC could continue producing perfectly plausible-looking samples while the timing reference had silently disappeared.\nThe resulting data might look valid in a waveform viewer while no longer being tied to the intended external timebase.\nFor a measurement system, that distinction is more important than simply keeping the GUI alive.\nAcquisition thread and double buffering The acquisition path runs on a dedicated thread with time-critical operating-system priority.\nThe DAQ is serviced using double-buffered asynchronous reads.\nWhile one buffer is being processed, the hardware/driver can already be filling the next one.\nConceptually:\nDAQ → buffer A → processing\nDAQ → buffer B → processing\nDAQ → buffer A → processing\nand so on.\nThe acquisition thread performs only lightweight work in the hot path:\nreceive a completed buffer; convert raw ADC codes into voltage values; push the resulting samples into the oscilloscope/ring-buffer path; prepare the next acquisition operation. Expensive or unrelated work is deliberately kept outside this path.\nNotably, the buffer that feeds the on-screen plot is a separate ring buffer from the one that feeds the session-recording writer. That split matters in practice: an operator changing the number of points shown on the waveform plot, or resizing a window, touches only the display buffer — it has no effect on what actually gets written to disk. Display configuration and data integrity are two different codepaths by construction, not just by convention.\nThe acquisition deadline At a combined rate of 200 kHz, a buffer representing approximately 160 ms of signal provides only a finite amount of time for the software to service it.\nIf the acquisition thread does not process a completed buffer before the next one fills, samples can be lost.\nThat makes the acquisition loop a real scheduling problem rather than simply a function that reads data whenever convenient.\nThe design response is straightforward:\ndedicated acquisition thread; elevated scheduling priority; asynchronous double buffering; lightweight per-buffer processing; visualization decoupled from acquisition; file I/O kept out of the critical acquisition path. The goal is not maximum CPU utilization. It is maintaining a predictable enough acquisition path that the GUI and background tasks cannot starve it.\nThreading model The application separates the major responsibilities into independent workers.\nAcquisition thread Responsible for communicating with the DAQ and moving incoming samples into the software pipeline.\nThis is the time-critical part of the application.\nUI thread Responsible for rendering the interface, plots, controls, and user interaction.\nA slow redraw must not prevent the acquisition thread from receiving samples.\nSerial worker Reads and parses NMEA sentences from the GPS/GNSS receiver over a configurable serial port.\nGPS parsing is therefore independent from the DAQ data path.\nBefore a sentence is trusted for anything — a timestamp, a position, a fix flag — its checksum is validated. A corrupted line on a noisy serial link is discarded rather than partially parsed, and coordinate fields are converted from the NMEA degrees-and-minutes format into plain decimal degrees so the rest of the application never has to deal with two different coordinate representations. The parsed result — fix status, UTC time, position, satellite count, ground speed — is the single shared snapshot of \u0026ldquo;what does the receiver currently think is true,\u0026rdquo; and everything downstream (readiness checks, session timestamps, the GPS dashboard) reads from that same snapshot instead of re-parsing raw NMEA text itself.\nStatistics worker Tracks:\nsample-pair counts; per-minute acquisition statistics; GPS fix freshness; GPS-driven session rotation; system readiness state. The resulting structure keeps acquisition, visualization, GPS parsing, and bookkeeping from becoming one large synchronous loop.\nConcurrency and state isolation Four threads sharing state is exactly the kind of situation where a single global lock is tempting and usually wrong — the acquisition thread would end up blocked behind whatever the UI or the statistics worker happens to be doing with that same lock.\nInstead, shared state is split into a few independent groups, each behind its own mutex: the GPS/serial snapshot, the display buffers, and the session-file writer are locked separately, so a slow disk write during session rotation can\u0026rsquo;t stall the code that\u0026rsquo;s converting the next ADC buffer into voltages, and neither of those can stall the UI reading the latest sample for a redraw.\nFor state that\u0026rsquo;s read constantly but written rarely — is the acquisition running, is the display paused, is auto-session mode on, how many sample pairs has this minute produced — the application leans on atomics instead of locks. The running sample-pair counter, for instance, is incremented from the acquisition thread on every buffer and read-and-reset by the statistics thread once a minute, with no lock in either direction; it\u0026rsquo;s a pattern that keeps the once-a-minute bookkeeping from ever being on the critical path of the once-every-10-µs acquisition loop.\nData flow A simplified software data flow looks like this:\nL-Card E14-140\n↓\nasynchronous acquisition\n↓\nraw ADC buffer\n↓\ncode → voltage conversion\n↓\nring buffers / processing\n↙︎　↘︎\nlive visualization　recording\n↓　↓\ntime domain / FFT / waterfall　binary session files\n↓\nsferic post-processing\nThis separation is important because the live display is only one consumer of the acquired data.\nRecording should not depend on whether a plot is currently visible, and inspecting a frozen plot should not affect the incoming sample stream.\nSession files and GPS-driven rotation Continuous recording is split into binary session files.\nA session rotates when either of two conditions occurs:\na new GPS minute begins; the configured sample-count limit is reached. GPS minute boundary The statistics worker monitors the minute field from the GPS receiver\u0026rsquo;s NMEA time data rather than using the PC clock as the primary rotation reference.\nWhen the minute changes, the application constructs a UTC minute-start timestamp:\nhour and minute come from GPS; date comes from the system clock; seconds are set to zero. The resulting timestamp is then used for the session boundary and associated file naming.\nSample-count limit Each session targets 6,000,000 sample pairs.\nAt 100,000 sample pairs per second:\n6,000,000 / 100,000 = 60 seconds\nSo the target file duration corresponds to approximately one minute of continuous acquisition.\nThe sample-count limit acts as a second boundary condition. If the file reaches the limit before the next GPS minute boundary arrives, it rotates rather than growing indefinitely.\nThis gives the system both a time-based and data-volume-based upper bound on individual session files.\nTwo logs, not one GPS state and ADC samples are written through the same logging component, but into two separate files rather than one interleaved stream — a compact, human-readable GPS log that can be opened and skimmed directly, and a large binary ADC file meant for programmatic reading. Keeping them apart means a quick check of \u0026ldquo;was the receiver reporting a fix at 03:14\u0026rdquo; never involves touching a multi-megabyte binary file, and the ADC stream never has to reserve space for text formatting in its hot path.\nBinary file structure Each session begins with a small binary header containing:\na timestamp; a running sample-pair count. When the file is closed, the final pair count is patched back into the header in place.\nThis means a completed file records its actual number of acquired sample pairs rather than relying on a nominal expected value.\nThat is useful when investigating interrupted sessions, synchronization events, or any other condition where the actual recorded duration differs from the target duration.\nGPS time and system time The application does not treat the PC clock and GPS time as interchangeable.\nWhen a valid GPS fix is available, GPS-reported time is used for the session timestamp and filename.\nDuring the startup period before a valid GPS fix is available, the application can fall back to system UTC time.\nThis distinction is important because the PC clock is useful for ordinary operating-system functions but is not the reference chosen for the acquisition timing architecture.\nThe design therefore separates:\nhardware acquisition timing — GPSDO/PPS; recording/session timing — GPS/NMEA; host operating-system time — fallback and calendar information where required. Auto-session mode Auto-session mode turns the recorder into a rolling unattended acquisition pipeline.\nOnce enabled, the application continuously produces approximately one session per GPS minute without requiring an operator to manually start or stop each recording.\nThe intended result is simple:\nstart the station → establish GPS readiness → acquire continuously → rotate files automatically → continue.\nThis mode is particularly important for a monitoring station because manual intervention at every file boundary would defeat the purpose of continuous unattended acquisition.\nGPS fix monitoring and system readiness The GPS/NMEA panel is not merely a clock display.\nGPS state affects whether the system considers itself ready to operate.\nThe statistics worker continuously monitors how recently a valid GPS fix/time report was received.\nIf the receiver stops reporting valid information for longer than the expected interval, the application marks the system as not ready rather than silently continuing as if the timing reference were healthy.\nAt startup, the acquisition logic waits for a valid fix within a bounded timeout before enabling the automatic logging/session/statistics pipeline.\nThis prevents a situation where the DAQ is electrically functioning but the recording system begins generating apparently valid files without a trustworthy timing reference.\nVisualization pipeline The application provides several complementary views because VLF signals can contain useful information in both the time and frequency domains.\nTime domain The waveform view shows the instantaneous signal and allows transient events to be inspected directly.\nThis is the most immediate representation for impulsive VLF events.\nSpectrum FFT-based spectral analysis provides a frequency-domain representation of the acquired signal.\nThis makes persistent spectral components and frequency-localized interference easier to identify.\nWaterfall The waterfall provides a scrolling time-frequency representation:\nfrequency on the X axis; time on the Y axis; intensity represented by color. Unlike a single spectrum snapshot, the waterfall makes changes over time visible.\nThis is useful for identifying recurring, drifting, or intermittent spectral features.\nSpectrum accumulation The application can also accumulate/average spectra over a configurable interval.\nThis trades instantaneous time resolution for a cleaner representation of persistent spectral content.\nMeasurement cursors The plots include draggable measurement cursors.\nOn the time-domain view they provide direct Δt measurements in microseconds.\nOn the spectrum view they provide direct Δf measurements in hertz.\nThis makes quick measurements possible directly inside the acquisition interface instead of requiring the operator to export the signal and perform the measurement in another application.\nFor a station operator, this reduces the distance between observing an event and extracting a basic quantitative measurement from it.\nPause without stopping acquisition The display can be paused independently of recording.\nThis is a small feature with an important architectural consequence.\nWhen an operator freezes the display to inspect a signal, the acquisition pipeline continues receiving and recording data.\nIn other words:\nPause display ≠ pause acquisition.\nThis prevents a visualization feature from introducing a gap into the scientific data simply because someone wanted to inspect a waveform more carefully.\nThe feature is therefore not merely cosmetic. It is another consequence of keeping acquisition and presentation as separate subsystems.\nRendering optimization Each visualization panel can be independently enabled or disabled.\nPoint counts and plot sizes can also be configured.\nThis allows the operator to reduce rendering work when a particular visualization is not required.\nFor a continuously running station, this is more useful than optimizing only for peak frame rate. The relevant question is how much unnecessary work the system performs over long periods of operation.\nThe application therefore treats rendering resources as something that should be configurable rather than assumed to be free.\nSferic auto-extraction Continuous VLF recording produces substantially more data than an operator can reasonably inspect manually.\nThe post-processing stage therefore extracts individual lightning sferics — impulsive VLF events — from completed session files.\nThe extraction process is intentionally separated from live acquisition.\nA simplified detection pipeline is:\ncontinuous waveform\n↓\nenvelope construction\n↓\nnoise estimation\n↓\nthreshold detection\n↓\nevent window extraction\n↓\nindividual sferic files/events\nThis allows the acquisition system to remain focused on reliably recording the raw data while the more computationally flexible analysis stage works on completed sessions.\nDetection method The detector uses an envelope-plus-threshold approach.\n1. Envelope construction A fast envelope is derived from instantaneous signal amplitude together with the sample-to-sample derivative.\nThe derivative component makes sharp transients more prominent, while the amplitude component preserves information about the signal magnitude.\n2. Noise estimation The recording is used to estimate the noise floor statistically.\nThe current approach uses the median and standard deviation.\n3. Thresholding An event is flagged when the envelope crosses:\nmean + k·σ\nwith k typically between 4 and 6.\nThe threshold can therefore be adjusted depending on how aggressively events should be detected.\n4. Event extraction Once a threshold crossing is detected, the application extracts a configurable window around the event.\nThe window includes:\na pre-trigger region; the detected event; a post-trigger region. A maximum event-duration limit prevents a long noisy section from becoming one enormous event.\nThe result is a set of compact events that can be inspected individually instead of searching manually through hours of continuous recording.\nWhy post-processing is separate from acquisition The detector does not need to run inside the critical acquisition path.\nThat separation is intentional.\nThe acquisition system has one job that cannot be recovered later:\ndo not lose the raw samples.\nThe event extractor has a different job:\nturn recorded samples into useful candidate events.\nKeeping these responsibilities separate means that a computationally expensive detection algorithm does not need to compete directly with the DAQ read deadline.\nIt also allows the detection method to evolve independently from the acquisition system.\nDevelopment log: two updates from the team chat The dual-channel milestone and the measurement/waterfall milestone were two distinct check-ins with the wider team, four days apart, and they read almost like a changelog for the two halves of this article.\nThe first landed the two-channel upgrade itself: simultaneous CH0/CH1 time-domain and spectrum views side by side, plus the first pass at rendering optimization — independently toggling each plot window\u0026rsquo;s drawing, setting its point count, and resizing it. That message also laid out the stack (C++, Dear ImGui for graphics, FFTW3 for the FFTs, the L-Card LUSB API for the hardware) and the plan for what came next: markers, post-processing, and a watchdog.\nThe second update, a few days later, delivered measurement cursors and the non-destructive pause described above, plus the waterfall display, spectrum accumulation mode, and error logging — the four features that turned the tool from \u0026ldquo;watch two channels\u0026rdquo; into something closer to a real bench instrument. The markers and the watchdog mentioned in the first message are still open — they\u0026rsquo;re the two items in What\u0026rsquo;s next, below.\nFeature build-out The application was developed incrementally around actual station requirements.\nSingle channel → dual channel The initial proof of concept was single-channel.\nThe system was expanded to display CH0 and CH1 simultaneously, including time-domain and spectral views.\nThis removed the need to switch between channels when monitoring the station.\nReal-time visualization The GUI evolved into a multi-view monitoring interface containing:\ntime-domain plots; spectrum views; waterfall displays; GPS/NMEA status; acquisition settings; measurement cursors; visualization controls. Rendering controls Plot visibility, point counts, and dimensions became configurable so the operator can trade visualization detail against system workload.\nMeasurement tools Cursors were added to provide direct Δt and Δf measurements.\nNon-destructive display pause Display inspection was separated from acquisition so that freezing the UI never means stopping the recorder.\nSpectrum accumulation Accumulated spectra were introduced to make persistent spectral features easier to identify.\nError logging A dedicated logger was added so acquisition and connection problems remain diagnosable after they occur, even when no operator is watching the station at the exact moment of failure.\nAutomatic session management GPS-driven session rotation removed the need for manual file management during continuous operation.\nAutomatic event extraction Post-processing was added to reduce hours of continuous recordings to a much smaller collection of candidate sferics.\nThe feature progression therefore follows a consistent pattern:\nacquire → observe → measure → record → automate → extract.\nFailure handling Continuous instrumentation has a different definition of failure from ordinary desktop software.\nA GUI that stops updating is obvious.\nA recorder that continues displaying plausible waveforms while its timing reference has disappeared is much more dangerous.\nThe application therefore explicitly monitors several classes of failure.\nMissing PPS The DAQ is restarted and waits for synchronization rather than silently continuing against an untrusted timebase.\nStale GPS information The system reports itself as not ready rather than silently accepting stale timing information.\nAcquisition errors The logging system records acquisition and connection errors for later diagnosis.\nSession boundaries The sample-count limit provides a second protection against uncontrolled file growth.\nStartup without GPS readiness Automatic recording waits for a valid GPS state instead of immediately producing files with potentially meaningless timestamps.\nThe overall design philosophy is therefore:\nwhen a dependency required for trustworthy acquisition fails, make the failure explicit rather than hiding it behind apparently normal output.\nInterface Dual-channel time domain (left), waterfall and spectrum (center), GPS/settings panel (right) The interface is organized around the operator\u0026rsquo;s workflow.\nThe left side contains the CH0/CH1 time-domain views.\nThe central area provides waterfall and spectrum visualization.\nThe right-hand control area contains:\nGPS/NMEA status; acquisition settings; sample-rate configuration; GNSS serial configuration; recording timestep; data and log directories; visualization controls; cursor readouts. The result is a single workstation view combining acquisition status, signal inspection, timing state, and recording configuration.\nTiming architecture across projects VLFRecorder is part of a broader timing-related engineering approach.\nThe same GPS-PPS discipline appears in the separate PPS timing system project.\nThere, PPS timing was characterized between two GNSS receivers using an STM32 timer.\nHere, the same general timing concept is applied at a different level:\nPPS provides the external timing event; GPSDO provides the acquisition clock; the DAQ hardware uses those references directly; GPS time determines session boundaries and timestamps. The two projects therefore address different layers of the same problem.\nThe PPS timing project examines the behavior of the timing reference itself.\nVLFRecorder uses that reference as part of a complete acquisition and recording system.\nWhat the project demonstrates Although VLFRecorder is a specific station application, the engineering problems it addresses are broader than VLF monitoring.\nThe project combines several areas that normally appear as separate subsystems:\nHardware interfacing Direct communication with a dedicated USB DAQ and configuration of external clock and trigger inputs.\nReal-time acquisition Continuous asynchronous acquisition with a finite processing deadline and double buffering.\nMultithreaded software Independent acquisition, visualization, GPS parsing, and statistics/session-management workers, coordinated through scoped locks and atomics rather than a single shared mutex.\nDigital signal processing FFT-based spectral analysis, waterfall generation, envelope construction, statistical thresholding, and event extraction.\nTime synchronization Using GPSDO/PPS at the hardware level and GPS-derived time at the recording/session level.\nData engineering Binary session files, explicit headers, sample counts, automatic rotation, and post-processing.\nReliability engineering Explicit handling of missing synchronization, stale GPS state, acquisition errors, and unattended operation.\nOperator tooling Live visualization, measurement cursors, configurable rendering, and non-destructive display pause.\nThe interesting part is not any single feature.\nIt is the interaction between them.\nA typical operating cycle A normal unattended session can be summarized as:\n1. Start application\n↓\n2. Establish GPS/GNSS communication\n↓\n3. Wait for valid GPS state\n↓\n4. Synchronize DAQ acquisition to external PPS\n↓\n5. Start continuous 200 kHz acquisition\n↓\n6. Process incoming buffers\n↓\n7. Update live CH0/CH1 visualization\n↓\n8. Record binary session data\n↓\n9. Monitor GPS state and sample counts\n↓\n10. Rotate the session at the GPS minute boundary or sample-count limit\n↓\n11. Optionally run sferic extraction on the completed session\n↓\n12. Continue with the next session\nThe operator can inspect the live data without interrupting this pipeline.\nWhat\u0026rsquo;s next The planned additions at the time of writing are focused on reducing the remaining points of manual intervention.\nManual event markers Allow the operator to place event markers directly on the live plots during monitoring.\nThis would preserve human observations alongside automatically detected events.\nAcquisition watchdog Add a watchdog capable of detecting and recovering from a DAQ connection or GPS-link failure during unattended operation.\nThe goal is to move another class of recoverable faults from:\noperator notices → operator diagnoses → operator restarts\ntoward:\nsystem detects → system recovers → system logs the event.\nDesign principles Several principles ended up shaping the system repeatedly.\nHardware timing should stay hardware timing If the timing reference is available at the hardware level, there is little reason to recreate it through host software.\nAcquisition should be boring The most important part of the system is the part that should attract the least attention during normal operation.\nSamples should arrive, be processed, and be recorded without the UI becoming part of the critical path.\nVisualization is a consumer, not the source of truth The live display exists to help an operator understand the data.\nIt should never determine whether the underlying acquisition continues.\nFail explicitly A missing GPS reference should not look like valid data.\nA failed connection should be logged.\nA stale timestamp should not silently become a trustworthy timestamp.\nSeparate raw data from interpretation Continuous recording preserves the underlying signal.\nSferic extraction and visualization operate on top of that data.\nThis allows analysis methods to evolve without changing the fundamental acquisition layer.\nIsolate what fails independently Serial state, sample buffers, and the session-file writer are kept behind separate locks, and the flags that cross thread boundaries most often are atomics rather than mutex-guarded fields. A slow disk, a jittery serial link, or a busy UI frame should each be contained to its own subsystem rather than able to stall the acquisition thread.\nA note on the source The source code of VLFRecorder is the property of the Institute of Solar-Terrestrial Physics, Siberian Branch of the Russian Academy of Sciences (ISTP SB RAS), and is not publicly available.\nThe purpose of this write-up is therefore not to reproduce the implementation, but to document the system architecture, acquisition strategy, synchronization design, processing pipeline, and engineering decisions behind the application.\n","permalink":"https://signal-chain.tech/projects/vlf_recorder/","summary":"\u003cdiv class=\"result-callout\"\u003e\n\u003cdiv class=\"callout-number\"\u003e2 ch\u003c/div\u003e\n\u003cdiv class=\"callout-label\"\u003eSimultaneous time-domain, spectrum, and waterfall monitoring with GPS-disciplined acquisition and unattended recording\u003c/div\u003e\n\u003c/div\u003e\n\u003ch2 id=\"overview\"\u003eOverview\u003c/h2\u003e\n\u003cp\u003eVLFRecorder is a Windows desktop application for continuous, unattended acquisition and monitoring of Very Low Frequency signals at lightning detection stations.\u003c/p\u003e\n\u003cp\u003eThe application grew from a single-channel proof of concept into a dual-channel real-time acquisition system with:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003econtinuous two-channel recording;\u003c/li\u003e\n\u003cli\u003ehardware-disciplined sample timing;\u003c/li\u003e\n\u003cli\u003eGPS/PPS synchronization;\u003c/li\u003e\n\u003cli\u003ereal-time time-domain and spectral visualization;\u003c/li\u003e\n\u003cli\u003escrolling waterfall spectrograms;\u003c/li\u003e\n\u003cli\u003emeasurement cursors;\u003c/li\u003e\n\u003cli\u003eGPS-aligned session management;\u003c/li\u003e\n\u003cli\u003eacquisition and connection error logging;\u003c/li\u003e\n\u003cli\u003eautomatic post-processing and sferic extraction.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe software is used for day-to-day operation of the station\u0026rsquo;s data acquisition hardware, so the primary design constraint is not simply displaying data quickly. The acquisition pipeline must continue receiving samples while visualization, GPS parsing, file I/O, and analysis are running independently.\u003c/p\u003e","title":"VLFRecorder: Real-Time Dual-Channel VLF Acquisition Software"},{"content":" 2 sites Digital twin system for TEC, Doppler and other measurements Overview IONOSPHERE_PRO is a web dashboard and processing pipeline for a small network of ground stations that measure ionospheric total electron content (TEC) by tracking the coherent dual-frequency beacon transmitted by the Ionosfera-M satellite pair. Each station records raw IQ samples at 150 MHz and 400 MHz during a satellite pass; the backend turns those recordings into phase and TEC curves; the frontend presents all of it as a live map with a per-station history of processed sessions.\nIt\u0026rsquo;s the software layer that sits between \u0026ldquo;two SDR receivers pointed at the sky\u0026rdquo; and \u0026ldquo;a scientist looking at a TEC curve\u0026rdquo; — ingest, signal processing, storage, auth, and visualization, built as one coherent system rather than a pile of one-off scripts.\nThe source code is the property of the Institute of Solar-Terrestrial Physics, Siberian Branch of the Russian Academy of Sciences (ISTP SB RAS), and is not publicly available. This write-up describes the architecture, the measurement technique, and the engineering decisions behind the application without reproducing any part of the implementation.\nThe measurement technique Ionosfera-M transmits two phase-coherent tones, at 150.048 MHz and 400.128 MHz — the same technique used by classic ionospheric beacon satellites for decades. Because both tones originate from the same onboard oscillator, their frequencies are locked in an exact 3:8 ratio. As the signal passes through the ionosphere, the two frequencies pick up different amounts of phase delay from the same electron content along the path — the delay is frequency-dependent. Scale the 400 MHz phase down by 3/8 and subtract it from the 150 MHz phase, and what\u0026rsquo;s left is a curve proportional to the total electron content the signal traversed. Everything else about the pipeline exists to get from raw IQ samples to that one subtraction cleanly.\nThere\u0026rsquo;s a second signal buried in the same recording: the satellite\u0026rsquo;s own along-track velocity imposes a large, smoothly varying Doppler shift on both tones — tens of kilohertz over the few minutes of a pass, following the characteristic S-curve shape of a near-overhead LEO pass (v0 - A·tanh(k·(t − t_close)), a good fit for the along-track geometry near closest approach). That Doppler shift has to be estimated and removed before the TEC subtraction means anything, because it dwarfs the phase perturbation caused by the ionosphere itself.\nSignal processing pipeline Each recorded pass goes through the same sequence, per station, per satellite:\nraw IQ (150 MHz + 400 MHz) → coarse Doppler fit → residual Doppler tracking → coherent demodulation \u0026amp; integration → phase unwrap → 150/400 differencing → relative TEC\nCoarse Doppler: fitting the pass geometry The 400 MHz channel is chopped into short windows, and the FFT peak frequency in each window is tracked over the whole pass. That noisy peak-frequency trace gets a median filter, then a least-squares fit to the tanh model above (scipy.optimize.leastsq). The result is a smooth prediction of the pass\u0026rsquo;s bulk Doppler at any point in time, driven by orbital geometry rather than by tracking noise sample-to-sample.\nResidual Doppler: what the coarse fit misses The tanh fit captures the geometric trend but not everything — receiver oscillator drift, finer structure the model doesn\u0026rsquo;t cover. So a second pass repeats the FFT-peak-tracking idea on 200 evenly spaced windows across the recording, this time after subtracting the coarse model, and interpolates the leftover (residual) frequency into a continuous function of time.\nCoherent demodulation With both a coarse and a residual Doppler estimate, every short window of both channels gets mixed down by the combined predicted Doppler for that instant, then coherently accumulated. This is the computationally heavy part of the pipeline — a complex mix-and-sum over every sample in the recording — and it\u0026rsquo;s implemented twice:\na GPU path (CuPy), vectorized across the whole batch of windows at once, used when a CUDA device is available (developed against an RTX 3060); a CPU path (Numba, @njit(parallel=True)), used automatically whenever the GPU path is unavailable or throws at runtime — the code tries GPU first and falls back to CPU inside the same batch loop, so a machine without a GPU, or a GPU that fails for any reason mid-run, degrades to CPU rather than crashing the session. From phase to TEC The accumulated complex values for each window are unwrapped in phase (np.unwrap(np.angle(...))) to give continuous phase-vs-time curves for both channels. The 400 MHz phase is scaled by 3/8 and subtracted from the 150 MHz phase — that difference is the relative TEC curve. All three curves (150 MHz phase, 400 MHz phase, relative TEC) are rendered and saved as the output of a processed session.\nFrom desktop script to a multi-station service The processing code didn\u0026rsquo;t start life as a service. The original version (graph.py) had station, satellite, and file names hardcoded in a Main() function, and it printed a RESULT_JSON: line to stdout specifically so a wrapping Electron desktop app could parse it — a single-operator, single-machine tool.\nTurning that into something a network of stations could share meant lifting every hardcoded assumption into a parameter: which station, which pass, which pair of IQ files, and — importantly — where the output goes. The reworked entry point (process_session.py) takes station ID and file paths as arguments and writes its output into a predictable, self-describing layout:\ndata/\u0026lt;station_id\u0026gt;/sessions/\u0026lt;session_id\u0026gt;/meta.json data/\u0026lt;station_id\u0026gt;/sessions/\u0026lt;session_id\u0026gt;/tec.png data/\u0026lt;station_id\u0026gt;/sessions/\u0026lt;session_id\u0026gt;/phase150.png data/\u0026lt;station_id\u0026gt;/sessions/\u0026lt;session_id\u0026gt;/phase400.png That structure is the entire contract between the processing script and the web backend — the backend never needs to know anything about Doppler models or coherent integration, only how to walk a directory of meta.json files. New stations, in principle, need nothing more than a new entry in a station registry and a place on disk for their sessions to land; the processing and serving code doesn\u0026rsquo;t change.\nBackend and API The Flask backend is deliberately thin on top of that file layout. A station registry (stations.json — id, name, coordinates, description, the satellite names it targets) is the source of truth for what\u0026rsquo;s on the map; a small manage_users.py CLI hashes passwords into users.json for the handful of named operator accounts, checked against on login and exchanged for a JWT.\nFrom there, the API is close to a direct read of the session directory structure:\nGET /api/stations — the registry, each entry annotated with its most recent session\u0026rsquo;s metadata so the frontend can show which stations have recent data; GET /api/stations/\u0026lt;id\u0026gt;/sessions — that station\u0026rsquo;s session history, newest first; GET /api/stations/\u0026lt;id\u0026gt;/sessions/\u0026lt;session_id\u0026gt; — one session\u0026rsquo;s metadata; GET /api/data/\u0026lt;id\u0026gt;/\u0026lt;session_id\u0026gt;/\u0026lt;filename\u0026gt; — the actual plot images. The one deliberate wrinkle: \u0026lt;img\u0026gt; tags can\u0026rsquo;t send an Authorization header, so the JWT library is configured to also accept the token as a ?token= query parameter, and the frontend appends it when building plot URLs. Everything else stays on bearer-token auth in the header.\nSatellites are a separate, honest layer Ground stations are fixed points; satellites are not, and the frontend treats them accordingly — two different overlays on the same map rather than one blurred concept. GET /api/satellites reads the target satellite names out of the station registry, resolves them against current TLE data (Skyfield/SGP4), and returns a short predicted ground track for each. A companion GET /api/satellites/debug endpoint reports exactly how many TLEs loaded, which targets matched, which didn\u0026rsquo;t, and where the TLE data actually came from — useful precisely because \u0026ldquo;the map shows no satellites\u0026rdquo; is otherwise a silent failure with several unrelated possible causes (bad TLE fetch, a name that doesn\u0026rsquo;t match what\u0026rsquo;s in the station registry, an empty TLE set), and staring at an empty overlay doesn\u0026rsquo;t tell you which one it was.\nFrontend The interface is a single-page dark-themed dashboard: a station list on the left, a resizable map/session-panel split filling the rest.\nThe map (Leaflet) shows every registered station as a marker, colored by whether it has produced a session in the last 24 hours, plus the live satellite ground tracks as a separate overlay with its own color per satellite. Selecting a station loads its session history into a panel below the map, each session showing the processed phase/TEC plots for that pass. The map/panel split is user-resizable by dragging a divider, rather than a fixed layout — a small feature, but one that matters when a plot needs more room than the default split gives it. The API client uses a relative base URL (API_BASE = ''), so the frontend, once built, is just static assets that work regardless of what host or port they\u0026rsquo;re served from — a small design choice that turned out to remove an entire category of deployment complexity later (more on that below).\nDeployment: running unattended on an air-gapped machine The central server this runs on has no outbound internet access at all, is reachable only through a chain of SSH jump hosts and a NAT port forward, and is operated by someone who isn\u0026rsquo;t a developer — so the whole stack had to reduce to \u0026ldquo;copy one archive over, run one script.\u0026rdquo;\nThat constraint ruled out the obvious approach (pip install from a wheelhouse on the target) outright: the target doesn\u0026rsquo;t even have python3-venv installed, and can\u0026rsquo;t install it without the apt access it doesn\u0026rsquo;t have. The solution was to do all dependency resolution on a machine with internet — resolve the full dependency graph, download each package individually pinned to the target\u0026rsquo;s exact platform/ABI (one at a time, since a batch pip download under a platform restriction silently fetches nothing if even one package lacks a matching wheel), and unpack the resulting wheels directly into a vendor/ folder. On the target, there\u0026rsquo;s no install step at all: PYTHONPATH=./vendor python3 app.py, nothing to compile, nothing to activate. The one dependency with no prebuilt wheel — a small pure-Python package — gets built locally as a py3-none-any wheel, which runs anywhere regardless of what machine built it.\nThat, plus the fact that the frontend already used a relative API base URL, removed the need for nginx as well — Flask serves the built React assets directly via a catch-all route registered after the API routes, so one process on one port handles everything. TLS on that port is a self-signed certificate (no ACME/CA reachable from an air-gapped host), fine for a handful of named operators who click through one browser warning.\nThe harder lessons were less about the happy path and more about failure modes that only show up once a service is meant to run unattended:\nA first version supervised the process with nohup ... \u0026amp;, which survives a closed SSH session but not much else — nothing restarts a process that crashes, and nothing brings it back after a reboot. Worse, the liveness check (kill -0 $PID) turned out to lie in two different ways: a dead process\u0026rsquo;s PID gets recycled by the OS, so kill -0 on a stale PID can report \u0026ldquo;alive\u0026rdquo; for a completely unrelated process; and a PID file owned by root makes kill -0 fail with a permission error that\u0026rsquo;s indistinguishable, from a plain shell check, from \u0026ldquo;no such process.\u0026rdquo; Both problems disappeared by handing supervision to systemd (Restart=always, enabled at boot) and asking systemctl for status instead of reasoning about /proc by hand. The app fetches fresh satellite TLE data from CelesTrak at startup. On a network that actively refuses the connection, that fails fast; on a genuinely air-gapped network — no route, just silence — an unbounded socket call can hang for the OS-level TCP timeout, stalling every single startup. A five-second socket.setdefaulttimeout() scoped around just that call, paired with a local TLE snapshot fallback, bounds the worst case regardless of which kind of \u0026ldquo;no network\u0026rdquo; it turns out to be. The most expensive bug wasn\u0026rsquo;t a crash at all — it was a redeploy that came back up in Flask\u0026rsquo;s debug mode, on plain HTTP, because the bundle had been rebuilt from an older, unmodified copy of the source tree rather than the current one. A \u0026ldquo;working\u0026rdquo; service running the wrong build is a more dangerous failure mode than an obviously broken one, since nothing about its outward behavior flags it — that\u0026rsquo;s what pushed the deploy script toward an explicit startup self-check (do the vendored imports actually resolve?) rather than trusting that \u0026ldquo;the process came up\u0026rdquo; means \u0026ldquo;the correct process came up.\u0026rdquo; What the project demonstrates IONOSPHERE_PRO touches several areas that normally live in separate subsystems, tied together end to end:\nRadio physics and signal processing — coherent dual-frequency demodulation, Doppler curve fitting against real pass geometry, and a differential-phase TEC derivation, implemented with a GPU path and an automatic CPU fallback for the heavy numerical core.\nSystem design that mirrors the data\u0026rsquo;s own shape — a session directory layout (data/\u0026lt;station\u0026gt;/sessions/\u0026lt;id\u0026gt;/) that is the contract between the offline processing script and the web backend, letting new stations plug in without touching either side\u0026rsquo;s code.\nA small, honest API surface — read-mostly endpoints that map closely onto that directory structure, plus a debug endpoint built specifically to make an otherwise-silent failure (an empty satellite overlay) diagnosable instead of mysterious.\nA frontend that treats different kinds of geography differently — fixed ground stations and moving satellite tracks as two distinct map layers, rather than one blurred concept, because they behave differently and an operator needs to reason about them differently.\nDeployment engineering for a machine that starts from zero — no outbound internet, no guaranteed pip/venv, no CA, run by a non-developer. Getting a real scientific tool to run reliably and unattended there meant re-deriving several things (dependency installation, process supervision, TLS trust, network failure handling) that a normal deployment gets for free.\nDesign principles The output format is the interface process_session.py and the Flask backend never share code, a queue, or an RPC boundary — just a directory structure. That\u0026rsquo;s a small, boring, very robust integration point precisely because it\u0026rsquo;s just files.\nGPU is an optimization, not a requirement The coherent-integration core has two implementations behind one call site, and the code decides which one to use at runtime rather than at deploy time. A missing or misbehaving GPU degrades performance, not correctness.\nNothing installs on the target If a dependency can be fully resolved and unpacked ahead of time, there is no reason to give a machine you can\u0026rsquo;t easily walk up to an installer to run at all.\nFail loudly, not gracefully, when something foundational is missing An empty satellite overlay, a silently-empty TEC plot, or a service that \u0026ldquo;looks running\u0026rdquo; but isn\u0026rsquo;t the build you tested are all worse than an explicit error, because none of them tell the operator anything is wrong.\nThe deployed artifact and the reviewed artifact must be provably the same thing A working-but-wrong deployment (right process, wrong source tree) is more dangerous than a broken one, because nothing about its outward behavior flags it.\nA note on scope This is an internal tool built for a small network of monitoring stations at a research institute, not a public product — there\u0026rsquo;s no public repository, and this write-up describes the architecture, the measurement technique, and the engineering decisions rather than reproducing implementation details.\n","permalink":"https://signal-chain.tech/projects/digital_twin_for_isu_ionosphere/","summary":"\u003cdiv class=\"result-callout\"\u003e\n\u003cdiv class=\"callout-number\"\u003e2 sites\u003c/div\u003e\n\u003cdiv class=\"callout-label\"\u003eDigital twin system for TEC, Doppler and other measurements\u003c/div\u003e\n\u003c/div\u003e\n\u003ch2 id=\"overview\"\u003eOverview\u003c/h2\u003e\n\u003cp\u003eIONOSPHERE_PRO is a web dashboard and processing pipeline for a small network of ground stations that measure ionospheric total electron content (TEC) by tracking the coherent dual-frequency beacon transmitted by the \u003cstrong\u003eIonosfera-M\u003c/strong\u003e satellite pair. Each station records raw IQ samples at 150 MHz and 400 MHz during a satellite pass; the backend turns those recordings into phase and TEC curves; the frontend presents all of it as a live map with a per-station history of processed sessions.\u003c/p\u003e","title":"Digital twin system for TEC, Doppler and other measurements"},{"content":"RF Systems Engineer with hands-on experience designing and integrating hardware for scientific measurement systems — from photometers to VLF lightning detection networks.\nAt the Institute of Solar-Terrestrial Physics, I work across the full stack of embedded hardware development: signal chain design (including high-order Chebyshev filters for DAQ modules), PCB design, firmware, and real-time monitoring tools for lab and field deployment.\nI care about building things that actually work under real deadlines — reliable, well-instrumented systems over polished-but-fragile ones. Comfortable moving between hardware, firmware, and tooling to get a project across the finish line.\nExperience Research and Development Hardware Engineer Institute of Solar-Terrestrial Physics · Part-time Feb 2026 – Present · Irkutsk, Russia\nDesigning and developing embedded hardware and software solutions for specialized data acquisition systems Interfacing and configuring microcontrollers (STM32) and high-performance ADC modules, including L-Card and AIT instruments Developing front-end electronics and readout systems for Silicon Photomultipliers (SiPM) Creating schematic designs and multi-layer PCB layouts using KiCad and Altium Designer Developing desktop applications and automation scripts for real-time data acquisition, signal processing, and analysis using C++, Python, and C# Education Irkutsk State University — Bachelor\u0026rsquo;s degree, Radiophysics Aug 2024 – Jun 2027\nGet in touch Feel free to reach out via LinkedIn.\n","permalink":"https://signal-chain.tech/about/","summary":"\u003cp\u003eRF Systems Engineer with hands-on experience designing and integrating hardware for scientific measurement systems — from photometers to VLF lightning detection networks.\u003c/p\u003e\n\u003cp\u003eAt the Institute of Solar-Terrestrial Physics, I work across the full stack of embedded hardware development: signal chain design (including high-order Chebyshev filters for DAQ modules), PCB design, firmware, and real-time monitoring tools for lab and field deployment.\u003c/p\u003e\n\u003cp\u003eI care about building things that actually work under real deadlines — reliable, well-instrumented systems over polished-but-fragile ones. Comfortable moving between hardware, firmware, and tooling to get a project across the finish line.\u003c/p\u003e","title":"About"}]