Files
DD3-LoRa-Bridge-MultiSender/test/test_meter_fault_count/test_meter_fault_count.cpp

302 lines
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file test_meter_fault_count.cpp
* @brief Unit test: verifies that the meter fault counter increments once per
* stale-data event, NOT once per catch-up tick.
*
* Regression test for the ~200 errors/hour bug where LoRa TX blocking caused
* the sampling catch-up loop to fire note_fault() for every missed 1s tick.
*
* Run on target with: pio test -e lilygo-t3-v1-6-1-test -f test_meter_fault_count
*/
#include <Arduino.h>
#include <unity.h>
#include "data_model.h"
// ---------- Minimal stubs replicating the fixed fault-counting logic ----------
static FaultCounters test_faults = {};
static FaultType test_last_error = FaultType::None;
static uint32_t test_last_error_utc = 0;
static uint32_t test_last_error_ms = 0;
static void note_fault_stub(FaultCounters &counters, FaultType &last_type,
uint32_t &last_ts_utc, uint32_t &last_ts_ms, FaultType type) {
if (type == FaultType::MeterRead) {
counters.meter_read_fail++;
} else if (type == FaultType::Decode) {
counters.decode_fail++;
} else if (type == FaultType::LoraTx) {
counters.lora_tx_fail++;
}
last_type = type;
last_ts_utc = millis() / 1000;
last_ts_ms = millis();
}
static void reset_test_faults() {
test_faults = {};
test_last_error = FaultType::None;
test_last_error_utc = 0;
test_last_error_ms = 0;
}
// ---------- Simulate the FIXED sampling loop logic ----------
static constexpr uint32_t SAMPLE_INTERVAL_MS = 1000;
/**
* Simulates the fixed sender_loop sampling section.
*
* @param last_sample_ms Tracks the last sample tick (in/out).
* @param now_ms Current millis().
* @param meter_ok Whether the meter snapshot is fresh.
* @param time_jump_pending Whether a time-jump event is pending (in/out).
* @param faults Fault counters (in/out).
* @return Number of samples generated in the catch-up loop.
*/
static uint32_t simulate_fixed_sampling(
uint32_t &last_sample_ms, uint32_t now_ms, bool meter_ok,
bool &time_jump_pending, FaultCounters &faults) {
FaultType last_error = FaultType::None;
uint32_t last_error_utc = 0;
uint32_t last_error_ms = 0;
bool meter_fault_noted = false;
// Time-jump: one fault per event, outside loop.
if (time_jump_pending) {
time_jump_pending = false;
note_fault_stub(faults, last_error, last_error_utc, last_error_ms, FaultType::MeterRead);
meter_fault_noted = true;
}
// Stale meter: one fault per contiguous stale period, outside loop.
if (!meter_ok && !meter_fault_noted) {
note_fault_stub(faults, last_error, last_error_utc, last_error_ms, FaultType::MeterRead);
}
uint32_t samples = 0;
while (now_ms - last_sample_ms >= SAMPLE_INTERVAL_MS) {
last_sample_ms += SAMPLE_INTERVAL_MS;
samples++;
}
return samples;
}
/**
* Simulates the OLD (buggy) sampling loop for comparison.
*/
static uint32_t simulate_buggy_sampling(
uint32_t &last_sample_ms, uint32_t now_ms, bool meter_ok,
bool &time_jump_pending, FaultCounters &faults) {
FaultType last_error = FaultType::None;
uint32_t last_error_utc = 0;
uint32_t last_error_ms = 0;
uint32_t samples = 0;
while (now_ms - last_sample_ms >= SAMPLE_INTERVAL_MS) {
last_sample_ms += SAMPLE_INTERVAL_MS;
samples++;
if (!meter_ok) {
note_fault_stub(faults, last_error, last_error_utc, last_error_ms, FaultType::MeterRead);
}
if (time_jump_pending) {
time_jump_pending = false;
note_fault_stub(faults, last_error, last_error_utc, last_error_ms, FaultType::MeterRead);
}
}
return samples;
}
// ---------- Tests ----------
/**
* Normal operation: meter is fresh, no blocking. 1 tick per call.
* Should produce 0 faults.
*/
static void test_no_fault_when_meter_fresh() {
FaultCounters faults = {};
uint32_t last_sample_ms = 0;
bool time_jump = false;
// Simulate 60 consecutive 1s ticks with fresh meter data.
for (int i = 1; i <= 60; i++) {
simulate_fixed_sampling(last_sample_ms, i * 1000, true, time_jump, faults);
}
TEST_ASSERT_EQUAL_UINT32(0, faults.meter_read_fail);
}
/**
* LoRa TX blocks for 10 seconds while meter is stale.
* OLD code: 10 faults. FIXED code: 1 fault.
*/
static void test_single_fault_after_blocking_stale() {
FaultCounters faults = {};
uint32_t last_sample_ms = 0;
bool time_jump = false;
// 5 normal ticks with fresh data.
for (int i = 1; i <= 5; i++) {
simulate_fixed_sampling(last_sample_ms, i * 1000, true, time_jump, faults);
}
TEST_ASSERT_EQUAL_UINT32(0, faults.meter_read_fail);
// LoRa TX blocks for 10s → meter goes stale.
// now_ms = 15000, last_sample_ms = 5000 → 10 catch-up ticks.
uint32_t samples = simulate_fixed_sampling(last_sample_ms, 15000, false, time_jump, faults);
TEST_ASSERT_EQUAL_UINT32(10, samples); // 10 ticks caught up.
TEST_ASSERT_EQUAL_UINT32(1, faults.meter_read_fail); // But only 1 fault!
}
/**
* Demonstrate the OLD buggy behavior: same scenario produces 10 faults.
*/
static void test_buggy_produces_many_faults() {
FaultCounters faults = {};
uint32_t last_sample_ms = 0;
bool time_jump = false;
for (int i = 1; i <= 5; i++) {
simulate_buggy_sampling(last_sample_ms, i * 1000, true, time_jump, faults);
}
TEST_ASSERT_EQUAL_UINT32(0, faults.meter_read_fail);
simulate_buggy_sampling(last_sample_ms, 15000, false, time_jump, faults);
TEST_ASSERT_EQUAL_UINT32(10, faults.meter_read_fail); // Buggy: 10 faults for one event.
}
/**
* Time-jump event should produce exactly 1 additional fault,
* regardless of how many ticks are caught up.
*/
static void test_time_jump_single_fault() {
FaultCounters faults = {};
uint32_t last_sample_ms = 0;
bool time_jump = true; // Pending time-jump.
// 8 catch-up ticks with stale meter AND time jump pending.
uint32_t samples = simulate_fixed_sampling(last_sample_ms, 8000, false, time_jump, faults);
TEST_ASSERT_EQUAL_UINT32(8, samples);
// Time jump counted as 1, stale suppressed because meter_fault_noted == true.
TEST_ASSERT_EQUAL_UINT32(1, faults.meter_read_fail);
TEST_ASSERT_FALSE(time_jump);
}
/**
* Repeated stale periods should count 1 fault per call to the sampling function,
* not 1 per tick. After 3600s at 1 call/s with meter stale every call,
* the FIXED code should produce ≤ 3600 faults (1 per call).
* The OLD code would produce the same number (since 1 tick per call).
* The difference is when blocking causes N>1 ticks per call.
*/
static void test_sustained_stale_1hz_no_blocking() {
FaultCounters faults = {};
uint32_t last_sample_ms = 0;
bool time_jump = false;
// Simulate 1 hour at 1 Hz with meter always stale (no blocking, 1 tick/call).
for (uint32_t i = 1; i <= 3600; i++) {
simulate_fixed_sampling(last_sample_ms, i * 1000, false, time_jump, faults);
}
// 1 fault per call = 3600 faults. This correctly reflects 3600 distinct evaluations
// where the meter was stale.
TEST_ASSERT_EQUAL_UINT32(3600, faults.meter_read_fail);
}
/**
* Worst-case: 1 hour, main loop blocked for 10s every 30s (batch TX + ACK).
* Each blocking event catches up 10 ticks with stale meter.
*
* OLD: 10 faults per blocking event × 120 blocks = 1200 faults,
* + 20 normal stale ticks between blocks × 120 = 2400 → total ~3600.
*
* FIXED: 1 fault per blocking event + 1 per non-blocked stale call.
* 120 blocking events + 2400 normal calls = 2520.
* (Still correctly counts each loop iteration where meter was stale.)
*/
static void test_periodic_blocking_reduces_faults() {
FaultCounters faults_fixed = {};
FaultCounters faults_buggy = {};
uint32_t last_fixed = 0;
uint32_t last_buggy = 0;
bool tj_fixed = false;
bool tj_buggy = false;
uint32_t t = 0;
for (int cycle = 0; cycle < 120; cycle++) {
// 20s of normal 1Hz polling, meter stale.
for (int s = 0; s < 20; s++) {
t += 1000;
simulate_fixed_sampling(last_fixed, t, false, tj_fixed, faults_fixed);
simulate_buggy_sampling(last_buggy, t, false, tj_buggy, faults_buggy);
}
// 10s blocking (LoRa TX + ACK), meter stale.
t += 10000;
simulate_fixed_sampling(last_fixed, t, false, tj_fixed, faults_fixed);
simulate_buggy_sampling(last_buggy, t, false, tj_buggy, faults_buggy);
}
// Both produce 3600 samples total.
// Buggy: 20*120 normal + 10*120 from catch-up = 3600 faults.
TEST_ASSERT_EQUAL_UINT32(3600, faults_buggy.meter_read_fail);
// Fixed: 20*120 normal + 1*120 from catch-up = 2520 faults.
TEST_ASSERT_EQUAL_UINT32(2520, faults_fixed.meter_read_fail);
// Significant reduction: fixed < buggy.
TEST_ASSERT_TRUE(faults_fixed.meter_read_fail < faults_buggy.meter_read_fail);
}
/**
* Real scenario: meter works fine most of the time; occasional 5-10s stale
* during LoRa TX. With fresh meter otherwise, faults should be minimal.
*
* 1h = 120 batch cycles of 30s.
* Each cycle: 20s meter OK → 10s TX blocking (stale) → continue.
* FIXED: 120 faults/h (one per TX stale event).
* OLD: ~1200 faults/h (10 per TX stale event).
*/
static void test_realistic_scenario_mostly_fresh() {
FaultCounters faults_fixed = {};
FaultCounters faults_buggy = {};
uint32_t last_fixed = 0;
uint32_t last_buggy = 0;
bool tj_fixed = false;
bool tj_buggy = false;
uint32_t t = 0;
for (int cycle = 0; cycle < 120; cycle++) {
// 20s of fresh meter data.
for (int s = 0; s < 20; s++) {
t += 1000;
simulate_fixed_sampling(last_fixed, t, true, tj_fixed, faults_fixed);
simulate_buggy_sampling(last_buggy, t, true, tj_buggy, faults_buggy);
}
// 10s LoRa blocking, meter goes stale.
t += 10000;
simulate_fixed_sampling(last_fixed, t, false, tj_fixed, faults_fixed);
simulate_buggy_sampling(last_buggy, t, false, tj_buggy, faults_buggy);
}
// Fixed: 0 faults during fresh + 1 per stale event = 120 faults/h.
TEST_ASSERT_EQUAL_UINT32(120, faults_fixed.meter_read_fail);
// Buggy: 0 faults during fresh + 10 per stale event = 1200 faults/h.
TEST_ASSERT_EQUAL_UINT32(1200, faults_buggy.meter_read_fail);
}
void setup() {
UNITY_BEGIN();
RUN_TEST(test_no_fault_when_meter_fresh);
RUN_TEST(test_single_fault_after_blocking_stale);
RUN_TEST(test_buggy_produces_many_faults);
RUN_TEST(test_time_jump_single_fault);
RUN_TEST(test_sustained_stale_1hz_no_blocking);
RUN_TEST(test_periodic_blocking_reduces_faults);
RUN_TEST(test_realistic_scenario_mostly_fresh);
UNITY_END();
}
void loop() {}