feat(chirp): Double Chirp — up+down sum with deterministic 1-D phase optimisation (ticket 41) #37

Merged
ykp merged 2 commits from double-chirp into master 2026-09-27 18:35:55 +02:00
Owner

Backlog ticket 41 (chirp-directions round, third ticket). Adds the Double Chirp waveform: the sum of the Up Chirp base and the Down Chirp base — identical geometry, element-wise aligned — rotated by a relative phase θ found by a deterministic 1-D search minimising the PAPR of the summed base (operator-confirmed option B: NOT the comb's PSO; no RNG, seed-free).

Phase search (pinned constants)

  • Coarse uniform grid of 720 candidates (0.5°) over [0, 2π) — vectorised pointwise |s(θ)|² = |up|² + |down|² + 2·Re(e^{−jθ}·up·conj(down)), chunked over samples so the working set stays O(base)
  • Golden-section refinement within ±1° of the best coarse candidate to a 1e-3 rad tolerance — kept only when it beats the coarse candidate (the local bracket can be non-unimodal; the search never returns worse than the grid)
  • Objective: PAPR(θ) = 20·log10(max|s(θ)| / sqrt(mean|s(θ)|²)) (full-scale-complex, the preview's formula), evaluated on the phase-1 base, never the repeated overall
  • chirp.optimal_double_chirp_phase(base_up, base_down) -> (θ*, papr) exposes the optimum for tests; no JSON/UI surface in this version

Level semantics (A1 default, operator left standing)

Each component carries the full band Level gain — no amplitude compensation of the sum; the combined peak can approach 2× a single chirp's (the operator's CFR/PAPR post-chain remains available).

Implementation

  • chirp.py: both bases built through the shared SWEEP_UP/SWEEP_DOWN machinery (identical _PhaseOne geometry); phase-2 repeat factored into _repeat_base_to_overall (shared by all three chirp types); generate_double_chirp sums with exp(1j·θ*) and rides the shared stitching path
  • model.py: WAVEFORM_DOUBLE_CHIRP (8 options); CHIRP_WAVEFORM_TYPES grows to the three-chirp family — validation L7/L8/L16 and the UI chirp controls/hints cover it automatically
  • generate.py: dispatch branch (same post chain + SigV3.1 write); Multi IQ / Other IQ still raise NotImplementedError
  • spec.md: §5.2.1c Double Chirp, §3.3 row, 8 options (§5.2.1b/c repositioned after §5.2.1's complete bullet list)

Gate

  • pytest: 1203 passed / 63 skipped (headless tier), coverage 95.40 % ≥ 85 %
  • ruff + mypy: clean
  • New tests/test_double_chirp.py (14 tests): determinism + θ* stability, search beats the grid neighbours, measured PAPR equals the reported optimum, single-block base == the phase-optimised sum (element-wise), A1 no-compensation bound, triangle-inequality envelope bound, shared contract, L8/policy family coverage, band isolation, dispatch, L14 preflight, phase-2 stitching; GUI Double-Chirp generate test added
Backlog ticket 41 (chirp-directions round, third ticket). Adds the **Double Chirp** waveform: the sum of the Up Chirp base and the Down Chirp base — identical geometry, element-wise aligned — rotated by a relative phase θ found by a **deterministic 1-D search** minimising the PAPR of the summed base (operator-confirmed option B: NOT the comb's PSO; no RNG, seed-free). ## Phase search (pinned constants) - Coarse uniform grid of **720 candidates** (0.5°) over `[0, 2π)` — vectorised pointwise `|s(θ)|² = |up|² + |down|² + 2·Re(e^{−jθ}·up·conj(down))`, chunked over samples so the working set stays O(base) - Golden-section refinement within **±1°** of the best coarse candidate to a **1e-3 rad** tolerance — kept only when it beats the coarse candidate (the local bracket can be non-unimodal; the search never returns worse than the grid) - Objective: `PAPR(θ) = 20·log10(max|s(θ)| / sqrt(mean|s(θ)|²))` (full-scale-complex, the preview's formula), evaluated on the phase-1 base, never the repeated overall - `chirp.optimal_double_chirp_phase(base_up, base_down) -> (θ*, papr)` exposes the optimum for tests; no JSON/UI surface in this version ## Level semantics (A1 default, operator left standing) Each component carries the full band `Level` gain — **no amplitude compensation of the sum**; the combined peak can approach 2× a single chirp's (the operator's CFR/PAPR post-chain remains available). ## Implementation - `chirp.py`: both bases built through the shared `SWEEP_UP`/`SWEEP_DOWN` machinery (identical `_PhaseOne` geometry); phase-2 repeat factored into `_repeat_base_to_overall` (shared by all three chirp types); `generate_double_chirp` sums with `exp(1j·θ*)` and rides the shared stitching path - `model.py`: `WAVEFORM_DOUBLE_CHIRP` (8 options); `CHIRP_WAVEFORM_TYPES` grows to the three-chirp family — validation L7/L8/L16 and the UI chirp controls/hints cover it automatically - `generate.py`: dispatch branch (same post chain + SigV3.1 write); Multi IQ / Other IQ still raise `NotImplementedError` - `spec.md`: §5.2.1c Double Chirp, §3.3 row, 8 options (§5.2.1b/c repositioned after §5.2.1's complete bullet list) ## Gate - `pytest`: **1203 passed / 63 skipped** (headless tier), coverage **95.40 %** ≥ 85 % - `ruff` + `mypy`: clean - New `tests/test_double_chirp.py` (14 tests): determinism + θ* stability, search beats the grid neighbours, measured PAPR equals the reported optimum, single-block base == the phase-optimised sum (element-wise), A1 no-compensation bound, triangle-inequality envelope bound, shared contract, L8/policy family coverage, band isolation, dispatch, L14 preflight, phase-2 stitching; GUI Double-Chirp generate test added
feat(chirp): Double Chirp — up+down sum with deterministic 1-D phase optimisation (ticket 41)
Some checks failed
test / test (ubuntu-latest) (pull_request) Failing after 2m44s
01150d884d
Adds the "Double Chirp" waveform (operator-confirmed option B): the sum
of the Up Chirp base and the Down Chirp base — identical geometry,
element-wise aligned — rotated by a relative phase θ found by a
DETERMINISTIC 1-D search minimising the PAPR of the summed base
(NOT the comb's PSO; no RNG, seed-free):

- chirp.py: optimal_double_chirp_phase (coarse 720-point grid over
  [0, 2π) computed vectorised pointwise |s(θ)|² = |up|² + |down|² +
  2·Re(e^{-jθ}·up·conj(down)), chunked to keep the working set O(base),
  followed by golden-section refinement within ±1° of the best coarse
  candidate — kept only when it beats the coarse candidate, so the
  search never returns worse than the grid); generate_double_chirp
  builds both bases through the shared SWEEP_UP/SWEEP_DOWN machinery,
  sums with exp(1j·θ*) and repeats the combined base through the shared
  phase-2 stitching path (factored into _repeat_base_to_overall);
  PHASE_SEARCH_POINTS = 720 module constant
- Level semantics (A1 default, operator left standing): each component
  carries the full band Level gain — no amplitude compensation of the
  sum (combined peak can approach 2×; CFR post-chain remains available)
- model.py: WAVEFORM_DOUBLE_CHIRP (8 options) + CHIRP_WAVEFORM_TYPES
  grows to the three-chirp family
- generate.py: dispatch branch (same post chain + write); Multi IQ /
  Other IQ still raise NotImplementedError
- validation/ui: L7/L8/L16 and the chirp controls/hints cover the
  family automatically via CHIRP_WAVEFORM_TYPES
- tests: new tests/test_double_chirp.py (14 tests: determinism + θ*
  stability, search beats the grid neighbours, measured PAPR equals the
  reported optimum, single-block base == the phase-optimised sum, A1
  no-compensation bound, triangle-inequality envelope bound, shared
  contract, L8/policy family coverage, band isolation, dispatch, L14
  preflight, phase-2 stitching); premise tests updated (8 options,
  __all__ surface); GUI Double Chirp generate test added
- spec.md: §5.2.1c Double Chirp, §3.3 row, 8 options; §5.2.1b/c
  repositioned after §5.2.1's complete bullet list

Gate: pytest 1203 passed / 63 skipped (headless tier), coverage 95.40%;
ruff + mypy clean.
test(ui): model-driven waveform-dropdown assertions (drop the 6-entry snapshot)
All checks were successful
test / test (ubuntu-latest) (pull_request) Successful in 2m44s
553b9025f7
test_waveform_dropdown_lists_comb_iq_as_sixth_entry froze the ticket-32
layout (Comb IQ as the 6th of 6 entries) in a hardcoded count + last-index
pair. The list legitimately grows every round — Down Chirp (ticket 40)
made it 7 (master is red on this test right now: 'assert 7 == 6'), Double
Chirp (ticket 41, this PR) makes it 8.

The durable invariant is that the dropdown mirrors the model:
- keep values == list(model.WAVEFORM_TYPES) and len == len(WAVEFORM_TYPES);
- keep the Comb-IQ intent without freezing position: membership + index
  alignment with model.WAVEFORM_TYPES;
- drop values[-1] == WAVEFORM_COMB_IQ (positional snapshot);
- rename to test_waveform_dropdown_mirrors_model_waveform_types.

The dropdown is built from policy.COMBO_FIELDS['waveform_type'] ==
model.WAVEFORM_TYPES, so the mirror holds for any future waveform list.
ykp merged commit 05e5c6a6f2 into master 2026-09-27 18:35:55 +02:00
Sign in to join this conversation.
No description provided.