/* * test_battery_coulomb.c — Battery coulomb counter unit tests (Issue #325) * * Verifies: * - coulomb_init() initialization and state reset * - coulomb_tick() integration accuracy with simulated INA219 readings * - SOC calculation (0–100%) against known discharge profiles * - Flash read/write operations with magic checksum validation * - Cycle tracking and depth-of-discharge (DOD) computation * - Calibration offset/scale application and persistence * - Charging vs. discharging state detection * - Boundary conditions and saturation */ #include #include #include #include #include /* ══════════════════════════════════════════════════════════════════════════ */ /* BATTERY COULOMB COUNTER CONSTANTS (from header) */ /* ══════════════════════════════════════════════════════════════════════════ */ #define BATTERY_CAPACITY_MAH 2200 #define BATTERY_CAPACITY_Q (BATTERY_CAPACITY_MAH / 3.6f) /* ~611 coulombs */ #define INA219_I2C_ADDR 0x40 #define INA219_SHUNT_OHM 0.01f #define INA219_MAX_CURRENT_A 3.2f #define COULOMB_SAMPLE_RATE_HZ 1000 #define COULOMB_INTEGRATION_S 1.0f #define COULOMB_FLASH_ADDR 0x0800D000 #define COULOMB_FLASH_SIZE 4096 /* ══════════════════════════════════════════════════════════════════════════ */ /* DATA STRUCTURES (from header) */ /* ══════════════════════════════════════════════════════════════════════════ */ typedef struct { float current_a; float coulombs_total; float coulombs_discharged; float soc_percent; uint32_t tick_count; bool charging; uint16_t cycle_count; } coulomb_state_t; typedef struct { uint32_t magic; float current_offset_a; float current_scale; uint32_t total_cycles; uint32_t timestamp; uint16_t reserved[6]; } coulomb_calibration_t; typedef struct { float coulombs_capacity; float depth_of_discharge; uint32_t duration_s; float avg_current_a; } coulomb_cycle_t; /* ══════════════════════════════════════════════════════════════════════════ */ /* SIMULATOR & MOCKS */ /* ══════════════════════════════════════════════════════════════════════════ */ /* Mock INA219 readings (raw ADC values) */ static int16_t mock_ina219_current_raw = 0; /* Mock flash storage (simulated 4KB sector) */ static uint8_t mock_flash[COULOMB_FLASH_SIZE]; /* Simulated coulomb counter state */ static coulomb_state_t g_coulomb_state = {0}; static coulomb_calibration_t g_calibration = {0}; #define COULOMB_CYCLE_HISTORY_SIZE 16 static coulomb_cycle_t g_cycle_history[COULOMB_CYCLE_HISTORY_SIZE]; static uint8_t g_cycle_write_idx = 0; /* Runtime simulation variables */ static float g_battery_capacity_q = BATTERY_CAPACITY_Q; /* ──────────────────────────────────────────────────────────────────────────*/ /* Mock I2C / INA219 Functions */ /* ──────────────────────────────────────────────────────────────────────────*/ static int mock_i2c_write_reg(uint8_t addr, uint8_t reg, uint16_t value) { /* Simulate INA219 register write */ if (addr != INA219_I2C_ADDR) return -1; return 0; } static int mock_i2c_read_reg(uint8_t addr, uint8_t reg, uint16_t *out_value) { /* Simulate INA219 current register read */ if (addr != INA219_I2C_ADDR) return -1; *out_value = mock_ina219_current_raw; return 0; } static int mock_ina219_init(void) { /* Simulate INA219 config via I2C */ mock_i2c_write_reg(INA219_I2C_ADDR, 0x00, 0x399F); /* Config register */ mock_i2c_write_reg(INA219_I2C_ADDR, 0x05, 0x0800); /* Calibration */ return 0; } static int16_t mock_ina219_read_current_raw(void) { uint16_t raw = 0; mock_i2c_read_reg(INA219_I2C_ADDR, 0x01, &raw); return (int16_t)raw; } /* ──────────────────────────────────────────────────────────────────────────*/ /* Mock Flash Functions */ /* ──────────────────────────────────────────────────────────────────────────*/ static int mock_flash_write(uint32_t addr, const void *data, uint16_t len) { if (addr < COULOMB_FLASH_ADDR || (addr + len) > (COULOMB_FLASH_ADDR + COULOMB_FLASH_SIZE)) { return -1; } uint32_t offset = addr - COULOMB_FLASH_ADDR; memcpy(&mock_flash[offset], data, len); return 0; } static int mock_flash_read(uint32_t addr, void *data, uint16_t len) { if (addr < COULOMB_FLASH_ADDR || (addr + len) > (COULOMB_FLASH_ADDR + COULOMB_FLASH_SIZE)) { return -1; } uint32_t offset = addr - COULOMB_FLASH_ADDR; memcpy(data, &mock_flash[offset], len); return 0; } static int mock_flash_erase_sector(uint32_t addr) { if (addr < COULOMB_FLASH_ADDR || addr >= (COULOMB_FLASH_ADDR + COULOMB_FLASH_SIZE)) { return -1; } memset(mock_flash, 0xFF, COULOMB_FLASH_SIZE); return 0; } /* ══════════════════════════════════════════════════════════════════════════ */ /* COULOMB COUNTER IMPLEMENTATION (for testing) */ /* ══════════════════════════════════════════════════════════════════════════ */ static int coulomb_init_test(uint16_t capacity_mah) { g_battery_capacity_q = capacity_mah / 3.6f; memset(&g_coulomb_state, 0, sizeof(g_coulomb_state)); memset(&g_calibration, 0, sizeof(g_calibration)); memset(g_cycle_history, 0, sizeof(g_cycle_history)); g_cycle_write_idx = 0; if (mock_ina219_init() != 0) return -1; /* Load calibration from mock flash */ if (mock_flash_read(COULOMB_FLASH_ADDR, &g_calibration, sizeof(g_calibration)) == 0) { if (g_calibration.magic != 0xCAFEBABE) { /* No valid calibration, use defaults */ g_calibration.magic = 0xCAFEBABE; g_calibration.current_offset_a = 0.0f; g_calibration.current_scale = 1.0f; g_calibration.total_cycles = 0; } } g_coulomb_state.soc_percent = 100.0f; return 0; } static void coulomb_tick_test(void) { int16_t raw = mock_ina219_read_current_raw(); float current_a = (raw * 0.0001f) + g_calibration.current_offset_a; current_a *= g_calibration.current_scale; g_coulomb_state.current_a = current_a; g_coulomb_state.charging = (current_a < 0.0f); /* Integration: dt = 1ms */ float dt_s = 1.0f / COULOMB_SAMPLE_RATE_HZ; float coulombs_delta = current_a * dt_s; g_coulomb_state.coulombs_total += coulombs_delta; /* Discharging threshold: 50mA */ if (current_a > 0.05f) { g_coulomb_state.coulombs_discharged += coulombs_delta; } /* SOC calculation */ g_coulomb_state.soc_percent = 100.0f * (1.0f - (g_coulomb_state.coulombs_discharged / g_battery_capacity_q)); if (g_coulomb_state.soc_percent < 0.0f) g_coulomb_state.soc_percent = 0.0f; if (g_coulomb_state.soc_percent > 100.0f) g_coulomb_state.soc_percent = 100.0f; g_coulomb_state.tick_count++; } static float coulomb_get_soc_test(void) { return g_coulomb_state.soc_percent; } static float coulomb_get_current_a_test(void) { return g_coulomb_state.current_a; } static float coulomb_get_coulombs_discharged_test(void) { return g_coulomb_state.coulombs_discharged; } static uint16_t coulomb_get_cycle_count_test(void) { return g_coulomb_state.cycle_count; } static void coulomb_mark_full_charge_test(void) { g_coulomb_state.coulombs_discharged = 0.0f; g_coulomb_state.soc_percent = 100.0f; } static void coulomb_mark_fully_discharged_test(void) { g_coulomb_state.coulombs_discharged = g_battery_capacity_q; g_coulomb_state.soc_percent = 0.0f; g_coulomb_state.cycle_count++; } static void coulomb_set_soc_test(float soc_percent) { if (soc_percent < 0.0f) soc_percent = 0.0f; if (soc_percent > 100.0f) soc_percent = 100.0f; g_coulomb_state.soc_percent = soc_percent; g_coulomb_state.coulombs_discharged = g_battery_capacity_q * (1.0f - (soc_percent / 100.0f)); } static int coulomb_save_calibration_test(void) { g_calibration.magic = 0xCAFEBABE; return mock_flash_write(COULOMB_FLASH_ADDR, &g_calibration, sizeof(g_calibration)); } static int coulomb_load_calibration_test(void) { coulomb_calibration_t tmp; if (mock_flash_read(COULOMB_FLASH_ADDR, &tmp, sizeof(tmp)) != 0) return -1; if (tmp.magic != 0xCAFEBABE) return -1; g_calibration = tmp; return 0; } static int coulomb_factory_reset_test(void) { mock_flash_erase_sector(COULOMB_FLASH_ADDR); memset(&g_coulomb_state, 0, sizeof(g_coulomb_state)); memset(&g_calibration, 0, sizeof(g_calibration)); g_coulomb_state.soc_percent = 100.0f; return 0; } /* ══════════════════════════════════════════════════════════════════════════ */ /* UNIT TEST FRAMEWORK */ /* ══════════════════════════════════════════════════════════════════════════ */ static int test_count = 0, test_passed = 0, test_failed = 0; #define TEST(name) do { test_count++; printf("\n TEST %d: %s\n", test_count, name); } while(0) #define ASSERT(cond, msg) do { \ if (cond) { \ test_passed++; \ printf(" ✓ %s\n", msg); \ } else { \ test_failed++; \ printf(" ✗ %s\n", msg); \ } \ } while(0) #define ASSERT_FLOAT(val, expected, tol, msg) do { \ float diff = (val) - (expected); \ if (diff < 0) diff = -diff; \ if (diff <= (tol)) { \ test_passed++; \ printf(" ✓ %s (%.4f ≈ %.4f)\n", msg, val, expected); \ } else { \ test_failed++; \ printf(" ✗ %s (%.4f ≠ %.4f, diff=%.4f)\n", msg, val, expected, diff); \ } \ } while(0) /* ══════════════════════════════════════════════════════════════════════════ */ /* TEST CASES */ /* ══════════════════════════════════════════════════════════════════════════ */ void test_initialization(void) { TEST("Initialization and state reset"); coulomb_init_test(2200); ASSERT(g_coulomb_state.current_a == 0.0f, "Current starts at 0A"); ASSERT(g_coulomb_state.coulombs_total == 0.0f, "Total coulombs starts at 0"); ASSERT(g_coulomb_state.coulombs_discharged == 0.0f, "Discharged coulombs starts at 0"); ASSERT_FLOAT(g_coulomb_state.soc_percent, 100.0f, 0.1f, "SOC starts at 100%"); ASSERT(g_coulomb_state.tick_count == 0, "Tick count starts at 0"); ASSERT(g_coulomb_state.charging == false, "Not charging initially"); ASSERT(g_coulomb_state.cycle_count == 0, "Cycle count starts at 0"); } void test_coulomb_tick_zero_current(void) { TEST("coulomb_tick() with zero current"); coulomb_init_test(2200); mock_ina219_current_raw = 0; /* Simulate 100 ticks (100 ms) with no current */ for (int i = 0; i < 100; i++) { coulomb_tick_test(); } ASSERT_FLOAT(coulomb_get_coulombs_discharged_test(), 0.0f, 0.001f, "No coulombs discharged"); ASSERT_FLOAT(coulomb_get_soc_test(), 100.0f, 0.1f, "SOC remains 100%"); ASSERT(g_coulomb_state.tick_count == 100, "Tick count incremented"); } void test_coulomb_tick_constant_discharge(void) { TEST("coulomb_tick() with constant 1A discharge"); coulomb_init_test(2200); /* 1A = 10000 raw counts (at 0.0001 scale) */ mock_ina219_current_raw = 10000; /* Simulate 3600 ticks (3.6 seconds, at 1kHz = 3.6 coulombs at 1A) */ for (int i = 0; i < 3600; i++) { coulomb_tick_test(); } float discharged = coulomb_get_coulombs_discharged_test(); ASSERT_FLOAT(discharged, 3.6f, 0.1f, "1A for 3.6s = ~3.6 coulombs"); float capacity_q = 2200.0f / 3.6f; /* ~611 coulombs */ float expected_soc = 100.0f * (1.0f - (3.6f / capacity_q)); ASSERT_FLOAT(coulomb_get_soc_test(), expected_soc, 0.5f, "SOC decreases proportionally"); } void test_charging_detection(void) { TEST("Charging vs. discharging detection"); coulomb_init_test(2200); /* Negative current (charging): -1A = -10000 raw */ mock_ina219_current_raw = -10000; coulomb_tick_test(); ASSERT(g_coulomb_state.charging == true, "Negative current = charging"); /* Positive current (discharging): +1A = +10000 raw */ mock_ina219_current_raw = 10000; coulomb_tick_test(); ASSERT(g_coulomb_state.charging == false, "Positive current = discharging"); } void test_soc_calculation_accuracy(void) { TEST("SOC calculation accuracy"); coulomb_init_test(2200); float capacity_q = BATTERY_CAPACITY_Q; /* ~611 coulombs */ /* Full discharge: 611 coulombs */ mock_ina219_current_raw = 10000; /* 1A */ for (int i = 0; i < 611 * 3600; i++) { coulomb_tick_test(); } ASSERT_FLOAT(coulomb_get_soc_test(), 0.0f, 1.0f, "Full discharge = 0% SOC"); ASSERT(coulomb_get_soc_test() >= 0.0f && coulomb_get_soc_test() <= 100.0f, "SOC within bounds"); } void test_soc_clamping(void) { TEST("SOC clamping to 0–100%"); coulomb_init_test(2200); /* Manually set excessive discharge to test clamping */ g_coulomb_state.coulombs_discharged = BATTERY_CAPACITY_Q + 100.0f; coulomb_tick_test(); ASSERT(coulomb_get_soc_test() <= 0.0f, "SOC clamped to ≤0%"); /* Manually set negative discharge */ g_coulomb_state.coulombs_discharged = -100.0f; coulomb_tick_test(); ASSERT(coulomb_get_soc_test() >= 100.0f, "SOC clamped to ≥100%"); } void test_set_soc(void) { TEST("Manual SOC adjustment (coulomb_set_soc)"); coulomb_init_test(2200); coulomb_set_soc_test(50.0f); ASSERT_FLOAT(coulomb_get_soc_test(), 50.0f, 0.1f, "SOC set to 50%"); ASSERT_FLOAT(coulomb_get_coulombs_discharged_test(), BATTERY_CAPACITY_Q * 0.5f, 1.0f, "Coulombs adjusted"); coulomb_set_soc_test(0.0f); ASSERT_FLOAT(coulomb_get_soc_test(), 0.0f, 0.1f, "SOC set to 0%"); coulomb_set_soc_test(100.0f); ASSERT_FLOAT(coulomb_get_soc_test(), 100.0f, 0.1f, "SOC set to 100%"); } void test_mark_full_charge(void) { TEST("Mark battery as fully charged"); coulomb_init_test(2200); /* Simulate partial discharge */ coulomb_set_soc_test(50.0f); ASSERT_FLOAT(coulomb_get_soc_test(), 50.0f, 0.1f, "Initial SOC = 50%"); /* Mark as full */ coulomb_mark_full_charge_test(); ASSERT_FLOAT(coulomb_get_soc_test(), 100.0f, 0.1f, "SOC = 100% after mark_full"); ASSERT_FLOAT(coulomb_get_coulombs_discharged_test(), 0.0f, 0.1f, "Discharged coulombs reset"); } void test_mark_fully_discharged(void) { TEST("Mark battery as fully discharged"); coulomb_init_test(2200); coulomb_set_soc_test(100.0f); uint16_t cycle_before = coulomb_get_cycle_count_test(); coulomb_mark_fully_discharged_test(); ASSERT_FLOAT(coulomb_get_soc_test(), 0.0f, 0.1f, "SOC = 0% after mark_discharged"); ASSERT(coulomb_get_cycle_count_test() == (cycle_before + 1), "Cycle count incremented"); } void test_flash_calibration_write_read(void) { TEST("Flash calibration write and read"); coulomb_init_test(2200); g_calibration.magic = 0xCAFEBABE; g_calibration.current_offset_a = 0.05f; g_calibration.current_scale = 1.02f; g_calibration.total_cycles = 42; ASSERT(coulomb_save_calibration_test() == 0, "Calibration saved to flash"); /* Clear runtime calibration */ memset(&g_calibration, 0, sizeof(g_calibration)); /* Reload from flash */ ASSERT(coulomb_load_calibration_test() == 0, "Calibration loaded from flash"); ASSERT_FLOAT(g_calibration.current_offset_a, 0.05f, 0.001f, "Offset preserved"); ASSERT_FLOAT(g_calibration.current_scale, 1.02f, 0.001f, "Scale preserved"); ASSERT(g_calibration.total_cycles == 42, "Cycle count preserved"); } void test_flash_magic_validation(void) { TEST("Flash calibration magic checksum validation"); coulomb_factory_reset_test(); /* Write invalid magic to flash */ coulomb_calibration_t bad_cal; bad_cal.magic = 0xDEADBEEF; /* Invalid magic */ bad_cal.current_offset_a = 0.1f; mock_flash_write(COULOMB_FLASH_ADDR, &bad_cal, sizeof(bad_cal)); /* Try to load - should fail */ int result = coulomb_load_calibration_test(); ASSERT(result != 0, "Invalid magic rejected"); } void test_calibration_offset_application(void) { TEST("Current offset calibration application"); coulomb_init_test(2200); /* Set calibration offset */ g_calibration.current_offset_a = 0.1f; /* +100mA offset */ g_calibration.current_scale = 1.0f; /* Read with offset: raw 0 + 0.1A offset = 0.1A actual */ mock_ina219_current_raw = 0; coulomb_tick_test(); ASSERT_FLOAT(coulomb_get_current_a_test(), 0.1f, 0.001f, "Offset applied to current"); } void test_calibration_scale_application(void) { TEST("Current scale calibration application"); coulomb_init_test(2200); g_calibration.current_offset_a = 0.0f; g_calibration.current_scale = 1.1f; /* +10% scale */ /* Raw 1A with 1.1x scale = 1.1A */ mock_ina219_current_raw = 10000; /* 1A */ coulomb_tick_test(); ASSERT_FLOAT(coulomb_get_current_a_test(), 1.1f, 0.01f, "Scale applied to current"); } void test_factory_reset(void) { TEST("Factory reset clears all state"); coulomb_init_test(2200); /* Corrupt state */ g_coulomb_state.coulombs_discharged = 500.0f; g_coulomb_state.cycle_count = 42; g_calibration.current_offset_a = 0.5f; coulomb_save_calibration_test(); /* Reset */ coulomb_factory_reset_test(); ASSERT(g_coulomb_state.coulombs_discharged == 0.0f, "Coulombs reset"); ASSERT(g_coulomb_state.cycle_count == 0, "Cycle count reset"); ASSERT_FLOAT(g_coulomb_state.soc_percent, 100.0f, 0.1f, "SOC reset to 100%"); /* Verify flash erased */ coulomb_calibration_t tmp; mock_flash_read(COULOMB_FLASH_ADDR, &tmp, sizeof(tmp)); ASSERT(tmp.magic != 0xCAFEBABE, "Flash sector erased"); } void test_capacity_variant(void) { TEST("Different battery capacities"); coulomb_init_test(3000); /* 3000 mAh variant */ float capacity_q = 3000.0f / 3.6f; /* ~833 coulombs */ /* Discharge 1 coulomb and check SOC */ mock_ina219_current_raw = 10000; /* 1A */ for (int i = 0; i < 3600; i++) { coulomb_tick_test(); } float expected_soc = 100.0f * (1.0f - (1.0f / capacity_q)); ASSERT_FLOAT(coulomb_get_soc_test(), expected_soc, 0.5f, "Capacity affects SOC calculation"); } void test_low_current_threshold(void) { TEST("Low current threshold (50mA) for discharge detection"); coulomb_init_test(2200); /* Current just below 50mA threshold (25mA) - should NOT count toward discharge */ mock_ina219_current_raw = 250; /* 0.025A = 25mA */ for (int i = 0; i < 1000; i++) { coulomb_tick_test(); } float discharged_below = coulomb_get_coulombs_discharged_test(); ASSERT_FLOAT(discharged_below, 0.0f, 0.001f, "Below 50mA not counted as discharge"); /* Reset and test above threshold */ coulomb_init_test(2200); mock_ina219_current_raw = 600; /* 0.06A = 60mA, above threshold */ for (int i = 0; i < 1000; i++) { coulomb_tick_test(); } float discharged_above = coulomb_get_coulombs_discharged_test(); ASSERT_FLOAT(discharged_above, 0.06f, 0.01f, "Above 50mA counted as discharge"); } void test_integration_over_long_duration(void) { TEST("Integration accuracy over extended discharge (10 seconds)"); coulomb_init_test(2200); float capacity_q = BATTERY_CAPACITY_Q; /* Discharge at 0.5A for 10 seconds = 5 coulombs */ mock_ina219_current_raw = 5000; /* 0.5A */ uint32_t ticks_per_10s = COULOMB_SAMPLE_RATE_HZ * 10; for (uint32_t i = 0; i < ticks_per_10s; i++) { coulomb_tick_test(); } float discharged = coulomb_get_coulombs_discharged_test(); ASSERT_FLOAT(discharged, 5.0f, 0.1f, "0.5A for 10s = 5 coulombs"); float expected_soc = 100.0f * (1.0f - (5.0f / capacity_q)); ASSERT_FLOAT(coulomb_get_soc_test(), expected_soc, 0.5f, "SOC accurate after extended discharge"); } void test_discharge_then_charge(void) { TEST("Discharge then recharge cycle"); coulomb_init_test(2200); /* Partial discharge (1A for 3.6 seconds = 3.6 coulombs) */ mock_ina219_current_raw = 10000; for (int i = 0; i < COULOMB_SAMPLE_RATE_HZ * 3.6; i++) { coulomb_tick_test(); } float soc_after_discharge = coulomb_get_soc_test(); ASSERT_FLOAT(soc_after_discharge, 100.0f * (1.0f - (3.6f / BATTERY_CAPACITY_Q)), 0.5f, "SOC after discharge"); /* Recharge (negative current: -1A) */ mock_ina219_current_raw = -10000; for (int i = 0; i < 3600; i++) { coulomb_tick_test(); } ASSERT(g_coulomb_state.charging == true, "Charging detected"); /* Note: SOC doesn't automatically increase on negative current (only manual update via coulomb_set_soc) */ } void test_tick_count_rollover(void) { TEST("Tick count increments correctly"); coulomb_init_test(2200); ASSERT(g_coulomb_state.tick_count == 0, "Initial tick = 0"); for (int i = 0; i < 100; i++) { coulomb_tick_test(); } ASSERT(g_coulomb_state.tick_count == 100, "Tick count incremented to 100"); /* Simulate run-time overflow near uint32_t max */ g_coulomb_state.tick_count = 0xFFFFFFF0; /* 4 billion ticks */ coulomb_tick_test(); ASSERT(g_coulomb_state.tick_count == 0xFFFFFFF1, "Tick count continues past large values"); } /* ══════════════════════════════════════════════════════════════════════════ */ /* MAIN TEST RUNNER */ /* ══════════════════════════════════════════════════════════════════════════ */ int main(void) { printf("\n════════════════════════════════════════════════════════════════════════\n"); printf(" Battery Coulomb Counter — Unit Tests (Issue #325)\n"); printf("════════════════════════════════════════════════════════════════════════\n"); test_initialization(); test_coulomb_tick_zero_current(); test_coulomb_tick_constant_discharge(); test_charging_detection(); test_soc_calculation_accuracy(); test_soc_clamping(); test_set_soc(); test_mark_full_charge(); test_mark_fully_discharged(); test_flash_calibration_write_read(); test_flash_magic_validation(); test_calibration_offset_application(); test_calibration_scale_application(); test_factory_reset(); test_capacity_variant(); test_low_current_threshold(); test_integration_over_long_duration(); test_discharge_then_charge(); test_tick_count_rollover(); printf("\n────────────────────────────────────────────────────────────────────────\n"); printf(" Results: %d/%d tests passed, %d failed\n", test_passed, test_count, test_failed); printf("────────────────────────────────────────────────────────────────────────\n\n"); return (test_failed == 0) ? 0 : 1; }