-
Software
07/18/2019 at 20:36 • 0 commentsThese code snippets are what I'm using to interface with the C2D. This first one is the class for the C2D converter. When instantiated it performs a self calibration for gain and offset. Otherwise, the user must customize it for use.
#!/usr/bin/env python3 # # Connections are: # CLK => SCLK # DOUT => MISO # DIN => MOSI # CS => CE0 import time import sys import spidev import math class C2D: # commands SelfCalibration = 0x90 SystemOffsetCal = 0xA0 SystemGainCal = 0xB0 Convert1p0 = 0x80 Convert2p5 = 0x81 Convert5p0 = 0x82 Convert10 = 0x83 # register addresses regSTAT = 0xC0 regCTRL1 = 0xC2 regCTRL2 = 0xC4 regCTRL3 = 0xC6 regDATA = 0xC8 regSOC = 0xCA regSGC = 0xCC regSCOC = 0xCE regSCGC = 0xD0 def __init__(self, debug=False): self.spi = spidev.SpiDev() self.spi.open(0,0) self.spi.max_speed_hz=(1000000) # Enable Self Calibration self.writeReg(self.regCTRL3,[0x18]) time.sleep(0.1) # Performing System Self Calibration. self.command([self.SelfCalibration]) time.sleep(0.3) def command(self, register): self.spi.xfer(register) return def writeReg(self, register, dataList): registerData = [register] for data in dataList: registerData.append(data) self.spi.xfer2(registerData) def readReg(self, register, dataList): registerData = [register+1] for data in dataList: registerData.append(0) r = self.spi.xfer2(registerData) return r[-len(dataList):] # toss out the first byte def twosComplement(self,data): result = data[0] << 16 | data[1] << 8 | data[2] if result > (1 << 23) - 1: result = result - (1 << 24) return result def convert2volts(self, data): v = data/(2**23-1) * 3.6 return v def readADC(self): r = self.readReg(self.regDATA,[0,0,0]) return self.twosComplement(r) def readSelfCalOffset(self): r = self.readReg(self.regSCOC,[0,0,0]) return self.twosComplement(r) def readSelfCalGain(self): r = self.readReg(self.regSCGC,[0,0,0]) return self.twosComplement(r)/2**23 def readSystemOffset(self): r = self.readReg(self.regSOC,[0,0,0]) return int(self.twosComplement(r)/4) def readSystemGain(self): r = self.readReg(self.regSGC,[0,0,0]) return self.twosComplement(r) def meanstdv(self, x): """ Calculate mean and standard deviation of data x[]: mean = {\sum_i x_i \over n} std = math.sqrt(\sum_i (x_i - mean)^2 \over n-1) """ n, mean, std = len(x), 0, 0 for a in x: mean = mean + a mean = mean / float(n) for a in x: std = std + (a - mean)**2 if(n > 1): std = math.sqrt(std / float(n-1)) else: std = 0.0 return mean, std if __name__ == '__main__': # instantiate C2D cap2dig = C2D(debug=True) print("-1 = {0}".format(cap2dig.twosComplement([0xff,0xff,0xff]))) print("1 = {0}".format(cap2dig.twosComplement([0x00,0x00,0x01]))) # set CTRL3 register cap2dig.writeReg(cap2dig.regCTRL3,[0x18]) CTRL3 = cap2dig.readReg(cap2dig.regCTRL3,[0]) print("CTRL3 = {}".format(hex(CTRL3[0]))) #config register: SCYCLE = 1, SIGBUF = 0 cap2dig.writeReg(cap2dig.regCTRL1,[0x02]) time.sleep(1) CTRL1 = cap2dig.readReg(cap2dig.regCTRL1, [0]) print("CTRL1 = {}".format(hex(CTRL1[0]))) print("Self Cal Offset = {0}".format(int(cap2dig.readSelfCalOffset()))) print("Self Cal Gain = {0}".format(cap2dig.readSelfCalGain())) cap2dig.command([cap2dig.SystemOffsetCal]) time.sleep(0.5) print("System Offset = {0}".format(cap2dig.readSystemOffset())) print("System Gain = {0}".format(cap2dig.readSystemGain())) result_array = [] oldSTAT = 0x00 n = 0 sd_avg2 = float(0) try: while True: # start conversion cap2dig.command([cap2dig.Convert10]) # wait for result time.sleep(0.11) STAT = cap2dig.readReg(cap2dig.regSTAT,[0]) if (STAT != oldSTAT): print("STAT = {}".format(hex(STAT[0]))) oldSTAT = STAT val = cap2dig.readADC() print ("ADC Result: {0}".format(int(val))) result_array.append(int(val)) if (len(result_array) == 10): n += 1 mean,sd = cap2dig.meanstdv(result_array) result_array = [] print("\n\tmean: {0} Counts".format(mean)) print("\tstd dev: {0:.4f} Counts".format(sd)) dnr = 20 * math.log(0.8 * 2**24/sd,10) nfbits = math.log(0.8 * 2**24/(6 * sd),2) print("\tDynamic Range = {0:.1f}db, ({1:.2f} bits)".format(dnr, nfbits)) sd_avg2 += sd**2 sd_avg = math.sqrt(sd_avg2/n) print("\tAvg Std Dev = {0:.2f} Counts".format(sd_avg)) avg_dnr = 20*math.log(0.8*2**24/sd_avg,10) avg_nfbits = math.log(0.8*2**24/(6 * sd_avg),2) print("\tAvg Dynamic Range = {0:.1f}db, ({1:.2f} bits)\n".format(avg_dnr, avg_nfbits)) time.sleep(3) except KeyboardInterrupt: cap2dig.spi.close() sys.exit(0)
This while loop is used to get the C2D data and provide it to the ringserver daemon. The C2D is instantiated as cap2dig(). Sample period for the loop is set to 0.5 seconds (2 sps) when the C2D is set to 2.5sps.
def getData(): ''' TBD ''' global shared, seedArray, resultArray sample_time = 0.5 # throw away first conversion result ts = datetime.utcnow() # a timestamp for the seed file timeNow = time.time() * 1000 # this creates a unix timestamp with millisecond resolution cap2dig.command([cap2dig.Convert2p5]) time.sleep(sample_time) while True: # start next conversion timeStart = time.time() cap2dig.command([cap2dig.Convert2p5]) CapCount = cap2dig.readADC() ts_next = datetime.utcnow() # a timestamp for the seed file timeNow_next = time.time() * 1000 # this creates a unix timestamp with millisecond resolution CapCount = cap2dig.readADC() resultArray.append([timeNow, CapCount]) seedArray.append([ts.isoformat(),int(CapCount)]) # raw data to seedlink server # limit array length to 1 hour if (len(resultArray) > 7200): # must use pop method for manager.list object. resultArray.pop(0) packetSize = 512 # 512 is standard packet size for mseed. if(len(seedArray) == packetSize): # write data stream to ascii file #logger.info("Writing seed file.") asciiFile = open('/home/pi/programs/Seismo/slist.ascii', 'w') asciiFile.write("TIMESERIES EI_AEGI__BHZ, {0} samples, {2:.4f} sps, {1}, SLIST, INTEGER, Counts\n".format(packetSize, seedArray[0][0], 1/sample_time)) # write header n = 0 for line in seedArray: n += 1 if (n == 1): text = repr(line[1]).rjust(10) else: text = text + repr(line[1]).rjust(12) if (n == 6): text = text + " \n" asciiFile.write(text) n = 0 text = text + " \n" # finish off any partial lines asciiFile.write(text) # write the last line asciiFile.close() mseedFileName = seedArray[0][0].replace(":", "_") command = 'ascii2mseed -r {0} -o /home/pi/ringserver/mseed/EI_AEGI__BHZ_{1}.mseed /home/pi/programs/Seismo/slist.ascii > /dev/null 2>&1 &'.format(packetSize,mseedFileName) seedArray = [] os.system(command) ts = ts_next timeNow = timeNow_next if (sample_time > (sample_time - time.time() + timeStart) > 0): time.sleep(sample_time - time.time() + timeStart) else: logger.error("Sample timing error.") time.sleep(sample_time)
The C2D is instructed to start a new conversion before the results of the previous conversion are captured. There should be plenty of time to obtain the ADC result before the register is overwritten with the new data. After the conversion is started all of the other housekeeping can be accomplished in the intervening 0.5 seconds. The loop is trying to keep the sample period as close to 0.5 seconds as possible. Every 512 samples -- about 4 minutes -- a miniseed file is provided to the ringserver daemon.
-
The Answer is Blowin' in the Wind
07/18/2019 at 17:41 • 0 commentsThe diet cola experiment was a success. The raw data over 2.5 days shows the daily peak-peak variation dropped almost 10X:
But there are still periods of jiggyness(?):
The artifacts that have waveforms that start with large amplitudes and tail off are probably small magnitude quakes. The artifacts that appear to be ball-shaped are apparently caused by wind. The sharp spike around 2019-07-16 00:00 is probably a nearby lightning strike. I was monitoring the weather outside over this period (except at night) and found that the jiggyness correlated closely with an uptick in the windspeed. The times indicated above are UTC (00:00 UTC is 6:00pm MDT). That last bit of jiggyness occurred around 2:00am MDT and I happened to be awake enough to hear it.
I did not see these artifacts with the AD7745 because the noise floor is higher and the filter BW is now 0.01Hz - 0.5Hz vs. 0.025Hz - 0.5Hz for the AD7745. Also, the wind tends to increase around the same time of day lately, which corresponds to the transition from rising to falling in the raw waveform. This led me to assume that it was somehow related to temperature. Another interesting item: the wind seems to cause the house foundation to tilt, which is evidenced by the flattening of the raw data curves at the peaks.
Moving On:
I took the seismometer electronics apart and installed the latest C2D board with a 6-inch ribbon cable, shielding and foam insulation. I located the sensor inside the seismometer enclosure and ran the ribbon cable outside the enclosure to the RPi-B+. This should keep the RPi-B+ from causing temperature variation inside the enclosure. The ribbon cable running under one side of the enclosure may cause a small air leak, but I'm hoping that the cola cans absorb it. So far, everything appears to be working but I'm not getting the 21.5 bits of dynamic range that I saw on the bench:
At this point I don't have any explanation as to the decreased performance when the C2D is coupled to the sensor. It is 3-4X better than the AD7745. That will have to be good enough until I have the time and ambition to investigate further.
-
21.5 Bits!
07/16/2019 at 13:31 • 2 commentsAnother C2D board to play with:
Built a second C2D using noise optimized components and TP07 opamps. The TP07 opamps are quite a bit less expensive than the OPA376 opamps, but the common mode range of the TP07 inputs is GND to VCC - 2V, so the COMMON bias voltage was dropped to 1.53V. The full scale range of the C2D converter is not affected because a ±4pF capacitance change at the input produces ±1.44V at the output.
I verified that the C2D was operating correctly and generating appropriate full-scale output voltages by connecting a 4.7pF capacitor between the two CAP inputs and EXC pins. This second board even has an offset voltage with the inputs unconnected that is within a few mV of the first board.
When I connected the new C2D to a Raspberry Pi 3B+ with nothing connected to the inputs, the resulting noise waveform was a bit disappointing. The peak-peak count variation was less than 10, but there was a -15 count spike that occurred with a period of exactly 60 seconds. I made sure that there were no other daemon programs of mine running that would cause this kind of interference. Varying the sample rate of the C2D had no effect on the 60 second spike. The standard deviation of the ADC output was around 2 counts, which is 18.5 bits of NFDNR.
I then connected the C2D to a Raspberry Pi Zero-W, again with no inputs connected. This time the output waveform noise was much worse -- 20 counts p-p -- but the 60 second spike disappeared. So now I have some evidence that the C2D board is being influenced by its proximity to the RPi.
Getting Some DIstance:
I found a 6-inch long ribbon cable with 0.1 inch header sockets on both ends that I used to separate the C2D from the RPi. I also added copper foil shielding and foam insulation to the C2D board. Things improved a lot, but there were still some strange drift in the output that I could not explain. This disappeared when I connected the GND of the C2D to earth ground.
Here's the output of the C2D connected to the RPi-3B+, sampled at 2 sps and bandpass filtered to 0.01Hz - 0.5Hz:
I dumped the waveform data to ascii and imported it to a spreadsheet to extract the standard deviation -- 0.7344 bits, or 21.54 bits of noise-free dynamic range! That's right inline with the design target.
When connected to the RP--ZeroW, the raw output over 10 minutes (1200 samples) looks like this (the ZeroW doesn't have a ringserver installed):
This is unfiltered data, which includes a small amount of temperature drift, but the p-p variation is 6 counts. Again, the NFDNR is 21.5 bits when averaged down to 1 sps (equivalent to the filtered BW of the previous data.)
(I must declare at this point that I did not expect to ever see the output of a 24-bit ADC with just a ±2-count wiggle -- that's only 0.12ppm!)
Diet Cola Experiment is Ongoing:
The seismometer with the first C2D board (unshielded without insulation) has been running for a few days now. It still hasn't settled to a recognizable equilibrium, but it seems to be getting closer. Here's the raw data over the last 1.5 days:
I'm expecting the 24-hr peak-peak swing to be less than 50,000 counts. Prior to the diet cola the swing was about 300,000 counts. This has to be a good thing. Here's the above data with a 0.01Hz - 0.5Hz bandpass applied:
There is a bit of increased noise as the raw waveform changes direction from rising to falling. I wish that I could divine the mechanism that causes this. It seams to be less pronounced than pre-diet-cola. The spiking could also be associated with some monsoon weather that is a regular occurrence this time of year. I will let it go for a few more days before making any conclusions.
Next Steps:
- Complete the diet cola testing.
- Replace the first C2D with the second C2D which has copper shielding, insulation and ribbon cable.
-
The Perfect is the Enemy of the Good.
07/13/2019 at 04:58 • 7 commentsThe seismometer has been running for a couple of days with the new C2D converter. I made some changes to the original setup to accommodate the C2D converter:
- The 15 inch long cabling from the sensor to the C2D converter was too long. I thought that the amount of parasitic capacitance to ground was excessive. I shortened the cables both the EXC and CAP leads to about 6 inches. The CAP leads were set about 1/4" apart and covered with copper foil tied to earth ground. The EXC leads do not have any shielding.
- The Raspberry Pi unit was moved into the inside of the enclosure with the seismometer due to the shorter cable lengths. It is just resting on the glass plate near the cap sensor.
- I removed the foam insulation and copper foil shielding from the C2D sensor to make it easier to assemble to the Raspberry Pi. The C2D must be connected to the cabling while the seismometer and C2D are outside of the enclosure, otherwise it is too difficult to connect the cabling. (See note about NP0 capacitors below.)
The reason that the Raspberry Pi was previously mounted outside the enclosure was to avoid the heat of the RPi from causing temperature variation inside the enclosure. Going forward I expect to put the RPi inside a small metal enclosure to both shield it and reduce the rate of temperature variation that it can produce.
There were some problems with the code crashing early on, but those are now fixed and there is data for 24 hours to evaluate. There's good news and bad news. First the good news: the new C2D converter is between 2X and 4X lower noise than the AD7745. The bad news: I was expecting 10X-30X improvement.
This morning (2019-07-12) the seismometer recorded a 4.9 magnitude quake in central California:
The quake was nearly 700 miles( 1100km) distant. I don't ever remember getting a clear record of a < 5 magnitude quake in California before. But there are problems.
The above waveform is the raw data from the seismometer sensor over a 24 hour period. The daily peak-peak variation in the data is nearly 150,000 counts. I believe that all of this is due to temperature variation of the mechanical system of the seismometer. The spring and frame are metal, which tends to expand and contract with temperature. 150k counts is about 1% of the range of the seismometer -- it's not really a problem. But the jagged artifacts near the 23:00 hour are a real problem. They cause the baseline of the waveform to jiggle and tend to obscure the data unless the corner of the high-pass filter is moved upward, which would decrease the sensitivity of the seismometer at lower frequencies.
Checking the Damping:
I thought that the damping mechanism may need to be re-tuned so I performed another step response test with the following result:
It looks fine (when sampled at 2 Hz).
Reducing the Rate of Temperature Swing:
Now I'm thinking that the glitching is caused by the seismometer mechanism responding to the change in temperature within the enclosure, and if the temperature variation could be reduced it would reduce/eliminate the glitching. One way to do this is to increase the thermal mass within the enclosure. The simplest approach that I could think of to increase the thermal mass was to stack 24 12 oz. diet cola cans along both sides of the frame. I did this yesterday morning. It will require several days to let the seismometer settle to a new equilibrium before I will know if there is any improvement.
A Note About NP0 Capacitors:
I had thought that NP0 capacitors exhibited zero temperature variation. The C2D was showing a bit of variation which was obviously due to temperature. It turns out that NP0 capacitors can have up to ±30ppm/C of temperature coefficient. In my system this translates to 500 counts per °C. So in addition to shielding the board from electrical interference I must also insulate the board to prevent even small rapid temperature variations from affecting the output. Slow temperature changes will be removed by the high-pass filter, so that is OK.
The board is mounted directly onto the GPIO header on the Raspberry Pi. The RPi heats and cools with various demands of it's programming. There are noticeable glitches when the webpage server is accessed and the increased loading causes a temperature change that is visible in the waveform. A simple foam enclosure around the C2D board may fix this issue, but it might require that the board be distant ( a few inches) and use a short cable to connect to the RPi (and still have an insulating enclosure).
Next Steps:
- Put the Raspberry Pi unit in a metal enclosure.
- Re-apply the copper shielding and foam insulation to the C2D board to reduce its thermal variance.
- Make a harness to connect the C2D board to the RPi to reduce thermal coupling.
- Build and evaluate another C2D board with all of the noise optimization included.
-
A bit of Noise Optimization
07/06/2019 at 14:43 • 0 commentsThe MCP6V81 zero-drift opamps arrived yesterday, along with a few small ceramic capacitors with ±0.25pF tolerances. I quickly replaced the OPA376 opamps for these in the input integrators (U6 & U7). I also changed C1 & C2, in the AC amps, from 20pF to 5pF. The GS8591 opamps in the output integrator were left in the circuit, as well as some other small noise contributors that I did not believe matter much at this point.
I had to wrap the board with copper foil and 1/2" of foam to improve interference and temperature effects.
Then I left it to collect data overnight at 2 sps with an active ringserver. I can now process the resulting data with ObsPy via the ringserver. Here's 1 hour of data with a 2-pole bandpass filter 0.025Hz - 0.5Hz:
There is a noticeable and significant improvement over the circuit using the OPA376. I ran this data through a spreadsheet, which came up with a standard deviation of 2.0769bits. That sounds pretty good, but when you account for peak-peak variation the resulting dynamic range is "only" 20.02 bits of noise-free resolution (SNR = 136.2dB or 22.6 bits). But if you consider the 16 bit NFDNR of the AD7745, this circuit is 16X better.
Going Forward - Next Steps:
- Connect the current board to the seismometer and evaluate performance.
- If it shows promise with the real capacitance sensor, then construct another board with all of the noise optimization and evaluate it.
- Try to find out what is causing the poor temperature drift.
I'm glad that I did not mess with the seismometer yesterday. It recorded its biggest-ever response to the 7.1 magnitude quake in California yesterday -- ±400,000 counts (that's nearly 0.25mm, 5% of the total dynamic range of the sensor).
-
Noise Visualization
07/03/2019 at 16:01 • 0 commentsThe capacitance to digital converter is now working steadily at 2 sps. I have the data plotting to a web page to visualize the data. Here's raw data from the C2D:
There is a slight improvement, but not much. I have the unit located in an isolated room to reduce any external interference from me sitting next to it (yes...it's that sensitive.)
Eventually, started to think that the 1/f noise component of the OPA376 was causing a lot of what I was seeing. The plot above has a frequency range, or bandwidth, of roughly 0.0003Hz - 1Hz. If the noise density was flat there would be no increased noise voltage with an increased observation time period. But 1/f noise should show more noise as the observation period is increased.
I resorted to Mathematica to help me understand what I thought I was seeing. Here's the analysis of the expected peak-peak counts observed over a 10 second period with a 2 sps ADC rate:
I just got lucky here -- it predicts about 10 counts of variation over a 10 second period. Thats about what I see on the above plots. I changed to observed period to 1 hour (3600 seconds) to see what to expect from the 1/f noise.
Lo and behold!!! The peak to peak variation rises to over 200 counts. So I'm betting that the low frequency noise terms will decrease dramatically when the MCP6V81 is substituted for the OPA376. The new opamps are in the mail.
[Edit 2019-07-04: New information.]
I implemented a ringserver to collect data and allow high-pass and low-pass filtering of the data. The C2D converter ran overnight and I collected the data:
OK...so it's not noise...and it appears to be temperature related. The increasing drift as the house cools down at night is pretty obvious now. Along the way the various noise elements add and subtract from the trend at much lower numbers. The next step is to insulate the board and see if that reduces the temperature effect.
And here's the same data processed by a 0.01Hz to 1Hz 2-pole high-pass and 2-pole low-pass filters:
Just for grins and giggles I connected the AD7745 C2D sensor and plotted its filtered noise pattern without a sensor connected:
Both of these are ±4pF full scale output. It appears that we're moving in the right direction. The noise has improved by a factor ~4X over the AD7745, and I expect more improvement with better opamps.
-
Debugging the ADC
07/01/2019 at 19:30 • 0 commentsI had not used a SPI interface with the Raspberry Pi before starting on this circuit. The documentation for the Python implementation is sparse compared to what's available for C. It required a day of tinkering to obtain the first valid data from the ADC. I had to get my Rigol oscilloscope involved. After that things progressed quickly.
At first, the only data returned from the ADC was 0xFF. I found two things that needed understanding before the data would come. The Raspberry Pi has a wicked fast SPI interface. It was cruising along at more than 100MHz, but the poor ADC couldn't go faster than 5MHz, according to the spec. I had to slow it down with the following line of code, which sets the SCLK frequency at 1MHz.
spi.max_speed_hz=(1000000)
Now I could see square waves pinging between VDD and GND.
Next I determined that the SPI had to keep clocking data into the ADC after it was given a command byte in order to clock data out of the ADC and into the Raspberry Pi. There isn't a built-in read or write command for the SPI protocol -- it's doing both at the same time. So if you need to just write a single-byte command to start an ADC conversion it is pretty simple:
spi.xfer([Convert2p5])
If you need to read or write data from/to a register then the SPI must send a command byte followed by the required bytes for the read/write:
r = spi.xfer2([regDATA, 0, 0, 0]) # for a read
spi.xfer2([regSOC, 0x1f, 0xCA, 0x02]) # for a write
And the last thing to understand is that the data comes little-endian. For the read statement above, r is a list of 4 bytes, for example:
r = [0xff, 0x0f, 0x12, 0xD1]
r[0] can be tossed, r[1] is the MSB and r[3] is the LSB.
Here's the code that I cobbled together to get an understanding of the ADC:
#!/usr/bin/env python # # Bitbang'd SPI interface with an MCP3008 ADC device # MCP3008 is 8-channel 10-bit analog to digital converter # Connections are: # CLK => SCLK # DOUT => MISO # DIN => MOSI # CS => CE0 import time import sys import spidev import math SelfCalibration = 0x90 SystemOffsetCal = 0xA0 SystemGainCal = 0xB0 Convert2p5 = 0x81 regSTAT = 0xC0 regCTRL1 = 0xC2 regCTRL2 = 0xC4 regCTRL3 = 0xC6 regDATA = 0xC9 regSOC = 0xCA regSGC = 0xCC spi = spidev.SpiDev() spi.open(0,0) spi.max_speed_hz=(1000000) #spi.mode = 0b11 def buildReadCommand(channel): startBit = 0x01 singleEnded = 0x08 return [startBit, singleEnded|(channel<<4), 0] def processAdcValue(result): '''Take in result as array of three bytes. Return the two lowest bits of the 2nd byte and all of the third byte''' byte2 = (result[1] & 0x03) return (byte2 << 8) | result[2] def command(register): spi.xfer(register) return def writeReg(register, dataList): registerData = [register] for data in dataList: registerData.append(data) spi.xfer2(registerData) def readReg(register, dataList): registerData = [register+1] for data in dataList: registerData.append(data) r = spi.xfer2(registerData) return r def readAdc(): r = spi.xfer2([regDATA,0,0,0]) result = r[1]*256*256 + r[2]*256 + r[3] if result > (2**23)-1: result = (result - 2**24) return result def convert2volts(data): v = data/(2**23-1) * 3.6 return v def meanstdv(x): """ Calculate mean and standard deviation of data x[]: mean = {\sum_i x_i \over n} std = math.sqrt(\sum_i (x_i - mean)^2 \over n-1) """ n, mean, std = len(x), 0, 0 for a in x: mean = mean + a mean = mean / float(n) for a in x: std = std + (a - mean)**2 if(n > 1): std = math.sqrt(std / float(n-1)) else: std = 0.0 return mean, std if __name__ == '__main__': writeReg(regSOC,[0,0,0]) time.sleep(0.1) CTRL1 = readReg(regCTRL1, [0]) print("CTRL1 = {}".format(hex(CTRL1[1]))) CTRL3 = readReg(regCTRL3, [0]) print("CTRL3 = {}".format(hex(CTRL3[1]))) time.sleep(1) print("Enable Self Calibration") writeReg(regCTRL3,[0x18]) time.sleep(1) CTRL3 = readReg(regCTRL3,[0]) print("CTRL3 = {}".format(hex(CTRL3[1]))) writeReg(regCTRL1,[0x02]) time.sleep(1) CTRL1 = readReg(regCTRL1, [0]) print("CTRL1 = {}".format(hex(CTRL1[1]))) SOC = readReg(regSOC,[0,0,0]) print("System Offset = {0}".format(SOC)) SGC = readReg(regSGC,[0,0,0]) print("System Gain = {0}".format(SGC)) print("Performing System Self Calibration...") command([SelfCalibration]) time.sleep(1) SOC = readReg(regSOC,[0,0,0]) print("System Offset = {0}".format(SOC)) #print("Writing 0x0f to SOC register") #writeReg(regSOC,[0,0x0C,0x0F]) time.sleep(0.1) SOC = readReg(regSOC,[0,0,0]) print("System Offset = {0}".format(SOC)) SGC = readReg(regSGC,[0xff,0xff,0xff]) print("System Gain = {0}".format(SGC)) result_array = [] oldSTAT = 0x00 n = 0 sd_avg2 = float(0) try: while True: STAT = readReg(regSTAT,[0]) if (STAT != oldSTAT): print("STAT = {}".format(hex(STAT[1]))) oldSTAT = STAT val = readAdc() print ("ADC Result: {0:.7f}".format(convert2volts(val))) result_array.append(convert2volts(val)) if (len(result_array) == 20): n += 1 mean,sd = meanstdv(result_array) result_array = [] print("\n\tVoltage mean: {0:.7f} V".format(mean)) print("\tVoltage std dev: {0:.2f} uV".format(1000000*sd)) dnr = 20 * math.log(1.6 * 3.6/sd,10) nfbits = math.log(1.6 * 3.6/(6 * sd),2) print("\tDynamic Range = {0:.1f}db, ({1:.2f} bits)".format(dnr, nfbits)) sd_avg2 += sd**2 sd_avg = math.sqrt(sd_avg2/n) print("\tAvg Std Dev = {0:.2f} uV".format(1000000*sd_avg)) avg_dnr = 20*math.log(1.6*3.6/sd_avg,10) avg_nfbits = math.log(1.6 * 3.6/(6 * sd_avg),2) print("\tAvg Dynamic Range = {0:.1f}db, ({1:.2f} bits)\n".format(avg_dnr, avg_nfbits)) command([Convert2p5]) time.sleep(.5) except KeyboardInterrupt: spi.close() sys.exit(0)
I will clean it up later and convert it to a Class module.
I started out perfoming a conversion every 5 seconds, but found that it took too long to accumulate the data for the standard deviation and mean -- the part was drifting over the long acquisition time. It was better to collect about 20 samples in 10 seconds. I then added a scheme to average the standard deviation values across all of the collected sample groups. This approach acts like a high pass filter for very low frequencies and therefore eliminates the drift terms.
This is the output near the end of a half-hour run time:
Right away you notice that the circuit is not producing 22-bits of NFDNR. I have a couple of explanations for that.
- The circuit was built with components that were not optimized for noise.
- The GS8591 has 33nV/√Hz noise density instead of the 13nV/√Hz of the MCP6V81.
- The 1/f noise component of the OPA376 opamp used in the input integrator wipes out everything else.
The input integrator needs to be a zero-drift chopper opamp with no 1/f component at DC. Here's the difference between the OPA376 and the MCP6V81:
The OPA376 1/f noise density is nearly 550nV/√Hz at 0.1Hz, if you just continue the trend below 1Hz. This noise gets multiplied by nearly 4x when referred to the input of the ADC. The MCP6V81 noise density is only 13nV/√Hz to DC. It has a spike between 3kHz and 100kHz, but this should get taken out by the demodulator. I expect this to make a huge difference in the noise performance of the next board.
By accounting for the component values used in this circuit, following the guidelines of my log about noise analysis, I calculated the total noise of this circuit, with no capacitors connected to the inputs, to be about 2µVrms (18.6 bits NFDNR). That's pretty close to what the ADC is reporting. The next board will have optimized components and should perform closer to expected design targets. I must now order some more parts from Digikey...
-
First Pass Early Results
06/30/2019 at 19:11 • 0 commentsThe PCB and components arrived simultaneously. The assembly was longer than usual due to the quantity of parts and some shorted pins on the SC70 components. I used an old 26-pin header instead of a newer 40-pin header to use up some inventory.
I applied power and no smoke was visible (always a welcome occurrence). When I measured the output voltages I got some weird results. The signals to the output integrator were slowly sinking toward ground and the integrator outputs weren't biased to the proper voltage. It was at this point that I took a close look at the schematic and discovered that the output integrators did not have any DC bias. I shorted out the capacitors in the demodulator, which stopped the slow drifting but the outputs still had improper voltages - like the opamps were installed backward. I reviewed the datasheet pinout for the MCP6V81 and found that there is an alternative pinout for the SOT23 package that has a "U" designation. I had unknowingly ordered the U part from Digikey instead of the standard pinout. After swapping out the MCP6V81U parts for two Gainsil GS8591 zero-drift opamps from my inventory, the board started behaving as expected.
The initial data:
Vcommon = 1.803V
VCC = 3.609V @ 7mA
Open inputs:
Vout+ = 1.823V
Vout- = 1.893V
Vdiff = -77.6mV
I was a bit disappointed that there was such a large differential offset voltage, but then I calculated the error as only 0.11pF.
With 4.7pF caps connected between EXCs and INPUTs:
Vout+ = 3.421V
Vout- = 0.308V
Vdiff = 3.119V
Swapped EXC connections to capacitors:
Vout+ = 0.231V
Vout- = 3.495V
Vdiff = -3.276V
If the capacitors were exactly 4.7pF then output voltage should have been 3.3925V. The above reading indicate that the capacitors were -5.72% low. When the offset capacitance is taken into account, the full scale readings were within 0.06% of each other.
So far so good. Now I have to figure out how to read ADC data over the SPI on the Raspberry Pi.
-
Noise Analysis
06/01/2019 at 14:40 • 0 comments[Edit 2019-06-29: entire log was scrapped and replaced. I was totally wrong about how to go about analyzing the various noise contributions to the output of the circuit. I may still be wrong, but I think the following analysis is on the correct path.]
The purpose of this exercise is to analyze the circuit topology to determine the contribution of each source of noise and then select component values that minimize the overall noise at the circuit's output. The goal is to keep the peak-peak noise level below the amount necessary to guarantee 22-bits of dynamic range from the ADC.
The positive and negative signal paths use identical circuitry -- it is only necessary to calculate the noise in one path and then mulriply by √2 to get the total noise at the differential outputs. The demodulator appears to be essentially noiseless -- consisting of a small switch resistance -- so I will ignore it.
I determined that the best approach was to insert a pseudo noise source into the simulated circuit and see what how the noise source affected the output voltage. Then it is a matter of just adding up all of the noise contributions to get the total.
Noise contribution of U7:
I inserted a voltage source, EN7, to see what the voltage noise of U7 contributed to the output voltage. (This is not the circuit used for simulation. It is just an equivalent schematic.)
I made EN7 a sinewave source with a magnitude of 10mV peak. EN7 emulates the equivalent input voltage of the opamp U7. The simulation produced the following ouput.
The simulation indicates that the gain from EN7 to the output is 2.072 V/V. The noise contribution is present at DC and also at the modulation frequencies and its harmonics. Therefore U7 should probably be a zero-drift chopper amplifier that has no 1/f noise component at DC.
I found through simulation that the value of C1 should be as small as possible, and R1 should be large, to minimize the noise contribution of U7. But R1 is also a noise source that should be minimized as well.
Noise contribution of U3:
I inserted a voltage source at the positive input of U3 to simulate its contribution.
The simulation indicated that there was no significant low frequency noise contribution from U3 because U7 removed it. Unfortunately, the input voltage noise of U3 is not reduced by U7 at the 100kHz modulation frequency. The gain to the output from EnU3 at 100kHz is 2.84 V/V. I found this out by increasing the frequency of EnU3 to 100.050kHz and measuring the amplitude of the resultant waveform at the output. U7 should have low input voltage noise at frequencies above 90kHz.
Noise Contribution of R8:
The noise voltage source is now in series with R8 to simulate its contribution to the output.
The noise from R8 only contributes at DC with a gain of 1. The takeaway: make R8 as small as possible.
Noise Contribution of R1:
A voltage source is inserted in series with R1 to estimate its contribution to the output.
The gain from R1 to the output is 0.2 V/V at 100kHz and harmonics. U7 removes the noise at DC. The tradeoff here is to make R1 as large as possible, to reduce noise from U7, but not so large that its thermal noise dominates.
Noise Contribution of U5:
The simulation indicates that the gain from ENU5 to the output is 2.2 V/V. This noise is only injected at DC.
Noise Contribution of R10 and R6:
Simulation predicts the gain from these two resistors ( at DC) to the output is 2.16 V/V.
Putting it all together:
The individual noise components are assumed to be independent, therefore they can be added by superposition.
[Edit 2019-07-11: Noise at 100kHz and the 3x harmonic are uncorrelated. Therefore the noise components at different frequencies must be squared and added. ]
Noise at the output of the positive signal channel is the sum of the square noise components:
VnOut^2 = (EnU7*2.2)^2*(1^2 + 1^2 + 1/3^2) + (EnU3 * 2.84)^2 * (1 + 1/3^2) + EnR8^2*(1 + 1/3^2) + (EnR1 * 0.2)^2 * (1 +1/3^2) + (EnU5 * 2.2)^2 + (EnR10R6 * 2.16)^2
Note that terms multiplied by 2.33 are present at DC and the modulation frequency plus harmonics. If the term is multiplied by 1.33 then the DC content is removed by U7, but is present at the modulation frequencies.
From the datasheets of the OPA376 and MCPV81:
EnU3 = 7.5 nV/√Hz, EnU7 and EnU5 = 13 nV/√Hz.
Calculated thermal noise of R1 and R8:
EnR1 = 127.2 nV/√Hz
EnR8 = 12.72 nV/√Hz
EnR10R6 = 5.7nV/√Hz
Therefore, the total rms noise density of the total differential output is
VrmsOut = √2*sqrt( (13nV * 2.2)^2 * (2.11) + (7.5nV*2.84)^2 * (1.11) + 12.72nV^2*1.11 + (127.2nV*0.2)^2 * 1.11 + (13nV*2.2)^2 + (5.7nV*2.16)^2 )
= 90.5nV/√Hz.
The MAX11200 ADC has an input referred noise of 575nVrms when sampliing at 10Hz. We can infer a noise density by dividing the input noise by √5Hz, the Nyquist frequency, to get 257nV/√Hz. Adding the noise of the chopper amplifier to the ADC yields:
VrmsTotal = sqrt(90nv^2 + 257nV^2) = 272nV/√Hz.
To obtain peak-peak noise multiply by a factor of six and the sqrt of the signal bandwidth:
Vp-p = 6 * 285nV = 1.63µVp-p for a 1Hz bandwidth.
Expected noise-free dynamic range is then:
NFDNR = 2 * 0.8 * VCC/1.63µV = 3.53 x 10^6 = 131dB, or 21.8 bits.
The noise optimized schematic at this point in time looks like this:
-
Nulling Feedback Demodulator
05/31/2019 at 12:38 • 0 commentsThis idea originated from L.K. Baxter's "Capacitive Sensors" paper, which I have included in the files section of this project. Baxter covers signal conditioning in Chapter 5, but only using block diagrams. This is the diagram of a simple synchronous demodulator from the paper ( I implemented this as a first pass.)
But it was this block diagram, which I call a nulling feedback demodulator, and his comments that I did not immediately understand in any meaningful way:
He is using current source symbols for AC voltage sources. It was unclear to me how V2 was implemented, but the highlighted comment about the SPDT CMOS switch is the clue. V2 is a voltage variable AC source controlled by the output of the integrated demodulator signal. I eventually understood enough to translate the above diagram into this simple differential circuit concept to use with my cap sensor:
All of the switches are in phase with the modulator. This circuit includes capacitors in the demodulator to eliminate the DC terms present in the AC amplifier that should get removed by the chopper operation.
The overall gain of the circuit only depends upon the cap sensor, the feedback capacitors C1 & C2, and the excitation voltage, which is the supply voltage:
Vout = (∆C/C1 - -∆C/C2) VCC = 2∆C/C1 VCC = ±VCC/2 , for ∆C =±4pF, C1 = C2 = 16pF.
In order to more fully understand the operation of this circuit I simulated it using LTSpice. The LTSpice schematic is too messy to present here, but the simulated signals lend some understanding of what the circuit is doing and the sensitivities of the component values on the circuit performance.
Without the nulling feedback the AC amplifier outputs are required to slew and settle to large differential outputs, as shown here for a full scale cap sensor ∆C.
The amplifier (a simulated ADA4500, with a 16pF and 10Meg for feedback elements) must get to the final output voltage and then settle quickly before the end of the sampling period, in this case 2.5µs. The sloped square waves are caused by the resistor used for DC bias that is discharging the capacitor during the interval -- this causes a gain error.
Here's the what the amplifier outputs look like when producing the same full scale ∆C when nulled in a steady state with a 100kHz excitation frequency:
The simulated amplifier is the same ADA4500, with 10pF and 10MegΩ feedback elements. There is an initial spike when the charge on the capacitors is applied to the amplifier inputs, but since the charges are nearly equal the amplifier settles to a low output voltage and there is very little error produced by the 10MegΩ feedback resistor -- eliminating the gain error due to discharge. Baxter is correct in claiming that a lower bandwidth amplifier can be used, with lower slew and settle requirements. In addition, the resistor feedback element used for DC bias can be made lower, which reduces the sensitivity to leakage currents at the inputs. But it doesn't eliminate it.
I got a shock when I simulated a 10nA leakage current injected into the input of one of the AC amplifiers. The leakage current produced a 100mV error in the output voltage. After a long struggle to understand and fix the problem I implemented the following circuit.
The leakage current created a DC voltage across the AC amplifier. Because the Demodulator switch samples only one edge of the AC amp output a DC component would appear to get passed through to the output because the charge at the input was not properly cancelled. The integrator wrapped around the AC amplifier sets the average output voltage of the AC amp to equal the common voltage node. Now the simple switch in the Demodulator has no offset voltage to develop any DC voltage at its output. This scheme works really well: using 100kΩ resistors to set the gain of the AC amplifiers, the output error to a huge 1µA leakage current at either input will only develop 33µV of error at the differential outputs! And because the AC amps don't develop any significant output voltage there is plenty of common mode range.
I also removed the capacitors in the Demodulator. There is no longer a DC component in the signal, except for the residual offset and offset drift of the input integrators. And it was eventually apparent to me that there was no DC path to bias the output integrator if the capacitors remained in the Demodulator.
There are a few additional passive components sprinkled around the circuit. They are necessary for good performance.
- The high frequency RC filter in front of the output integrators are designed to swallow the short pulses from the demodulator so that the integrator doesn't have to deal with them.
- Similarly the RC between the output and the switches that generate the AC feedback voltage is there to prevent any glitching from the capacitor discharging into the input from affecting the integrator output.
There are a number of general notes about the components:
- The AC amps remain relatively high performance TP07 for now. It might be possible to use slower opamps with poorer specs.
- The four opamps that perform integrator functions are nothing special to get good DC performance. I expect that these opamps will be selected based upon their noise performance
- The switches S1 and S2 can be discrete SPST analog switches, unless they need low charge injection. Simulation required.
- The S3 & S4 switches are SPDT and are available in single and dual per package. There are similar issues with charge injection. Many analog switch ICs don’t specify charge injection so the selection process might be difficult if low injection is required.
- The output integrators are low impedance and can replace the input buffers in the MAX11700 ADC. This might be a good noise performance tradeoff.
- C1 and C2 must be C0G or NP0 with tight (1%) tolerance. I might reduce their values to increase the output voltage range to near ±VCC if the ADC can handle it.
The next step is to finish up a few loose ends on this concept and then perform a noise analysis. More stuff in the next log.