Skip to content

Commit 833185e

Browse files
secupclaude
andcommitted
feat(papr): Active Constellation Extension (ACE) — zero-harm PAPR reduction (ULTRA_ACE_PAPR, default off)
Tone reservation doesn't fit our packed spectrum (59 carriers in ~61 in-band bins, only DC+1 edge spare → would need a frame-geometry redesign). ACE fits: it extends OUTER constellation points OUTWARD (away from decision boundaries) to shave time-domain peaks — the RX sees points farther from where it'd misread them, so BER is equal-or-better, never worse. No reserved carriers, no frame change, no RX change. - src/ofdm/papr_ace.hpp: clip-and-project ACE with per-mod outward-safe projection (PSK radial; QAM per-axis outer-level). Fixed clip level from initial RMS (recomputing chases the rising RMS and stalls at 0.1 dB). - test_papr_ace: proves both invariants (every point moves only outward, pilots/empty bins untouched) + benefit. Measured: QPSK 0.92 / 8PSK 0.82 / 16QAM 0.83 / 32QAM 0.71 dB reduction, 12/12 improved. - Wired into modulator createOFDMSymbol, DATA symbols only (genie_capture), coherent mods only. ~0.8 dB more average power after peak-normalization. Unlike the crude clip (rig-measured −58% on clean channels, net −12%), ACE is strictly non-negative. Verified: ctest 86/86; ACE-on good@20 sim PASS 2700 bps (no decode regression — sim shows safety, rig shows the power benefit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019btwFxDt7D19SZSqPYvZnk
1 parent edeeb40 commit 833185e

4 files changed

Lines changed: 353 additions & 0 deletions

File tree

src/ofdm/modulator.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
#include "ultra/logging.hpp"
66
#include "demodulator_constants.hpp"
77
#include "pilot_pattern.hpp"
8+
#include "papr_ace.hpp"
89
#include "genie_tx_capture.hpp"
10+
#include "ultra/ofdm_link_adaptation.hpp"
911
#include <algorithm>
1012
#include <random>
1113

@@ -280,6 +282,26 @@ struct OFDMModulator::Impl {
280282
ultra::genie::txCapture().symbols.push_back(freq_domain);
281283
}
282284

285+
// ACE PAPR reduction (ULTRA_ACE_PAPR, default off; 2026-07-13). DATA
286+
// symbols only (genie_capture marks them — never LTS/probe/preamble, so
287+
// sync/channel-estimation waveforms stay pristine) and coherent mods
288+
// only. Extends outer constellation points OUTWARD to shave time peaks
289+
// → after the TX peak-normalization, ~0.8 dB more average power on the
290+
// air, with ZERO harmful EVM (points move only away from decision
291+
// boundaries → RX BER equal-or-better). No RX change. Unit-proven in
292+
// test_papr_ace. Unlike the crude clip (rig-measured −58% on clean
293+
// channels), ACE is strictly non-negative.
294+
static const bool ace_enabled = [] {
295+
const char* e = std::getenv("ULTRA_ACE_PAPR");
296+
return e != nullptr && e[0] != '\0' && e[0] != '0';
297+
}();
298+
if (ace_enabled && genie_capture &&
299+
ultra::ofdm_link_adaptation::isCoherentModulation(config.modulation)) {
300+
ofdm::papr_ace::applyAce(freq_domain, data_carrier_indices,
301+
config.modulation, fft,
302+
/*clip_ratio=*/1.6f, /*max_iters=*/8, /*mu=*/1.0f);
303+
}
304+
283305
// IFFT to time domain
284306
auto& time_domain = time_domain_scratch;
285307
time_domain.resize(config.fft_size);

src/ofdm/papr_ace.hpp

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
#pragma once
2+
// Active Constellation Extension (ACE) PAPR reduction.
3+
//
4+
// WHY ACE (not clipping, not tone reservation) — measured on the rig 2026-07-13:
5+
// - Crude amplitude clipping recovers ~4.5 dB of average power (real,
6+
// hardware-only benefit under peak-normalization) but injects EVM that
7+
// CRATERS clean channels (−58%: it damages a signal the RX decodes fine).
8+
// - Tone reservation needs ~4-6 spare in-band carriers; our spectrum is
9+
// packed (59 carriers in ~61 in-band bins, only DC+1 edge spare), so it
10+
// would require a frame-geometry redesign.
11+
// ACE fits a packed spectrum: it extends OUTER constellation points OUTWARD
12+
// (away from their decision boundaries) to cancel time-domain peaks. The RX
13+
// sees points FARTHER from where it would misread them, so BER is
14+
// equal-or-better — never worse. No reserved carriers, no frame change, no
15+
// RX change (the demapper's decision regions are unbounded on the outward
16+
// side). The two invariants below are what make it safe; the unit test
17+
// (test_papr_ace) proves both hold.
18+
//
19+
// INVARIANT 1 (safety): every data carrier moves ONLY in its outward-safe
20+
// region — radially outward for PSK, axis-outward on an outer level for QAM.
21+
// No point moves toward any decision boundary. Pilots and empty bins are
22+
// never touched (channel estimation and the reserved bins are preserved).
23+
// INVARIANT 2 (benefit): post-PAPR <= pre-PAPR (peaks only shrink).
24+
//
25+
// Algorithm: clip-and-project (Krongold & Jones 2003). Clip the time signal,
26+
// take the clip residual's spectrum, add back ONLY the outward-safe component
27+
// on data carriers, iterate a few times. The projection is what confines the
28+
// correction to the safe region.
29+
30+
#include "ultra/types.hpp"
31+
#include "ultra/dsp.hpp"
32+
#include "ofdm/demodulator_constants.hpp"
33+
34+
#include <algorithm>
35+
#include <cmath>
36+
#include <vector>
37+
38+
namespace ultra {
39+
namespace ofdm {
40+
namespace papr_ace {
41+
42+
struct AceResult {
43+
bool applied = false;
44+
float pre_papr_db = 0.0f;
45+
float post_papr_db = 0.0f;
46+
int iterations = 0;
47+
size_t data_carriers_extended = 0;
48+
};
49+
50+
// Outer-level magnitude (in scaled constellation units) per QAM axis: the
51+
// point is "outer" on an axis if its coordinate sits beyond the last inner
52+
// decision boundary, so extending it further out crosses no boundary.
53+
// 16QAM I/Q levels {-3,-1,1,3}·s → outer at ±3·s, inner/outer boundary at 2·s.
54+
// 32QAM (cross): I {-3..3}·s, Q {-7..7}·s → outer boundary 2·s (I) / 6·s (Q).
55+
struct QamAxisSpec {
56+
float scale = 0.0f; // constellation scale (1/sqrt(Es))
57+
float i_outer_boundary = 0.0f; // |I| beyond this ⇒ outer on I
58+
float q_outer_boundary = 0.0f; // |Q| beyond this ⇒ outer on Q
59+
bool valid = false;
60+
};
61+
62+
// QAM16_SCALE lives in modulator.cpp (not the shared demod constants); mirror
63+
// the value here (1/sqrt(10)) so ACE and the TX constellation agree exactly.
64+
inline constexpr float kAceQam16Scale = 0.3162277660168379f; // 1/sqrt(10)
65+
66+
inline QamAxisSpec qamAxisSpec(Modulation mod) {
67+
using namespace demod_constants;
68+
QamAxisSpec s;
69+
if (mod == Modulation::QAM16) {
70+
s.scale = kAceQam16Scale;
71+
s.i_outer_boundary = 2.0f * kAceQam16Scale; // between level 1 and 3
72+
s.q_outer_boundary = 2.0f * kAceQam16Scale;
73+
s.valid = true;
74+
} else if (mod == Modulation::QAM32) {
75+
s.scale = QAM32_SCALE;
76+
s.i_outer_boundary = 2.0f * QAM32_SCALE; // I levels ±1,±3 → outer ±3
77+
s.q_outer_boundary = 6.0f * QAM32_SCALE; // Q levels ±1..±7 → outer ±7
78+
s.valid = true;
79+
}
80+
return s;
81+
}
82+
83+
inline bool isPskMod(Modulation mod) {
84+
// All constellations whose decisions are by phase sector (points on a
85+
// circle) → safe extension is radially outward. QAM8 is the 8PSK
86+
// constellation (types.hpp:69), so it belongs here, not with rectangular QAM.
87+
return mod == Modulation::DBPSK || mod == Modulation::BPSK ||
88+
mod == Modulation::DQPSK || mod == Modulation::QPSK ||
89+
mod == Modulation::D8PSK || mod == Modulation::QAM8;
90+
}
91+
92+
// Project a desired correction `delta` for a data symbol `s` onto its
93+
// outward-safe region. Returns the allowed part (added to s stays decodable).
94+
inline Complex aceProject(Complex s, Complex delta, Modulation mod,
95+
const QamAxisSpec& qam) {
96+
if (isPskMod(mod)) {
97+
// PSK decisions are by phase sector: safe = radially OUTWARD only
98+
// (increase magnitude, never rotate). Project delta onto the unit
99+
// vector s/|s| and keep only the outward (positive-radial) part.
100+
const float mag = std::abs(s);
101+
if (mag < 1e-9f) return Complex(0.0f, 0.0f);
102+
const Complex u = s / mag;
103+
const float radial = delta.real() * u.real() + delta.imag() * u.imag();
104+
if (radial <= 0.0f) return Complex(0.0f, 0.0f);
105+
return u * radial;
106+
}
107+
if (qam.valid) {
108+
// QAM decisions are per-axis at integer half-levels: safe = extend an
109+
// OUTER-level coordinate further out on that axis only. Inner
110+
// coordinates are pinned (moving them crosses a boundary).
111+
float dI = 0.0f, dQ = 0.0f;
112+
if (s.real() > qam.i_outer_boundary && delta.real() > 0.0f) dI = delta.real();
113+
else if (s.real() < -qam.i_outer_boundary && delta.real() < 0.0f) dI = delta.real();
114+
if (s.imag() > qam.q_outer_boundary && delta.imag() > 0.0f) dQ = delta.imag();
115+
else if (s.imag() < -qam.q_outer_boundary && delta.imag() < 0.0f) dQ = delta.imag();
116+
return Complex(dI, dQ);
117+
}
118+
return Complex(0.0f, 0.0f); // unknown mod → no extension (safe no-op)
119+
}
120+
121+
inline float paprDb(const std::vector<Complex>& x) {
122+
double sum_sq = 0.0, peak_sq = 0.0;
123+
for (const auto& v : x) {
124+
const double p = static_cast<double>(v.real()) * v.real() +
125+
static_cast<double>(v.imag()) * v.imag();
126+
sum_sq += p;
127+
if (p > peak_sq) peak_sq = p;
128+
}
129+
if (sum_sq <= 0.0) return 0.0f;
130+
const double mean = sum_sq / static_cast<double>(x.size());
131+
return static_cast<float>(10.0 * std::log10(peak_sq / mean));
132+
}
133+
134+
// In-place ACE on the freq-domain symbol. `clip_ratio` is the peak/RMS target
135+
// in linear (e.g. 10^(target_db/20)); `mu` is the per-iteration step (0..1);
136+
// `max_iters` typically 3-4. Data carriers only; pilots/empty bins untouched.
137+
inline AceResult applyAce(std::vector<Complex>& freq_domain,
138+
const std::vector<int>& data_indices,
139+
Modulation mod, FFT& fft,
140+
float clip_ratio, int max_iters, float mu) {
141+
AceResult r;
142+
const size_t N = freq_domain.size();
143+
if (N == 0 || data_indices.empty() || clip_ratio <= 0.0f) return r;
144+
const QamAxisSpec qam = qamAxisSpec(mod);
145+
if (!isPskMod(mod) && !qam.valid) return r; // unsupported mod
146+
147+
std::vector<Complex> x(N), excess(N), E(N);
148+
fft.inverse(freq_domain.data(), x.data());
149+
r.pre_papr_db = paprDb(x);
150+
151+
// FIXED clip level from the INITIAL RMS: ACE extends points outward, which
152+
// raises RMS — recomputing A each iteration would chase the rising RMS and
153+
// stop clipping (self-defeating; measured 0.1 dB). Holding A at the target
154+
// peak level lets successive iterations keep pushing the peak down toward it.
155+
double init_sq = 0.0;
156+
for (const auto& v : x) init_sq += static_cast<double>(v.real()) * v.real() +
157+
static_cast<double>(v.imag()) * v.imag();
158+
const float init_rms = std::sqrt(static_cast<float>(init_sq / static_cast<double>(N)));
159+
const float A = clip_ratio * init_rms;
160+
if (A <= 0.0f) return r;
161+
162+
for (int it = 0; it < max_iters; ++it) {
163+
bool any = false;
164+
for (size_t i = 0; i < N; ++i) {
165+
const float m = std::abs(x[i]);
166+
if (m > A) {
167+
excess[i] = x[i] * ((m - A) / m); // the part above the clip level
168+
any = true;
169+
} else {
170+
excess[i] = Complex(0.0f, 0.0f);
171+
}
172+
}
173+
if (!any) break;
174+
175+
// X-domain rep of the time residual: forward(excess). This FFT
176+
// convention has forward∘inverse = identity (the modulator uses
177+
// inverse for X→time), so no 1/N — E[bin] is already in freq_domain's
178+
// units, ready to add to a data carrier.
179+
fft.forward(excess.data(), E.data());
180+
size_t extended = 0;
181+
for (int bin : data_indices) {
182+
const Complex s = freq_domain[bin];
183+
// Subtract the residual on this carrier (reduces the peak), but only
184+
// the outward-safe component.
185+
const Complex delta_desired = E[bin] * (-mu);
186+
const Complex allowed = aceProject(s, delta_desired, mod, qam);
187+
if (allowed.real() != 0.0f || allowed.imag() != 0.0f) {
188+
freq_domain[bin] = s + allowed;
189+
++extended;
190+
}
191+
}
192+
r.data_carriers_extended = std::max(r.data_carriers_extended, extended);
193+
fft.inverse(freq_domain.data(), x.data());
194+
r.iterations = it + 1;
195+
if (extended == 0) break; // nothing safe left to do
196+
}
197+
198+
r.post_papr_db = paprDb(x);
199+
r.applied = r.iterations > 0;
200+
return r;
201+
}
202+
203+
} // namespace papr_ace
204+
} // namespace ofdm
205+
} // namespace ultra

tests/CMakeLists.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ target_include_directories(test_papr_reduction PRIVATE
4646
${CMAKE_SOURCE_DIR}/thirdparty/pocketfft)
4747
add_test(NAME PaprReduction COMMAND test_papr_reduction)
4848

49+
# ACE PAPR reduction — invariant proofs (points move only outward; PAPR down).
50+
add_executable(test_papr_ace test_papr_ace.cpp)
51+
target_link_libraries(test_papr_ace PRIVATE ultra_core ota_channel_core)
52+
target_include_directories(test_papr_ace PRIVATE
53+
${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/thirdparty/pocketfft)
54+
add_test(NAME PaprAce COMMAND test_papr_ace)
55+
4956
add_executable(test_real_hf_loop_channel test_real_hf_loop_channel.cpp)
5057
target_link_libraries(test_real_hf_loop_channel PRIVATE ota_channel_core)
5158
add_test(NAME RealHfLoopChannel COMMAND test_real_hf_loop_channel)

tests/test_papr_ace.cpp

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// ACE PAPR reduction — proves the two safety invariants and the benefit.
2+
#include "ofdm/papr_ace.hpp"
3+
#include "ultra/dsp.hpp"
4+
5+
#include <cstdio>
6+
#include <random>
7+
#include <vector>
8+
9+
using namespace ultra;
10+
using ultra::ofdm::papr_ace::applyAce;
11+
using ultra::ofdm::papr_ace::AceResult;
12+
using ultra::ofdm::papr_ace::qamAxisSpec;
13+
using ultra::ofdm::papr_ace::isPskMod;
14+
15+
namespace {
16+
int g_failures = 0;
17+
void check(bool ok, const char* msg) {
18+
if (!ok) { std::printf("FAIL: %s\n", msg); ++g_failures; }
19+
}
20+
21+
// Build a random OFDM freq-domain symbol for `mod` on `data_indices`.
22+
std::vector<Complex> makeSymbol(Modulation mod, const std::vector<int>& data_indices,
23+
size_t fft, uint32_t seed) {
24+
std::mt19937 rng(seed);
25+
std::vector<Complex> X(fft, Complex(0, 0));
26+
const auto qam = qamAxisSpec(mod);
27+
for (int bin : data_indices) {
28+
if (isPskMod(mod)) {
29+
int npsk = (mod == Modulation::QAM8 || mod == Modulation::D8PSK) ? 8
30+
: (mod == Modulation::DBPSK || mod == Modulation::BPSK) ? 2 : 4;
31+
float ph = (2.0f * float(M_PI) / npsk) * (rng() % npsk) +
32+
((npsk == 4) ? float(M_PI) / 4.0f : 0.0f);
33+
X[bin] = Complex(std::cos(ph), std::sin(ph));
34+
} else {
35+
// QAM: pick random levels
36+
const float sI = qam.scale;
37+
int iL, qL;
38+
if (mod == Modulation::QAM16) { int L[4]={-3,-1,1,3}; iL=L[rng()%4]; qL=L[rng()%4]; }
39+
else { int LI[4]={-3,-1,1,3}; int LQ[8]={-7,-5,-3,-1,1,3,5,7}; iL=LI[rng()%4]; qL=LQ[rng()%8]; }
40+
X[bin] = Complex(iL * sI, qL * sI);
41+
}
42+
}
43+
return X;
44+
}
45+
46+
// Decision-region check: does the ACE'd point still decode to the SAME symbol
47+
// as the original (i.e. it only moved within/beyond its own region)?
48+
bool sameDecision(Complex orig, Complex aced, Modulation mod) {
49+
const auto qam = qamAxisSpec(mod);
50+
if (isPskMod(mod)) {
51+
// phase sector unchanged, magnitude only grew
52+
int npsk = (mod == Modulation::QAM8 || mod == Modulation::D8PSK) ? 8
53+
: (mod == Modulation::DBPSK || mod == Modulation::BPSK) ? 2 : 4;
54+
float sector = 2.0f * float(M_PI) / npsk;
55+
float d = std::arg(aced * std::conj(orig)); // phase change
56+
bool phase_ok = std::abs(d) < sector * 0.499f;
57+
bool mag_ok = std::abs(aced) >= std::abs(orig) - 1e-4f; // outward (or unchanged)
58+
return phase_ok && mag_ok;
59+
}
60+
// QAM: each coordinate must stay in the same level bin (or extend an outer one out)
61+
auto axisOk = [](float o, float a, float outer) {
62+
if (o > outer) return a >= o - 1e-4f; // was +outer, only grew
63+
if (o < -outer) return a <= o + 1e-4f; // was -outer, only grew (more negative)
64+
return std::abs(a - o) < 1e-4f; // inner: must be pinned
65+
};
66+
return axisOk(orig.real(), aced.real(), qam.i_outer_boundary) &&
67+
axisOk(orig.imag(), aced.imag(), qam.q_outer_boundary);
68+
}
69+
70+
void testMod(Modulation mod, const char* name) {
71+
const size_t fft = 1024;
72+
// 59 carriers, k=-29..30 skip 0 (production layout)
73+
std::vector<int> data_indices;
74+
for (int k = -29; k <= 30; ++k) {
75+
if (k == 0) continue;
76+
data_indices.push_back((k + int(fft)) % int(fft));
77+
}
78+
FFT fftp(fft);
79+
int improved = 0, samples = 12;
80+
double total_reduction = 0.0;
81+
for (int t = 0; t < samples; ++t) {
82+
auto X = makeSymbol(mod, data_indices, fft, 0x1000u + t * 131u);
83+
auto X0 = X; // original (pre-ACE)
84+
AceResult r = applyAce(X, data_indices, mod, fftp,
85+
/*clip_ratio=*/1.6f, /*max_iters=*/10, /*mu=*/1.0f);
86+
// INVARIANT 2: PAPR did not increase
87+
check(r.post_papr_db <= r.pre_papr_db + 0.05f, "PAPR must not increase");
88+
if (r.post_papr_db < r.pre_papr_db - 0.1f) ++improved;
89+
total_reduction += (r.pre_papr_db - r.post_papr_db);
90+
// INVARIANT 1: every data carrier still decodes to its original symbol
91+
for (int bin : data_indices) {
92+
if (!sameDecision(X0[bin], X[bin], mod)) {
93+
check(false, "data carrier left its decision region");
94+
break;
95+
}
96+
}
97+
// pilots/empty bins untouched
98+
for (size_t i = 0; i < fft; ++i) {
99+
bool is_data = false;
100+
for (int b : data_indices) if (int(i) == b) { is_data = true; break; }
101+
if (!is_data) check(std::abs(X[i] - X0[i]) < 1e-6f, "non-data bin modified");
102+
}
103+
}
104+
std::printf(" %-6s: mean PAPR reduction %.2f dB, improved %d/%d\n",
105+
name, total_reduction / samples, improved, samples);
106+
check(improved >= samples / 2, "ACE should reduce PAPR on most symbols");
107+
}
108+
} // namespace
109+
110+
int main() {
111+
std::printf("== ACE PAPR reduction: invariants + benefit ==\n");
112+
testMod(Modulation::QPSK, "QPSK");
113+
testMod(Modulation::QAM8, "8PSK");
114+
testMod(Modulation::QAM16, "16QAM");
115+
testMod(Modulation::QAM32, "32QAM");
116+
if (g_failures == 0) std::printf("PASS: ACE invariants hold; PAPR reduced.\n");
117+
else std::printf("FAIL: %d check(s) failed.\n", g_failures);
118+
return g_failures == 0 ? 0 : 1;
119+
}

0 commit comments

Comments
 (0)