hitl_tester.test_cases.bms.test_nicd_cell

Turn on and off relays for charging. 700 mA for 14 hours After charged, turn on relay for discharge resistor Measure how long (time) it takes for the cell to get to 1.21V and 1.2V Stop discharging at 1.2V Cells that last >5 hours before the 1.21V cutoff need to go to the 20S pack Cells that last >5 hours before the 1.2V cutoff can go to 17S or 20S pack Any cells that do not last 5 hours should not be used in a pack

We are using this hardware: ADCplate (https://pi-plates.com/adcplate-users-guide/) x3 RELAYplate2 (https://pi-plates.com/relayplate2/) x3

D0 - D3 = voltages 3 plates = 12 voltages

Used in these test plans:

  • nicd_qc_test ⠀⠀⠀(bms/nicd_qc_test.plan)

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

  • ./hitl_tester.py nicd_qc_test -DREPORT_FILE=""
  1"""
  2Turn on and off relays for charging. 700 mA for 14 hours
  3After charged, turn on relay for discharge resistor
  4Measure how long (time) it takes for the cell to get to 1.21V and 1.2V
  5Stop discharging at 1.2V
  6Cells that last >5 hours before the 1.21V cutoff need to go to the 20S pack
  7Cells that last >5 hours before the 1.2V cutoff can go to 17S or 20S pack
  8Any cells that do not last 5 hours should not be used in a pack
  9
 10We are using this hardware:
 11ADCplate (https://pi-plates.com/adcplate-users-guide/) x3
 12RELAYplate2 (https://pi-plates.com/relayplate2/) x3
 13
 14D0 - D3 = voltages
 153 plates = 12 voltages
 16"""
 17
 18from __future__ import annotations
 19
 20import atexit
 21import datetime
 22import pathlib
 23import platform
 24import signal
 25import sys
 26import time
 27from dataclasses import dataclass
 28from enum import Enum
 29
 30import pytest
 31import yaml
 32from colorama import init, Fore
 33
 34from hitl_tester.modules.bms.bms_hw import BMSHardware
 35from hitl_tester.modules.bms_types import BMSFlags, ResourceNotFoundError
 36from hitl_tester.modules.bms.csv_tables import CSVRecorders
 37from hitl_tester.modules.logger import logger
 38
 39if hasattr(pytest, "flags") and isinstance(pytest.flags, BMSFlags) and pytest.flags.dry_run:
 40    from hitl_tester.modules.bms.pseudo_hardware import ADC
 41    from hitl_tester.modules.bms.pseudo_hardware import RELAY2
 42else:
 43    import piplates.ADCplate as ADC  # type: ignore[no-redef]
 44    import piplates.RELAYplate2 as RELAY2  # type: ignore[no-redef]
 45
 46init(True, strip=True)  # Initialize colorama (autoreset)
 47
 48REPORT_FILE = ""
 49
 50
 51@dataclass
 52class Port:
 53    """The board and channel on a PiPlate."""
 54
 55    board_id: int
 56    channel_id: int
 57
 58
 59class CellState(Enum):
 60    """The current state of the NiCd cell."""
 61
 62    RESTING = Fore.LIGHTBLACK_EX
 63    DISCHARGING = Fore.CYAN
 64    PASSED_20S = Fore.LIGHTGREEN_EX
 65    PASSED_17S_OR_20S = Fore.GREEN
 66    FAILED = Fore.LIGHTRED_EX
 67
 68
 69@dataclass
 70class Cell:
 71    """A NiCd cell attached to the HITL."""
 72
 73    adc_port: Port
 74    relay_port: Port
 75    ident: int
 76    timestamp_start: float = 0.0
 77    timestamp_1210_ma: float = 0.0
 78    timestamp_1200_ma: float = 0.0
 79    _state: CellState = CellState.RESTING
 80    csv: CSVRecorders | None = None
 81
 82    def __post_init__(self):
 83        """Set sample rate for cell voltage."""
 84        ADC.setMODE(self.adc_port.board_id, "ADV")  # Put board_id into advanced
 85        ADC.configINPUT(
 86            self.adc_port.board_id, f"D{self.adc_port.channel_id}", 7, True
 87        )  # 0nly 8 can be enabled at any time
 88
 89        BMSHardware().report_filename = REPORT_FILE
 90        self.csv = CSVRecorders(BMSHardware())
 91
 92        @atexit.register
 93        def __atexit__():
 94            """Configure a safe shut down for when the class instance is destroyed."""
 95            logger.write_info_to_report(f"Disabling cell B{self.relay_port.board_id}:C{self.relay_port.channel_id}")
 96            self.state = CellState.RESTING
 97
 98    @property
 99    def state(self):
100        """Get current cell state."""
101        return self._state
102
103    @state.setter
104    def state(self, new_state: CellState):
105        """Set current cell state."""
106        if new_state == CellState.DISCHARGING:
107            RELAY2.relayON(self.relay_port.board_id, self.relay_port.channel_id)
108        else:
109            RELAY2.relayOFF(self.relay_port.board_id, self.relay_port.channel_id)
110        self._state = new_state
111
112    @property
113    def voltage(self) -> float:
114        """Measure a voltage using the ADC plates."""
115        return float(ADC.readSINGLE(self.adc_port.board_id, f"D{self.adc_port.channel_id}") or 0.0)
116
117    def __str__(self) -> str:
118        """Formatted string representation."""
119        return (
120            f"{self.state.value}{self.state.name}{Fore.RESET}"
121            f"[B{self.relay_port.board_id}:C{self.relay_port.channel_id}, {self.voltage}V]"
122        )
123
124
125def safe_shutdown_handler(_signo, _stack_frame):
126    """
127    This handler will be called when a HANGUP signal is received.
128    i.e. ssh connection drops out. In this event, we're going to
129    ensure that the test setup is transitioned to a safe state.
130    """
131    signal.signal(_signo, signal.SIG_IGN)  # Ignore additional signals
132    logger.write_critical_to_report(f"Caught {signal.Signals(_signo).name}, Powering OFF HITL!!!")
133    sys.exit(0)  # exit gracefully (Calls bms_teardown)
134
135
136def test_safe_shutdown():
137    """Initialize safe shutdown handler."""
138    for signal_flag in (signal.SIGABRT, signal.SIGHUP, signal.SIGINT, signal.SIGTERM, signal.SIGCHLD):
139        signal.signal(signal_flag, safe_shutdown_handler)
140
141
142def test_logger():
143    """Initialize the log file."""
144    global REPORT_FILE
145
146    hitl_config = pathlib.Path(__file__).parent.resolve() / ".." / "hitl_config.yml"
147    with open(hitl_config, "r", encoding="utf-8") as config_file:
148        for config in yaml.safe_load_all(config_file):
149            if config["plate_set_id"] == pytest.flags.plateset_id:
150                # Record the report filename
151                REPORT_FILE = pytest.flags.report_filename
152
153                # initialize the logger
154                logger.use_file_mode(REPORT_FILE, config["log_level"])
155                logger.write_info_to_report("Found plateset")
156                break
157        else:
158            raise ResourceNotFoundError(f'Plateset "{pytest.flags.plateset_id}" does not exist in "{hitl_config.name}"')
159
160
161def formatted_time(seconds: float) -> str:
162    """Return time formatted as hh:mm:ss."""
163    return str(datetime.timedelta(seconds=seconds)).partition(".")[0]
164
165
166def test_discharge():
167    """Charge at 700 mA for 14 hours."""
168    logger.write_info_to_report("Beginning discharge test")
169    cell_holder = "A" if platform.node() == "HITL-4-1" else "B" if platform.node() == "HITL-4-2" else "?"
170    all_cells = [
171        Cell(adc_port=Port(0, 0), relay_port=Port(3, 8), ident=1),
172        Cell(adc_port=Port(0, 1), relay_port=Port(3, 1), ident=2),
173        Cell(adc_port=Port(0, 2), relay_port=Port(3, 2), ident=3),
174        Cell(adc_port=Port(0, 3), relay_port=Port(3, 3), ident=4),
175        Cell(adc_port=Port(1, 0), relay_port=Port(3, 4), ident=5),
176        Cell(adc_port=Port(1, 1), relay_port=Port(3, 5), ident=6),
177        Cell(adc_port=Port(1, 2), relay_port=Port(3, 6), ident=7),
178        Cell(adc_port=Port(1, 3), relay_port=Port(3, 7), ident=8),
179        Cell(adc_port=Port(2, 0), relay_port=Port(4, 8), ident=9),
180        Cell(adc_port=Port(2, 1), relay_port=Port(4, 1), ident=10),
181        Cell(adc_port=Port(2, 2), relay_port=Port(4, 2), ident=11),
182        Cell(adc_port=Port(2, 3), relay_port=Port(4, 3), ident=12),
183    ]
184    logger.write_info_to_report(", ".join(map(str, all_cells)))
185    test_start = time.perf_counter()
186
187    # Initialize cells
188    for cell in all_cells:
189        cell.timestamp_start = time.perf_counter()
190        cell.state = CellState.DISCHARGING
191        cell.csv.nicd.create_file("0000_", f"_{cell_holder}{cell.ident:02}")
192
193    # Discharge cells to 1.2V
194    while CellState.DISCHARGING in [cell.state for cell in all_cells]:
195        time_remaining_seconds = max(0.0, (5 * 3600) - (time.perf_counter() - test_start))
196        time_remaining_string = formatted_time(time_remaining_seconds)
197        logger.write_info_to_report(f"Time remaining (H:M:S): {time_remaining_string}")
198        logger.write_info_to_report(", ".join(map(str, all_cells)))
199        for cell in all_cells:
200            old_cell_state = cell.state
201            volts = cell.voltage
202            if not cell.timestamp_1210_ma and volts <= 1.21:
203                cell.timestamp_1210_ma = time.perf_counter()
204            elif not cell.timestamp_1200_ma and volts <= 1.20:
205                cell.timestamp_1200_ma = time.perf_counter()
206
207                if cell.timestamp_1210_ma - cell.timestamp_start >= 5 * 3600:  # >5 hours before 1.21V = 20S
208                    cell.state = CellState.PASSED_20S
209                elif cell.timestamp_1200_ma - cell.timestamp_start >= 5 * 3600:  # >5 hours before 1.2V = 17S/20S
210                    cell.state = CellState.PASSED_17S_OR_20S
211                else:
212                    cell.state = CellState.FAILED
213
214            # Record CSV
215            if cell.state == CellState.DISCHARGING or cell.state != old_cell_state:
216                cell.csv.nicd.record(
217                    time.perf_counter() - test_start,
218                    cell.relay_port.board_id,
219                    cell.relay_port.channel_id,
220                    volts,
221                    cell.state.name.title(),
222                    int(cell.timestamp_1210_ma - cell.timestamp_start) if cell.timestamp_1210_ma else None,
223                    int(cell.timestamp_1200_ma - cell.timestamp_start) if cell.timestamp_1200_ma else None,
224                )
225        time.sleep(10)
226
227    # Log results
228    logger.write_info_to_report("Completed discharge test")
229    for cell in all_cells:
230        logger.write_info_to_report(
231            f"Relay board: {cell.relay_port.board_id}, "
232            f"Relay channel: {cell.relay_port.channel_id}, "
233            f"Time to 1.21V (H:M:S): {formatted_time(cell.timestamp_1210_ma - cell.timestamp_start)}, "
234            f"Time to 1.20V (H:M:S): {formatted_time(cell.timestamp_1200_ma - cell.timestamp_start)}, "
235            f"Result: {cell.state.value}{cell.state.name}{Fore.RESET}"
236        )
REPORT_FILE = ''
@dataclass
class Port:
52@dataclass
53class Port:
54    """The board and channel on a PiPlate."""
55
56    board_id: int
57    channel_id: int

The board and channel on a PiPlate.

Port(board_id: int, channel_id: int)
board_id: int
channel_id: int
class CellState(enum.Enum):
60class CellState(Enum):
61    """The current state of the NiCd cell."""
62
63    RESTING = Fore.LIGHTBLACK_EX
64    DISCHARGING = Fore.CYAN
65    PASSED_20S = Fore.LIGHTGREEN_EX
66    PASSED_17S_OR_20S = Fore.GREEN
67    FAILED = Fore.LIGHTRED_EX

The current state of the NiCd cell.

RESTING = <CellState.RESTING: '\x1b[90m'>
DISCHARGING = <CellState.DISCHARGING: '\x1b[36m'>
PASSED_20S = <CellState.PASSED_20S: '\x1b[92m'>
PASSED_17S_OR_20S = <CellState.PASSED_17S_OR_20S: '\x1b[32m'>
FAILED = <CellState.FAILED: '\x1b[91m'>
Inherited Members
enum.Enum
name
value
@dataclass
class Cell:
 70@dataclass
 71class Cell:
 72    """A NiCd cell attached to the HITL."""
 73
 74    adc_port: Port
 75    relay_port: Port
 76    ident: int
 77    timestamp_start: float = 0.0
 78    timestamp_1210_ma: float = 0.0
 79    timestamp_1200_ma: float = 0.0
 80    _state: CellState = CellState.RESTING
 81    csv: CSVRecorders | None = None
 82
 83    def __post_init__(self):
 84        """Set sample rate for cell voltage."""
 85        ADC.setMODE(self.adc_port.board_id, "ADV")  # Put board_id into advanced
 86        ADC.configINPUT(
 87            self.adc_port.board_id, f"D{self.adc_port.channel_id}", 7, True
 88        )  # 0nly 8 can be enabled at any time
 89
 90        BMSHardware().report_filename = REPORT_FILE
 91        self.csv = CSVRecorders(BMSHardware())
 92
 93        @atexit.register
 94        def __atexit__():
 95            """Configure a safe shut down for when the class instance is destroyed."""
 96            logger.write_info_to_report(f"Disabling cell B{self.relay_port.board_id}:C{self.relay_port.channel_id}")
 97            self.state = CellState.RESTING
 98
 99    @property
100    def state(self):
101        """Get current cell state."""
102        return self._state
103
104    @state.setter
105    def state(self, new_state: CellState):
106        """Set current cell state."""
107        if new_state == CellState.DISCHARGING:
108            RELAY2.relayON(self.relay_port.board_id, self.relay_port.channel_id)
109        else:
110            RELAY2.relayOFF(self.relay_port.board_id, self.relay_port.channel_id)
111        self._state = new_state
112
113    @property
114    def voltage(self) -> float:
115        """Measure a voltage using the ADC plates."""
116        return float(ADC.readSINGLE(self.adc_port.board_id, f"D{self.adc_port.channel_id}") or 0.0)
117
118    def __str__(self) -> str:
119        """Formatted string representation."""
120        return (
121            f"{self.state.value}{self.state.name}{Fore.RESET}"
122            f"[B{self.relay_port.board_id}:C{self.relay_port.channel_id}, {self.voltage}V]"
123        )

A NiCd cell attached to the HITL.

Cell( adc_port: Port, relay_port: Port, ident: int, timestamp_start: float = 0.0, timestamp_1210_ma: float = 0.0, timestamp_1200_ma: float = 0.0, _state: CellState = <CellState.RESTING: '\x1b[90m'>, csv: hitl_tester.modules.bms.csv_tables.CSVRecorders | None = None)
adc_port: Port
relay_port: Port
ident: int
timestamp_start: float = 0.0
timestamp_1210_ma: float = 0.0
timestamp_1200_ma: float = 0.0
state
 99    @property
100    def state(self):
101        """Get current cell state."""
102        return self._state

Get current cell state.

voltage: float
113    @property
114    def voltage(self) -> float:
115        """Measure a voltage using the ADC plates."""
116        return float(ADC.readSINGLE(self.adc_port.board_id, f"D{self.adc_port.channel_id}") or 0.0)

Measure a voltage using the ADC plates.

def safe_shutdown_handler(_signo, _stack_frame):
126def safe_shutdown_handler(_signo, _stack_frame):
127    """
128    This handler will be called when a HANGUP signal is received.
129    i.e. ssh connection drops out. In this event, we're going to
130    ensure that the test setup is transitioned to a safe state.
131    """
132    signal.signal(_signo, signal.SIG_IGN)  # Ignore additional signals
133    logger.write_critical_to_report(f"Caught {signal.Signals(_signo).name}, Powering OFF HITL!!!")
134    sys.exit(0)  # exit gracefully (Calls bms_teardown)

This handler will be called when a HANGUP signal is received. i.e. ssh connection drops out. In this event, we're going to ensure that the test setup is transitioned to a safe state.

def test_safe_shutdown():
137def test_safe_shutdown():
138    """Initialize safe shutdown handler."""
139    for signal_flag in (signal.SIGABRT, signal.SIGHUP, signal.SIGINT, signal.SIGTERM, signal.SIGCHLD):
140        signal.signal(signal_flag, safe_shutdown_handler)

Initialize safe shutdown handler.

def test_logger():
143def test_logger():
144    """Initialize the log file."""
145    global REPORT_FILE
146
147    hitl_config = pathlib.Path(__file__).parent.resolve() / ".." / "hitl_config.yml"
148    with open(hitl_config, "r", encoding="utf-8") as config_file:
149        for config in yaml.safe_load_all(config_file):
150            if config["plate_set_id"] == pytest.flags.plateset_id:
151                # Record the report filename
152                REPORT_FILE = pytest.flags.report_filename
153
154                # initialize the logger
155                logger.use_file_mode(REPORT_FILE, config["log_level"])
156                logger.write_info_to_report("Found plateset")
157                break
158        else:
159            raise ResourceNotFoundError(f'Plateset "{pytest.flags.plateset_id}" does not exist in "{hitl_config.name}"')

Initialize the log file.

def formatted_time(seconds: float) -> str:
162def formatted_time(seconds: float) -> str:
163    """Return time formatted as hh:mm:ss."""
164    return str(datetime.timedelta(seconds=seconds)).partition(".")[0]

Return time formatted as hh:mm:ss.

def test_discharge():
167def test_discharge():
168    """Charge at 700 mA for 14 hours."""
169    logger.write_info_to_report("Beginning discharge test")
170    cell_holder = "A" if platform.node() == "HITL-4-1" else "B" if platform.node() == "HITL-4-2" else "?"
171    all_cells = [
172        Cell(adc_port=Port(0, 0), relay_port=Port(3, 8), ident=1),
173        Cell(adc_port=Port(0, 1), relay_port=Port(3, 1), ident=2),
174        Cell(adc_port=Port(0, 2), relay_port=Port(3, 2), ident=3),
175        Cell(adc_port=Port(0, 3), relay_port=Port(3, 3), ident=4),
176        Cell(adc_port=Port(1, 0), relay_port=Port(3, 4), ident=5),
177        Cell(adc_port=Port(1, 1), relay_port=Port(3, 5), ident=6),
178        Cell(adc_port=Port(1, 2), relay_port=Port(3, 6), ident=7),
179        Cell(adc_port=Port(1, 3), relay_port=Port(3, 7), ident=8),
180        Cell(adc_port=Port(2, 0), relay_port=Port(4, 8), ident=9),
181        Cell(adc_port=Port(2, 1), relay_port=Port(4, 1), ident=10),
182        Cell(adc_port=Port(2, 2), relay_port=Port(4, 2), ident=11),
183        Cell(adc_port=Port(2, 3), relay_port=Port(4, 3), ident=12),
184    ]
185    logger.write_info_to_report(", ".join(map(str, all_cells)))
186    test_start = time.perf_counter()
187
188    # Initialize cells
189    for cell in all_cells:
190        cell.timestamp_start = time.perf_counter()
191        cell.state = CellState.DISCHARGING
192        cell.csv.nicd.create_file("0000_", f"_{cell_holder}{cell.ident:02}")
193
194    # Discharge cells to 1.2V
195    while CellState.DISCHARGING in [cell.state for cell in all_cells]:
196        time_remaining_seconds = max(0.0, (5 * 3600) - (time.perf_counter() - test_start))
197        time_remaining_string = formatted_time(time_remaining_seconds)
198        logger.write_info_to_report(f"Time remaining (H:M:S): {time_remaining_string}")
199        logger.write_info_to_report(", ".join(map(str, all_cells)))
200        for cell in all_cells:
201            old_cell_state = cell.state
202            volts = cell.voltage
203            if not cell.timestamp_1210_ma and volts <= 1.21:
204                cell.timestamp_1210_ma = time.perf_counter()
205            elif not cell.timestamp_1200_ma and volts <= 1.20:
206                cell.timestamp_1200_ma = time.perf_counter()
207
208                if cell.timestamp_1210_ma - cell.timestamp_start >= 5 * 3600:  # >5 hours before 1.21V = 20S
209                    cell.state = CellState.PASSED_20S
210                elif cell.timestamp_1200_ma - cell.timestamp_start >= 5 * 3600:  # >5 hours before 1.2V = 17S/20S
211                    cell.state = CellState.PASSED_17S_OR_20S
212                else:
213                    cell.state = CellState.FAILED
214
215            # Record CSV
216            if cell.state == CellState.DISCHARGING or cell.state != old_cell_state:
217                cell.csv.nicd.record(
218                    time.perf_counter() - test_start,
219                    cell.relay_port.board_id,
220                    cell.relay_port.channel_id,
221                    volts,
222                    cell.state.name.title(),
223                    int(cell.timestamp_1210_ma - cell.timestamp_start) if cell.timestamp_1210_ma else None,
224                    int(cell.timestamp_1200_ma - cell.timestamp_start) if cell.timestamp_1200_ma else None,
225                )
226        time.sleep(10)
227
228    # Log results
229    logger.write_info_to_report("Completed discharge test")
230    for cell in all_cells:
231        logger.write_info_to_report(
232            f"Relay board: {cell.relay_port.board_id}, "
233            f"Relay channel: {cell.relay_port.channel_id}, "
234            f"Time to 1.21V (H:M:S): {formatted_time(cell.timestamp_1210_ma - cell.timestamp_start)}, "
235            f"Time to 1.20V (H:M:S): {formatted_time(cell.timestamp_1200_ma - cell.timestamp_start)}, "
236            f"Result: {cell.state.value}{cell.state.name}{Fore.RESET}"
237        )

Charge at 700 mA for 14 hours.