This repository has what you need to build a k-clock for applications such as optical coherence tomography (OCT):
- PCB schematics
- Firmware for trigger detection
- SCPI command interface and Python package (pySSTri)
You still need the external optics and detectors: swept laser, interferometer, and balanced photodiode (BPD).
A k-clock (swept-source interferometer trigger) produces equally spaced optical-frequency samples across a fixed bandwidth by detecting zero-crossings of the interferogram
The raw interferogram rate is set by the optical path-length mismatch between the interferometer arms. Signal conditioning and trigger generation run on a PSoC 5LP — a programmable SoC with a 32-bit MCU, configurable analog blocks, and programmable digital logic on one chip. This board digitally frequency-divides the BPD/camera trigger train by an arbitrary integer in hardware (no CPU in the divider path). Hosts talk over UART (115200 baud, CRLF). The Python package pySSTri wraps that protocol.
The laser is launched into one port of a 50/50 coupler and sweeps optical frequency
After balanced photodetection (BPD), TIA, and voltage amplification, the electrical interferogram is:
where
Swept-laser Mach–Zehnder interferometer used as a k-clock.
The BPD sinusoid is conditioned into a TTL (0 → +5 V) pulse train with the same period
This board digitally divides that TTL by an integer
(a) Raw BPD interferogram. (b) Rising-edge TTL after conditioning. (c) Same train after divide-by-4 ($Q=4$).
Tagged release:
pip install "pySSTri @ git+https://github.com/MarKo7s/swept-source-trigger-interferometer.git@v1.1.1"With notebook extras:
pip install "pySSTri[notebooks] @ git+https://github.com/MarKo7s/swept-source-trigger-interferometer.git@v1.1.1"Editable (dev):
git clone git@github.com:MarKo7s/swept-source-trigger-interferometer.git
cd swept-source-trigger-interferometer
pip install -e ".[notebooks]"Conda:
conda create -n pySSTri_env python=3.11 -y
conda activate pySSTri_env
pip install -e ".[notebooks]"
python -m ipykernel install --user --name pySSTri_env --display-name "Python (pySSTri_env)"There are two layers:
| Layer | Use when |
|---|---|
| Firmware (UART / SCPI) | Any host language, raw serial, or writing your own driver |
Python (pySSTri) |
Lab scripts / notebooks — preferred for day-to-day use |
Same baud rate (115200) and the same commands either way. Python methods map 1:1 onto SCPI (e.g. SetFreqDivision(4) → SIG:TRIG:DIV 4).
The board does not start the laser. The laser (or your control software) asserts the sweep gate sw; the PSoC only measures and emits camera TTL. How the host keeps up with sweeps falls into two patterns.
Laser sweeps on its own schedule. The host configures the board once (SIG:TRIG:DIV, maybe encoding), then queries when convenient: status, last count, frequency, or pull timestamps after the sweep (idle). No blocking wait on UART notify.
Typical uses: debugging, notebook checks, slow loops, “did the last sweep look OK?”
Laser ──► sw low ──► …sweep… ──► sw high
Host ·····························► GetSweepStatus? / GetTimestamps?
(poll when idle)
board.SetModeNormal() # SYS:TRIG:NOT OFF — no end-of-sweep UART dump
board.SetFreqDivision(2)
# … laser sweeps somehow …
while board.GetSweepStatus() == "1": # optional: wait until idle
pass
ts, ov, fc = board.GetTimestamps() # pull buffer after the sweepWhile sw is low, most commands return ERROR: 1; LASER:SWE:STATUS? still works.
Enable end-of-sweep notify (SYS:TRIG:NOT TIME|COUNT|FREQ|ALL|TIMESTAMP). When each sweep finishes, the board pushes a line (or a TSU frame) on UART.
Typical pattern: a watcher thread blocks on waitForSignal() / readline; the main thread runs the laser (or other work) and syncs with done.wait() when the notify arrives — so UART wait does not block the main thread.
sequenceDiagram
participant Main as Main thread
participant Watch as Watcher thread
participant Board as PSoC board
participant Laser
Main->>Board: set notify COUNT
Main->>Watch: start waitForSignal
Main->>Laser: start_sweep
Laser->>Board: sw low then high
Board-->>Watch: notify line
Watch->>Main: done.set
Note over Main: done.wait returns
import threading
done = threading.Event()
def watch_sweep_done():
board.waitForSignal() # blocks only this thread on UART notify
done.set()
board.SetModeCount()
board.flushSerialBuffer()
threading.Thread(target=watch_sweep_done, daemon=True).start()
laser.start_sweep() # main owns the sweep / other work
done.wait(timeout=30.0) # sync when board says sweep finishedFor timestamp notify on the watcher thread, use waitTimestamps() instead of waitForSignal().
| Aspect | Mode A (async) | Mode B (sync) |
|---|---|---|
| Notify | SYS:TRIG:NOT OFF |
TIME / COUNT / FREQ / ALL / TIMESTAMP |
| Host API | GetSweepStatus, GetTimestamps, … |
watcher: waitForSignal / waitTimestamps; main: Event.wait |
| Timing | Host decides when to ask | Main syncs when end-of-sweep notify arrives |
| Best for | Inspection, scripts | Acquisition loops without blocking main on UART |
Always flushSerialBuffer() after changing notify mode so a stale line does not satisfy the next wait.
Firmware is variant-specific (polarity / laser). Pick the folder under psoc5/firmware/ that matches your hardware:
| Project | Role |
|---|---|
PSOC_trigger_firmware_Trigger_Polarity_Low |
Production (FW 1.3.1) — active-low LEVEL sweep gate + timestamps |
PSOC_trigger_firmware_DEBUG |
Experimental |
Production firmware is tuned for an active-low sweep gate. Other lasers or trigger polarities can be supported on request (see Contact).
-
Install PSoC Programmer. If it asks to update the kit / MiniProg firmware, allow that, then close Programmer.
-
Install PSoC Creator (Cypress / Infineon).
-
In Creator, open the workspace for the project you chose, e.g. production:
psoc5/firmware/PSOC_trigger_firmware_Trigger_Polarity_Low/Trigger_PSOC5.cywrk -
Connect the board (USB / MiniProg as wired on your PCB).
-
Build if needed, then Debug → Program.
-
Verify over UART (115200 baud, CRLF): send
*IDN?— it should report firmware version and date (e.g. FW 1.3.1). With Python:board.ID()afterconnect().
Host control after flashing: see Installation (Python) and trigger_example.ipynb.
The sweep input sw is active-low LEVEL, not a falling-edge pulse:
- Sweeping while
swis held low - Idle while
swis high
While sweeping, most commands return ERROR: 1. Exception: LASER:SWE:STATUS? (1 = sweeping, 0 = idle).
Lines end with \r\n. Sets reply OK (or OK - WARNING: …). Queries reply a value. Errors: ERROR: 0 (bad command), ERROR: 1 (busy / sweeping).
| Command | Meaning |
|---|---|
*IDN? |
Identify |
SIG:TRIG:DIV <n> / ? |
Camera trigger divider (default 2) |
SIG:TRIG:EVENTS:COUNT? |
Trigger count last sweep |
SIG:TRIG:EVENTS:FREQ? |
Mean trigger rate [Hz] (first→last timestamp) |
SIG:TRIG:TIMESTAMP? |
Pull timestamp buffer (idle only) |
LASER:SWE:TIME? |
Sweep duration [µs] |
LASER:SWE:COUNT? / 0 / RESET |
Cumulative sweeps / clear |
LASER:SWE:STATUS? |
1 sweeping / 0 idle |
SYS:TRIG:NOT <mode> / ? |
Notify mode: OFF, TIME, COUNT, FREQ, ALL, TIMESTAMP |
SYS:TIMESTAMP:DELTAENC <mode> / ? |
Packing: OFF, UINT8, UINT16 (aliases 0, 1, 2) |
Each camera-trigger edge is stamped by a 24 MHz Timer during the sweep (ISR fill, up to 4000 samples). Values are µs from sweep start.
Typical flow
- Optionally set packing:
SYS:TIMESTAMP:DELTAENC UINT16(see delta encoding below). - Run a sweep (or enable
SYS:TRIG:NOT TIMESTAMPfor an automatic dump each sweep). - When idle:
SIG:TRIG:TIMESTAMP?→ one ASCII header line + binary payload.
Wire header (always the same fields):
TSU <n> <ov> <fc> <t0> <enc>\r\n
| Field | Meaning |
|---|---|
n |
Number of timestamps |
ov |
1 if buffer overflowed or a delta was clamped |
fc |
Hardware event count for the sweep |
t0 |
First sample [µs] |
enc |
Packing mode (0 / 1 / 2) — always last |
Then the payload depends on enc:
enc |
Name | Payload | Host reconstruct |
|---|---|---|---|
0 |
OFF (absolute) | n × uint32 LE absolute µs |
use as-is |
1 |
UINT8 deltas | (n−1) × uint8 gaps |
t[0]=t0, then accumulate |
2 |
UINT16 deltas | (n−1) × uint16 LE gaps |
same accumulate |
Delta encoding shrinks UART traffic when consecutive gaps are small. Choose a mode whose max gap fits your laser:
| Mode | Max gap | Safe if trigger rate |
|---|---|---|
| UINT8 | 255 µs | ≥ ~3.9 kHz |
| UINT16 | 65535 µs | ≥ ~15 Hz |
| OFF | (full uint32) | any |
If a gap is too large for the mode, firmware clamps it and sets ov=1 (lossy). For ~3 kHz SS sources, prefer UINT16 or OFF, not UINT8.
Setting the mode returns a warning with those limits, e.g.:
SYS:TIMESTAMP:DELTAENC 2
→ OK - WARNING: max_dt_us=65535 min_freq_hz=15
False triggers: noisy interferogram / BPD edges can create extra timestamps. A hardware deglitch filter is planned but not in this firmware yet — raise the comparator threshold / SNR, or filter bad gaps in software for now.
Eagle schematics and board files: pcb/Eagle_project/Trigger_PSOC5LP.
Example notebook: trigger_example.ipynb.
from pySSTri import SSTriggerInterferometer
board = SSTriggerInterferometer() # or COM="COM6"
board.connect() # autodiscovers via *IDN? if needed
board.discoverMethods() # list wrappers
print(board.ID())See How to operate for async poll vs sync wait. Short sync example:
board.SetFreqDivision(2)
board.SetModeCount() # notify: trigger count each sweep
board.flushSerialBuffer()
print(board.waitForSignal()) # blocks for next notify line
print(board.GetSweepStatus()) # "0" / "1"
print(board.GetFrequency(), board.GetSweepTime())board.SetTimestampsEncoding(2) # UINT16 deltas on the wire (FW aliases OK)
# board.SetModeTimestamp() # optional: auto-dump each sweep
# ts, ov, fc = board.waitTimestamps()
ts, ov, fc = board.GetTimestamps() # after a sweep, while idle
# ts: uint32 array [µs], regardless of enc in the headerGetTimestamps / waitTimestamps read the TSU header’s enc field and unpack the payload — you always get absolute times.
pySSTri/ Python package
psoc5/firmware/ PSoC Creator projects
pcb/ Eagle schematics
trigger_example.ipynb
scripts/release.py
CITATION.cff GitHub / Zenodo cite metadata
CITATION.bib BibTeX for manuscripts
The package version is defined in one place only: pyproject.toml → [project].version.
Do not edit pySSTri/__init__.py on each release. pySSTri.__version__ is read from pip metadata after install (importlib.metadata).
Check the installed version:
pip show pySSTri
python -c "import pySSTri; print(pySSTri.__version__)"Use semantic versioning: MAJOR.MINOR.PATCH.
Firmware has its own identity (FW_VERSION / FW_DATE in main.c, reported by *IDN?). Bump that when you change the PSoC image; it is independent of the pySSTri package version.
Default git branch is main.
- Add a
## [X.Y.Z] - YYYY-MM-DDsection at the top ofCHANGELOG.md(keep## [Unreleased]above it for WIP notes). - Set
version = "X.Y.Z"inpyproject.toml. - Commit everything (clean working tree).
- From the repo root:
python scripts/release.py --from-changelogThe script reads the version from pyproject.toml, pushes main, creates annotated git tag vX.Y.Z, and pushes the tag. With --from-changelog, the tag message is taken from the matching CHANGELOG.md section. Override with --message "..." if needed.
Dry run (no git changes):
python scripts/release.py --from-changelog --dry-runOptional GitHub Release page (gh CLI):
gh release create vX.Y.Z --title "pySSTri X.Y.Z" --notes-file CHANGELOG.mdAfter release, others install with:
pip install "pySSTri @ git+https://github.com/MarKo7s/swept-source-trigger-interferometer.git@vX.Y.Z"Requirements: clean working tree; tag vX.Y.Z must not already exist on GitHub.
Marcos Maestre Morote — m.maestremorote@uq.edu.au
Questions, bug reports, or requests to adapt the firmware to a different swept laser / sweep-gate polarity are welcome.
This project is licensed under the MIT License. Copyright (c) 2026 Marcos Maestre Morote.
Use these identifiers in the manuscript so Methods, Supplementary, and the bibliography stay consistent.
| Item | Value to cite |
|---|---|
| Software / archive | pySSTri v1.1.1 |
| Git tag | v1.1.1 |
| Zenodo DOI | 10.5281/zenodo.21639219 |
| Production firmware | Polarity Low 1.3.1 (*IDN? / board.ID()) |
| BibTeX key | MaestreMorote2026pySSTri (see CITATION.bib) |
@software{MaestreMorote2026pySSTri,
author = {Maestre Morote, Marcos},
title = {{Swept-source interferometer camera trigger (pySSTri)}},
year = {2026},
publisher = {Zenodo},
version = {v1.1.1},
doi = {10.5281/zenodo.21639219},
url = {https://doi.org/10.5281/zenodo.21639219},
note = {Firmware Polarity Low 1.3.1; source https://github.com/MarKo7s/swept-source-trigger-interferometer}
}LaTeX: \cite{MaestreMorote2026pySSTri}. Word / EndNote: import the DOI or use the formatted reference below.
Marcos Maestre Morote. Swept-source interferometer camera trigger (pySSTri). Zenodo (2026). Version v1.1.1. https://doi.org/10.5281/zenodo.21639219
Adapt to your tense/house style; keep the cite key and versions:
Wavelength / optical-frequency sampling during each laser sweep was provided by a fibre Mach–Zehnder k-clock interferometer and balanced photodiode. Zero-crossing edges were conditioned into a TTL train and divided by an integer
$Q$ in hardware on a custom PSoC 5LP camera-trigger board. Instrument control used the open-source package pySSTri (v1.1.1; firmware Polarity Low 1.3.1) \cite{MaestreMorote2026pySSTri}.
Trigger interferometer and camera timing. The auxiliary k-clock path is a fibre Mach–Zehnder interferometer with fixed delay; the balanced-photodiode interferogram is converted to a TTL pulse train at the free-spectral-range spacing. A PSoC 5LP board performs hardware frequency division of that train and exposes sweep timing over UART (115200 baud, SCPI-like commands). Host software was pySSTri v1.1.1 (GitHub tag
v1.1.1), archived at Zenodo (DOI: 10.5281/zenodo.21639219), with production firmware Trigger Polarity Low 1.3.1. Schematics, firmware, and the Python interface are available under the MIT License at https://github.com/MarKo7s/swept-source-trigger-interferometer. Further protocol details (notify vs poll modes, timestamp frames) are documented in the repository README.
GitHub also exposes Cite this repository from CITATION.cff.


