hitl_tester.test_cases.bms.flash_saving

Test MIL-PRF Compatability
GitHub Issue(s) turnaroundfactor/HITL#327
turnaroundfactor/HITL#342
Description Tests for MIL-PRF-32383/5A (16-December-2021) compatibility.

Used in these test plans:

  • prod_bms ⠀⠀⠀(bms/prod_bms.plan)
  • flash_save ⠀⠀⠀(bms/flash_save.plan)

Example Command (warning: test plan may run other test cases):

  • ./hitl_tester.py prod_bms -DFAST_MODE=False -DFAST_CAPACITY_AH=0.3 -DFLASH_SLEEP=7
  1"""
  2| Test                 | MIL-PRF Compatability                                        |
  3| :------------------- | :----------------------------------------------------------- |
  4| GitHub Issue(s)      | turnaroundfactor/HITL#327                        </br>\
  5                         turnaroundfactor/HITL#342                             |
  6| Description          | Tests for MIL-PRF-32383/5A (16-December-2021) compatibility. |
  7"""
  8
  9from __future__ import annotations
 10
 11import ctypes
 12import time
 13from contextlib import suppress
 14
 15import pytest
 16
 17from hitl_tester.modules.bms.adc_plate import ADCPlate
 18from hitl_tester.modules.bms.bms_hw import BMSHardware
 19from hitl_tester.modules.bms.bms_serial import serial_monitor
 20from hitl_tester.modules.bms.plateset import Plateset
 21from hitl_tester.modules.bms.smbus import SMBus
 22from hitl_tester.modules.bms.smbus_types import SMBusReg, BMSCommands
 23from hitl_tester.modules.bms_types import DischargeType, TimeoutExceededError
 24from hitl_tester.modules.logger import logger
 25
 26FAST_MODE = False  # In this mode test times are shortened
 27"""Whether to use smaller capacity or not (speeds up testing)."""
 28
 29FAST_CAPACITY_AH = 0.300
 30"""The capacity to use in fast mode."""
 31
 32FLASH_SLEEP = 7
 33"""Time to wait for flash to write."""
 34
 35_bms = BMSHardware(pytest.flags)  # type: ignore[arg-type]
 36_bms.init()
 37_plateset = Plateset()
 38_adc_plate = ADCPlate()
 39_smbus = SMBus()
 40
 41
 42@pytest.fixture(scope="function", autouse=True)
 43def reset_test_environment():
 44    """
 45    Before each test, set cell sim capacity.
 46    After each test, clean up modified objects.
 47    """
 48
 49    # Set cell sim capacity
 50    if len(_bms.cells) > 0:
 51        if FAST_MODE:
 52            logger.write_info_to_report(f"Setting cell capacity to {FAST_CAPACITY_AH} Ah")
 53            for cell in _bms.cells.values():
 54                cell.data.capacity = FAST_CAPACITY_AH
 55
 56    # Save logging function
 57    old_cycle_function = _bms.csv.cycle
 58
 59    yield  # Run test
 60
 61    _bms.csv.cycle = old_cycle_function  # Restore logging
 62
 63
 64def power_cycle_bms(temperature: float = 23, cell_soc: float = 0.50):
 65    """Reset cell sims."""
 66
 67    if len(_bms.cells) > 0:
 68        logger.write_info_to_report(f"Setting temperature to {temperature}°C")
 69        _plateset.thermistor1 = _plateset.thermistor2 = temperature
 70        logger.write_info_to_report("Powering down cell sims")
 71        time.sleep(5)
 72        for cell in _bms.cells.values():
 73            cell.disengage_safety_protocols = True
 74            cell.volts = 0.0001
 75        time.sleep(5)
 76        for cell in _bms.cells.values():
 77            logger.write_info_to_report(f"Powering up cell sim {cell.id} to {cell_soc:%}")
 78            cell.state_of_charge = cell_soc
 79            cell.disengage_safety_protocols = False
 80
 81        logger.write_info_to_report("Sleeping for 10 seconds before starting...")
 82        time.sleep(10)
 83
 84
 85def standard_charge(
 86    charge_current: float = 2,
 87    max_time: int = 8 * 3600,
 88    sample_interval: int = 10,
 89    minimum_readings: int = 3,
 90    termination_current: float = 0.100,
 91):
 92    """
 93    Helper function to charge batteries in accordance with 4.3.1 for not greater than three hours.
 94    4.3.1 = 23 ± 5°C (73.4°F) ambient pressure/relative humidity, with 2+ hours between charge and discharge.
 95    """
 96    _bms.voltage = 16.8
 97    _bms.ov_protection = _bms.voltage + 0.050  # 50mV above the charging voltage
 98    _bms.current = charge_current
 99    _bms.termination_current = termination_current  # 100 mA
100    _bms.max_time = max_time
101    _bms.sample_interval = sample_interval
102    _bms.minimum_readings = minimum_readings
103
104    # Run the Charge cycle
105    _plateset.ce_switch = True
106    _bms.run_li_charge_cycle()
107    _plateset.ce_switch = False
108
109
110def standard_rest(seconds: float = 2 * 3600, sample_interval: int = 10):
111    """Helper function to stabilize the batteries for 2+ hours."""
112    _bms.max_time = seconds
113    _bms.sample_interval = sample_interval
114    _bms.run_resting_cycle()
115
116
117def standard_discharge(
118    discharge_current: float = 2, max_time: int = 8 * 3600, sample_interval: int = 10, discharge_voltage: float = 10
119):
120    """Helper function to discharge at 2A until 10V."""
121    _bms.voltage = discharge_voltage
122    _bms.uv_protection = _bms.voltage - 0.500  # 500mV below voltage cutoff
123    _bms.current = discharge_current
124    _bms.discharge_type = DischargeType.CONSTANT_CURRENT
125    _bms.max_time = max_time
126    _bms.sample_interval = sample_interval
127
128    # Run the discharge cycle, returning the capacity
129    capacity = _bms.run_discharge_cycle()
130    logger.write_info_to_report(f"Discharge complete, capacity was {capacity * 1000.0} mAh")
131    return capacity
132
133
134@pytest.mark.sim_cells
135class TestCycleCount:
136    """Confirm cycle count is retained."""
137
138    def test_extended_cycle(self):
139        """
140        | Description          | Perform a long charge / discharge                                      |
141        | :------------------- | :--------------------------------------------------------------------- |
142        | GitHub Issue         | turnaroundfactor/HITL#413                                       |
143        | Instructions         | 1. Charge/discharge the battery                                   </br>\
144                                 2. Charge for a little bit (flash only saves while charging       </br>\
145                                 3. Power cycle                                                    </br>\
146                                 4. Confirm cycle count is still incremented by 1                       |
147        | Pass / Fail Criteria | Charge cycle increments by 1                                           |
148        | Estimated Duration   | 12 hours                                                               |
149        """
150
151        original_charge_cycle = serial_monitor.read()["charge_cycles"]
152        standard_charge()
153        standard_rest(seconds=30 if FAST_MODE else 3.1 * 3600)
154        standard_discharge()
155
156        logger.write_info_to_report("Charging for 30 seconds.")
157        with suppress(TimeoutExceededError):
158            standard_charge(max_time=30)
159
160        power_cycle_bms()
161
162        serial_monitor.read()  # Clear serial queue to get new packet
163        cycle_count = serial_monitor.read()["charge_cycles"]
164        logger.write_result_to_html_report(f"Cycle count: {original_charge_cycle} -> {cycle_count}")
165        assert cycle_count == original_charge_cycle + 1, "Cycle count did not increment."
166
167
168@pytest.mark.sim_cells
169class TestCalibration:
170    """Calibrate, power cycle, confirm calibration is good."""
171
172    average = 0
173    readings = 0
174
175    def bms_current(self):
176        """Measure serial current and calculate an average."""
177        new_reading = _bms.csv.cycle.last_serial_data["mamps"]
178        self.average = (new_reading + self.readings * self.average) / (self.readings + 1)
179        self.readings += 1
180        logger.write_info_to_report(f"BMS Serial Current (mA): {new_reading:.3f}")  # Output current on every sample
181
182    def test_calibration(self):
183        """
184        | Description          | Test calibration retention                                             |
185        | :------------------- | :--------------------------------------------------------------------- |
186        | GitHub Issue         | turnaroundfactor/HITL#413                                       |
187        | Instructions         | 1. Calibrate the BMS                                              </br>\
188                                 2. Power cycle                                                    </br>\
189                                 3. Confirm BMS is still calibrated                                     |
190        | Pass / Fail Criteria | Pass if calibrated                                                     |
191        | Estimated Duration   | 1 minute                                                               |
192        """
193        acceptable_error_ma = 5
194
195        _bms.csv.cycle = _bms.csv.cycle_smbus  # Record serial and SMBus
196        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
197        self.readings = 0
198
199        standard_rest(30, 5)
200        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
201        offset = int(round(self.average, 0))
202        logger.write_info_to_report(f"Setting offset current to {offset:.3f} mA")
203        data = (ctypes.c_uint8(offset).value << 8) | BMSCommands.CALIBRATE
204        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, data)
205        time.sleep(FLASH_SLEEP)
206        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
207        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
208
209        power_cycle_bms()
210
211        # Confirm current is in acceptable range
212        self.readings = 0  # Reset average
213        standard_rest(30, 5)
214        logger.write_result_to_html_report(f"Average rest current (calibrated): {self.average:.3f} mA")
215        assert (
216            acceptable_error_ma > self.average > -acceptable_error_ma
217        ), f"{self.average:.3f} mA outside limit of ±{acceptable_error_ma:.3f} mA"
218
219
220@pytest.mark.sim_cells
221class TestPermanentFault:
222    """Cause overtemp permanent fault, power cycle, confirm faulted."""
223
224    timeout_s = 60
225
226    def test_permanent_fault(self):
227        """
228        | Description          | Test permanent fault retention                                         |
229        | :------------------- | :--------------------------------------------------------------------- |
230        | GitHub Issue         | turnaroundfactor/HITL#413                                       |
231        | Instructions         | 1. Cause a permanent fault                                        </br>\
232                                 2. Power cycle                                                    </br>\
233                                 3. Confirm permanent fault is active                                   |
234        | Pass / Fail Criteria | Pass if faulted                                                        |
235        | Estimated Duration   | 1 minute                                                               |
236        """
237        _plateset.disengage_safety_protocols = True
238        _plateset.thermistor1 = 94
239        _plateset.disengage_safety_protocols = False
240
241        start = time.perf_counter()
242        serial_data = serial_monitor.read()
243        while not (
244            serial_data["flags.permanentdisable_overtemp"] and serial_data["flags.measure_output_fets_disabled"]
245        ):
246            serial_data = serial_monitor.read()
247            if time.perf_counter() - start > self.timeout_s:
248                logger.write_result_to_html_report(f"FETs: {serial_data['flags.measure_output_fets_disabled']}")
249                logger.write_result_to_html_report(f"Overtemp Fault: {serial_data['flags.permanentdisable_overtemp']}")
250                raise TimeoutError(f"Permanent temp fault or FETs were not set after {self.timeout_s} seconds.")
251
252        power_cycle_bms()
253
254        serial_monitor.read()  # Clear serial queue to get new packet
255        serial_data = serial_monitor.read()
256        fets_disable = serial_data["flags.measure_output_fets_disabled"]
257        permanent_disable = serial_data["flags.permanentdisable_overtemp"]
258        logger.write_result_to_html_report(f"FETs: {'Disabled' if fets_disable else 'Enabled'}")
259        logger.write_result_to_html_report(f"Overtemp Disable: {'Active' if permanent_disable else 'Inactive'}")
260        assert fets_disable, "FETs were not disabled."
261        assert permanent_disable, "Permanent Temp Disable was inactive."
262
263
264@pytest.mark.sim_cells
265class TestFlashErase:
266    """Test erasing flash."""
267
268    average = 0
269    readings = 0
270
271    def bms_current(self):
272        """Measure serial current and calculate an average."""
273        new_reading = _bms.csv.cycle.last_serial_data["mamps"]
274        self.average = (new_reading + self.readings * self.average) / (self.readings + 1)
275        self.readings += 1
276        logger.write_info_to_report(f"BMS Serial Current (mA): {new_reading:.3f}")  # Output current on every sample
277
278    def test_flash_erase(self):
279        """
280        | Description          | Test erasing flash                                                     |
281        | :------------------- | :--------------------------------------------------------------------- |
282        | GitHub Issue         | turnaroundfactor/HITL#435                                       |
283        | Instructions         | 1. Calibrate the BMS                                              </br>\
284                                 2. Power cycle                                                    </br>\
285                                 3. Confirm BMS is still calibrated                                </br>\
286                                 4. Send "Flash Erase" command with SMBus                          </br>\
287                                 5. Confirm BMS is no longer calibrated                                 |
288        | Pass / Fail Criteria | Pass if not calibrated                                                 |
289        | Estimated Duration   | 1 minute                                                               |
290        """
291        acceptable_error_ma = 5
292
293        _bms.csv.cycle = _bms.csv.cycle_smbus  # Record serial and SMBus
294        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
295        self.readings = 0
296        standard_rest(30, 5)
297        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
298
299        # Calibrate
300        offset = int(round(self.average, 0))
301        logger.write_info_to_report(f"Setting offset current to {offset:.3f} mA")
302        data = (ctypes.c_uint8(offset).value << 8) | BMSCommands.CALIBRATE
303        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, data)
304        time.sleep(FLASH_SLEEP)
305        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
306        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
307
308        power_cycle_bms()
309
310        # Confirm current is in acceptable range
311        self.readings = 0  # Reset average
312        standard_rest(30, 5)
313        logger.write_result_to_html_report(f"Average rest current (calibrated): {self.average:.3f} mA")
314        assert (
315            acceptable_error_ma > self.average > -acceptable_error_ma
316        ), f"{self.average:.3f} mA outside limit of ±{acceptable_error_ma:.3f} mA"
317
318        # Erase flash
319        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.ERASE_FLASH)
320        time.sleep(FLASH_SLEEP)  # Wait for erase to complete
321        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
322        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
323
324        # Confirm current is not in acceptable range
325        self.readings = 0  # Reset average
326        standard_rest(30, 5)
327        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
328        assert (
329            not acceptable_error_ma > self.average > -acceptable_error_ma
330        ), f"Appears to be calibrated. {self.average:.3f} mA is within the limit of ±{acceptable_error_ma:.3f} mA"
331
332
333@pytest.mark.usefixtures("cycle_smbus")
334@pytest.mark.sim_cells
335class TestOTP:
336    """Test OTP registers."""
337
338    average = 0
339    readings = 0
340    serial_id = 0xCAFE
341
342    def bms_current(self):
343        """Measure serial current and calculate an average."""
344        new_reading = _bms.csv.cycle.last_serial_data["mamps"]
345        self.average = (new_reading + self.readings * self.average) / (self.readings + 1)
346        self.readings += 1
347        logger.write_info_to_report(f"BMS Serial Current (mA): {new_reading:.3f}")  # Output current on every sample
348
349    def calibrate_current(self):
350        """Calibrate BMS current in flash."""
351        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
352        self.readings = 0
353        standard_rest(30, 5)
354        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
355
356        # Calibrate
357        offset = int(round(self.average, 0))
358        logger.write_info_to_report(f"Setting offset current to {offset:.3f} mA")
359        data = (ctypes.c_uint8(offset).value << 8) | BMSCommands.CALIBRATE
360        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, data)
361        time.sleep(FLASH_SLEEP)
362        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
363        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
364
365        _bms.csv.cycle.postfix_fn = lambda: ...
366
367    def is_calibrated(self) -> bool:
368        """Confirm current is in acceptable range"""
369        acceptable_error_ma = 5
370
371        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
372        self.readings = 0  # Reset average
373        standard_rest(30, 5)
374        logger.write_result_to_html_report(f"Average rest current (calibrated?): {self.average:.3f} mA")
375        return acceptable_error_ma > self.average > -acceptable_error_ma
376
377    def set_serial_id(self):
378        """Set the 16-bit serial ID."""
379        _smbus.write_register(SMBusReg.SERIAL_NUM, self.serial_id)
380        time.sleep(FLASH_SLEEP)
381
382    def is_serial_set(self) -> bool:
383        """Check if the 16-bit serial ID has been set."""
384        return _smbus.read_register(SMBusReg.SERIAL_NUM)[1] == self.serial_id.to_bytes(2, byteorder="little")
385
386    def enable_faults(self):
387        """Enable faults via SMBus."""
388        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.FAULT_ENABLE)
389        time.sleep(FLASH_SLEEP)
390
391    def is_faults_enabled(self) -> bool:
392        """Check if overtemp faults can be raised."""
393        timeout_s = 10
394
395        # Raise a fault
396        _plateset.thermistor1 = 65
397        start = time.perf_counter()
398        while (serial_data := serial_monitor.read(latest=True)) and not serial_data["flags.fault_overtemp_discharge"]:
399            if time.perf_counter() - start > timeout_s:
400                logger.write_debug_to_report(f"Over-temperature fault was not raised after {timeout_s} seconds.")
401                return False
402
403        # Clear the fault
404        _plateset.thermistor1 = 45
405        start = time.perf_counter()
406        while (serial_data := serial_monitor.read(latest=True)) and serial_data["flags.fault_overtemp_discharge"]:
407            if time.perf_counter() - start > timeout_s:
408                logger.write_debug_to_report(f"Over-temperature fault was not cleared after {timeout_s} seconds.")
409                return False
410
411        return True
412
413    def erase_flash(self):
414        """Erase the internal BMS flash."""
415        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.ERASE_FLASH)
416        time.sleep(FLASH_SLEEP)  # Wait for erase to complete
417        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
418        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
419
420    def test_flash_otp(self):
421        """
422        | Description          | Test OTP registers                                                     |
423        | :------------------- | :--------------------------------------------------------------------- |
424        | GitHub Issue         | turnaroundfactor/HITL#486                                       |
425        | Instructions         | 1. Set up flash values                                            </br>\
426                                  ⠀⠀⦁ Enable assembly mode                                         </br>\
427                                  ⠀⠀⦁ Calibrate the BMS and verify                                 </br>\
428                                  ⠀⠀⦁ Set the serial number and verify                             </br>\
429                                  ⠀⠀⦁ Enable the faults and verify                                 </br>\
430                                 2. Send "Flash Erase" command with SMBus                          </br>\
431                                 3. Confirm BMS flash is erased                                    </br>\
432                                  ⠀⠀⦁ BMS isn't calibrated                                         </br>\
433                                  ⠀⠀⦁ Faults aren't enabled                                        </br>\
434                                 4. Set up flash values                                            </br>\
435                                  ⠀⠀⦁ Calibrate the BMS and verify                                 </br>\
436                                  ⠀⠀⦁ Enable the faults and verify                                 </br>\
437                                 5. Enter manufacturing mode                                       </br>\
438                                 6. Send "Flash Erase" command with SMBus                          </br>\
439                                 7. Confirm BMS flash is not erased                                </br>\
440                                  ⠀⠀⦁ BMS is calibrated                                            </br>\
441                                  ⠀⠀⦁ Faults are enabled                                                |
442        | Pass / Fail Criteria | Pass flash cannot be erased                                            |
443        | Estimated Duration   | 1 minute                                                               |
444        """
445
446        # 1. Set up flash values and enter assembly mode
447        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.ASSEMBLY_MODE)
448        time.sleep(FLASH_SLEEP)
449        self.calibrate_current()
450        self.set_serial_id()
451        self.enable_faults()
452        power_cycle_bms()  # Power cycle
453        assert self.is_calibrated(), "Current was not calibrated after power cycle."
454        assert self.is_serial_set(), "Serial was not set after power cycle."
455        assert self.is_faults_enabled(), "Faults were not enabled after power cycle."
456
457        # 2/3. Erase flash, confirm BMS flash is erased
458        self.erase_flash()
459        assert not self.is_calibrated(), "Current was still calibrated after erase."
460        assert self.is_serial_set(), "Serial was not set after erase."
461        assert not self.is_faults_enabled(), "Faults were still enabled after erase."
462
463        # 4. Set up flash values
464        self.calibrate_current()
465        self.enable_faults()
466        power_cycle_bms()  # Power cycle
467        assert self.is_calibrated(), "Current was not calibrated after power cycle."
468        assert self.is_serial_set(), "Serial was not set after power cycle."
469        assert self.is_faults_enabled(), "Faults were not enabled after power cycle."
470
471        # 5. Enter manufacturing mode
472        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.PRODUCTION_MODE)
473        time.sleep(FLASH_SLEEP)
474
475        # 6. Erase flash, confirm BMS flash is not erased
476        self.erase_flash()
477        assert self.is_calibrated(), "Current was not calibrated after erase in manufacturing mode."
478        assert self.is_serial_set(), "Serial was not set after erase in manufacturing mode."
479        assert self.is_faults_enabled(), "Faults were not enabled after erase in manufacturing mode."
FAST_MODE = False

Whether to use smaller capacity or not (speeds up testing).

FAST_CAPACITY_AH = 0.3

The capacity to use in fast mode.

FLASH_SLEEP = 7

Time to wait for flash to write.

@pytest.fixture(scope='function', autouse=True)
def reset_test_environment():
43@pytest.fixture(scope="function", autouse=True)
44def reset_test_environment():
45    """
46    Before each test, set cell sim capacity.
47    After each test, clean up modified objects.
48    """
49
50    # Set cell sim capacity
51    if len(_bms.cells) > 0:
52        if FAST_MODE:
53            logger.write_info_to_report(f"Setting cell capacity to {FAST_CAPACITY_AH} Ah")
54            for cell in _bms.cells.values():
55                cell.data.capacity = FAST_CAPACITY_AH
56
57    # Save logging function
58    old_cycle_function = _bms.csv.cycle
59
60    yield  # Run test
61
62    _bms.csv.cycle = old_cycle_function  # Restore logging

Before each test, set cell sim capacity. After each test, clean up modified objects.

def power_cycle_bms(temperature: float = 23, cell_soc: float = 0.5):
65def power_cycle_bms(temperature: float = 23, cell_soc: float = 0.50):
66    """Reset cell sims."""
67
68    if len(_bms.cells) > 0:
69        logger.write_info_to_report(f"Setting temperature to {temperature}°C")
70        _plateset.thermistor1 = _plateset.thermistor2 = temperature
71        logger.write_info_to_report("Powering down cell sims")
72        time.sleep(5)
73        for cell in _bms.cells.values():
74            cell.disengage_safety_protocols = True
75            cell.volts = 0.0001
76        time.sleep(5)
77        for cell in _bms.cells.values():
78            logger.write_info_to_report(f"Powering up cell sim {cell.id} to {cell_soc:%}")
79            cell.state_of_charge = cell_soc
80            cell.disengage_safety_protocols = False
81
82        logger.write_info_to_report("Sleeping for 10 seconds before starting...")
83        time.sleep(10)

Reset cell sims.

def standard_charge( charge_current: float = 2, max_time: int = 28800, sample_interval: int = 10, minimum_readings: int = 3, termination_current: float = 0.1):
 86def standard_charge(
 87    charge_current: float = 2,
 88    max_time: int = 8 * 3600,
 89    sample_interval: int = 10,
 90    minimum_readings: int = 3,
 91    termination_current: float = 0.100,
 92):
 93    """
 94    Helper function to charge batteries in accordance with 4.3.1 for not greater than three hours.
 95    4.3.1 = 23 ± 5°C (73.4°F) ambient pressure/relative humidity, with 2+ hours between charge and discharge.
 96    """
 97    _bms.voltage = 16.8
 98    _bms.ov_protection = _bms.voltage + 0.050  # 50mV above the charging voltage
 99    _bms.current = charge_current
100    _bms.termination_current = termination_current  # 100 mA
101    _bms.max_time = max_time
102    _bms.sample_interval = sample_interval
103    _bms.minimum_readings = minimum_readings
104
105    # Run the Charge cycle
106    _plateset.ce_switch = True
107    _bms.run_li_charge_cycle()
108    _plateset.ce_switch = False

Helper function to charge batteries in accordance with 4.3.1 for not greater than three hours. 4.3.1 = 23 ± 5°C (73.4°F) ambient pressure/relative humidity, with 2+ hours between charge and discharge.

def standard_rest(seconds: float = 7200, sample_interval: int = 10):
111def standard_rest(seconds: float = 2 * 3600, sample_interval: int = 10):
112    """Helper function to stabilize the batteries for 2+ hours."""
113    _bms.max_time = seconds
114    _bms.sample_interval = sample_interval
115    _bms.run_resting_cycle()

Helper function to stabilize the batteries for 2+ hours.

def standard_discharge( discharge_current: float = 2, max_time: int = 28800, sample_interval: int = 10, discharge_voltage: float = 10):
118def standard_discharge(
119    discharge_current: float = 2, max_time: int = 8 * 3600, sample_interval: int = 10, discharge_voltage: float = 10
120):
121    """Helper function to discharge at 2A until 10V."""
122    _bms.voltage = discharge_voltage
123    _bms.uv_protection = _bms.voltage - 0.500  # 500mV below voltage cutoff
124    _bms.current = discharge_current
125    _bms.discharge_type = DischargeType.CONSTANT_CURRENT
126    _bms.max_time = max_time
127    _bms.sample_interval = sample_interval
128
129    # Run the discharge cycle, returning the capacity
130    capacity = _bms.run_discharge_cycle()
131    logger.write_info_to_report(f"Discharge complete, capacity was {capacity * 1000.0} mAh")
132    return capacity

Helper function to discharge at 2A until 10V.

@pytest.mark.sim_cells
class TestCycleCount:
135@pytest.mark.sim_cells
136class TestCycleCount:
137    """Confirm cycle count is retained."""
138
139    def test_extended_cycle(self):
140        """
141        | Description          | Perform a long charge / discharge                                      |
142        | :------------------- | :--------------------------------------------------------------------- |
143        | GitHub Issue         | turnaroundfactor/HITL#413                                       |
144        | Instructions         | 1. Charge/discharge the battery                                   </br>\
145                                 2. Charge for a little bit (flash only saves while charging       </br>\
146                                 3. Power cycle                                                    </br>\
147                                 4. Confirm cycle count is still incremented by 1                       |
148        | Pass / Fail Criteria | Charge cycle increments by 1                                           |
149        | Estimated Duration   | 12 hours                                                               |
150        """
151
152        original_charge_cycle = serial_monitor.read()["charge_cycles"]
153        standard_charge()
154        standard_rest(seconds=30 if FAST_MODE else 3.1 * 3600)
155        standard_discharge()
156
157        logger.write_info_to_report("Charging for 30 seconds.")
158        with suppress(TimeoutExceededError):
159            standard_charge(max_time=30)
160
161        power_cycle_bms()
162
163        serial_monitor.read()  # Clear serial queue to get new packet
164        cycle_count = serial_monitor.read()["charge_cycles"]
165        logger.write_result_to_html_report(f"Cycle count: {original_charge_cycle} -> {cycle_count}")
166        assert cycle_count == original_charge_cycle + 1, "Cycle count did not increment."

Confirm cycle count is retained.

def test_extended_cycle(self):
139    def test_extended_cycle(self):
140        """
141        | Description          | Perform a long charge / discharge                                      |
142        | :------------------- | :--------------------------------------------------------------------- |
143        | GitHub Issue         | turnaroundfactor/HITL#413                                       |
144        | Instructions         | 1. Charge/discharge the battery                                   </br>\
145                                 2. Charge for a little bit (flash only saves while charging       </br>\
146                                 3. Power cycle                                                    </br>\
147                                 4. Confirm cycle count is still incremented by 1                       |
148        | Pass / Fail Criteria | Charge cycle increments by 1                                           |
149        | Estimated Duration   | 12 hours                                                               |
150        """
151
152        original_charge_cycle = serial_monitor.read()["charge_cycles"]
153        standard_charge()
154        standard_rest(seconds=30 if FAST_MODE else 3.1 * 3600)
155        standard_discharge()
156
157        logger.write_info_to_report("Charging for 30 seconds.")
158        with suppress(TimeoutExceededError):
159            standard_charge(max_time=30)
160
161        power_cycle_bms()
162
163        serial_monitor.read()  # Clear serial queue to get new packet
164        cycle_count = serial_monitor.read()["charge_cycles"]
165        logger.write_result_to_html_report(f"Cycle count: {original_charge_cycle} -> {cycle_count}")
166        assert cycle_count == original_charge_cycle + 1, "Cycle count did not increment."
Description Perform a long charge / discharge
GitHub Issue turnaroundfactor/HITL#413
Instructions 1. Charge/discharge the battery
2. Charge for a little bit (flash only saves while charging
3. Power cycle
4. Confirm cycle count is still incremented by 1
Pass / Fail Criteria Charge cycle increments by 1
Estimated Duration 12 hours
pytestmark = [Mark(name='sim_cells', args=(), kwargs={})]
@pytest.mark.sim_cells
class TestCalibration:
169@pytest.mark.sim_cells
170class TestCalibration:
171    """Calibrate, power cycle, confirm calibration is good."""
172
173    average = 0
174    readings = 0
175
176    def bms_current(self):
177        """Measure serial current and calculate an average."""
178        new_reading = _bms.csv.cycle.last_serial_data["mamps"]
179        self.average = (new_reading + self.readings * self.average) / (self.readings + 1)
180        self.readings += 1
181        logger.write_info_to_report(f"BMS Serial Current (mA): {new_reading:.3f}")  # Output current on every sample
182
183    def test_calibration(self):
184        """
185        | Description          | Test calibration retention                                             |
186        | :------------------- | :--------------------------------------------------------------------- |
187        | GitHub Issue         | turnaroundfactor/HITL#413                                       |
188        | Instructions         | 1. Calibrate the BMS                                              </br>\
189                                 2. Power cycle                                                    </br>\
190                                 3. Confirm BMS is still calibrated                                     |
191        | Pass / Fail Criteria | Pass if calibrated                                                     |
192        | Estimated Duration   | 1 minute                                                               |
193        """
194        acceptable_error_ma = 5
195
196        _bms.csv.cycle = _bms.csv.cycle_smbus  # Record serial and SMBus
197        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
198        self.readings = 0
199
200        standard_rest(30, 5)
201        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
202        offset = int(round(self.average, 0))
203        logger.write_info_to_report(f"Setting offset current to {offset:.3f} mA")
204        data = (ctypes.c_uint8(offset).value << 8) | BMSCommands.CALIBRATE
205        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, data)
206        time.sleep(FLASH_SLEEP)
207        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
208        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
209
210        power_cycle_bms()
211
212        # Confirm current is in acceptable range
213        self.readings = 0  # Reset average
214        standard_rest(30, 5)
215        logger.write_result_to_html_report(f"Average rest current (calibrated): {self.average:.3f} mA")
216        assert (
217            acceptable_error_ma > self.average > -acceptable_error_ma
218        ), f"{self.average:.3f} mA outside limit of ±{acceptable_error_ma:.3f} mA"

Calibrate, power cycle, confirm calibration is good.

average = 0
readings = 0
def bms_current(self):
176    def bms_current(self):
177        """Measure serial current and calculate an average."""
178        new_reading = _bms.csv.cycle.last_serial_data["mamps"]
179        self.average = (new_reading + self.readings * self.average) / (self.readings + 1)
180        self.readings += 1
181        logger.write_info_to_report(f"BMS Serial Current (mA): {new_reading:.3f}")  # Output current on every sample

Measure serial current and calculate an average.

def test_calibration(self):
183    def test_calibration(self):
184        """
185        | Description          | Test calibration retention                                             |
186        | :------------------- | :--------------------------------------------------------------------- |
187        | GitHub Issue         | turnaroundfactor/HITL#413                                       |
188        | Instructions         | 1. Calibrate the BMS                                              </br>\
189                                 2. Power cycle                                                    </br>\
190                                 3. Confirm BMS is still calibrated                                     |
191        | Pass / Fail Criteria | Pass if calibrated                                                     |
192        | Estimated Duration   | 1 minute                                                               |
193        """
194        acceptable_error_ma = 5
195
196        _bms.csv.cycle = _bms.csv.cycle_smbus  # Record serial and SMBus
197        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
198        self.readings = 0
199
200        standard_rest(30, 5)
201        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
202        offset = int(round(self.average, 0))
203        logger.write_info_to_report(f"Setting offset current to {offset:.3f} mA")
204        data = (ctypes.c_uint8(offset).value << 8) | BMSCommands.CALIBRATE
205        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, data)
206        time.sleep(FLASH_SLEEP)
207        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
208        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
209
210        power_cycle_bms()
211
212        # Confirm current is in acceptable range
213        self.readings = 0  # Reset average
214        standard_rest(30, 5)
215        logger.write_result_to_html_report(f"Average rest current (calibrated): {self.average:.3f} mA")
216        assert (
217            acceptable_error_ma > self.average > -acceptable_error_ma
218        ), f"{self.average:.3f} mA outside limit of ±{acceptable_error_ma:.3f} mA"
Description Test calibration retention
GitHub Issue turnaroundfactor/HITL#413
Instructions 1. Calibrate the BMS
2. Power cycle
3. Confirm BMS is still calibrated
Pass / Fail Criteria Pass if calibrated
Estimated Duration 1 minute
pytestmark = [Mark(name='sim_cells', args=(), kwargs={})]
@pytest.mark.sim_cells
class TestPermanentFault:
221@pytest.mark.sim_cells
222class TestPermanentFault:
223    """Cause overtemp permanent fault, power cycle, confirm faulted."""
224
225    timeout_s = 60
226
227    def test_permanent_fault(self):
228        """
229        | Description          | Test permanent fault retention                                         |
230        | :------------------- | :--------------------------------------------------------------------- |
231        | GitHub Issue         | turnaroundfactor/HITL#413                                       |
232        | Instructions         | 1. Cause a permanent fault                                        </br>\
233                                 2. Power cycle                                                    </br>\
234                                 3. Confirm permanent fault is active                                   |
235        | Pass / Fail Criteria | Pass if faulted                                                        |
236        | Estimated Duration   | 1 minute                                                               |
237        """
238        _plateset.disengage_safety_protocols = True
239        _plateset.thermistor1 = 94
240        _plateset.disengage_safety_protocols = False
241
242        start = time.perf_counter()
243        serial_data = serial_monitor.read()
244        while not (
245            serial_data["flags.permanentdisable_overtemp"] and serial_data["flags.measure_output_fets_disabled"]
246        ):
247            serial_data = serial_monitor.read()
248            if time.perf_counter() - start > self.timeout_s:
249                logger.write_result_to_html_report(f"FETs: {serial_data['flags.measure_output_fets_disabled']}")
250                logger.write_result_to_html_report(f"Overtemp Fault: {serial_data['flags.permanentdisable_overtemp']}")
251                raise TimeoutError(f"Permanent temp fault or FETs were not set after {self.timeout_s} seconds.")
252
253        power_cycle_bms()
254
255        serial_monitor.read()  # Clear serial queue to get new packet
256        serial_data = serial_monitor.read()
257        fets_disable = serial_data["flags.measure_output_fets_disabled"]
258        permanent_disable = serial_data["flags.permanentdisable_overtemp"]
259        logger.write_result_to_html_report(f"FETs: {'Disabled' if fets_disable else 'Enabled'}")
260        logger.write_result_to_html_report(f"Overtemp Disable: {'Active' if permanent_disable else 'Inactive'}")
261        assert fets_disable, "FETs were not disabled."
262        assert permanent_disable, "Permanent Temp Disable was inactive."

Cause overtemp permanent fault, power cycle, confirm faulted.

timeout_s = 60
def test_permanent_fault(self):
227    def test_permanent_fault(self):
228        """
229        | Description          | Test permanent fault retention                                         |
230        | :------------------- | :--------------------------------------------------------------------- |
231        | GitHub Issue         | turnaroundfactor/HITL#413                                       |
232        | Instructions         | 1. Cause a permanent fault                                        </br>\
233                                 2. Power cycle                                                    </br>\
234                                 3. Confirm permanent fault is active                                   |
235        | Pass / Fail Criteria | Pass if faulted                                                        |
236        | Estimated Duration   | 1 minute                                                               |
237        """
238        _plateset.disengage_safety_protocols = True
239        _plateset.thermistor1 = 94
240        _plateset.disengage_safety_protocols = False
241
242        start = time.perf_counter()
243        serial_data = serial_monitor.read()
244        while not (
245            serial_data["flags.permanentdisable_overtemp"] and serial_data["flags.measure_output_fets_disabled"]
246        ):
247            serial_data = serial_monitor.read()
248            if time.perf_counter() - start > self.timeout_s:
249                logger.write_result_to_html_report(f"FETs: {serial_data['flags.measure_output_fets_disabled']}")
250                logger.write_result_to_html_report(f"Overtemp Fault: {serial_data['flags.permanentdisable_overtemp']}")
251                raise TimeoutError(f"Permanent temp fault or FETs were not set after {self.timeout_s} seconds.")
252
253        power_cycle_bms()
254
255        serial_monitor.read()  # Clear serial queue to get new packet
256        serial_data = serial_monitor.read()
257        fets_disable = serial_data["flags.measure_output_fets_disabled"]
258        permanent_disable = serial_data["flags.permanentdisable_overtemp"]
259        logger.write_result_to_html_report(f"FETs: {'Disabled' if fets_disable else 'Enabled'}")
260        logger.write_result_to_html_report(f"Overtemp Disable: {'Active' if permanent_disable else 'Inactive'}")
261        assert fets_disable, "FETs were not disabled."
262        assert permanent_disable, "Permanent Temp Disable was inactive."
Description Test permanent fault retention
GitHub Issue turnaroundfactor/HITL#413
Instructions 1. Cause a permanent fault
2. Power cycle
3. Confirm permanent fault is active
Pass / Fail Criteria Pass if faulted
Estimated Duration 1 minute
pytestmark = [Mark(name='sim_cells', args=(), kwargs={})]
@pytest.mark.sim_cells
class TestFlashErase:
265@pytest.mark.sim_cells
266class TestFlashErase:
267    """Test erasing flash."""
268
269    average = 0
270    readings = 0
271
272    def bms_current(self):
273        """Measure serial current and calculate an average."""
274        new_reading = _bms.csv.cycle.last_serial_data["mamps"]
275        self.average = (new_reading + self.readings * self.average) / (self.readings + 1)
276        self.readings += 1
277        logger.write_info_to_report(f"BMS Serial Current (mA): {new_reading:.3f}")  # Output current on every sample
278
279    def test_flash_erase(self):
280        """
281        | Description          | Test erasing flash                                                     |
282        | :------------------- | :--------------------------------------------------------------------- |
283        | GitHub Issue         | turnaroundfactor/HITL#435                                       |
284        | Instructions         | 1. Calibrate the BMS                                              </br>\
285                                 2. Power cycle                                                    </br>\
286                                 3. Confirm BMS is still calibrated                                </br>\
287                                 4. Send "Flash Erase" command with SMBus                          </br>\
288                                 5. Confirm BMS is no longer calibrated                                 |
289        | Pass / Fail Criteria | Pass if not calibrated                                                 |
290        | Estimated Duration   | 1 minute                                                               |
291        """
292        acceptable_error_ma = 5
293
294        _bms.csv.cycle = _bms.csv.cycle_smbus  # Record serial and SMBus
295        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
296        self.readings = 0
297        standard_rest(30, 5)
298        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
299
300        # Calibrate
301        offset = int(round(self.average, 0))
302        logger.write_info_to_report(f"Setting offset current to {offset:.3f} mA")
303        data = (ctypes.c_uint8(offset).value << 8) | BMSCommands.CALIBRATE
304        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, data)
305        time.sleep(FLASH_SLEEP)
306        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
307        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
308
309        power_cycle_bms()
310
311        # Confirm current is in acceptable range
312        self.readings = 0  # Reset average
313        standard_rest(30, 5)
314        logger.write_result_to_html_report(f"Average rest current (calibrated): {self.average:.3f} mA")
315        assert (
316            acceptable_error_ma > self.average > -acceptable_error_ma
317        ), f"{self.average:.3f} mA outside limit of ±{acceptable_error_ma:.3f} mA"
318
319        # Erase flash
320        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.ERASE_FLASH)
321        time.sleep(FLASH_SLEEP)  # Wait for erase to complete
322        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
323        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
324
325        # Confirm current is not in acceptable range
326        self.readings = 0  # Reset average
327        standard_rest(30, 5)
328        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
329        assert (
330            not acceptable_error_ma > self.average > -acceptable_error_ma
331        ), f"Appears to be calibrated. {self.average:.3f} mA is within the limit of ±{acceptable_error_ma:.3f} mA"

Test erasing flash.

average = 0
readings = 0
def bms_current(self):
272    def bms_current(self):
273        """Measure serial current and calculate an average."""
274        new_reading = _bms.csv.cycle.last_serial_data["mamps"]
275        self.average = (new_reading + self.readings * self.average) / (self.readings + 1)
276        self.readings += 1
277        logger.write_info_to_report(f"BMS Serial Current (mA): {new_reading:.3f}")  # Output current on every sample

Measure serial current and calculate an average.

def test_flash_erase(self):
279    def test_flash_erase(self):
280        """
281        | Description          | Test erasing flash                                                     |
282        | :------------------- | :--------------------------------------------------------------------- |
283        | GitHub Issue         | turnaroundfactor/HITL#435                                       |
284        | Instructions         | 1. Calibrate the BMS                                              </br>\
285                                 2. Power cycle                                                    </br>\
286                                 3. Confirm BMS is still calibrated                                </br>\
287                                 4. Send "Flash Erase" command with SMBus                          </br>\
288                                 5. Confirm BMS is no longer calibrated                                 |
289        | Pass / Fail Criteria | Pass if not calibrated                                                 |
290        | Estimated Duration   | 1 minute                                                               |
291        """
292        acceptable_error_ma = 5
293
294        _bms.csv.cycle = _bms.csv.cycle_smbus  # Record serial and SMBus
295        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
296        self.readings = 0
297        standard_rest(30, 5)
298        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
299
300        # Calibrate
301        offset = int(round(self.average, 0))
302        logger.write_info_to_report(f"Setting offset current to {offset:.3f} mA")
303        data = (ctypes.c_uint8(offset).value << 8) | BMSCommands.CALIBRATE
304        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, data)
305        time.sleep(FLASH_SLEEP)
306        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
307        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
308
309        power_cycle_bms()
310
311        # Confirm current is in acceptable range
312        self.readings = 0  # Reset average
313        standard_rest(30, 5)
314        logger.write_result_to_html_report(f"Average rest current (calibrated): {self.average:.3f} mA")
315        assert (
316            acceptable_error_ma > self.average > -acceptable_error_ma
317        ), f"{self.average:.3f} mA outside limit of ±{acceptable_error_ma:.3f} mA"
318
319        # Erase flash
320        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.ERASE_FLASH)
321        time.sleep(FLASH_SLEEP)  # Wait for erase to complete
322        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
323        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
324
325        # Confirm current is not in acceptable range
326        self.readings = 0  # Reset average
327        standard_rest(30, 5)
328        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
329        assert (
330            not acceptable_error_ma > self.average > -acceptable_error_ma
331        ), f"Appears to be calibrated. {self.average:.3f} mA is within the limit of ±{acceptable_error_ma:.3f} mA"
Description Test erasing flash
GitHub Issue turnaroundfactor/HITL#435
Instructions 1. Calibrate the BMS
2. Power cycle
3. Confirm BMS is still calibrated
4. Send "Flash Erase" command with SMBus
5. Confirm BMS is no longer calibrated
Pass / Fail Criteria Pass if not calibrated
Estimated Duration 1 minute
pytestmark = [Mark(name='sim_cells', args=(), kwargs={})]
@pytest.mark.usefixtures('cycle_smbus')
@pytest.mark.sim_cells
class TestOTP:
334@pytest.mark.usefixtures("cycle_smbus")
335@pytest.mark.sim_cells
336class TestOTP:
337    """Test OTP registers."""
338
339    average = 0
340    readings = 0
341    serial_id = 0xCAFE
342
343    def bms_current(self):
344        """Measure serial current and calculate an average."""
345        new_reading = _bms.csv.cycle.last_serial_data["mamps"]
346        self.average = (new_reading + self.readings * self.average) / (self.readings + 1)
347        self.readings += 1
348        logger.write_info_to_report(f"BMS Serial Current (mA): {new_reading:.3f}")  # Output current on every sample
349
350    def calibrate_current(self):
351        """Calibrate BMS current in flash."""
352        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
353        self.readings = 0
354        standard_rest(30, 5)
355        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
356
357        # Calibrate
358        offset = int(round(self.average, 0))
359        logger.write_info_to_report(f"Setting offset current to {offset:.3f} mA")
360        data = (ctypes.c_uint8(offset).value << 8) | BMSCommands.CALIBRATE
361        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, data)
362        time.sleep(FLASH_SLEEP)
363        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
364        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
365
366        _bms.csv.cycle.postfix_fn = lambda: ...
367
368    def is_calibrated(self) -> bool:
369        """Confirm current is in acceptable range"""
370        acceptable_error_ma = 5
371
372        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
373        self.readings = 0  # Reset average
374        standard_rest(30, 5)
375        logger.write_result_to_html_report(f"Average rest current (calibrated?): {self.average:.3f} mA")
376        return acceptable_error_ma > self.average > -acceptable_error_ma
377
378    def set_serial_id(self):
379        """Set the 16-bit serial ID."""
380        _smbus.write_register(SMBusReg.SERIAL_NUM, self.serial_id)
381        time.sleep(FLASH_SLEEP)
382
383    def is_serial_set(self) -> bool:
384        """Check if the 16-bit serial ID has been set."""
385        return _smbus.read_register(SMBusReg.SERIAL_NUM)[1] == self.serial_id.to_bytes(2, byteorder="little")
386
387    def enable_faults(self):
388        """Enable faults via SMBus."""
389        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.FAULT_ENABLE)
390        time.sleep(FLASH_SLEEP)
391
392    def is_faults_enabled(self) -> bool:
393        """Check if overtemp faults can be raised."""
394        timeout_s = 10
395
396        # Raise a fault
397        _plateset.thermistor1 = 65
398        start = time.perf_counter()
399        while (serial_data := serial_monitor.read(latest=True)) and not serial_data["flags.fault_overtemp_discharge"]:
400            if time.perf_counter() - start > timeout_s:
401                logger.write_debug_to_report(f"Over-temperature fault was not raised after {timeout_s} seconds.")
402                return False
403
404        # Clear the fault
405        _plateset.thermistor1 = 45
406        start = time.perf_counter()
407        while (serial_data := serial_monitor.read(latest=True)) and serial_data["flags.fault_overtemp_discharge"]:
408            if time.perf_counter() - start > timeout_s:
409                logger.write_debug_to_report(f"Over-temperature fault was not cleared after {timeout_s} seconds.")
410                return False
411
412        return True
413
414    def erase_flash(self):
415        """Erase the internal BMS flash."""
416        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.ERASE_FLASH)
417        time.sleep(FLASH_SLEEP)  # Wait for erase to complete
418        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
419        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
420
421    def test_flash_otp(self):
422        """
423        | Description          | Test OTP registers                                                     |
424        | :------------------- | :--------------------------------------------------------------------- |
425        | GitHub Issue         | turnaroundfactor/HITL#486                                       |
426        | Instructions         | 1. Set up flash values                                            </br>\
427                                  ⠀⠀⦁ Enable assembly mode                                         </br>\
428                                  ⠀⠀⦁ Calibrate the BMS and verify                                 </br>\
429                                  ⠀⠀⦁ Set the serial number and verify                             </br>\
430                                  ⠀⠀⦁ Enable the faults and verify                                 </br>\
431                                 2. Send "Flash Erase" command with SMBus                          </br>\
432                                 3. Confirm BMS flash is erased                                    </br>\
433                                  ⠀⠀⦁ BMS isn't calibrated                                         </br>\
434                                  ⠀⠀⦁ Faults aren't enabled                                        </br>\
435                                 4. Set up flash values                                            </br>\
436                                  ⠀⠀⦁ Calibrate the BMS and verify                                 </br>\
437                                  ⠀⠀⦁ Enable the faults and verify                                 </br>\
438                                 5. Enter manufacturing mode                                       </br>\
439                                 6. Send "Flash Erase" command with SMBus                          </br>\
440                                 7. Confirm BMS flash is not erased                                </br>\
441                                  ⠀⠀⦁ BMS is calibrated                                            </br>\
442                                  ⠀⠀⦁ Faults are enabled                                                |
443        | Pass / Fail Criteria | Pass flash cannot be erased                                            |
444        | Estimated Duration   | 1 minute                                                               |
445        """
446
447        # 1. Set up flash values and enter assembly mode
448        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.ASSEMBLY_MODE)
449        time.sleep(FLASH_SLEEP)
450        self.calibrate_current()
451        self.set_serial_id()
452        self.enable_faults()
453        power_cycle_bms()  # Power cycle
454        assert self.is_calibrated(), "Current was not calibrated after power cycle."
455        assert self.is_serial_set(), "Serial was not set after power cycle."
456        assert self.is_faults_enabled(), "Faults were not enabled after power cycle."
457
458        # 2/3. Erase flash, confirm BMS flash is erased
459        self.erase_flash()
460        assert not self.is_calibrated(), "Current was still calibrated after erase."
461        assert self.is_serial_set(), "Serial was not set after erase."
462        assert not self.is_faults_enabled(), "Faults were still enabled after erase."
463
464        # 4. Set up flash values
465        self.calibrate_current()
466        self.enable_faults()
467        power_cycle_bms()  # Power cycle
468        assert self.is_calibrated(), "Current was not calibrated after power cycle."
469        assert self.is_serial_set(), "Serial was not set after power cycle."
470        assert self.is_faults_enabled(), "Faults were not enabled after power cycle."
471
472        # 5. Enter manufacturing mode
473        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.PRODUCTION_MODE)
474        time.sleep(FLASH_SLEEP)
475
476        # 6. Erase flash, confirm BMS flash is not erased
477        self.erase_flash()
478        assert self.is_calibrated(), "Current was not calibrated after erase in manufacturing mode."
479        assert self.is_serial_set(), "Serial was not set after erase in manufacturing mode."
480        assert self.is_faults_enabled(), "Faults were not enabled after erase in manufacturing mode."

Test OTP registers.

average = 0
readings = 0
serial_id = 51966
def bms_current(self):
343    def bms_current(self):
344        """Measure serial current and calculate an average."""
345        new_reading = _bms.csv.cycle.last_serial_data["mamps"]
346        self.average = (new_reading + self.readings * self.average) / (self.readings + 1)
347        self.readings += 1
348        logger.write_info_to_report(f"BMS Serial Current (mA): {new_reading:.3f}")  # Output current on every sample

Measure serial current and calculate an average.

def calibrate_current(self):
350    def calibrate_current(self):
351        """Calibrate BMS current in flash."""
352        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
353        self.readings = 0
354        standard_rest(30, 5)
355        logger.write_result_to_html_report(f"Average rest current: {self.average:.3f} mA")
356
357        # Calibrate
358        offset = int(round(self.average, 0))
359        logger.write_info_to_report(f"Setting offset current to {offset:.3f} mA")
360        data = (ctypes.c_uint8(offset).value << 8) | BMSCommands.CALIBRATE
361        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, data)
362        time.sleep(FLASH_SLEEP)
363        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
364        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")
365
366        _bms.csv.cycle.postfix_fn = lambda: ...

Calibrate BMS current in flash.

def is_calibrated(self) -> bool:
368    def is_calibrated(self) -> bool:
369        """Confirm current is in acceptable range"""
370        acceptable_error_ma = 5
371
372        _bms.csv.cycle.postfix_fn = self.bms_current  # Get current on each sample
373        self.readings = 0  # Reset average
374        standard_rest(30, 5)
375        logger.write_result_to_html_report(f"Average rest current (calibrated?): {self.average:.3f} mA")
376        return acceptable_error_ma > self.average > -acceptable_error_ma

Confirm current is in acceptable range

def set_serial_id(self):
378    def set_serial_id(self):
379        """Set the 16-bit serial ID."""
380        _smbus.write_register(SMBusReg.SERIAL_NUM, self.serial_id)
381        time.sleep(FLASH_SLEEP)

Set the 16-bit serial ID.

def is_serial_set(self) -> bool:
383    def is_serial_set(self) -> bool:
384        """Check if the 16-bit serial ID has been set."""
385        return _smbus.read_register(SMBusReg.SERIAL_NUM)[1] == self.serial_id.to_bytes(2, byteorder="little")

Check if the 16-bit serial ID has been set.

def enable_faults(self):
387    def enable_faults(self):
388        """Enable faults via SMBus."""
389        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.FAULT_ENABLE)
390        time.sleep(FLASH_SLEEP)

Enable faults via SMBus.

def is_faults_enabled(self) -> bool:
392    def is_faults_enabled(self) -> bool:
393        """Check if overtemp faults can be raised."""
394        timeout_s = 10
395
396        # Raise a fault
397        _plateset.thermistor1 = 65
398        start = time.perf_counter()
399        while (serial_data := serial_monitor.read(latest=True)) and not serial_data["flags.fault_overtemp_discharge"]:
400            if time.perf_counter() - start > timeout_s:
401                logger.write_debug_to_report(f"Over-temperature fault was not raised after {timeout_s} seconds.")
402                return False
403
404        # Clear the fault
405        _plateset.thermistor1 = 45
406        start = time.perf_counter()
407        while (serial_data := serial_monitor.read(latest=True)) and serial_data["flags.fault_overtemp_discharge"]:
408            if time.perf_counter() - start > timeout_s:
409                logger.write_debug_to_report(f"Over-temperature fault was not cleared after {timeout_s} seconds.")
410                return False
411
412        return True

Check if overtemp faults can be raised.

def erase_flash(self):
414    def erase_flash(self):
415        """Erase the internal BMS flash."""
416        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.ERASE_FLASH)
417        time.sleep(FLASH_SLEEP)  # Wait for erase to complete
418        data = _smbus.read_register(SMBusReg.MANUFACTURING_ACCESS)[0]
419        logger.write_info_to_report(f"{SMBusReg.MANUFACTURING_ACCESS.fname}: {data:04X}")

Erase the internal BMS flash.

def test_flash_otp(self):
421    def test_flash_otp(self):
422        """
423        | Description          | Test OTP registers                                                     |
424        | :------------------- | :--------------------------------------------------------------------- |
425        | GitHub Issue         | turnaroundfactor/HITL#486                                       |
426        | Instructions         | 1. Set up flash values                                            </br>\
427                                  ⠀⠀⦁ Enable assembly mode                                         </br>\
428                                  ⠀⠀⦁ Calibrate the BMS and verify                                 </br>\
429                                  ⠀⠀⦁ Set the serial number and verify                             </br>\
430                                  ⠀⠀⦁ Enable the faults and verify                                 </br>\
431                                 2. Send "Flash Erase" command with SMBus                          </br>\
432                                 3. Confirm BMS flash is erased                                    </br>\
433                                  ⠀⠀⦁ BMS isn't calibrated                                         </br>\
434                                  ⠀⠀⦁ Faults aren't enabled                                        </br>\
435                                 4. Set up flash values                                            </br>\
436                                  ⠀⠀⦁ Calibrate the BMS and verify                                 </br>\
437                                  ⠀⠀⦁ Enable the faults and verify                                 </br>\
438                                 5. Enter manufacturing mode                                       </br>\
439                                 6. Send "Flash Erase" command with SMBus                          </br>\
440                                 7. Confirm BMS flash is not erased                                </br>\
441                                  ⠀⠀⦁ BMS is calibrated                                            </br>\
442                                  ⠀⠀⦁ Faults are enabled                                                |
443        | Pass / Fail Criteria | Pass flash cannot be erased                                            |
444        | Estimated Duration   | 1 minute                                                               |
445        """
446
447        # 1. Set up flash values and enter assembly mode
448        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.ASSEMBLY_MODE)
449        time.sleep(FLASH_SLEEP)
450        self.calibrate_current()
451        self.set_serial_id()
452        self.enable_faults()
453        power_cycle_bms()  # Power cycle
454        assert self.is_calibrated(), "Current was not calibrated after power cycle."
455        assert self.is_serial_set(), "Serial was not set after power cycle."
456        assert self.is_faults_enabled(), "Faults were not enabled after power cycle."
457
458        # 2/3. Erase flash, confirm BMS flash is erased
459        self.erase_flash()
460        assert not self.is_calibrated(), "Current was still calibrated after erase."
461        assert self.is_serial_set(), "Serial was not set after erase."
462        assert not self.is_faults_enabled(), "Faults were still enabled after erase."
463
464        # 4. Set up flash values
465        self.calibrate_current()
466        self.enable_faults()
467        power_cycle_bms()  # Power cycle
468        assert self.is_calibrated(), "Current was not calibrated after power cycle."
469        assert self.is_serial_set(), "Serial was not set after power cycle."
470        assert self.is_faults_enabled(), "Faults were not enabled after power cycle."
471
472        # 5. Enter manufacturing mode
473        _smbus.write_register(SMBusReg.MANUFACTURING_ACCESS, BMSCommands.PRODUCTION_MODE)
474        time.sleep(FLASH_SLEEP)
475
476        # 6. Erase flash, confirm BMS flash is not erased
477        self.erase_flash()
478        assert self.is_calibrated(), "Current was not calibrated after erase in manufacturing mode."
479        assert self.is_serial_set(), "Serial was not set after erase in manufacturing mode."
480        assert self.is_faults_enabled(), "Faults were not enabled after erase in manufacturing mode."
Description Test OTP registers
GitHub Issue turnaroundfactor/HITL#486
Instructions 1. Set up flash values
⠀⠀⦁ Enable assembly mode
⠀⠀⦁ Calibrate the BMS and verify
⠀⠀⦁ Set the serial number and verify
⠀⠀⦁ Enable the faults and verify
2. Send "Flash Erase" command with SMBus
3. Confirm BMS flash is erased
⠀⠀⦁ BMS isn't calibrated
⠀⠀⦁ Faults aren't enabled
4. Set up flash values
⠀⠀⦁ Calibrate the BMS and verify
⠀⠀⦁ Enable the faults and verify
5. Enter manufacturing mode
6. Send "Flash Erase" command with SMBus
7. Confirm BMS flash is not erased
⠀⠀⦁ BMS is calibrated
⠀⠀⦁ Faults are enabled
Pass / Fail Criteria Pass flash cannot be erased
Estimated Duration 1 minute
pytestmark = [Mark(name='sim_cells', args=(), kwargs={}), Mark(name='usefixtures', args=('cycle_smbus',), kwargs={})]