diff --git a/docs/radio_chirp_math_validation.md b/docs/radio_chirp_math_validation.md index f8ee7e48..19b8613c 100644 --- a/docs/radio_chirp_math_validation.md +++ b/docs/radio_chirp_math_validation.md @@ -2,12 +2,15 @@ Historical validation record: the nominal 600 us policy described below has been replaced by the activation self-test and 10% guarded measured budget. +The later automatic policy also removed the pair-specific 88-symbol SF8/500 +floor and the fixed 300 us loop allowance; the older calculations below remain +as test history. See [radio profiles](radio_profiles.md#preamble) for current behavior. Implemented in the `keymindCascade` working tree, 2026-09-14. The initial 4.1/4000-us implementation record below is retained as history; the -[4.6/300-us policy update](#automatic-46-chirp--300-us-policy-update) is now current. +[4.6/300-us policy update](#automatic-46-chirp--300-us-policy-update) was a prior stage. No radios were flashed or RF experiments started for either function-only change. ## Initial implementation scope diff --git a/docs/radio_dwell_policy_validation.md b/docs/radio_dwell_policy_validation.md index 692da929..b0515893 100644 --- a/docs/radio_dwell_policy_validation.md +++ b/docs/radio_dwell_policy_validation.md @@ -2,6 +2,9 @@ Historical validation record: the nominal 600 us switch allowance below has been replaced by the activation self-test and a 10% guarded measured budget. +The current automatic policy also removed the fixed 300 us loop allowance and +the pair-specific SF8/500 88-symbol floor. Values below are retained as bench +history, not current automatic defaults. See [radio profiles](radio_profiles.md#preamble) for current behavior. The shared `RadioProfiles` function uses a 4.6-symbol minimum for both logical diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 6d56df27..68d22c36 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -300,30 +300,31 @@ profile**, then spends the remaining budgeted time on the faster profile, also with a **4.6-symbol floor**. Slower means a longer LoRa symbol (`2^SF / bandwidth`), regardless of profile number. The slower preamble sets the return deadline. The calculation allows two slow -visits within that preamble, reserving the self-tested switch time per hop and 0.3 ms of main-loop -margin. Fast-channel transmit preambles include sixteen acquisition symbols. -For a primary SF7 / 62.5 kHz profile with a *measured* 600 us guarded switch allowance: +visits within that preamble, reserving the self-tested, 10%-guarded switch time +per hop. There is no extra fixed main-loop allowance. Fast-channel transmit +preambles include sixteen acquisition symbols. +For a primary SF7 / 62.5 kHz profile with a 763 us measured maximum switch +(840 us after the 10% guard, from the BLE-on T1000-E trial): | Secondary profile | Automatic secondary preamble | Previously tested explicit preamble | Primary preamble | | --- | ---: | ---: | ---: | | SF9 / 500 kHz | 32 | 32 | 32 | -| SF8 / 500 kHz | 88 | 64 | 32 | +| SF8 / 500 kHz | 40 | 64 | 32 | | SF7 / 500 kHz | 64 | 80 | 32 | With preamble 32 on that slower profile, its visit is 9.421 ms and the faster -visit is 21.847 ms, plus the actual switching time. Increasing the slow preamble +visit is 21.667 ms, plus the actual switching time. Increasing the slow preamble also increases the time available on the fast channel. If the symbols are equal in length, the primary profile goes first. Detected packets hold the current channel until reception finishes. -The SF8 / 500 kHz value also retains the measured 88-symbol floor from the -production V4/XIAO tests, where 72 and 80 each missed a packet. -The explicit values come from the -[production validation](radio_profiles_validation.md): the final fast-channel -attempts each received 50/50 packets at 4.8 slow symbols. Automatic values use -the switching and loop-jitter allowances below. +The SF8 / 500 kHz value is calculated from the same formula as every other +profile; the older pair-specific 88-symbol floor has been removed. The explicit +values come from [historical production validation](radio_profiles_validation.md): +the final fast-channel attempts each received 50/50 packets at 4.8 slow symbols. +Automatic values use the measured switch allowance below. Those explicit-preamble tests used the older 4.8-symbol policy; they do not -validate the new 4.6-symbol slow dwell / 0.3 ms reserve. The +validate the newer 4.6-symbol slow dwell. The [4.6/0.3 ms two-profile bench](radio_dwell_policy_validation.md#hardware-context) received 197/200 at SF7/62.5 + SF8/500 with explicit 32-symbol preambles. The newer four-channel 5.1/6.1/7.7 tests also had misses, so this is a timing policy, not a claim of zero packet loss. @@ -338,13 +339,27 @@ receiver's setting does not lengthen packets sent by other nodes. `get radio2.timing` (alias `get radio.timing`) reports the active pair in `radio,radio2` order: dwell in chirps, recommended preambles in symbols, and the switching/loop allowances. Before calibration it reports `timing self-test -pending` and the provisional preambles. For SF7/62.5 on `radio` and SF7/500 on -`radio2` after a 600 us guarded measurement: +pending` and the provisional preambles. On the T1000-E, the Full Companion now +uses RTC2 for both retune timing and scan visits (about 31 us resolution). +Previously, the nRF52 `micros()` fallback counted 1,024 Hz RTOS ticks: a real +sub-millisecond retune could be reported as exactly 977 us, and two ticks as +1,953 us. Those old numbers are timer artifacts, not measured RF costs. +For SF7/62.5 on `radio` and SF8/500 on `radio2`, one live BLE-on read returned: ```text -> chirps=4.60,85.34; need=32,64; switch=600us; loop=300us (estimate); test=4/4,4/4 ready; WARN recommended preamble: radio2=64 +> chirps=4.60,42.84; need=32,40; switch=706us; loop=0us; test=4/4,4/4 ready; WARN recommended preamble: radio2=40 ``` +Later good hops in another BLE-on trial raised the measured maximum to 763 us +(840 us with the 10% guard); the recommendation stayed at 40 symbols. + +The measured switch duration is wall time, so an interrupt during the hop is +included. Bluetooth's SoftDevice may still preempt application code; this +clock correction does not stop it. Ordinary Companion/USB work in the main +loop does not run inside the synchronous retune, but it can delay when the +next scan visit begins. The zero fixed loop allowance is not a guarantee of +lossless reception under arbitrary application load. + `WARN recommended preamble` identifies **each** profile whose recommendation exceeds the standard 32 symbols or its explicitly selected value. For example, with SF7/62.5 on the primary profile, `set radio2 910.5,500,7,5,rx,32` @@ -383,14 +398,13 @@ T[i] = 2^SF[i] * 1000 / BW_kHz[i] slow = profile with the longer T (radio wins ties) L[slow] = max(requested_visit_us[slow], ceil(4.6 * T[slow])) L[fast] = max(requested_visit_us[fast], ceil(4.6 * T[fast])) -O = 2 * switch_us + loop_margin_us +O = 2 * switch_us C = L[radio] + L[radio2] + O P[slow] = round_up_to_8(max(32, 2 * C / T[slow])) P[fast] = round_up_to_8(max(32, (L[slow] + O) / T[fast] + 16)) ``` -The previous SF8/500 versus SF7/62.5 measured 88-symbol floor remains in force. Automatic selection uses the minimum visits to choose a preamble, then allocates the fast visit from half the slow preamble's duration. Diagnostics feed the actual allocated visits back into the same function, including longer visits @@ -406,11 +420,11 @@ fast_dwell = max(ceil(4.6 * T[fast]), ``` For the tested SF7/62.5 + SF8/500 pair with slow preamble 32, this yields -9,421 us slow and 21,847 us fast (42.67 fast chirps). Swapping `radio` and -`radio2` swaps the allocations automatically. The 0.3 ms reserve is not a sleep. +9,421 us slow and 21,197 us fast (41.40 fast chirps) with the 1,075 us guarded +switch allowance above. Swapping `radio` and `radio2` swaps the allocations automatically. Production scheduling and automatic preambles use the **self-tested maximum -successful switch plus 10%**, together with 300 us per cycle. Subsequent +successful switch plus 10%**, without an additional fixed loop allowance. Subsequent successful hops can only raise that allowance until the next profile change. The measured recommendation is rounded up to eight symbols and capped at 128 for automatic settings. The calculator and warnings retain the *uncapped* diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h index d2f4b985..bcebf53d 100644 --- a/src/RadioProfiles.h +++ b/src/RadioProfiles.h @@ -42,8 +42,9 @@ class RadioProfiles { static constexpr uint16_t MaxPreamble = 65528; static constexpr uint16_t MaxAutomaticPreamble = 128; // Successful RX-to-RX hops self-test the board-specific switch allowance. - // Packet receptions can extend a visit; these budgets describe an idle scan. - static constexpr uint32_t LoopBudgetUs = 300; + // Packet receptions can extend a visit. No fixed loop allowance is added to + // the self-tested, guarded switch time in the idle-scan timing model. + static constexpr uint32_t LoopBudgetUs = 0; static constexpr uint8_t SwitchTestSamplesPerDirection = 4; struct ChirpTiming { @@ -148,21 +149,17 @@ class RadioProfiles { return isfinite(symbol) && symbol > 0 && symbol <= UINT32_MAX / symbols ? (uint32_t)ceil(symbols * symbol) : 0; } - static double preambleForVisits(const RadioProfileParams& p, const RadioProfileParams& other, - bool slow, double own_us, double other_us, double overhead_us) { + static double preambleForVisits(const RadioProfileParams& p, bool slow, + double own_us, double other_us, double overhead_us) { const double symbol = symbolUs(p); if (symbol <= 0) return 32; - double result = slow ? roundPreamble(2.0 * (own_us + other_us + overhead_us) / symbol) + return slow ? roundPreamble(2.0 * (own_us + other_us + overhead_us) / symbol) : roundPreamble((other_us + overhead_us) / symbol + 2 * AcquisitionSymbols); - // Production V4/XIAO empirical floor; retain prior measured margin. - if (!slow && p.sf == 8 && p.bw == 500 && other.sf == 7 && other.bw == 62.5f && result < 88) - result = 88; - return result; } // A two-profile idle-scan timing policy, NOT a fitted zero-loss model. // Use a 4.6-symbol minimum on both profiles. Reserve // two return opportunities on the slow profile, and blind time + 16 - // acquisition symbols on the fast one. The 4.6/300-us pair bench received + // acquisition symbols on the fast one. The 4.6-symbol pair bench received // 197/200: a sweep fitting inside 32 is not a guarantee of acquisition. // Explicit visit times let diagnostics account for the actual scheduler, // including a longer fast visit selected by an explicit slow preamble. @@ -180,8 +177,8 @@ class RadioProfiles { t.switch_us = switch_us; t.loop_us = loop_us; const double overhead = 2.0 * switch_us + loop_us; t.cycle_us = double(t.listen_us[0]) + t.listen_us[1] + overhead; - t.preamble[0] = preambleForVisits(a, b, t.slow == 0, t.listen_us[0], t.listen_us[1], overhead); - t.preamble[1] = preambleForVisits(b, a, t.slow == 1, t.listen_us[1], t.listen_us[0], overhead); + t.preamble[0] = preambleForVisits(a, t.slow == 0, t.listen_us[0], t.listen_us[1], overhead); + t.preamble[1] = preambleForVisits(b, t.slow == 1, t.listen_us[1], t.listen_us[0], overhead); t.valid = true; return t; } @@ -190,7 +187,7 @@ class RadioProfiles { // Keep the hot retune path to one profile's calculation; the full // validated diagnostic structure is only needed by CLI/reporting. const uint8_t slow = slowerProfile(); - return preambleForVisits(params(profile), params(profile ^ 1), profile == slow, + return preambleForVisits(params(profile), profile == slow, minimumListenUs(params(profile), profile == slow), minimumListenUs(params(profile ^ 1), (profile ^ 1) == slow), 2.0 * switchBudgetUs() + LoopBudgetUs); } diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index afe9a8cb..8f0a4181 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -528,9 +528,10 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, radio_->profilePreamble(0), radio_->profilePreamble(1)); else if (!t.valid) snprintf(reply, capacity, "Error: timing unavailable"); else { - snprintf(reply, capacity, "> chirps=%.2f,%.2f; need=%.0f,%.0f; switch=%luus; loop=%luus (estimate)", + snprintf(reply, capacity, "> chirps=%.2f,%.2f; need=%.0f,%.0f; switch=%luus; loop=%luus%s", t.listen_us[0] / t.symbol_us[0], t.listen_us[1] / t.symbol_us[1], - t.preamble[0], t.preamble[1], (unsigned long)t.switch_us, (unsigned long)t.loop_us); + t.preamble[0], t.preamble[1], (unsigned long)t.switch_us, + (unsigned long)t.loop_us, t.loop_us ? " (estimate)" : ""); size_t used = strlen(reply); if (used < capacity) snprintf(reply + used, capacity - used, "; test=%u/%u,%u/%u%s", p.switch_test_samples[0], RadioProfiles::SwitchTestSamplesPerDirection, diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index a9cfea30..b4858665 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -21,6 +21,39 @@ static volatile uint8_t state = STATE_IDLE; +// The Adafruit nRF52 core's micros() falls back to 1024 Hz RTOS ticks when +// DWT is disabled. A sub-millisecond retune then appears to take exactly +// 977 us (or 1953 us across two ticks), making the learned switch allowance +// and scan dwell inaccurate. RTC2 is otherwise unused on the T1000-E and +// provides a continuous 32768 Hz clock without changing micros() globally or +// holding the high-frequency clock on for this measurement. +static uint32_t profileTimestamp() { +#if defined(T1000_E) && defined(NRF52_PLATFORM) + static bool started = false; + if (!started) { + NRF_RTC2->TASKS_STOP = 1; + NRF_RTC2->PRESCALER = 0; + NRF_RTC2->TASKS_CLEAR = 1; + NRF_RTC2->TASKS_START = 1; + started = true; + } + return NRF_RTC2->COUNTER; +#else + return micros(); +#endif +} + +static uint32_t profileElapsedUs(uint32_t since, uint32_t now) { +#if defined(T1000_E) && defined(NRF52_PLATFORM) + // RTC2 is 24-bit and wraps every 512 seconds. Active scanning samples it + // many times per second; a freshly configured profile gets a new visit stamp. + const uint32_t ticks = (now - since) & 0x00ffffffUL; + return static_cast((uint64_t(ticks) * 15625 + 511) >> 9); +#else + return now - since; +#endif +} + // this function is called when a complete packet // is transmitted by the module static @@ -33,6 +66,7 @@ void setFlag(void) { } void RadioLibWrapper::begin() { + (void) profileTimestamp(); #ifdef USE_CC310_HW_CRYPTO // Initialize CryptoCell once from normal task context. The session helper // still initializes lazily if an earlier crypto operation runs first. @@ -254,7 +288,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, } } - _profile_visit_us = micros(); + _profile_visit_stamp = profileTimestamp(); return success ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; } @@ -287,7 +321,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { if (!_profile_refresh_required && _active_profile == profile && _profile_generation == _profiles.generation[profile] && _physical_preamble == preamble) return mesh::RadioParamApplyResult::APPLIED; if (!validateProfile(p)) return mesh::RadioParamApplyResult::FAILED; - const uint32_t started = micros(); + const uint32_t started = profileTimestamp(); beginProfileRetune(isInRecvMode() && !_rx_ps_armed && !_rx_ps_enabled); const uint8_t resume = beginReconfigure(); if (resume > 1) { @@ -330,16 +364,17 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { if (restored) endReconfigure(resume); if (!restored || (resume && !isInRecvMode())) _profile_refresh_required = true; } - _profile_visit_us = micros(); + _profile_visit_stamp = profileTimestamp(); // A profile retune re-enters RX on a different tuple. Do not read its // instantaneous RSSI until the chip family's settle interval has elapsed. if (applied && _nf_refresh_requested) { _nf_sample_from = millis() + NF_CALIB_SETTLE_MS; } - const uint32_t elapsed = _profile_visit_us - started; + const uint32_t elapsed = profileElapsedUs(started, _profile_visit_stamp); if (elapsed > _profiles.longest_switch_us) _profiles.longest_switch_us = elapsed; - // Learn the actual board's RX-to-RX retune cost on the first four good hops - // in each direction. Deferred and rolled-back operations are not samples. + // Require four good RX-to-RX hops in each direction before using the budget, + // then continue raising the observed maximum as later good hops run. + // Deferred and rolled-back operations are not samples. if (applied && resume && old_profile != profile) { _profiles.sampleSwitch(old_profile, profile, elapsed); } @@ -442,13 +477,13 @@ void RadioLibWrapper::serviceProfileScan() { && visit_us < NF_CALIB_SETTLE_MS * 1000UL) { visit_us = NF_CALIB_SETTLE_MS * 1000UL; } - if ((uint32_t)(micros() - _profile_visit_us) >= visit_us) target ^= 1; + if (profileElapsedUs(_profile_visit_stamp, profileTimestamp()) >= visit_us) target ^= 1; } const auto result = tuneProfile(target); if (restart_scan && result == mesh::RadioParamApplyResult::APPLIED) { _profile_scan_generation[0] = _profiles.generation[0]; _profile_scan_generation[1] = _profiles.generation[1]; - _profile_visit_us = micros(); + _profile_visit_stamp = profileTimestamp(); } if (result == mesh::RadioParamApplyResult::FAILED) { // Retry a rejected or failing profile at a bounded rate; retain primary RX. @@ -1046,7 +1081,7 @@ void RadioLibWrapper::startRecv() { int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { // A very short frame may complete while startReceiveMode() returns. - _profile_visit_us = micros(); + _profile_visit_stamp = profileTimestamp(); // Retain that RX interrupt instead of overwriting it with software state. noInterrupts(); state = (state & STATE_INT_READY) | STATE_RX; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index e796bb12..f5eed90d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -81,7 +81,7 @@ protected: uint16_t _physical_preamble = 0; uint32_t _profile_generation = 0; uint32_t _profile_scan_generation[2] = {}; - uint32_t _profile_visit_us = 0; + uint32_t _profile_visit_stamp = 0; uint32_t _profile_retry_at = 0; bool _profile_rxps_suspended = false; bool _profile_saved_rxps = false; diff --git a/test/fixtures/radio_profiles/chirp_math.cpp b/test/fixtures/radio_profiles/chirp_math.cpp index e97f834b..685f2a2d 100644 --- a/test/fixtures/radio_profiles/chirp_math.cpp +++ b/test/fixtures/radio_profiles/chirp_math.cpp @@ -11,7 +11,7 @@ int main() { assert(Profiles::MinListenSymbols==4.6); assert(Profiles::SlowListenSymbols==4.6); assert(Profiles::MinFastListenSymbols==4.6); - assert(Profiles::LoopBudgetUs==300); + assert(Profiles::LoopBudgetUs==0); auto a=params(10,125),b=params(10,125); auto t=Profiles::calculateChirpTiming(a,b,1,0,1000,4000); assert(t.valid && t.slow==0 && t.symbol_us[0]==8192); @@ -31,9 +31,9 @@ int main() { b=params(10,1e-30f);assert(Profiles::minimumListenUs(b)==0); a=params(7,62.5);b=params(8,500); t=Profiles::calculateChirpTiming(a,b); - assert(t.preamble[1]==88); // Existing measured acquisition floor retained. + assert(t.preamble[1]==40); // Derived from dwell and acquisition, not a pair-specific floor. auto swapped=Profiles::calculateChirpTiming(b,a); - assert(swapped.slow==1 && swapped.preamble[0]==88); + assert(swapped.slow==1 && swapped.preamble[0]==40); assert(swapped.preamble[1]==t.preamble[0]); for (float bw0: {7.8f,62.5f,125.0f,250.0f,500.0f}) for (float bw1: {7.8f,62.5f,125.0f,250.0f,500.0f}) @@ -76,8 +76,8 @@ int main() { for (unsigned n=0;nbefore.preamble[1]); @@ -97,23 +97,23 @@ int main() { p.primary=params(7,62.5);p.primary.preamble=32; p.secondary.params=params(8,500);p.secondary.params.preamble=32; p.secondary.mode=mesh::RadioProfileMode::Rx; - assert(p.automaticPreambleFits() && p.listenUs(0)==9421 && p.listenUs(1)==21847); + assert(p.automaticPreambleFits() && p.listenUs(0)==9421 && p.listenUs(1)==22147); assert(p.preamble(0,32)==32 && p.preamble(1,32)==32); - assert(p.chirpTiming().preamble[1]==88); + assert(p.chirpTiming().preamble[1]==40); std::swap(p.primary,p.secondary.params); - assert(p.slowerProfile()==1 && p.listenUs(1)==9421 && p.listenUs(0)==21847); + assert(p.slowerProfile()==1 && p.listenUs(1)==9421 && p.listenUs(0)==22147); p.secondary.params.preamble=8; assert(!p.automaticPreambleFits()); - // This formerly fit the 4.1 fast floor (1050 us), but not 4.6 (1178 us). + // With no fixed loop reserve, 27 symbols cannot cover both 4.6-symbol visits. p.primary=params(7,500);p.secondary.params=params(7,500); - p.primary.preamble=30; + p.primary.preamble=27; assert(p.listenUs(0)==1178 && p.listenUs(1)>=1178); assert(!p.automaticPreambleFits()); p.primary.preamble=32; - assert(p.automaticPreambleFits() && p.listenUs(1)==1418); + assert(p.automaticPreambleFits() && p.listenUs(1)==1718); // Long requested visits remain visible, not replaced by policy minima. t=Profiles::calculateChirpTiming(params(7,62.5),params(8,500),20000,30000); - assert(t.listen_us[0]==20000 && t.listen_us[1]==30000 && t.cycle_us==50300); + assert(t.listen_us[0]==20000 && t.listen_us[1]==30000 && t.cycle_us==50000); Profiles measured; measured.primary=params(7,500); measured.secondary.params=params(7,500); @@ -138,4 +138,17 @@ int main() { measured.setSecondary(replacement,true); assert(!measured.switchTestReady() && measured.switchBudgetUs()==0); assert(measured.preamble(0,32)==128 && measured.preamble(1,32)==88); + + // A T1000-E-sized 977 us hop calibrates to 1075 us, yet SF8/500 no longer + // inherits the historical 88-symbol floor or a fixed 300 us loop pad. + Profiles t1000; + t1000.primary=params(7,62.5); + t1000.secondary.params=params(8,500); + t1000.secondary.mode=mesh::RadioProfileMode::RxTx; + for (unsigned n=0;n