Monday, 24 August 2015

LabPSU - ADC Linearization

Putting it Together

It's been a while since I updated the blog but I've actually made lots of progress with this project. I have most of the schematic in CircuitMaker and have been creating footprints for components as I go. I prototyped selecting the transformer windings with a relay and coded a lot of the control software including the ADC control. I have a new toy and I used this to gain some insights into the linearity of the ADC.

Switching between 15V and 30V Mode

The plan has been to configure the transformer windings so when greater than 15V is required the windings are configured in series but when less than 15V is required they are in parallel. The plan is to use a relay to do the switching and have the micro-controller on board each power supply module control the relay.


In this schematic you can see the three transformer windings coming in off the connector on the left and you can see the relay for switching the two windings either in series or parallel. There is a transistor switching the relay current based on a signal from the micro-controller.

The V+ output is the bias voltage for the voltage control opamps and MOSFET gate. When we are in 15-30V mode this is configured for 40V but in 0-15V mode this is set to 24V. The transistor connected to the adjust pin of the LM317 makes this adjustment.

The other two LM317s generate the 5V required by the digital logic and some 5V analog circuits plus the 6V required for the relay.

The relay and V+ control lines are separate which allows the micro-controller to switch the relay before it switches the voltage regulator. Only then will it update the ADC to set the desired output voltage. This ensures the voltages remain stable through the transition.

During testing I had problems with this setup which is that the 6V regulator gets quite hot. The relay draws around 100mA when activated and when in this mode the V+ rail is at 40V. This means it is dissipating (40-6)*0.1 = 3.4W of power which doesn't sound like much but is enough to get it pretty hot. Way too much for the surface mount versions of the LM317 I planned to use also. I found another relay with 8A rating that requires 12V to activate. This one requires 60mA to hold which means the regulator dissipates (40-12)*0.06 = 1.7W which is much better. For now I am using the 6V relay in my proto-type and have a chunk of metal attached to it for heat.

Voltage/Current Control DAC

I decided to go with the AD5689 digital to analog converter (DAC) from Analog Devices. This part has an awesome 2ppm internal reference, is SPI and dual channel so I can drive both the voltage and current set points from the same device.


The image above is where I am at currently with the digital control circuitry, There is an MCP2200 USB to UART converter used to receive commands from the USB bus. This goes via an ADUM1201 isolator so the micro-controller is galvanically isolated from the USB bus. Using the isolator means I can have multiple power supply channels all connected to the same USB bus without a common ground point. The microcontroller has a 6 wire ICSP port so the firmware can be re-flashed.

The AD5689 chip is connected to the SPI bus lines of the microcontroller. I don't really need to synchronise updating the two channels so the DAC's LDAC line is permanently pulled low. This has the effect of sending updates straight out to the analog output. The gain pin is pulled high to allow the output to go from 0-5V. The RESET line is also pulled high as we won't need to reset the part.

At some point I will split the analog and digital 5V lines with a small (10 ohm) resistor to limit transmission of noise. For now I haven't done this.

The output of the voltage DAC goes into an op amp configured with a gain of (1 + 47/8.2) = 6.7. Ideally we need a gain of 6 (5 * 6 = 30) but in reality the DAC can't go right to the rails and this only means a very marginal loss in resolution. It's a trade-off between choice of resistors and also we want minimal current flow in the op amp resistors.

Driving the DAC

I decided to build an SPIDevice base class for configuring the ATMEL SPI bus registers and for doing common things like setting up the SS pin etc. The class allows you to create a sub-class for a particular device and in the sub-class you call a setup routine and then write/read each byte on the bus and then shutdown. The class provides enumerated types for all the different options and functions to set this up.

class SPIDevice
{
public:

    enum BitOrder
    {
        MSB_FIRST,
        LSB_FIRST
    };
    
    enum ClockPolarity
    {    
        CLOCK_POLARITY_RISING_LEADS,
        CLOCK_POLARITY_FALLING_LEADS 
    };
          
    enum ClockPhase
    {
        CLOCK_PHASE_SAMPLE_ON_LEADING,
        CLOCK_PHASE_SAMPLE_ON_TRAILING   
    };
    
    enum ClockRate
    {
        RATE_DIV_4=0,
        RATE_DIV_16=1,
        RATE_DIV_64=2,
        RATE_DIV_128=3,
        RATE_DIV_DBL_2=4,
        RATE_DIV_DBL_8=5,
        RATE_DIV_DBL_32=6,
        RATE_DIV_DBL_64=7
    };
    
    /*
    Constructs the SPI Device with default setup.
    By default we are MSB first, SPI Master and interrupts
    are disabled.
    */
 SPIDevice(bool master, int selectPin);

    /*
    Sets the SPI Mode (0-2) which sets the clock phase and polarity
    Mode 0 = CLOCK_POLARITY_RISING_LEADS | CLOCK_PHASE_SAMPLE_ON_LEADING
    Mode 1 = CLOCK_POLARITY_RISING_LEADS | CLOCK_PHASE_SAMPLE_ON_TRAILING
    Mode 2 = CLOCK_POLARITY_FALLING_LEADS | CLOCK_PHASE_SAMPLE_ON_LEADING
    Mode 3 = CLOCK_POLARITY_FALLING_LEADS | CLOCK_PHASE_SAMPLE_ON_TRAILING
    */
    void setSPIMode(const int mode);
    
    /*
    Returns true if the next operation will be done using interrupts.
    */
    void enableInterrup(bool enable=true);
    
    /*
    Returns true if interrupts are enabled for the next operation
    */
    bool isInterruptsEnabled() const;
    
    /*
    Sets the bit order that will be used for the next operation.
    */
    void setBitOrdering( const BitOrder order );
    
    /*
    Returns the bit order that will be used for the next operation.
    */
    const BitOrder getBitOrdering() const;
    
    /*
    Sets the mode of the AVR. Set to true if the AVR is the master and 
    false otherwise
    */
    void setMaster( bool master = true);
    
    /*
    Returns the mode of the AVR for the next communication. 
    Set to true if the AVR is the master
    */
    bool isMaster() const;
    
    /*
    Sets the clock rate divider that will set the rate used to
    communicate on the SPI bus for the next communication.
    */
    void setClockRate( const ClockRate rate );
    
    /*
    Returns the clock rate divider that will set the rate used
    to communicate on the SPI bus for the next communication.
    */
    const ClockRate getClockRate() const;
    
    /*
    Returns the clock polarity that will be used for the next
    transaction.
    */
    const ClockPolarity getClockPolarity() const;
    
    /*
    Sets the clock polarity that will be used for the next transaction
    */
    void setClockPolarity(const ClockPolarity polarity);

    /*
    Returns the clock phas used for the next transaction
    */
    const ClockPhase getClockPhase() const;
    
    /*
    Sets the clock phase that will be used for the next
    transaction
    */
    void setClockPhase(const ClockPhase phase);
    
    /*
    Enablse double speed mode on the SPI bus for the next
    operation.
    */
    void setDoubleSpeedModeEnabled( bool enable=true);

    /*
    Returns true if double speed is enabled for the next
    operation.
    */
    bool isDoubleSpeedModeEnabled() const;
    
protected:

    /*
    Returns the status of the interrupt flag from the SPI Status
    register. This indicates if the operation has completed.
    */
    bool getInterruptStatus() const;
    
    /*
    Returns true if a collision occurred on the SPI bus during
    the last operation. This is derived from the collision flag
    in the SPI status register
    */
    bool getWriteCollisionFlag() const;
    
    /*
    Sets up the SPI hardware ready for an operation.
    */
    void setup();
    
    /*
    Asserts the select line so the device knows we are talking to it
    */
    void setupSelectLine() const;
    
    /*
    Clears the select line because the transaction is finished
    */
    void clearSelectLine() const;
    
    /*
    Initiates a write operation with the byte specified
    Returns the value returned by the device
    */
    uint8_t writeByte( uint8_t byte );
    
    /*
    Reads a byte of data from the device
    */
    uint8_t read();
    
private:

    int             m_selectPin;
    BitOrder        m_bitOrder;
    ClockPolarity   m_clockPolarity;    
    ClockPhase      m_clockPhase;
    ClockRate       m_clockRate;
    bool            m_interruptEnabled;
    bool            m_master;
    bool            m_doubleClockSpeed;
};

Then implementing the ADC involves

  • Calling the right methods to configure the clock phase, clock divider, master mode, bit ordering and so on 
  • When performing an operation the sub-class calls setup(), setupSelectLine() then either write() or read() and then finally clearSelectLine()
I had a few problems at first where nothing would appear on the bus (looking at the lines with the scope). It turned out that you have to make sure the SS pin is configured as an output as otherwise the SPI hardware can interpret any stray signal as the SS line going low and will switch from master to slave mode.

The AD5689 has lots of different features but I don't really need any of them. The way it works is you write a one byte control byte that specifies the command and which of the channels the command applies and then two more bytes of data. As I just want the value to go straight out I just used the 'Write to input register' (command 1) and set the channels to update. I don't have the MISO hooked up so no response is received.

New Toy

I've been eyeing one of these off for a while. I think for the money they are pretty much unbeatable. I recently bought a Keysight 34461Am 6 1/2 digit multimeter. It has a big clear LCD display and can display trends, histograms and stats. It has Ethernet connectivity! I contemplated buying a GPIP to USB converter for my other DMM but now I pretty much don't need to! The accuracy and resolution are excellent for what I want.

Amusingly it isn't total over-kill for my needs (I kind of expected it would be). Here is a photo of the meter watching the output of a AD780 voltage reference (I plan to use this with the ADC - the DAC has a built in reference). As you can see the device drifted by less than 10uV over the 25 minutes I had it powered up! Impressive!



Accuracy and Linearisation

So the output of the DAC must then be multiplied up by roughly 6 so 0-5V then controls 0-30V. The DAC doesn't quite make it all the way to 5V so the multiplication needs to be slightly more than 6.

As a simple first pass I set the DAC to close to full scale and calculated a volts-per-step number. I then modified my software so I could set the output to a voltage and it would calculate this using the volts-per-step number I hard-coded. I found this was pretty inaccurate at various points on the range.

The power supply is easy to control using the serial interface and the DMM can be driven from essentially telnet by sending SCPI commands. I knocked together this python script to sweep the power supply from 0.5V to 30V in 0.1V steps. At each step it pauses to let the voltage settle (more on this later) and then takes a measurement on the DMM. The PSU spits out the DAC code as debug and the python script gathers this up with the measurement from the DMM and prints it out.

import socket
import os
import time

s = socket.socket()
s.connect(("192.168.1.37",5025))
s.send("*IDN?\n")
print(s.recv(300).decode("UTF-8"))

tty=open("/dev/cu.usbmodem1411","r+")

tty.write("ISet=2.0\n")
str = tty.readline()
str = tty.readline()

tty.write("VSet=5.0\n")
str = tty.readline()
str = tty.readline()

time.sleep(1.0)
voltage = 0.5

while voltage < 30.0:
    tty.write("VSet=%f\n" % voltage)
    tty.readline();
    count = tty.readline().split()[4]

    time.sleep(10.0);

    s.send("MEASURE:VOLTAGE:DC?\n")
    print(count+" "+s.recv(300).decode("UTF-8"))
    voltage += 0.1

s.close
tty.close


I took the output of this and saved it as a CSV file and loaded it into Excel. Here is the graph of code vs voltage. It looks *very* linear with a very small offset.



This doesn't explain the inaccuracies I was seeing however so I thought I would calculate the gradient between successive points and see how that looks.
Now the variation in the graph is pretty small however its apparent that:
  • There is a big change at the point where the PSU switches from the mode where the windings are in parallel to series.
  • There is some variations at low voltages. This could be a settling issue
  • The gradient is *not* uniform.
My plan is to do a piece-wise continuous approximation. I created a class called Linearizer that looks like this:
class Linearizer
{
public:
 struct Point
 {  
  uint16_t code;
  float  value;  
 };
 
 static const struct Point ZERO_POINT;
 
 Linearizer(const Point *points, int numPoints);
 
 /*
 Calculates the code for the value provided by using
 the table of points provided in the constructor.
 */
 const uint16_t valueToCode(const float value) const;
 
 /*
 Calculates the value given the code provided by using
 the table of points provided in the constructor
 */
 const float codeToValue(const uint16_t code) const;
 
protected:

 /*
 Calculates the code by interpolating using the points provided.
 Uses point1 and point2 to calculat the gradient and then extrapolates
 using this gradient from the basepoint provided.
 */
 uint16_t interpolate( 
  const Point& point1, 
  const Point& point2, 
  const Point& basePoint,
  const float  value  ) const;
  
 const Point *m_points;
 int   m_numPoints;
};


The way it works is you construct the Linearizer with a table of code/value points that were measured from the device output. When you want to set the output to a specific value you call valueToCode and it searches the table for two points that bracket the value you want. It then linearly interpolates between these points to approximate the code you need to get that value.
If the value is less than the first point in the table it will interpolate between (0,0) and the first point. If the value is after the last point it will use the gradient between the last two points and will extrapolate from the last point to calculate the code.
Here is that algorithm in code:
const uint16_t Linearizer::valueToCode(const float value) const
{
 //
 // If the value is between zero and the first point
 // we interpolate between zero and this point and use Zero as the
 // base point
 if ( value < m_points[0].value )
 {
  return interpolate(ZERO_POINT,m_points[1],ZERO_POINT,value);
 }
 
 for(int i=1;i<m_numPoints;i++)
 {
  if ( value < m_points[i].value )
  {
   return interpolate(m_points[i-1],m_points[i],m_points[i-1],value);
  }
 }
 
 //
 // So the value is greater than the biggest point. In this case we extrapolate
 // from the last point using the gradient between the last two points
 //
 if ( m_numPoints < 2 )
 {
  return interpolate(
  ZERO_POINT,   // Second last point
  m_points[m_numPoints-1], // Last Point
  m_points[m_numPoints-1], // Last Point
  value);
 }
 else
 {
  return interpolate(
   m_points[m_numPoints-2], // Second last point
   m_points[m_numPoints-1], // Last Point
   m_points[m_numPoints-1], // Last Point
   value);
 }
}
Here is the code to calculate the gradient between two points and then work out the final code by extrapolating from a base-point. Usually the base-point would be point1 but for the case where we are going beyond the last point in the table it is point2
uint16_t Linearizer::interpolate(
 const Point& point1,
 const Point& point2,
 const Point& basePoint,
 const float  value  ) const
{
 float gradient = (point2.value - point1.value)/(float)(point2.code - point1.code);
 
 return round((double)((value - basePoint.value)/gradient + (float)basePoint.code));
}

So the next step was to re-write my python code to measure the output again but this time I did it in one volt steps and I got the code to print it out essentially as a C constant definition. I ran this again and cut-and-pasted it into my code, re-flashed the mico-controller and tried again.

I was disappointed by the result. It was always around 2mV off at lower ranges and as much as 5mV at higher ranges. At a few spots it was closer however. I tried increasing the wait period so it would allow 60s for the voltage to settle before taking a measurement, ran the scan again and re-flashed the device but this didn't make a difference.

Also on the settling time issue - I noticed that it can take as much as 20s for the voltage to settle after a change. The settling time is bigger if the voltage change is bigger. I suspect it is bigger when I cross over the threshold where it switches the bias supply between 24V and 40V too but not certain.

So still lots more to do but progress nonetheless.


1 comment:

  1. This comment has been removed by a blog administrator.

    ReplyDelete