Decision log¶
Design decisions, newest at the bottom. Each entry records what was decided, why, and which alternatives were considered. Entries are not edited after the fact; a later entry supersedes an earlier one and says so.
D-001 Simulator and receiver processing live in one repository (2026-09-30)¶
- Decision: The host-side code (simulator, acquisition, evaluation) lives in one
repository,
snappnt. ESP32 firmware, if it ever needs changes, goes into a separate fork of ESP-SDR. - Why: The simulator and the receiver share the signal definitions (spreading codes, carrier, chip rate). One repository lets CI run the "generate, acquire, compare with truth" loop in one place. For now one person changes both sides at the same time.
- Alternatives: A separate
snappnt-simpackage. To be reconsidered if the simulator needs to be distributed on its own, or once the signal definitions are stable enough to become a library.
D-002 License: BSD-2-Clause (2026-09-30)¶
- Decision: BSD-2-Clause.
- Why: Wide adoption comes first. It matches RTKLIB and its derivatives, so code can move between those projects without licence questions.
- Alternatives: Apache-2.0, which adds an explicit patent grant from contributors. To be reconsidered if companies start contributing. Until then the Developer Certificate of Origin (sign-off on each commit, see CONTRIBUTING.md) is the minimum safeguard.
D-003 Pure Python; no dependency on MATLAB (2026-09-30)¶
- Decision: Runtime dependencies are numpy, scipy and pyyaml only. MATLAB waveform generators may be used for cross-checks but are never required.
- Why: Anyone can run the project without a toolbox licence.
D-004 Minimal in-house SigMF reader and writer (2026-09-30)¶
- Decision:
io/sigmf_io.pyimplements the small subset needed; thesigmfpackage is not a dependency. - Why: The needed features (cf32/ci16/ci8, a truth annotation) are small. Fewer dependencies also means fewer licences to review.
- Alternatives: The official
sigmfpackage, if schema validation becomes necessary.
D-005 Spreading codes are checked against values printed in the ICD (2026-09-30)¶
- Decision: Every code generator has a test that compares its output with values printed in the ICD (for example the first 10 chips in octal), typed into the test independently of the generator.
- Why: Expected values derived from the generator itself would turn any generator bug into "truth".
- Status: All 28 NavIC L5/S SPS codes match Table 7 of the IRNSS SIS ICD for SPS v1.1. The G2 initial-state table also matches the one in PocketSDR (BSD-2-Clause). GPS L1 C/A PRN 1–10 match IS-GPS-200.
D-006 README in English, design notes in Japanese (2026-09-30)¶
- Superseded by D-007.
D-007 All project records in English; documentation site with MkDocs (2026-10-01)¶
- Decision: Everything committed to the repository or posted on GitHub (code comments,
documentation, commit messages, issues, pull requests, review replies) is written in English.
Documentation is built with MkDocs and the Material theme. CI builds the site with
--stricton every pull request. The site is deployed to GitHub Pages by hand, and only once the repository is public. - Why: The project is intended as open source. A browsable site makes the reasoning behind the code easier to follow later. GitHub Pages sites are public even when the repository is private, so deploying before publication would leak unpublished material.
- Alternatives: Zensical, the successor to Material for MkDocs from the same team; it reads
mkdocs.yml, so switching later should be cheap. Material for MkDocs is in maintenance mode (security and critical fixes only), which is acceptable for a documentation site. MkDocs is pinned below 2.0 because 2.0 is a separate rewrite.
D-008 Agent orchestration with a separate orchestrator (2026-10-01)¶
- Decision: Work is done by Claude Code sessions on the maintainer's machine (workers). A
separate Claude session (the orchestrator) approves plans, handles reviews and merges pull
requests for issues labelled
auto, following Agent orchestration. Greptile reviews every pull request. The maintainer handles everything the rules escalate. - Why: Keeps work moving without waiting on the maintainer for routine approvals, while keeping the approving context separate from the context that wrote the change.
- Alternatives: The maintainer merges every pull request (slower). The orchestrator only recommends merges for a trial period (considered, not chosen).
D-009 Public-safety rules and a CI check (2026-10-01)¶
- Decision: Personal information about the maintainer and site-specific details must never enter the repository or GitHub records. The rules are in Public-safety rules. CI runs a generic pattern check; any list of specific private terms is kept outside the repository.
- Why: The repository will be published. A deny list of private terms committed to the repository would itself publish those terms.
D-010 Agents run on GitHub Actions (2026-10-01)¶
- Decision: The worker and the orchestrator run as GitHub Actions workflows
(
anthropics/claude-code-action@v1in automation mode), started by events and hourly as a fallback. They authenticate to Claude with the maintainer's plan (CLAUDE_CODE_OAUTH_TOKEN) and to GitHub with a fine-grained personal access token limited to this repository. A repository variable,AGENTS_ENABLED, switches both on and off. This supersedes the worker location in D-008 (the maintainer's machine) for everything except hardware work. - Why: Work continues without the maintainer's computer being on, and each hand-off happens when the triggering event arrives instead of at the next hourly check.
- Merge evidence: the orchestrator no longer re-runs the tests itself (as D-008 described).
Its session holds a write-capable token, so it never checks out or runs pull request code;
it merges only when the token-less
ciworkflow and a Greptile review have both completed on the exact head commit, and only for branches of this repository named after the issue. - Alternatives: A scheduled job on the maintainer's machine running
claude -p(depends on the machine being awake; hourly hand-offs). A scheduled cloud task (hourly hand-offs; package installation was not available in that environment). The Claude GitHub App instead of a personal access token: avoids a personal token, but its broad permission set and bot identity needallowed_botsfor every hand-off; the token keeps start conditions simple.
D-011 One SigMF recording per ESP-SDR capture (2026-10-01)¶
- Decision:
snappnt capture --count Nwrites N separate SigMF recordings,<output>_0000,<output>_0001, ..., and the plain<output>when N is 1. Each recording carries its own host time (snappnt:host_time_utc,core:datetime) and gain. - Why:
snappnt acquireandread_sigmftreat a data file as one contiguous snapshot. The firmware takes one burst per capture command, so separate captures are not contiguous in time. Joining them in one file would make acquisition correlate across the gaps and give a wrong code phase and Doppler frequency. - Alternatives: One file with several SigMF
capturessegments. It is valid SigMF, butread_sigmfandacquirewould have to be changed to treat each segment separately, which is outside this issue.
D-012 Link budget calculator defaults (2026-10-01)¶
- Decision:
snappnt link-budgetcomputes the receiver noise density at a reference temperature of 290 K (−174 dBm/Hz plus the noise figure). Its check for software-added noise requires the injected noise density to be at least 10 dB above the receiver's own; the margin can be changed with--margin-db. The generator power, the losses and the noise figures have no defaults. - Why: 290 K is the convention in which noise figures are specified. At 10 dB the receiver's own noise raises the total noise density by 0.41 dB, so the C/N0 error stays below 0.5 dB; 10 dB is this project's choice, not a value from a source.
- Alternatives: Reading noise figures from the device YAML files: no verified value exists there yet. A smaller margin such as 6 dB: the C/N0 error would be about 1 dB.
D-013 Frequency plans in the simulator (2026-10-01)¶
- Decision: A scenario may set
frequency_plan: {lo_hz, lo_side, tuned_hz}; the RF frequency is the signal'scarrier_hz.baseband_offset_hzis then the plan's value, and giving it in the receiver section as well is an error. With a plan, the carrier offset in the samples isdoppler_sign * doppler_hz, wheredoppler_signis −1 for a high-side LO. The receiver crystal error (clock_offset_ppm) is applied totuned_hz, because the crystal drives the receiver's own LO. The external LO error (lo_offset_ppm, valid only with a plan) is applied tolo_hzand shifts the IF by −δ for a low-side LO and +δ for a high-side LO (δ =lo_offset_ppm· 1e-6 ·lo_hz). The carrier Doppler rate is mirrored the same way (doppler_sign * doppler_rate_hzps; the truth keeps the RF value indoppler_rate_hzpsand gives the value in the samples asexpected_doppler_rate_hzps). Code Doppler keeps the RF sign, and the simulator holds the chip rate constant over a snapshot (the Doppler rate acts on the carrier phase only).lo_sidemust beloworhigh; anything else raisesValueError.snappnt simwritestuned_hzas the SigMF centre frequency when a plan is present. - Why: IF = RF − LO (low side) or LO − RF (high side), so a shift of the LO moves the IF in
the opposite or the same direction. The mixer does not change the chip rate, so the code
rate follows the RF Doppler. Without a plan, nothing changes (the crystal error still
scales with
carrier_hz). - Alternatives: Applying the crystal error to
carrier_hzwith a plan too: it would describe a receiver tuned to the RF, which is not the case behind a mixer.tuned_hzequal to the IF in the C-band scenarios is an assumption until a real receiver setup is chosen.
D-014 SigMF pair replacement uses backup and restore (2026-10-01)¶
- Decision:
write_sigmfwrites both files under.tmpnames. If a recording with the same base name exists, it moves its two files to.baknames, renames the new files into place, and deletes the backups only after both renames have succeeded. If any step fails, it removes the files it placed, moves the backups back and removes the temporary files, so the old recording stays complete and readable. If moving a backup back fails too, the.bakfiles are kept and the raised error names them, so the old recording can be recovered by hand. This is the only case in which a temporary or backup file remains. SigMF keys and file formats do not change. - Details of the rollback: Each cleanup step is tried even if an earlier one failed. If a
placed new file cannot be removed, no backup is moved back, because a restored old file next
to a new one would read as a pair of different recordings; all backups are kept and named in
the error together with the new files that could not be removed (this also applies to a first
write, where there are no backups and a partial recording stays at the final path). If only some backups cannot be moved back, the old files that were restored are
never next to a new file, so at worst the recording is incomplete and does not read. Before
anything is moved,
write_sigmfrefuses withFileExistsErrorwhen a.bakfile already exists, as a file or a dangling symbolic link, so kept backups are never overwritten. If every rename succeeded and only deleting a backup fails, the write counts as successful and a log warning (theloggingmodule, so that warnings turned into errors cannot make a finished write look failed) names the backup; the next write to the same base name refuses until that backup is deleted. - Known limit: While an existing recording is replaced, its two final paths are missing
for a short time (between moving them to
.bakand placing the new files). A reader in another process can fail withFileNotFoundErrorin that window. Before this change both paths were always present, but a failed second rename could leave a mismatched pair. A single-writer tool such as snappnt does not need concurrent reads; this is accepted. - Why: Two renames cannot be made atomic together. Before this change, a failure of the second rename left the new samples next to the old metadata, a pair that reads without error but describes the wrong samples.
- Alternatives: Renaming the metadata first leaves the old samples paired with the new
metadata when the data rename fails, which is the same mismatch in the other direction.
Refusing
--overwritewhen the metadata file cannot be replaced is a check made in advance, and it does not cover a failure between the two renames.
D-015 ESP-SDR firmware is used unmodified; no fork for now (2026-10-02)¶
- Decision: M3 uses the ESP-SDR firmware (
ESPARGOS/esp-sdr, GPL-3.0) as published, without changes. If M4 (long captures on the ESP32-C61) needs firmware changes, they are first proposed upstream. Only if upstream does not take them is the firmware forked, in a separate repository that stays under GPL-3.0, with source published alongside any binaries. No firmware code is copied into snappnt, and firmware functions are not translated into Python; snappnt implements the protocol and data formats from their description. - Why: snappnt is a separate program that talks to the firmware over a serial link, so the GPL does not extend to it and snappnt stays BSD-2-Clause (D-002). Describing the protocol in our own words and citing firmware file and line, as ESP-SDR protocol and ESP32-C61 capture do, is not copying. Nothing in M3 needs a firmware change, and a fork would have to be maintained. #13 found that continuous capture on the C61 would need firmware changes, which is when this decision is revisited.
- Alternatives: Forking now (maintenance without a present need). Bringing firmware code into snappnt (would put snappnt under the GPL; rejected).
- Not a legal opinion: the licence reasoning is to be checked again before publication (#12).
D-016 DC offset and fixed spurs in the simulated receiver (2026-10-02)¶
- Decision: Scenarios may set
receiver.dc_offset(fraction of ADC full scale per component) andreceiver.spurs(baseband offset in Hz and power in dB). The DC offset is applied by a new functionquantize_with_offset;quantizeis unchanged. A spur's power is the tone power divided by the total noise power per sample at the generation rate. The DC offset is constant over a snapshot. Spur phases are drawn from a second random generator seeded from the scenario seed. - Why: Noise-only captures from one ESP32-C3 board (see ESP32-C3 bench checks) show a DC offset of about half of full scale on I and fixed narrow lines. The offset must be added after the gain of the automatic gain control is set: if it is added before, the gain would include it in the RMS, and at the default 12 dB back-off (RMS of 0.25 of full scale per component) an offset of −0.5 of full scale cannot be reached. A second random generator keeps the noise and data symbols of existing scenarios, and of scenarios with spurs, identical.
- Alternatives: Adding the offset in input units before
quantize(cannot reach the requested fraction). Changing the signature ofquantize(a public function). Defining the spur level as height above the noise floor in a spectrum (depends on the FFT length). - Not modelled: drift of the DC offset within one capture (about 25 counts in 205 µs on
the board measured). The values in
scenarios/navic_s_esp32c3_dc.yamlcome from one board and one bench session.
D-017 DC offset removal is an option of acquisition, default off (2026-10-02)¶
- Decision:
acquiretakesremove_dc(none,meanorlinear), andsnappnt acquireandsnappnt sweeptake--remove-dc. The default isnone, so existing results do not change. The maintainer approved this default on issue #43. - Why: In the simulated ESP32-C3 case with a DC offset of −0.5 of full scale, acquisition without removal never detects up to 60 dB-Hz. With mean removal the 50 % point is 50.38 dB-Hz against 50.29 dB-Hz without an offset, a loss of 0.09 dB. Mean removal on a snapshot with no offset costs 0.08 dB at the 50 % point and 0.10 dB at the 90 % point (200 trials per point, uncertainty about ±0.2 dB). See DC offset removal. The default can be revisited from these numbers once real captures have been checked.
- Alternatives: Removal on by default (changes the result of an existing command; left to the maintainer). Estimating the offset from a separate noise-only capture (needs a second capture per setting).
- Not measured: real captures; drift within a capture (
linearis tested only with a synthetic ramp).
D-018 Python environment is managed with uv (2026-10-02)¶
- Decision: Development uses uv.
devanddocsare dependency groups (PEP 735) and[tool.uv] default-groupsinstalls both with a plainuv sync.hwandplotstay optional extras, because they are installed by users of the package and not only by developers.uv.lockis one universal lock file for Python 3.11 and newer and is committed. CI, the documentation deployment and the worker workflow install withuv sync --locked, which fails whenuv.lockis out of date..python-versioncontains3.12, the version the documentation job already used; the CI test job overrides it for 3.11, 3.12 and 3.13. - Why: The same dependency versions are used on every machine and in CI, so a result or a failure can be reproduced.
- Consequence:
pip install -e ".[dev]"and".[docs]"no longer work, because those names are not extras any more.pip install -e ".[hw]"still works. pip 25.1 or newer can install a group withpip install --group dev. - Not verified: that
claude-code-actionfindsuvon its PATH after the setup step; the first worker run after the merge settles it.
D-019 snappnt capture records the firmware's low-pass code, not an estimated bandwidth (2026-10-03)¶
- Decision:
snappnt capturesendsLPF?immediately before every capture and records the reply in that recording assnappnt:espsdr_lpf_reply, with the parsed capacitor code (snappnt:espsdr_lpf_code, −1 meaning the chip's calibrated codes) and the two calibrated register codes (snappnt:espsdr_lpf_calibrated_codes).snappnt:analog_bandwidth_mhzis always written: the requested value when--bandwidth-mhzis given,null(unknown) otherwise. A reply other than anLPFline, such asERR commandfrom firmware without the query, or a code outside 0 to 63, is recorded as it is, the parsed fields arenull, and the capture continues. The query is repeated for each capture because the firmware applies the code at capture time and frees its hold on the radio after 5 s without a command, so another program may change the setting between captures of one run. No command-line option or default changes. The maintainer chose this on issue #44. - Why: The ESP-SDR firmware keeps its low-pass setting while powered, across host
connections, so a capture without
--bandwidth-mhzuses whatever another program set last. In the first ESP32-C3 bench session a capture taken after the browser viewer had set 20 MHz carried that pass band with no trace in its metadata (ESP32-C3 bench checks). The firmware stores only a capacitor code and never reports MHz (ESP-SDR serial protocol, "Analog low-pass setting"), so the code is the setting that can be recorded exactly. - Alternatives: Estimating MHz from the code with the firmware's per-chip table (the table
is approximate, has no value for code −1, and copying it into snappnt is a question for
issue #7; the recorded code can be converted later). Making
--bandwidth-mhzrequired, or always sending a default bandwidth (both change the command line). - Verified on hardware: the
LPF?replies on one ESP32-C3 listed on the protocol page. A fullsnappnt capturerun that writes these keys was checked on the same board.
D-020 snappnt capture queries the gain before every capture; the frequency is not confirmed (2026-10-03)¶
- Decision:
snappnt capturesendsGAIN?immediately before every capture, afterLPF?, and records the reply in that recording assnappnt:espsdr_gain_reply, with the parsed mode (snappnt:espsdr_gain_mode,hardwareormanual) and index (snappnt:espsdr_gain_index,nullin hardware mode).snappnt:gain_modeandsnappnt:gain_indexkeep their meaning, the requested setting. If the reported setting differs from the requested one, a warning naming the capture is printed and the capture is kept. A reply other than aGAINline is recorded as it is, withnullparsed keys. The frequency is not re-sent and not confirmed; the conducted-test guide states this for each recorded setting. No command-line option or default changes. The maintainer chose this on issue #51 (option (a) of four). - Why:
FREQandGAINare sent once per run, and another program can change them before a later capture: the firmware frees its hold on the radio after 5 s without a command, and it keeps the hold per transport (USB or UART), so a second program on the same serial device is not refused at all (ESP-SDR serial protocol, "Transport"). Only a query confirms a setting. The firmware answersGAIN?but has no query for the frequency. The browser client sendsGAIN?before each capture as well (ESP-SDR serial protocol, "Capture request"). - Alternatives: Re-sending
FREQbefore every capture (it runs the receiver preparation again, including the Wi-Fi channel set-up; the time this takes cannot be read from the source and was not measured, and calibration would be repeated for every recording). Re-sending the settings only when more than 5 s have passed since the last command (does not cover a second program on the same transport). Documenting the limitation only. - Verified on hardware: on one ESP32-C3,
snappnt captureruns of two captures each with--gain 30and with--gain autowroteGAIN MANUAL 30 0 79 1andGAIN HARDWARE -1 0 79 0with the parsed keys into every recording, without a warning (ESP-SDR serial protocol, "Capture request"). The warning for a changed gain was tested only with a fake serial port.
D-021 Satellite visibility uses the sgp4 package in an optional extra sky (2026-10-03)¶
- Decision:
tools/visibility.pypropagates two-line element sets (TLE) with thesgp4package, installed through a new optional extrasky(uv sync --extra sky). The rotation from the TEME frame to an Earth-fixed frame and the conversion to azimuth and elevation are written in snappnt with numpy, so they can be tested withoutsgp4. CI installs the extra for the test job so that the propagation tests run there too. The receiver position is a command-line argument only. Issue #14. - Why: of the NavIC satellites, NVS-01 is geostationary with a small inclination (about
2 degrees in the TLE of 2026-10-03), but IRNSS-1B and IRNSS-1I are in inclined
geosynchronous orbits (about 29 degrees), whose look angles change by tens of degrees over
a day; a fixed longitude is not enough for them. TLEs are meant to be used with the SGP4
model, and
sgp4is the reference implementation of that model for Python, pure Python with optional compiled speed-up, under the MIT licence. - Alternatives: Skyfield (larger, adds downloads of ephemeris files for features not needed here). Writing SGP4 inside snappnt (long, and errors would be hard to find). A geostationary-only formula from the published longitude (wrong for the inclined geosynchronous satellites).
- Verified: the Greenwich mean sidereal time matches Vallado's Example 3-5
(152.578787810 degrees); elevations match the closed-form formula for a geostationary
satellite over a spherical Earth; the NVS-01 TLE gives sub-satellite longitudes of 129.38
to 129.55 degrees east over 2026-10-03, against the published slot of 129.5 degrees east
(
tests/test_visibility.py).
D-022 Detection on real captures: threshold and bracketed frequency, no code-phase check (2026-10-04)¶
- Decision: In the conducted test (
tools/conducted_pd.py pd), a capture counts as a detection when its detection metric is above the threshold forpfa= 1e-3 over the search grid and its frequency is within one bin of the run's reference frequency. The reference is the mean of the median detected frequencies of two runs of 20 captures at 60 dB-Hz taken just before and just after the run (brackets). If the two brackets differ by more than one bin, the tolerance is two bins. The code phase is not checked. The receiver's gain index and analog bandwidth are fixed for all runs (no AGC), and every capture is acquired with--remove-dc mean. Issue #11. - Why: For a real capture neither the code phase (the generator loops freely and the capture is not synchronised to it) nor the carrier offset (two independent crystals) is known. At 60 dB-Hz every capture is detected, so the brackets give the frequency the receiver sees at that time, and bracketing each run follows the drift of the crystals with temperature (1 ppm together is about one bin of a 0.2 ms capture at 2492 MHz). A fixed gain keeps the conditions the same for all runs and is recorded; the AGC's choice is not reported by the firmware. The simulated curve the test is compared with has no DC offset.
- Alternatives: Checking the code phase against a time reference shared by generator and receiver (not available with these devices). One reference frequency for the whole session (wrong after a drift of more than a bin; seen after the receiver was powered up again). Hardware AGC (the gain differs from capture to capture and is not recorded).
- Verified: in the conducted test of 2026-10-04 all nine brackets had their median in the same bin, so the one-bin tolerance was used for every run (Conducted test).
D-023 Code Doppler in acquisition: groups of frequency bins with scaled replicas, direct reception only (2026-10-04)¶
- Decision:
snappnt.rx.acquiretakescode_doppler(defaultFalse; command-line--code-doppleronsnappnt acquireandsnappnt sweep). When it is true, the frequency bins are split into groups of neighbouring bins at most 2 × 0.1 × f_c / (R_c × T) wide, where f_c is the carrier frequency, R_c the nominal chip rate and T the snapshot length. Each group is correlated with a replica of chip rate R_c × (1 + f_g / f_c), where f_g is the middle of the group measured from the centre of the search. The groups are processed one at a time, so only one group's replica spectra are in memory. The option is for direct reception only:sweep()and both commands stop with an error when the frequency plan has an external LO (lo_hz). Issue #72. - Why: in direct reception one crystal drives the LO and the ADC, so a carrier offset Δf (satellite Doppler and receiver clock error together) comes with a code-rate change of Δf / f_c relative to the sample clock. At Δf = 25 kHz the code drifts about 2 chips over 0.2 s, which spreads the correlation peak over several lags when one replica at R_c is used for every bin. The group width keeps the code drift left within a group below 0.1 chip. With an external mixer the offset at the receiver also contains the external LO error, which shifts the IF without changing the code rate, so the same scaling would be wrong.
- Alternatives: A replica for every frequency bin (exact, but one replica FFT per bin and per block). One replica scaled by the middle of the search range (a ±50 kHz search over 0.2 s still leaves up to about 4 chips of drift at the edges). Keeping one replica and shifting each block's correlation by f / f_c × R_c × t_b for the block start time t_b, with interpolation for the fractional sample, before the blocks are added (exact per bin, with no extra FFTs or memory; within a 4 ms block the drift is about 0.04 chip at 25 kHz). This last option may be taken up in a later issue.
- Verified: with the option off, the result equals the frozen pre-change implementation
(
tests/test_code_doppler.py::test_off_matches_reference). A simulated 0.2 s snapshot at 4 MSa/s with a −10 ppm clock error (+24.92 kHz), 36 dB-Hz and 50 blocks of 4 ms gives a code-phase error of 0.109 chip with the option and 1.129 chip without, and a metric of 13.5 against 6.1 (test_long_snapshot_with_clock_error).