From 1db89fca794da40c80b798427ff5c9a17de0e003 Mon Sep 17 00:00:00 2001 From: Razvalyaev Date: Wed, 12 Aug 2026 12:11:55 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=D1=81=D1=8F=D0=BA=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- OpticalChannelTester/App.cpp | 29 ++++++----- OpticalChannelTester/Config.h | 8 ++- OpticalChannelTester/Config_Text.h | 2 - OpticalChannelTester/Core.cpp | 70 ++++++++++++++++++++++++-- OpticalChannelTester/Core.h | 12 ++++- OpticalChannelTester/Measurement.cpp | 56 ++++++++++++++++++--- OpticalChannelTester/Measurement.h | 11 ++-- OpticalChannelTester/SettingsStore.cpp | 6 +-- README.md | 6 ++- 9 files changed, 163 insertions(+), 37 deletions(-) diff --git a/OpticalChannelTester/App.cpp b/OpticalChannelTester/App.cpp index 6b4f026..8740c80 100644 --- a/OpticalChannelTester/App.cpp +++ b/OpticalChannelTester/App.cpp @@ -166,7 +166,7 @@ void App::update() { } if (state_ == AppState::MENU) { if (modeEvent == ButtonEvent::SHORT) { - menuItem_ = (menuItem_ + 1U) % 5U; Log::printf("ACTION", "menu item selected index=%u", menuItem_); showMenu(); + menuItem_ = (menuItem_ + 1U) % 4U; Log::printf("ACTION", "menu item selected index=%u", menuItem_); showMenu(); } else if (modeEvent == ButtonEvent::LONG) { sanitizeRange(); const bool saved = store_.save(settings_); params_ = store_.params(settings_); @@ -237,7 +237,7 @@ void App::changeMenu(int d) { case 1: value = &settings_.endIndex; count = countOf(END_FREQ_OPTIONS_HZ); break; case 2: value = &settings_.accuracyIndex; count = countOf(ACCURACY_OPTIONS_PCT); break; case 3: value = &settings_.timeIndex; count = countOf(TEST_TIME_OPTIONS_MS); break; - default: value = &settings_.dutyIndex; count = countOf(DUTY_OPTIONS_PCT); break; + default: return; } *value = static_cast((*value + count + d) % count); Log::printf("ACTION", "menu item=%u changed direction=%+d new-index=%u", menuItem_, d, *value); @@ -263,14 +263,10 @@ void App::showMenu() { snprintf(value, sizeof(value), "+/-%g%%", params_.accuracyPct); label = UiText::MENU_ACCURACY; break; - case 3: + default: snprintf(value, sizeof(value), "%.1fs", params_.testTimeMs / 1000.0f); label = UiText::MENU_TEST_TIME; break; - default: - snprintf(value, sizeof(value), "%u%%", params_.dutyPct); - label = UiText::MENU_PWM_DUTY; - break; } formatMenuLine(label, value, one, sizeof(one)); formatMenuLine(UiText::MENU_TOTAL_TIME, all, total, sizeof(total)); @@ -340,7 +336,8 @@ bool App::prepareStage(bool showProgress) { } const uint32_t plannedRxHz = receiver_.plannedTickHz(actual_.actualHz, actual_.actualDutyPct); const FailReason resolution = validateResolution(actual_.actualHz, actual_.actualDutyPct, params_.accuracyPct, - plannedRxHz, actual_.bits); + plannedRxHz, actual_.bits, + MEASUREMENT_AVERAGING_PERIODS); if (resolution != FailReason::NONE) { Log::printf("PWM", "resolution rejected: actual=%luHz duty=%.3f%% bits=%u RXclock=%luHz tolerance=%.3f%%", actual_.actualHz, actual_.actualDutyPct, actual_.bits, plannedRxHz, @@ -357,10 +354,11 @@ bool App::prepareStage(bool showProgress) { } bool App::startLocalMeasurement(float hz, float duty) { - Log::printf("MEASURE", "arming expected=%.3fHz duty=%.3f%% tolerance=%.3f%% RX=%luHz settle=%u cycles window=%lums; per-pulse logging suspended", + Log::printf("MEASURE", "arming expected=%.3fHz duty=%.3f%% tolerance=%.3f%% RX=%luHz settle=%u cycles window=%lums average=%u periods; per-pulse logging suspended", hz, duty, effectiveTolerancePct(params_.accuracyPct), receiver_.plannedTickHz(static_cast(hz + 0.5f), duty), - PWM_SETTLE_CYCLES, params_.testTimeMs); - const bool ok = measurement_.start(hz, duty, params_.accuracyPct, params_.testTimeMs, PWM_SETTLE_CYCLES); + PWM_SETTLE_CYCLES, params_.testTimeMs, MEASUREMENT_AVERAGING_PERIODS); + const bool ok = measurement_.start(hz, duty, params_.accuracyPct, params_.testTimeMs, + MEASUREMENT_AVERAGING_PERIODS, PWM_SETTLE_CYCLES); Log::printf("MEASURE", "receiver start %s, RMT chunk=%u symbols", ok ? "OK" : "FAILED", receiver_.receiveChunkSymbols()); return ok; @@ -686,7 +684,14 @@ void App::finish(bool pass, FailReason reason, bool preserveDisplay) { armSlave(true); return; } - if (static_cast(settings_.role) == Role::SLAVE) slaveRearmAtMs_ = millis() + 2000; + // The result has already been acknowledged before a normal measurement + // failure reaches here. Re-arm ESP-NOW immediately so a quick retry from + // Master is not hidden behind the former two-second delay; preserve the + // failure screen while listening. + if (static_cast(settings_.role) == Role::SLAVE) { + armSlave(true); + return; + } if (preserveDisplay) return; char one[64]; if (pass) { diff --git a/OpticalChannelTester/Config.h b/OpticalChannelTester/Config.h index d3107f3..7655bba 100644 --- a/OpticalChannelTester/Config.h +++ b/OpticalChannelTester/Config.h @@ -104,6 +104,12 @@ constexpr uint16_t RMT_MAX_RECEIVE_SYMBOLS = 512; constexpr uint32_t RMT_TARGET_CHUNK_US = 5000; constexpr uint8_t RMT_QUEUE_BLOCKS = 8; constexpr uint16_t PERIOD_BATCH_SIZE = 128; +// Frequency and duty are validated only by their averages over this many +// complete periods. Individual tick variation is retained for diagnostics but +// is not itself a test failure. +constexpr uint16_t MEASUREMENT_AVERAGING_PERIODS = 100; +static_assert(MEASUREMENT_AVERAGING_PERIODS > 0, + "Averaging window must contain at least one period"); constexpr uint8_t MEASUREMENT_PROGRESS_STEPS = 10; constexpr uint32_t OLED_PROGRESS_UPDATE_MS = 15; @@ -149,6 +155,6 @@ constexpr uint32_t TEST_FREQUENCIES_HZ[] = { }; constexpr float ACCURACY_OPTIONS_PCT[] = {1.0f, 2.0f, 5.0f, 10.0f}; constexpr uint32_t TEST_TIME_OPTIONS_MS[] = {100, 250, 500, 1000, 2000, 5000}; -constexpr uint8_t DUTY_OPTIONS_PCT[] = {10, 25, 50, 75, 90}; +constexpr uint8_t TEST_DUTY_PCT = 50; template constexpr size_t countOf(const T (&)[N]) { return N; } diff --git a/OpticalChannelTester/Config_Text.h b/OpticalChannelTester/Config_Text.h index 3fbe1c7..370cfde 100644 --- a/OpticalChannelTester/Config_Text.h +++ b/OpticalChannelTester/Config_Text.h @@ -39,7 +39,6 @@ constexpr const char *MENU_START_FREQUENCY = "ЧАСТОТА ОТ:"; constexpr const char *MENU_END_FREQUENCY = "ЧАСТОТА ДО:"; constexpr const char *MENU_ACCURACY = "ТОЧНОСТЬ:"; constexpr const char *MENU_TEST_TIME = "ВРЕМЯ ВЫБОРКИ:"; -constexpr const char *MENU_PWM_DUTY = "ЗАПОЛНЕНИЕ:"; constexpr const char *MENU_TOTAL_TIME = "ОБЩЕЕ ВРЕМЯ:"; constexpr const char *FREQUENCY_UNIT = " Гц"; @@ -89,7 +88,6 @@ constexpr const char *MENU_START_FREQUENCY = "START FREQ:"; constexpr const char *MENU_END_FREQUENCY = "END FREQ:"; constexpr const char *MENU_ACCURACY = "ACCURACY:"; constexpr const char *MENU_TEST_TIME = "TEST TIME:"; -constexpr const char *MENU_PWM_DUTY = "PWM DUTY:"; constexpr const char *MENU_TOTAL_TIME = "TOTAL TIME:"; constexpr const char *FREQUENCY_UNIT = " Hz"; diff --git a/OpticalChannelTester/Core.cpp b/OpticalChannelTester/Core.cpp index 80e5417..fae1b46 100644 --- a/OpticalChannelTester/Core.cpp +++ b/OpticalChannelTester/Core.cpp @@ -89,7 +89,7 @@ bool dutyWithin(float measured, float expected, float tolerance) { } float effectiveTolerancePct(float configured) { - return configured > 0.0f && configured <= 1.0001f ? 1.25f : configured; + return configured; } uint8_t choosePwmResolution(uint32_t frequencyHz, uint32_t sourceClockHz, @@ -166,14 +166,17 @@ bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz, } FailReason validateResolution(uint32_t frequencyHz, float dutyPct, float accuracyPct, - uint32_t captureHz, uint8_t pwmBits) { - if (!frequencyHz || !captureHz || !pwmBits) return FailReason::RESOLUTION; + uint32_t captureHz, uint8_t pwmBits, + uint16_t averagingPeriods) { + if (!frequencyHz || !captureHz || !pwmBits || !averagingPeriods) + return FailReason::RESOLUTION; const float periodTicks = static_cast(captureHz) / frequencyHz; const float activeTicks = periodTicks * dutyPct / 100.0f; const float inactiveTicks = periodTicks - activeTicks; if (periodTicks < 4.0f || activeTicks < 2.0f || inactiveTicks < 2.0f) return FailReason::RESOLUTION; - const float timerPeriodError = 100.0f / periodTicks; - const float timerDutyError = 100.0f / periodTicks; + const float averagedTicks = periodTicks * averagingPeriods; + const float timerPeriodError = 100.0f / averagedTicks; + const float timerDutyError = 100.0f / averagedTicks; // Measurement uses the duty actually programmed into LEDC. A coarse PWM // step is not itself an error when the requested value (e.g. 50%) is exactly // representable; only the selected value's actual quantization matters. @@ -256,3 +259,60 @@ FailReason evaluatePeriodFast(const PulsePeriod &p, uint32_t tickHz, } return reason; } + +FailReason evaluatePeriodWindow(uint64_t periodSum, uint64_t activeSum, + uint32_t periodCount, uint32_t tickHz, + float expectedHz, float expectedDuty, + float tolerance, + uint32_t minPeriod, uint32_t maxPeriod, + uint8_t repeat, + StageStats &s) { + if (!periodSum || !periodCount || activeSum >= periodSum || !tickHz) + return FailReason::EXTRA_EDGE; + const float hz = static_cast( + static_cast(tickHz) * periodCount / periodSum); + const float duty = static_cast( + 100.0 * static_cast(activeSum) / periodSum); + bool frequencyOk = periodWithin(hz, expectedHz, tolerance); + if (!frequencyOk && maxPeriod == minPeriod + 1U) { + // At a tolerance boundary, alternating adjacent RMT counts prove that the + // result is quantization-limited. Accept only when a one-tick correction + // toward the expected value returns the averaged frequency into tolerance. + // Consecutive periods telescope into one first-to-last edge interval, so + // the whole window has a one-tick endpoint uncertainty, not one tick per + // period. + uint64_t correctedPeriodSum = periodSum; + if (hz > expectedHz) ++correctedPeriodSum; + else if (periodSum > 1U) --correctedPeriodSum; + const float correctedHz = static_cast( + static_cast(tickHz) * periodCount / correctedPeriodSum); + frequencyOk = periodWithin(correctedHz, expectedHz, tolerance); + } + + bool dutyOk = dutyWithin(duty, expectedDuty, tolerance); + if (!dutyOk) { + // Unlike full periods, active intervals do not telescope: every pulse is + // bounded by a different rising/falling edge pair. With slowly drifting + // asynchronous clocks an entire short window can therefore quantize to + // the same adjacent count (e.g. 41/80 for a true 50% duty). Apply one tick + // per active interval even when minActive == maxActive. + const bool dutyHigh = duty > expectedDuty; + const uint64_t correctedActive = dutyHigh + ? (activeSum > periodCount ? activeSum - periodCount : 0U) + : activeSum + periodCount; + const float correctedDuty = static_cast( + 100.0 * static_cast(correctedActive) / periodSum); + dutyOk = dutyWithin(correctedDuty, expectedDuty, tolerance); + } + + FailReason reason = !frequencyOk ? FailReason::PERIOD_OUT : + (!dutyOk ? FailReason::DUTY_OUT : FailReason::NONE); + if (reason != FailReason::NONE && s.reason == FailReason::NONE) { + s.reason = reason; + s.firstBadPeriod = s.periods >= periodCount ? s.periods - periodCount + 1U : 1U; + s.firstBadRepeat = repeat; + s.badFrequency = hz; + s.badDuty = duty; + } + return reason; +} diff --git a/OpticalChannelTester/Core.h b/OpticalChannelTester/Core.h index 38dbea8..37da304 100644 --- a/OpticalChannelTester/Core.h +++ b/OpticalChannelTester/Core.h @@ -19,7 +19,7 @@ struct Settings { uint8_t endIndex; uint8_t accuracyIndex; uint8_t timeIndex; - uint8_t dutyIndex; + uint8_t reserved; uint32_t checksum; }; @@ -83,7 +83,8 @@ bool chooseIntegerPwmConfig(uint32_t requestedHz, uint32_t sourceClockHz, uint8_t maxBits, uint8_t dutyPct, IntegerPwmConfig &config); FailReason validateResolution(uint32_t frequencyHz, float dutyPct, float accuracyPct, - uint32_t captureResolutionHz, uint8_t pwmBits); + uint32_t captureResolutionHz, uint8_t pwmBits, + uint16_t averagingPeriods); FailReason evaluatePeriod(const PulsePeriod &period, uint32_t tickHz, float expectedHz, float expectedDuty, float tolerancePct, uint8_t repeat, StageStats &stats); @@ -92,3 +93,10 @@ bool makePeriodLimits(uint32_t expectedHz, float expectedDuty, float tolerancePc FailReason evaluatePeriodFast(const PulsePeriod &period, uint32_t tickHz, const PeriodLimits &limits, uint8_t repeat, StageStats &stats); +FailReason evaluatePeriodWindow(uint64_t periodSum, uint64_t activeSum, + uint32_t periodCount, uint32_t tickHz, + float expectedHz, float expectedDuty, + float tolerancePct, + uint32_t minPeriod, uint32_t maxPeriod, + uint8_t repeat, + StageStats &stats); diff --git a/OpticalChannelTester/Measurement.cpp b/OpticalChannelTester/Measurement.cpp index 102a7c2..ca551eb 100644 --- a/OpticalChannelTester/Measurement.cpp +++ b/OpticalChannelTester/Measurement.cpp @@ -3,17 +3,17 @@ #include bool Measurement::start(float hz, float duty, float tolerance, uint32_t timeMs, - uint8_t settleCycles) { + uint16_t averagingPeriods, uint8_t settleCycles) { if (!task_ && xTaskCreate(taskEntry, "optical-rx", 4096, this, 4, &task_) != pdPASS) return false; expectedHz_ = static_cast(hz + 0.5f); expectedDutyPct_ = duty; tolerance = effectiveTolerancePct(tolerance); - if (!expectedHz_ || !timeMs || + if (!expectedHz_ || !timeMs || !averagingPeriods || !receiver_.start(expectedHz_, expectedDutyPct_)) return false; - if (!makePeriodLimits(expectedHz_, duty, tolerance, receiver_.tickHz(), limits_)) { - receiver_.stop(); return false; - } settleCycles_ = settleCycles; settleLeft_ = settleCycles; + tolerancePct_ = tolerance; + averagingPeriods_ = averagingPeriods; + resetAveragingWindow(); stepTimeMs_ = (timeMs + MEASUREMENT_PROGRESS_STEPS - 1U) / MEASUREMENT_PROGRESS_STEPS; stepTicks_ = static_cast(receiver_.tickHz()) * timeMs / (1000ULL * MEASUREMENT_PROGRESS_STEPS); @@ -30,6 +30,13 @@ bool Measurement::start(float hz, float duty, float tolerance, uint32_t timeMs, return true; } +void Measurement::resetAveragingWindow() { + windowPeriodCount_ = 0; + windowPeriodSum_ = windowActiveSum_ = 0; + windowMinPeriod_ = UINT32_MAX; + windowMaxPeriod_ = 0; +} + void Measurement::taskEntry(void *context) { static_cast(context)->taskLoop(); } @@ -51,6 +58,14 @@ void Measurement::completeMeasurement() { receiver_.stop(); stats_.droppedItems += receiver_.takeDroppedItems(); if (receiver_.overflowed()) { fail(FailReason::GLITCH); return; } + if (windowPeriodCount_) { + const FailReason result = evaluatePeriodWindow( + windowPeriodSum_, windowActiveSum_, windowPeriodCount_, receiver_.tickHz(), + expectedHz_, expectedDutyPct_, tolerancePct_, + windowMinPeriod_, windowMaxPeriod_, + currentStep_ + 1U, stats_); + if (result != FailReason::NONE) { fail(result); return; } + } publishStats(); if (++currentStep_ < MEASUREMENT_PROGRESS_STEPS) { state_ = MeasureState::STEP_READY; @@ -99,8 +114,32 @@ MeasureState Measurement::processOnce() { const uint64_t endTick = period.startTick + period.periodTicks; if (period.startTick < measurementStartTick_) continue; // leading incomplete period if (endTick > deadlineTick_) { completeMeasurement(); return state_; } // trailing incomplete period - const FailReason r = evaluatePeriodFast(period, receiver_.tickHz(), limits_, 1, stats_); - if (r != FailReason::NONE) { fail(r); return state_; } + if (!period.periodTicks || period.activeTicks >= period.periodTicks) { + fail(FailReason::EXTRA_EDGE); return state_; + } + + ++stats_.periods; + stats_.periodSum += period.periodTicks; + stats_.activeSum += period.activeTicks; + if (period.periodTicks < stats_.minPeriod) stats_.minPeriod = period.periodTicks; + if (period.periodTicks > stats_.maxPeriod) stats_.maxPeriod = period.periodTicks; + if (period.activeTicks < stats_.minActive) stats_.minActive = period.activeTicks; + if (period.activeTicks > stats_.maxActive) stats_.maxActive = period.activeTicks; + + if (period.periodTicks < windowMinPeriod_) windowMinPeriod_ = period.periodTicks; + if (period.periodTicks > windowMaxPeriod_) windowMaxPeriod_ = period.periodTicks; + ++windowPeriodCount_; + windowPeriodSum_ += period.periodTicks; + windowActiveSum_ += period.activeTicks; + if (windowPeriodCount_ >= averagingPeriods_) { + const FailReason result = evaluatePeriodWindow( + windowPeriodSum_, windowActiveSum_, windowPeriodCount_, receiver_.tickHz(), + expectedHz_, expectedDutyPct_, tolerancePct_, + windowMinPeriod_, windowMaxPeriod_, + currentStep_ + 1U, stats_); + resetAveragingWindow(); + if (result != FailReason::NONE) { fail(result); return state_; } + } } } if (receivedPeriod && state_ == MeasureState::RUNNING) lastPeriodMs_ = millis(); @@ -136,6 +175,9 @@ bool Measurement::continueAfterDisplay() { fail(FailReason::UNSUPPORTED); return false; } + // receiver_.start() starts a new RMT timebase and therefore a new sampling + // phase. Start a fresh averaging window for the new continuous capture. + resetAveragingWindow(); settleLeft_ = settleCycles_; measurementStartTick_ = deadlineTick_ = 0; startedMs_ = millis(); measurementStartMs_ = lastPeriodMs_ = 0; diff --git a/OpticalChannelTester/Measurement.h b/OpticalChannelTester/Measurement.h index 9426145..a17fb1e 100644 --- a/OpticalChannelTester/Measurement.h +++ b/OpticalChannelTester/Measurement.h @@ -7,7 +7,8 @@ class Measurement { public: explicit Measurement(PulseReceiver &receiver) : receiver_(receiver) {} bool start(float expectedHz, float expectedDuty, float tolerancePct, - uint32_t testTimeMs, uint8_t settleCycles); + uint32_t testTimeMs, uint16_t averagingPeriods, + uint8_t settleCycles); MeasureState update(); bool continueAfterDisplay(); void abort(); @@ -23,15 +24,19 @@ class Measurement { void fail(FailReason reason); void completeMeasurement(); void publishStats(); + void resetAveragingWindow(); PulseReceiver &receiver_; volatile MeasureState state_ = MeasureState::IDLE; TaskHandle_t task_ = nullptr; StageStats stats_ = {}; StageStats publishedStats_ = {}; mutable portMUX_TYPE statsMux_ = portMUX_INITIALIZER_UNLOCKED; - PeriodLimits limits_ = {}; uint32_t expectedHz_ = 0; - float expectedDutyPct_ = 0.0f; + float expectedDutyPct_ = 0.0f, tolerancePct_ = 0.0f; + uint16_t averagingPeriods_ = 1; + uint32_t windowPeriodCount_ = 0; + uint64_t windowPeriodSum_ = 0, windowActiveSum_ = 0; + uint32_t windowMinPeriod_ = UINT32_MAX, windowMaxPeriod_ = 0; uint8_t settleCycles_ = 0, settleLeft_ = 0; uint64_t measurementStartTick_ = 0, deadlineTick_ = 0, stepTicks_ = 0; uint32_t startedMs_ = 0, measurementStartMs_ = 0, lastPeriodMs_ = 0; diff --git a/OpticalChannelTester/SettingsStore.cpp b/OpticalChannelTester/SettingsStore.cpp index d45df0e..1c4e0ce 100644 --- a/OpticalChannelTester/SettingsStore.cpp +++ b/OpticalChannelTester/SettingsStore.cpp @@ -6,7 +6,7 @@ namespace { constexpr uint16_t SETTINGS_VERSION = 4; constexpr char NAMESPACE[] = "opt-test"; } void SettingsStore::defaults(Settings &s) const { - s = {SETTINGS_VERSION, static_cast(Role::SOLO), 0, 4, 2, 3, 2, 0}; + s = {SETTINGS_VERSION, static_cast(Role::SOLO), 0, 4, 2, 3, 0, 0}; s.checksum = settingsChecksum(s); } @@ -14,7 +14,7 @@ bool SettingsStore::valid(const Settings &s) const { return s.version == SETTINGS_VERSION && s.role <= static_cast(Role::SLAVE) && s.startIndex < countOf(START_FREQ_OPTIONS_HZ) && s.endIndex < countOf(END_FREQ_OPTIONS_HZ) && s.accuracyIndex < countOf(ACCURACY_OPTIONS_PCT) && - s.timeIndex < countOf(TEST_TIME_OPTIONS_MS) && s.dutyIndex < countOf(DUTY_OPTIONS_PCT) && + s.timeIndex < countOf(TEST_TIME_OPTIONS_MS) && s.checksum == settingsChecksum(s) && END_FREQ_OPTIONS_HZ[s.endIndex] > START_FREQ_OPTIONS_HZ[s.startIndex]; } @@ -41,5 +41,5 @@ bool SettingsStore::save(Settings &s) { TestParams SettingsStore::params(const Settings &s) const { return {START_FREQ_OPTIONS_HZ[s.startIndex], END_FREQ_OPTIONS_HZ[s.endIndex], ACCURACY_OPTIONS_PCT[s.accuracyIndex], TEST_TIME_OPTIONS_MS[s.timeIndex], - DUTY_OPTIONS_PCT[s.dutyIndex]}; + TEST_DUTY_PCT}; } diff --git a/README.md b/README.md index 43d9d97..be81e02 100644 --- a/README.md +++ b/README.md @@ -207,9 +207,11 @@ max(TEST_TIME, periods / RX_PROCESSING_PERIODS_PER_SECOND) 3. очищает статистику; 4. проверяет все полные периоды десяти участков TEST TIME и между ними показывает промежуточный результат. -Настройка точности `1%` использует фактический допуск `1,25%`, соответствующий одному такту RMT 80 МГц на частоте сигнала 1 МГц. +Все тесты формируют PWM с фиксированным duty `TEST_DUTY_PCT = 50%`. Настройки duty в меню нет, но фактически подаваемое значение по-прежнему отображается во время теста и передаётся между MASTER и SLAVE. -Незавершённый период в начале и конце окна не учитывается. Период, пересекающий границу повторов, не теряется. Для каждого периода отдельно вычисляются частота и duty; средние используются только для диагностики. Любой один выход за допуск немедленно завершает всю проверку. +Частота и duty проверяются по среднему за `MEASUREMENT_AVERAGING_PERIODS` полных периодов (по умолчанию 100). Ошибка возникает, если среднее окна устойчиво выходит за выбранный допуск 1%, 2%, 5% или 10%. Для частоты последовательные периоды образуют один интервал от первого до последнего одинакового фронта, поэтому квантовочная неопределённость составляет один такт на всё окно и применяется только при наличии соседних значений RMT. Активные интервалы duty ограничены отдельной парой разных фронтов в каждом периоде, поэтому для duty допускается один такт активной длительности на каждый период даже тогда, когда всё короткое окно квантовалось в одно значение. Например, 41/80 при ожидаемых 50% объясняется одним тактом, а 42/80 при точности 1% уже завершается ошибкой. + +Незавершённый период в начале и конце временного участка не учитывается. Перед перезапуском RMT между десятью участками незавершённое окно усреднения сбрасывается: новая фаза квантования не сравнивается с предыдущей. Общая статистика и итоговое среднее по всем принятым полным периодам этапа сохраняются. Причины: `NO SIGNAL`, `PERIOD OUT`, `DUTY OUT`, `EXTRA EDGE`, `GLITCH`, `LOST EDGE`, `DATA LOSS ERROR`, `LINK LOST`, `UNSUPPORTED`, `RESOLUTION`, `ABORTED`.