blob: b183838f836ffa84bfc8d4be406126d46c71bca1 [file] [log] [blame]
# Copyright 2017 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# pylint: disable=line-too-long
"""Calibration test for light sensor (a chamber is needed).
Description
-----------
This is a station-based test which calibrates light sensors.
The test controls a light chamber to switch light intensity between different
light preset and reads the value from the light sensor of a DUT.
The calibration method is linear regression. The test samples multiple
data points (e.g., LUX1, LUX2) and find out a new linear equation to fit the
validating data point (e.g., LUX3). The calibrated coefficients scale factor and
bias will be saved to the VPD.
The default chamber connection driver is PL2303 over RS232. You can speicify a
new driver to ``chamber_conn_params``. You also need to provide the
``chamber_cmd`` with which a station can command the light chamber.
Besides the arguments, there are still many configurations in the
light_sensor_calibration.json. For examples:
{
"version": "v0.01",
"light_seq": ["LUX1", "LUX2", "LUX3"],
"n_samples": 5,
"read_delay": 2.0,
"light_delay": 6.0,
"luxs": [40, 217],
"validating_light": "LUX3",
"validating_lux": 316,
"validating_err_limit": 0.2,
"force_light_init": false
}
The most important entries are ``luxs`` and ``validating_lux``. They are the
preset illuminance value of a light chamber fixture. You need a lux meter
to evaluate the preset light settings from the light chamber fixture to get
these values. After you have these values, don't forget to update the runtime
configuration by calling
``cros.factory.utils.config_utils.SaveRuntimeConfig('light_sensor_calibration', new_config)``
so that you have the correct preset light information. There are many things
that influence the preset light value of the light chamber. It could be the
unstable elecrtic environment or if the light chamber's bulb is broken.
Test Procedure
--------------
This is an automated test. Before you start the test, prepare the
physical setup and calibrate the light chamber itself by a lux meter:
1. Connects the station and the DUT.
2. Connects the station and the light chamber.
3. Press start test.
4. After finishing the test, disconnects the station and the DUT.
Dependency
----------
- A light chamber with at least three luminance settings.
Examples
--------
To automatically calibrate the light_sensor with the given ``chamber_cmd``, add
this into test list::
{
"pytest_name": "light_sensor_calibration",
"args": {
"control_chamber": true,
"assume_chamber_connected": true,
"chamber_cmd": {
"LUX1": [
[
"LUX1_ON",
"LUX1_READY"
]
],
"LUX2": [
[
"LUX2_ON",
"LUX2_READY"
]
],
"LUX3": [
[
"LUX3_ON",
"LUX3_READY"
]
],
"OFF": [
[
"OFF",
"OFF_READY"
]
]
}
}
}
To debug and use a mocked light chamber::
{
"pytest_name": "light_sensor_calibration",
"args": {
"control_chamber": true,
"mock_mode": true
}
}
To manually switch chamber light::
{
"pytest_name": "light_sensor_calibration",
"args": {
"control_chamber": false
}
}
Trouble Shooting
----------------
If you found error related to load configuration file:
- This is probably your runtime config format is incorrect.
If you found error connecting to light chamber:
1. Make sure the chamber and station are connected.
2. Make sure the dongle is correct one. If you are not using the dongle with
PL2303 driver, you need to provide one.
If you found the calibrated coefficients are skewd:
1. This is probably you don't calibrate the light chamber recently.
"""
from collections import namedtuple
import json
import logging
import time
import numpy as np
import factory_common # pylint: disable=unused-import
from cros.factory.device import ambient_light_sensor
from cros.factory.device import device_utils
from cros.factory.test import session
from cros.factory.test.fixture import fixture_connection
from cros.factory.test.fixture.light_sensor import light_chamber
from cros.factory.test import i18n
from cros.factory.test.i18n import _
from cros.factory.test import test_case
from cros.factory.test.utils import kbd_leds
from cros.factory.test.utils import media_utils
from cros.factory.testlog import testlog
from cros.factory.utils.arg_utils import Arg
from cros.factory.utils import config_utils
from cros.factory.utils import type_utils
# LED pattern.
LED_PATTERN = ((kbd_leds.LED_NUM | kbd_leds.LED_CAP, 0.05), (0, 0.05))
# Data structures.
EventType = type_utils.Enum(['START_TEST', 'EXIT_TEST'])
FIXTURE_STATUS = type_utils.Enum(['CONNECTED', 'DISCONNECTED'])
InternalEvent = namedtuple('InternalEvent', 'event_type aux_data')
FAIL_CONFIG = 'ConfigError' # Config file error.
FAIL_SN = 'SerialNumber' # Missing camera or bad serial number.
FAIL_CHAMBER_ERROR = 'ChamberError' # Chamber connection error.
FAIL_ALS_NOT_FOUND = 'AlsNotFound' # ALS not found.
FAIL_ALS_CLEAN = 'AlsClean' # ALS coefficient clean up error.
FAIL_ALS_SAMPLE = 'AlsSample' # ALS sampling error.
FAIL_ALS_ORDER = 'AlsOrder' # ALS order error.
FAIL_ALS_CALIB = 'AlsCalibration' # ALS calibration error.
FAIL_ALS_CALC = 'AlsCalculation' # ALS coefficient calculation error.
FAIL_ALS_VALID = 'AlsValidating' # ALS validating error.
FAIL_ALS_VPD = 'AlsVPD' # ALS write VPD error
FAIL_ALS_CONTROLLER = 'ALSController' # ALS controller error.
FAIL_UNKNOWN = 'UnknownError' # Unknown error
# ALS mock mode.
ALS_MOCK_VALUE = 10
# Chamber connection parameters
CHAMBER_CONN_PARAMS_DEFAULT = {
'driver': 'pl2303',
'serial_delay': 0,
'serial_params': {
'baudrate': 9600,
'bytesize': 8,
'parity': 'N',
'stopbits': 1,
'xonxoff': False,
'rtscts': False,
'timeout': None
},
'response_delay': 2
}
class ALSFixture(test_case.TestCase):
"""ALS fixture main class."""
ARGS = [
# chamber connection
Arg('control_chamber', bool, 'Whether or not to control the chart in the '
'light chamber.', default=False),
Arg('assume_chamber_connected', bool, 'Assume chamber is connected on '
'test startup. This is useful when running fixture-based testing. '
"The OP won't have to reconnect the fixture everytime.",
default=True),
Arg('chamber_conn_params', (dict, str), 'Chamber connection parameters, '
"either a dict, defaults to None", default=None),
Arg('chamber_cmd', dict, 'A dict for name of lightning to a '
'[cmd, cmd_response].'),
Arg('chamber_n_retries', int, 'Number of retries when connecting.',
default=10),
Arg('chamber_retry_delay', int, 'Delay between connection retries.',
default=2),
# test environment
Arg('mock_mode', bool, 'Mock mode allows testing without a fixture.',
default=False),
Arg('config_dict', dict, 'The config dictionary. '
'If None, then the config is loaded by config_utils.LoadConfig().',
default=None),
Arg('keep_raw_logs', bool,
'Whether to attach the log by Testlog',
default=True),
]
def setUp(self):
self.dut = device_utils.CreateDUTInterface()
try:
self.als_controller = self.dut.ambient_light_sensor.GetController()
except Exception as e:
self._LogFailure(FAIL_ALS_NOT_FOUND,
'Error getting ALS controller: %s' % e.message)
raise
# Loads config.
try:
self.config = self.args.config_dict or config_utils.LoadConfig()
if self.config is None:
raise ValueError('No available configuration.')
self._LogConfig()
except Exception as e:
logging.exception('Error logging config file: %s', e.message)
raise
self.read_delay = self.config['read_delay']
self.n_samples = self.config['n_samples']
try:
if self.args.chamber_conn_params is None:
chamber_conn_params = CHAMBER_CONN_PARAMS_DEFAULT
else:
chamber_conn_params = self.args.chamber_conn_params
self.fixture_conn = None
if self.args.control_chamber:
if self.args.mock_mode:
script = dict((k.strip(), v.strip())
for k, v in sum(self.args.chamber_cmd.values(), []))
self.fixture_conn = fixture_connection.MockFixtureConnection(script)
else:
self.fixture_conn = fixture_connection.SerialFixtureConnection(
**chamber_conn_params)
self.chamber = light_chamber.LightChamber(
fixture_conn=self.fixture_conn, fixture_cmd=self.args.chamber_cmd)
except Exception as e:
self._LogFailure(FAIL_CHAMBER_ERROR,
'Error setting up ALS chamber: %s' % e.message)
self.all_sampled_lux = [] # mean of sampled lux for each light
self.scale_factor = None # value of calibrated scale factor
self.bias = None # value of calibrated bias
self.light_index = -1 # ALS test stage
self.monitor = media_utils.MediaMonitor('usb-serial', None)
self.ui.SetTitle(_('ALS Sensor Calibration'))
# Group checker for Testlog.
self.group_checker = testlog.GroupParam(
'lux_value', ['name', 'value', 'elapsed'])
def _Log(self, text):
"""Custom log function to log."""
logging.info(text)
session.console.info(text)
def _LogArgument(self, key, value, description):
testlog.AddArgument(key, value, description)
self._Log("%s=%s" % (key, value))
def _LogConfig(self):
if self.args.keep_raw_logs:
testlog.AttachContent(
content=json.dumps(self.config),
name='light_sensor_calibration_config.json',
description='json of light sensor calibration config')
def _LogFailure(self, code, details):
testlog.AddFailure(code, details)
message = 'FAIL %r: %r' % (code, details)
logging.exception(message)
session.console.info(message)
def _ALSTest(self):
try:
self._ShowTestStatus(_('Cleaning up calibration values'))
if not self.args.mock_mode:
self.als_controller.CleanUpCalibrationValues()
except Exception as e:
self._LogFailure(FAIL_ALS_CLEAN, 'Error cleaning up calibration values:'
' %s' % e.message)
raise
while True:
try:
if not self._SwitchToNextLight():
break
light_name = self.config['light_seq'][self.light_index]
self._ShowTestStatus(
i18n.StringFormat(_('Sampling {name}'), name=light_name))
self._SampleALS(light_name)
except Exception as e:
self._LogFailure(FAIL_ALS_SAMPLE, 'Error sampling lighting %d %s: %s' %
(self.light_index, light_name, e.message))
raise
try:
self._ShowTestStatus(_('Checking ALS ordering'))
self._CheckALSOrdering()
except Exception as e:
self._LogFailure(FAIL_ALS_ORDER,
'Error checking als ordering: %s' % e.message)
raise
try:
self._ShowTestStatus(_('Calculating calibration coefficients'))
self._CalculateCalibCoef()
except Exception as e:
self._LogFailure(FAIL_ALS_CALC, 'Error calculating calibration'
' coefficient: %s' % e.message)
raise
try:
self._ShowTestStatus(_('Saving calibration coefficients to VPD'))
self._SaveCalibCoefToVPD()
except Exception as e:
self._LogFailure(FAIL_ALS_VPD, 'Error setting calibration'
' coefficient to VPD: %s' % e.message)
raise
try:
self._ShowTestStatus(_('Validating ALS'))
light_name = self.config['validating_light']
self._SwitchLight(light_name)
self._ValidateALS(light_name)
except Exception as e:
self._LogFailure(FAIL_ALS_VALID,
'Error validating calibrated ALS: %s' % e.message)
raise
def _OnU2SInsertion(self, device):
del device # unused
cnt = 0
while cnt < self.args.chamber_n_retries:
try:
self._SetupFixture()
self._SetFixtureStatus(FIXTURE_STATUS.CONNECTED)
return
except Exception:
cnt += 1
self._SetFixtureStatus(FIXTURE_STATUS.DISCONNECTED)
self.Sleep(self.args.chamber_retry_delay)
raise light_chamber.LightChamberError('Error connecting to light chamber')
def _OnU2SRemoval(self, device):
del device # unused
self._SetFixtureStatus(FIXTURE_STATUS.DISCONNECTED)
def _SetFixtureStatus(self, status):
if status == FIXTURE_STATUS.CONNECTED:
style = 'color-good'
label = _('Fixture Connected')
elif status == FIXTURE_STATUS.DISCONNECTED:
style = 'color-bad'
label = _('Fixture Disconnected')
else:
raise ValueError('Unknown fixture status %s' % status)
self.ui.SetHTML(
['<span class="%s">' % style, label, '</span>'], id='fixture-status')
def _SetupFixture(self):
"""Initialize the communication with the fixture."""
try:
self.chamber.Connect()
except Exception as e:
self._LogFailure(FAIL_CHAMBER_ERROR, 'Error initializing the ALS fixture:'
' %s' % e.message)
raise
self._Log('Test fixture successfully initialized.')
def _SwitchLight(self, light):
self._Log("Switching to lighting %s." % light)
self._ShowTestStatus(
i18n.StringFormat(_('Switching to lighting {name}'), name=light))
try:
self.chamber.SetLight(light)
except Exception as e:
self._LogFailure(FAIL_CHAMBER_ERROR,
'Error commanding ALS chamber: %s' % e.message)
raise
self.Sleep(self.config['light_delay'])
def _SwitchToNextLight(self):
self.light_index += 1
if self.light_index >= len(self.config['luxs']):
return False
self._SwitchLight(self.config['light_seq'][self.light_index])
return True
def _SampleLuxValue(self, param_name, delay, samples):
if self.args.mock_mode:
return ALS_MOCK_VALUE
try:
buf = []
start_time = time.time()
for unused_i in xrange(samples):
self.Sleep(delay)
buf.append(self.als_controller.GetLuxValue())
with self.group_checker:
elapsed_time = time.time() - start_time
testlog.LogParam('name', param_name)
testlog.LogParam('value', buf[-1])
testlog.LogParam('elapsed', elapsed_time)
self._Log('%r: %r' % (elapsed_time, buf[-1]))
except ambient_light_sensor.AmbientLightSensorException as e:
logging.exception('Error reading ALS value: %s', e.message)
raise
return float(np.mean(buf))
def _SampleALS(self, light_name):
param_name = 'Calibrating' + light_name
testlog.UpdateParam(
param_name,
description=('Sampled calibrating lux for %s over time' % light_name),
value_unit='lx')
sampled_lux = self._SampleLuxValue(
param_name, self.read_delay, self.n_samples)
preset_lux = self.config['luxs'][self.light_index]
self._LogArgument('Preset%s' % light_name, preset_lux,
'Preset calibrating lux value.')
self._LogArgument('Mean%s' % light_name, sampled_lux,
'Mean of sampled calibrating lux value.')
self.all_sampled_lux.append(sampled_lux)
def _ValidateALS(self, light_name):
# Validating test result with presetted validating light:
# y * (1 - validating_err_limit) <= x <= y * (1 + validating_err_limit)
# where y is light intensity v_lux, and x is read lux value v_val.
testlog.UpdateParam(
'ValidatingLux',
description=('Sampled validating lux for %s over time' % light_name),
value_unit='lx')
sampled_vlux = self._SampleLuxValue(
'ValidatingLux', self.read_delay, self.n_samples)
preset_vlux = float(self.config['validating_lux'])
self._LogArgument('Preset%s' % light_name, preset_vlux,
'Preset validating lux value.')
self._LogArgument('MeanValidatingLux', sampled_vlux,
'Mean of sampled validating lux value.')
testlog.UpdateParam(
name='ValidatingLuxMean',
description=('Mean of sampled validating lux for %s' % light_name),
value_unit='lx')
err_limit = float(self.config['validating_err_limit'])
lower_bound = preset_vlux * (1 - err_limit)
upper_bound = preset_vlux * (1 + err_limit)
result = testlog.CheckNumericParam(
'ValidatingLuxMean',
sampled_vlux,
min=lower_bound,
max=upper_bound)
self._Log('%s ValidatingLuxMean: %r (min=%s, max=%s)' %
(result, sampled_vlux, lower_bound, upper_bound))
if not result and not self.args.mock_mode:
raise ValueError('Error validating calibrated als, got %s out of'
' range (%s, %s)' % (sampled_vlux, lower_bound,
upper_bound))
def _CheckALSOrdering(self):
if self.args.mock_mode:
return
luxs = self.config['luxs']
for i, li in enumerate(luxs):
for j in range(i):
if ((li > luxs[j] and
self.all_sampled_lux[j] >= self.all_sampled_lux[i]) or
(li < luxs[j] and
self.all_sampled_lux[j] <= self.all_sampled_lux[i])):
raise ValueError('The ordering of ALS value is wrong.')
def _CalculateCalibCoef(self):
# Calculate bias and scale factor (sf).
# Scale Factor sf = mean(Slope((x0,y0), (x1,y1)), ... ,
# Slope((x0,y0), (xn,yn)))
# bias = y0/sf - x0
# Here our x is self.all_sampled_lux, y is self.config['luxs']
if self.args.mock_mode:
return
def Slope(base, sample):
return float((sample[1] - base[1]) / (sample[0] - base[0]))
def ScaleFactor(xs, ys):
base = (xs[0], ys[0])
samples = zip(xs[1:], ys[1:])
return float(np.mean([Slope(base, s) for s in samples]))
self.scale_factor = ScaleFactor(self.all_sampled_lux,
self.config['luxs'])
self.bias = (
self.config['luxs'][0] / self.scale_factor - self.all_sampled_lux[0])
self._LogArgument(
'CalibCoefficientScaleFactor',
self.scale_factor,
description='Calibrated coefficients scale factor.')
self._LogArgument(
'CalibCoefficientBias',
self.bias,
description='Calibrated coefficients bias.')
def _SaveCalibCoefToVPD(self):
if self.args.mock_mode:
return
self.dut.vpd.ro.Update({
'als_cal_slope': str(self.scale_factor),
'als_cal_intercept': str(self.bias)
})
if self.config['force_light_init']:
# Force als adopts the calibrated vpd values.
self.als_controller.ForceLightInit()
else:
# The light-init script doesn't act as expected, this is a workaround.
self.als_controller.SetCalibrationIntercept(self.bias)
self.als_controller.SetCalibrationSlope(self.scale_factor)
def tearDown(self):
self.monitor.Stop()
def runTest(self):
"""Main routine for ALS test."""
self.monitor.Start(
on_insert=self._OnU2SInsertion, on_remove=self._OnU2SRemoval)
if self.args.assume_chamber_connected:
self._SetFixtureStatus(FIXTURE_STATUS.CONNECTED)
try:
with kbd_leds.Blinker(LED_PATTERN):
if self.args.assume_chamber_connected:
self._SetupFixture()
self._ALSTest()
except Exception as e:
fail_msg = e.message
self._ShowTestStatus(
i18n.NoTranslation('ALS: FAIL %r' % fail_msg), style='color-bad')
self.fail('Test ALS failed - %r.' % fail_msg)
else:
self._ShowTestStatus(i18n.NoTranslation('ALS: PASS'),
style='color-good')
def _ShowTestStatus(self, msg, style='color-idle'):
"""Shows test status.
Args:
msg: i18n text.
style: CSS style.
"""
self.ui.SetHTML(
['<span class="%s">' % style, msg, '</span>'], id='test-status')