#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Originally part of the PsychoPy library
# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2022 Open Science Tools Ltd.
# Distributed under the terms of the GNU General Public License (GPL).
# Acknowledgements:
# This code was mostly written by Jon Peirce.
# CRS Ltd provided support as needed.
# Acknowledgements
# This code was written by Jon Peirce
import sys
try:
import serial
except Exception:
serial = False
import numpy
__docformat__ = "restructuredtext en"
# try to use psychopy logging but revert to system logging
try:
from psychopy import logging # from 1.73 onwards
except ImportError:
import logging # use the standard python logging
eol = "\n\r" # unusual for a serial port?!
[docs]
class ColorCAL:
"""A class to handle the CRS Ltd ColorCAL device
"""
# PsychoPy uses these two variables for matching classes to photometers
longName = "CRS ColorCAL"
driverFor = ["colorcal"]
def __init__(self, port=None, maxAttempts=2):
"""Open serial port connection with Colorcal II device
:Usage:
cc = ColorCAL(port, maxAttempts)
If no port is provided then the following defaults will be tried:
- /dev/cu.usbmodem0001 (OSX)
- /dev/ttyACM0
- COM3 (windows)
"""
super(ColorCAL, self).__init__()
if not serial:
raise ImportError('The module serial is needed to connect to '
'photometers. On most systems this can be '
'installed with\n\t easy_install pyserial')
# try to deduce port
if port is None:
if sys.platform == 'darwin':
port = '/dev/cu.usbmodem0001'
elif sys.platform.startswith('linux'):
port = '/dev/ttyACM0'
elif sys.platform.startswith('win'):
port = 3
if type(port) in (int, float):
# add one so that port 1=COM1
self.portNumber = port
self.portString = 'COM%i' % self.portNumber
else:
self.portString = port
self.portNumber = None
self.isOpen = 0
self.lastLum = None
self.lastCmd = ''
self.type = 'ColorCAL'
self.com = False
self.OK = True # until we fail
self.maxAttempts = maxAttempts
self._zeroCalibrated = False
# try to open the port
try:
self.com = serial.Serial(self.portString)
except Exception:
msg = ("Couldn't connect to port %s. Is it being used by "
"another program?")
self._error(msg % self.portString)
# setup the params for serial port
if self.OK:
self.com.close() # not sure why this helps but on win32 it does!!
try:
self.com.setBaudrate(115200) # actually, any baudrate is fine?
except:
self.com.baudrate = 115200 # setBaudrate() remov pyserial v3.0
try:
if not self.com.isOpen():
self.com.open()
except Exception:
msg = "Opened serial port %s, but couldn't connect to ColorCAL"
self._error(msg % self.portString)
else:
self.isOpen = 1
# check that we can communicate with it
self.ok, self.serialNum, self.firm, self.firmBuild = self.getInfo()
self.calibMatrix = self.getCalibMatrix()
[docs]
def sendMessage(self, message, timeout=0.1):
"""Send a command to the photometer and wait an allotted
timeout for a response.
"""
# flush the read buffer first
# read as many chars as are in the buffer
prevOut = self.com.read(self.com.inWaiting()).decode('utf-8')
if len(prevOut) and prevOut not in ('>' + eol, eol):
# do not use log messages here
print('Resp found to prev cmd (%s):%s' % (self.lastCmd, prevOut))
self.lastCmd = message
if type(message) is not bytes:
message = message.encode('utf-8')
if not (message.endswith(b'\n') or message.endswith(b'\n\r')):
message += b"\n" # append a newline if necess
# send the message
self.com.write(message)
self.com.flush()
# get reply (within timeout limit)
self.com.timeout = timeout
logging.debug('Sent command:%s' % (message[:-1])) # send complete msg
# get output lines using self.readline, not self.com.readline
# colorcal signals the end of a message by giving a command prompt
lines = []
thisLine = ''
nEmpty = 0
while (thisLine != '>') and (nEmpty <= self.maxAttempts):
# self.com.readline can't handle custom eol
thisLine = self.readline(eol=eol).decode('utf-8')
if thisLine in (eol, '>', ''): # lines we don't care about
nEmpty += 1
continue
else:
# line without any eol chars
lines.append(thisLine.strip(eol))
nEmpty = 0
# got all lines and reached '>'
if len(lines) == 1:
return lines[0] # return the string
else:
return lines # a list of lines
[docs]
def measure(self):
"""Conduct a measurement and return the X,Y,Z values
Usage::
ok, X, Y, Z = colorCal.measure()
Where:
ok is True/False
X, Y, Z are the CIE coordinates (Y is luminance in cd/m**2)
Following a call to measure, the values ColorCAL.lastLum will also be
populated with, for compatibility with other devices used by PsychoPy
(notably the PR650/PR655)
"""
# use a long timeout for measurement:
val = self.sendMessage('MES', timeout=5)
valstrip = val.strip('\n\r>')
vals = valstrip.split(',')
ok = (vals[0] == 'OK00')
# transform raw x,y,z by calibration matrix
xyzRaw = numpy.array([vals[1].strip(), vals[2].strip(), vals[3].strip()], dtype=float)
X, Y, Z = numpy.dot(self.calibMatrix, xyzRaw)
self.ok, self.lastLum = ok, Y
return ok, X, Y, Z
[docs]
def getLum(self):
"""Conducts a measurement and returns the measured luminance
.. note::
The luminance is always also stored as .lastLum
"""
self.measure()
return self.lastLum
[docs]
def getInfo(self):
"""Queries the device for information
usage::
(ok, serialNumber,
firmwareVersion, firmwareBuild) = colorCal.getInfo()
`ok` will be True/False
Other values will be a string or None.
"""
val = self.sendMessage(b'IDR')
valstrip = val.strip('\n\r>')
val = valstrip.split(',')
ok = (val[0] == 'OK00')
if ok:
firmware = val[2]
serialNum = val[4]
firmBuild = val[-1]
else:
firmware = 0
serialNum = 0
firmBuild = 0
return ok, serialNum, firmware, firmBuild
[docs]
def getNeedsCalibrateZero(self):
"""Check whether the device needs a dark calibration
In initial versions of CRS ColorCAL mkII the device stored
its zero calibration in volatile memory and needed to be
calibrated in darkness each time you connected it to the USB
This function will check whether your device requires that (based
on firmware build number and whether you've already done it
since python connected to the device).
:returns: True or False
"""
if self.firmBuild < '877' and not self._zeroCalibrated:
return True
else:
return False
[docs]
def calibrateZero(self):
"""Perform a calibration to zero light.
For early versions of the ColorCAL this had to be called after
connecting to the device. For later versions the dark calibration
was performed at the factory and stored in non-volatile memory.
You can check if you need to run a calibration with::
ColorCAL.getNeedsCalibrateZero()
"""
val = self.sendMessage(b"UZC", timeout=1.0)
val = val.strip('\n\r>')
if val == 'OK00':
pass
elif val == 'ER11':
logging.error(
"Could not calibrate ColorCAL2. Is it properly covered?")
return False
else: # unlikely
logging.warning(
"Received surprising result from ColorCAL2: %s)" % repr(val))
return False
# then take a measurement to see if we are close to zero lum (ie is it
# covered?)
self.ok, x, y, z = self.measure()
if y > 3:
logging.error('There seems to be some light getting to the '
'detector. It should be well-covered for zero '
'calibration')
return False
self._zeroCalibrated = True
self.calibMatrix = self.getCalibMatrix()
return True
[docs]
def getCalibMatrix(self):
"""Get the calibration matrix from the device, needed for transforming
measurements into real-world values.
This is normally retrieved during __init__ and stored as
`ColorCal.calibMatrix` so most users don't need to call this function.
"""
matrix = numpy.zeros((3, 3), dtype=float)
# alternatively use 'r99' which gets all rows at once, but then more
# parsing?
for rowN in range(3):
rowName = 'r0%i' % (rowN + 1)
val = self.sendMessage(rowName.encode('ascii'), timeout=1.0)
valstrip = val.strip('\n\r>')
vals = valstrip.split(',') # convert to list of values
if vals[0] == 'OK00' and len(vals) > 1:
# convert to numpy array
rawVals = numpy.array(vals[1:], dtype=int)
floats = _minolta2float(rawVals)
matrix[rowN, :] = floats
else:
msg = 'ColorCAL got this from command %s: %s'
print(msg % (rowName, repr(val)))
return matrix
def _error(self, msg):
self.OK = False
logging.error(msg)
[docs]
def readline(self, size=None, eol='\n\r'):
"""This should be used in place of the standard serial.Serial.readline()
because that doesn't allow us to set the eol character
"""
# The code here is adapted from
# pyserial 2.5: serialutil.FileLike.readline
# which is released under the python license.
# Copyright (C) 2001-2010 Chris Liechti
leneol = len(eol)
line = bytearray()
while True:
# NB timeout is applied here, so to each char read
c = self.com.read(1)
if c:
line += c
if line[-leneol:] == eol:
break
if size is not None and len(line) >= size:
break
else:
break
return bytes(line).strip()
def _minolta2float(inVal):
"""Takes a number, or numeric array (any shape) and returns the appropriate
float.
minolta stores;
+ve values as val * 10000
-ve values as -val * 10000 + 50000
>>> _minolta2Float(50347) # NB returns a single float
-0.034700000000000002
>>> _minolta2Float(10630)
1.0630999999999999
>>> _minolta2Float([10635, 50631]) # NB returns a numpy array
array([ 1.0635, -0.0631])
"""
# convert to array if needed
arr = numpy.asarray(inVal)
# handle single vals
if arr.shape == ():
if inVal < 50000:
return inVal/10000.0
else:
return (-inVal + 50000.0)/10000.0
# handle arrays
negs = (arr > 50000) # find negative values
out = arr/10000.0 # these are the positive values
out[negs] = (-arr[negs] + 50000.0)/10000.0
return out