| # Copyright 2012 The ChromiumOS Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """This test cycles the battery. |
| |
| Description |
| ----------- |
| It runs for a particular number of cycles or number of hours and records, |
| cycling the battery between a minimum charge (e.g., 5%) and a maximum |
| charge (e.g., 95%). Cycle times are logged to event logs. |
| |
| Internal references |
| ^^^^^^^^^^^^^^^^^^^ |
| |
| - https://chromeos.google.com/partner/dlm/docs/component-qual/index.html. See |
| the "Power" section -> "Battery_Qualification Test Plan" -> |
| "Battery Recharge Cycle Test". |
| |
| Test Procedure |
| -------------- |
| This is an automatic test that doesn't need any user interaction. |
| |
| Dependency |
| ---------- |
| - Device API ``cros.factory.device.power``. |
| |
| Examples |
| -------- |
| Add this into test list: |
| |
| .. test_list:: |
| |
| generic_battery_examples:BatteryCycle |
| |
| """ |
| |
| import collections |
| import enum |
| import logging |
| import time |
| |
| from cros.factory.device import device_utils |
| from cros.factory.test.event_log import Log |
| from cros.factory.test import test_case |
| from cros.factory.test import test_ui |
| from cros.factory.test.utils import stress_manager |
| from cros.factory.utils.arg_utils import Arg |
| from cros.factory.utils import debug_utils |
| from cros.factory.utils import time_utils |
| |
| |
| History = collections.namedtuple('History', ['cycle', 'charge', 'discharge']) |
| |
| |
| class Mode(str, enum.Enum): |
| CHARGE = 'CHARGE' |
| DISCHARGE = 'DISCHARGE' |
| CUTOFF = 'CUTOFF' |
| |
| def __str__(self): |
| return self.name |
| |
| |
| class BatteryCycleTest(test_case.TestCase): |
| related_components = (test_case.TestCategory.BATTERY, ) |
| ARGS = [ |
| Arg('num_cycles', int, 'Number of cycles to run', default=None), |
| Arg('max_duration_hours', (int, float), |
| 'Maximum number of hours to run', default=None), |
| Arg('cycle_timeout_secs', int, |
| 'Maximum time for one charge/discharge cycle', default=12 * 60 * 60), |
| Arg('minimum_charge_pct', (int, float), 'Minimum charge, in percent', |
| default=5), |
| Arg('maximum_charge_pct', (int, float), 'Maximum charge, in percent', |
| default=95), |
| Arg('charge_threshold_secs', int, |
| 'Amount of time the charge must remain above or below the ' |
| 'specified threshold to have considered to have finished ' |
| 'part of a cycle.', default=30), |
| Arg('idle_time_secs', int, 'Time to idle between battery checks.', |
| default=1), |
| Arg('log_interval_secs', int, 'Interval at which to log system status.', |
| default=30), |
| Arg('verify_cutoff', bool, |
| 'True to verify battery stops charging when ~100%', |
| default=False), |
| Arg('cutoff_charge_pct', (int, float), |
| 'Minimum charge level in percent allowed in cutoff state.', |
| default=98), |
| Arg('fast_discharge', bool, |
| 'Use stressapptest in discharge phase to discharge faster', |
| default=True) |
| ] |
| |
| def setUp(self): |
| self.dut = device_utils.CreateDUTInterface() |
| self.status = self.dut.status.Snapshot() |
| self.completed_cycles = 0 |
| self.mode = None |
| self.start_time = time.time() |
| self.cycle_start_time = None |
| self.history = [] # Array of History objects |
| self._UpdateHistory() |
| |
| def _Log(self, event, **kwargs): |
| """Logs an event to the event log. |
| |
| The current mode, cycle, and system are also logged. |
| |
| Args: |
| kwargs: Additional items to log. |
| """ |
| log_args = dict(kwargs) |
| log_args['mode'] = self.mode |
| log_args['cycle'] = self.completed_cycles |
| log_args['battery'] = self.dut.power.GetInfoDict() |
| Log(event, **log_args) |
| |
| def _UpdateHistory(self): |
| """Updates history in the UI.""" |
| history_lines = [] |
| for h in self.history[-5:]: |
| line = (f'{int(h.cycle + 1)}: Charged in ' |
| f'{time_utils.FormatElapsedTime(h.charge)}') |
| if h.discharge: |
| line += (f', discharged in {time_utils.FormatElapsedTime(h.discharge)}') |
| |
| history_lines.append(test_ui.Escape(line)) |
| |
| if not history_lines: |
| history_lines.append('(none)') |
| while len(history_lines) < 5: |
| history_lines.append('') |
| self.ui.SetHTML('\n'.join(history_lines), id='bc-history') |
| |
| def _RunPhase(self): |
| """Runs the charge or discharge part of a cycle.""" |
| |
| self._Log('phase_start') |
| logging.info('Starting %s, cycle=%d', self.mode, self.completed_cycles) |
| |
| target_charge_map = {Mode.CHARGE: self.args.maximum_charge_pct, |
| Mode.DISCHARGE: self.args.minimum_charge_pct, |
| Mode.CUTOFF: 100} |
| target_charge_pct = target_charge_map[self.mode] |
| |
| for elt_id, content in ( |
| ('bc-phase', 'Charging' if self.mode == Mode.CHARGE else 'Discharging'), |
| ('bc-current-cycle', self.completed_cycles + 1), |
| ('bc-cycles-remaining', |
| (self.args.num_cycles - |
| self.completed_cycles if self.args.num_cycles else '\u221e')), |
| ('bc-target-charge', f'{target_charge_pct:.2f}%')): |
| self.ui.SetHTML(content, id=elt_id) |
| |
| first_done_time = [None] |
| |
| def IsDone(): |
| """Returns True if the cycle really is done. |
| |
| This is True if IsDoneNow() has been continuously true for |
| charge_threshold_secs. |
| """ |
| if is_done_now(self.dut.power.GetChargePct(True)): |
| if not first_done_time[0]: |
| logging.info('%s cycle appears to be done. ' |
| 'Will continue checking for %d seconds', |
| self.mode, self.args.charge_threshold_secs) |
| first_done_time[0] = time.time() |
| return (time.time() - first_done_time[0] >= |
| self.args.charge_threshold_secs) |
| |
| if first_done_time[0]: |
| logging.info('%s cycle now appears not to be done. ' |
| 'Resetting threshold.', self.mode) |
| first_done_time[0] = None |
| return False |
| |
| if self.mode in (Mode.CHARGE, Mode.CUTOFF): |
| self.dut.power.SetChargeState(self.dut.power.ChargeState.CHARGE) |
| stress_manager_instance = stress_manager.DummyStressManager(self.dut) |
| if self.mode == Mode.CHARGE: |
| is_done_now = lambda x: x > target_charge_pct |
| else: |
| is_done_now = lambda x: (self.dut.power.GetBatteryCurrent() == 0 and |
| x > self.args.cutoff_charge_pct) |
| else: |
| self.dut.power.SetChargeState(self.dut.power.ChargeState.DISCHARGE) |
| if self.args.fast_discharge: |
| stress_manager_instance = stress_manager.StressManager(self.dut) |
| else: |
| stress_manager_instance = stress_manager.DummyStressManager(self.dut) |
| is_done_now = lambda x: x < target_charge_pct |
| |
| phase_start_time = time.time() |
| last_log_time = None |
| with stress_manager_instance.Run(): |
| while True: |
| self.status = self.dut.status.Snapshot() |
| now = time.time() |
| if (last_log_time is None or |
| now - last_log_time >= self.args.log_interval_secs): |
| last_log_time = now |
| self._Log('status') |
| |
| if now > self.cycle_start_time + self.args.cycle_timeout_secs: |
| self.fail(f'{self.mode} timed out') |
| |
| if IsDone(): |
| self._Log( |
| 'phase_end', duration_secs=(now - phase_start_time)) |
| logging.info('%s cycle completed in %d seconds', |
| self.mode, now - phase_start_time) |
| |
| if self.history and self.history[-1].discharge is None: |
| self.history[-1] = self.history[-1]._replace( |
| discharge=(now - phase_start_time)) |
| else: |
| self.history.append(History(self.completed_cycles, |
| now - phase_start_time, |
| None)) |
| self._UpdateHistory() |
| return |
| |
| for elt_id, elapsed_time in ( |
| ('bc-elapsed-time', now - self.start_time), |
| ('bc-cycle-elapsed-time', now - self.cycle_start_time), |
| ('bc-phase-elapsed-time', now - phase_start_time), |
| ('bc-time-remaining', ( |
| self.args.max_duration_hours * 60 * 60 - |
| (now - phase_start_time) |
| if self.args.max_duration_hours else None))): |
| self.ui.SetHTML( |
| time_utils.FormatElapsedTime(elapsed_time) |
| if elapsed_time else '\u221e', id=elt_id) |
| self.ui.SetHTML(f'{self.dut.power.GetChargePct(get_float=True):.2f}%', |
| id='bc-charge') |
| if first_done_time[0] is None: |
| message = '' |
| else: |
| time_cost = self.args.charge_threshold_secs - int( |
| round(now - first_done_time[0])) |
| message = f'(complete in {time_cost} s)' |
| self.ui.SetHTML(message, id='bc-phase-complete') |
| |
| self.Sleep(self.args.idle_time_secs) |
| |
| def runTest(self): |
| try: |
| self.start_time = time.time() |
| while True: |
| self.cycle_start_time = time.time() |
| if (self.args.num_cycles and |
| self.completed_cycles >= self.args.num_cycles): |
| logging.info('Completed %s cycles (num_cycles). Success.', |
| self.args.num_cycles) |
| return |
| |
| duration_hours = (time.time() - self.start_time) / (60 * 60) |
| if (self.args.max_duration_hours and |
| duration_hours >= self.args.max_duration_hours): |
| logging.info('Ran for %s hours. Success.', duration_hours) |
| return |
| |
| for mode in (Mode.CHARGE, Mode.DISCHARGE): |
| self.mode = mode |
| self._RunPhase() |
| self.completed_cycles += 1 |
| |
| if self.args.verify_cutoff: |
| self.mode = Mode.CUTOFF |
| self._RunPhase() |
| |
| self._Log('pass') |
| except Exception: |
| logging.exception('Test failed') |
| error_msg = debug_utils.FormatExceptionOnly() |
| self._Log('fail', error_msg=error_msg) |
| self.FailTask(error_msg) |