Request for Comments: DAC Interface

C programming, build, interpreter/VM.
Target audience: MicroPython Developers.
ul5255
Posts: 11
Joined: Thu Oct 23, 2014 9:08 am

Request for Comments: DAC Interface

Post by ul5255 » Thu Feb 12, 2015 2:48 pm

Dear MicroPythonista,

I hope this post belongs here, if not feel free to move it to a more appropriate place.

I did notice that the MicroPython documentation for the DAC class states that the interface is subject to change so this is my contribution to a (possibly already ongoing) discussion about that future DAC interface.

I propose to implement an arbitrary waveform generator within the limits of the hardware. It is a hybrid software/hardware DDS solution.
  1. The user provides an external reconstruction filter at the DAC output pin(s) (OpAmp-based, ...).
  2. The DAC(s) are configured in double buffer mode with DMA support. DAC(s) run at 12bit resolution.
  3. At the begining we populate both buffers.
  4. Then we register a callback to be triggered by the DMA controller to replenish one of the used buffers with the next batch of DAC values.
The DAC/DMA then cycles between these two buffers and only occassionally interrupt the CPU request new DAC values.

Code: Select all

import DAC

dac = DAC(pin=...,
          waveform=array.array('H', [...]),
          f_DAC=..., # I think maximum would be 1MHz
          outputBufferSize=...)

dac.frequency(...)   # works both with DAC running or stopped
dac.phaseOffset(...) # works both with DAC running or stopped

dac.start()
dac.stop()
In my experiments until now I have the software running fast enough to hyptothetically feed one DAC. If that DAC would run at a rate of 100kHz it would generate a CPU load of approx. 4% if we go with two buffers of 64 values per each buffer. It is a pure Python/inline assembler solution which is good enough to reproduce sine waves of up to 30kHz with a proper reconstruction filter. Frequency resolution and therefore lowest possible frequency which can be generated would be (f_DAC=100kHZ) / 2**24 ~ 0.006Hz. Phase shifts are possible in 360degree / 2**M ~ 0.02degree increments.

Implementing this on a lower level like built-in DAC would make it less taxing to the CPU. I did not yet make any attempts to initialize the DAC/DMA to operate in double buffer mode. The DAC class currently exposed in MicroPython would limit us to 8bit values anyways. So there is still plenty of work to do ...

Partial Proof of Concept:

Code: Select all

import array
from math import floor, sin, pi
import gc

class AWG:
    N = 24                  # width of phase_accu and tuning_word
    M = 14                  # number phase_accu's MSBs  used to
                            # index into PATTERN: len(PATTERN) = 2**M
    R = 12                  # MicroPython's DAC resolution is 12bit
    
    PATTERN = array.array('H', (0 for _ in range(2**M)))
                            # PATTERN stores one full period of the
                            # desired wave form, the default wave
    for i in range(2**M):   # form pattern is a sine wave
        PATTERN[i] = int(   
            floor((2**R-1)* 
                  (1.0+sin(pi*i/(2**(M-1)))) / 2.0))
    
    gc.collect()            # PATTERN init is taxing on free memory
    
    def __init__(self, f_clk):
        super().__init__()
        
        self.f_clk = f_clk  # frequency at which *individual* values
                            # are processed by DAC
                            
        self.state = array.array('L', [0, 0])
                            # phase_accu (24bit)
                            # tuning_word(24bit)
        
        self.output = array.array('H', [0]*64)
        
    def f_out(self, new_frequency):
        new_frequency = float(new_frequency)
        
        # can't go over Nyquist frequency
        assert(new_frequency < self.f_clk / 2.0)
        
        # negative frequencies are not possible either
        assert(not(new_frequency < 0))
        
        # just adapt the tuning word, phase_accu is untouched
        self.state[1] = int(new_frequency / self.f_clk * 2**AWG.N)

@micropython.asm_thumb
def next_64_values(r0, r1, r2):
                        # r0 = state = [phase_accu, tuning_word]
                        # r1 = PATTERN
                        # r2 = output[64]
                        
    ldr(r3, [r0, 0])    # r3 = phase_accu
    ldr(r4, [r0, 4])    # r4 = tuning_word
    movw(r5, 64)        # r5 = loop index
    
                        # r6 = tmp'
                        # r7 = tmp
    
    label(loop)
    add(r7, r3, r4)     # tmp = phase_accu + tuning_word
    movwt(r6, 0xffffff) # tmp' = 2**n - 1
    and_(r7, r6)        # tmp = tmp % tmp'
    mov(r3, r7)         # phase_accu = tmp
    
    mov(r6, 10)         # tmp = phase_accu >> (n - m)
    lsr(r7, r6)
    
    mov(r6, 1)          # tmp *= 2 because PATTERN is
    lsl(r7, r6)         # organized in 2 Byte words
    
    add(r6, r1, r7)     # output[i] = PATTERN[tmp]
    ldrh(r7, [r6, 0])
    strh(r7, [r2, 0])
    
    add(r2, r2, 2)      # output organized in half words
    
    sub(r5, 1)          # i--
    
    cmp(r5, 0)          # repeat until
    bne(loop)           # r5 is zero
    
    str(r3, [r0, 0])    # phase_accu = tmp
    

s = AWG(100000.0)       # DAC runs at f_DAC = 100kHz
s.f_out(1000.0)         # reproduces PATTERN at 1kHz
                        # can't be faster than 50kHz
                        # unless you really know what
                        # you do.

def main():
    st = s.state
    sp = s.PATTERN
    so = s.output
    # each value in timings[] is the time to fill the buffer
    # with the next 64 values which the DAC then is supposed
    # to output at f_DAC = 100kHz.
    # In other words: At a rate of 100kHz / 64 = 1562.5Hz
    # the DAC (or DMA controller?) would interrupt us and
    # ask to fill the next buffer of 64 values. On my board
    # it takes 23us to do that. 1562.5Hz means one interrupt
    # every 640us, then we block regular executions for 23us.
    # At this rate the CPU should have plenty of time to do
    # other tasks. Also the time to generate the next batch
    # of values is dominated by the overhead of the function
    # call. E.g. if I increase the buffer to 4*64 = 256 values
    # then the time to refill the buffer is just a little less
    # than 51us and not 4*23us = 92us. 
    timings = []
    for _ in range(256):
        t1 = pyb.micros()
        next_64_values(st, sp, so)
        t2 = pyb.micros()
        
        timings.append(t2-t1)
    
    return timings

blmorris
Posts: 348
Joined: Fri May 02, 2014 3:43 pm
Location: Massachusetts, USA

Re: Request for Comments: DAC Interface

Post by blmorris » Thu Feb 12, 2015 6:19 pm

ul5255 wrote:I hope this post belongs here, if not feel free to move it to a more appropriate place.
It's a good place to start the discussion; if you get to the point of where you are developing code to extend the functionality of the DAC class then you can open a pull request on GitHub.
I think that the DAC class is still open to enhancement to expose more of the hardware capabilities; if you can improve it then you aren't wasting your time.
Also, some of what you propose (setting up double-buffering with DMA to feed the DAC) will be relevant to an eventual I2S (Inter-IC-Sound) class, so I will be happy to continue to discuss here.
With that, I'm going to find some time to try the code you have shared :)
-Bryan

ul5255
Posts: 11
Joined: Thu Oct 23, 2014 9:08 am

Re: Request for Comments: DAC Interface

Post by ul5255 » Thu Feb 12, 2015 10:04 pm

Hi Bryan,
After thinking about this for a while I want to see how far I can push this proof of concept with just Python and inline assembler. I dont feel like setting up a cross compiler to hack on the C code base ;) . With stm.mem32 and friends there should be enough foundation for these experiments. Looking forward to lots of MicroPython crashes ... :lol:

blmorris
Posts: 348
Joined: Fri May 02, 2014 3:43 pm
Location: Massachusetts, USA

Re: Request for Comments: DAC Interface

Post by blmorris » Fri Feb 13, 2015 1:44 pm

ul5255 wrote:Hi Bryan,
After thinking about this for a while I want to see how far I can push this proof of concept with just Python and inline assembler. I dont feel like setting up a cross compiler to hack on the C code base ;) . With stm.mem32 and friends there should be enough foundation for these experiments. Looking forward to lots of MicroPython crashes ... :lol:
Nothing wrong with that, and you will probably be able to do what you want that way. @pythoncoder has put together some uPy assembler code which he just published yesterday, maybe you are already looking at it.
All the same, setting up the cross-compiler can be useful even if you aren't planning to hack on C - if nothing else you can try any enhancements or bug fixes without waiting a day to download a new firmware image.

Damien
Site Admin
Posts: 647
Joined: Mon Dec 09, 2013 5:02 pm

Post by Damien » Sat Feb 14, 2015 6:32 am

Yes the DAC class could definitely do with enhancement. Double buffering is one of the first things to add.

ul5255
Posts: 11
Joined: Thu Oct 23, 2014 9:08 am

Re: Request for Comments: DAC Interface

Post by ul5255 » Wed Feb 18, 2015 10:34 am

taking it in small steps:
Step 1 is to set up a cross compiler tool chain and test it by introducing a DAC.writeHighRes(...) method which accepts 12bit wide values. Would this be just a copy of the code of DAC.write with the only change in this line from DAC_ALIGN_8B_R to DAC_ALIGN_12B_R?
Or more generic question: What was the driving factor behind the limiting to 8bit values? Was it to save memory?

blmorris
Posts: 348
Joined: Fri May 02, 2014 3:43 pm
Location: Massachusetts, USA

Re: Request for Comments: DAC Interface

Post by blmorris » Wed Feb 18, 2015 6:38 pm

ul5255 wrote:taking it in small steps:
Step 1 is to set up a cross compiler tool chain and test it by introducing a DAC.writeHighRes(...) method which accepts 12bit wide values. Would this be just a copy of the code of DAC.write with the only change in this line from DAC_ALIGN_8B_R to DAC_ALIGN_12B_R?
That should work; note that the DAC.noise() (line 190) and DAC.triangle() (line 218) methods both use 12-bit values.
Or more generic question: What was the driving factor behind the limiting to 8bit values? Was it to save memory?
I don't think that you should read anything more into that than that the DAC class isn't really complete - a quick look at the git history shows that it hasn't changed much since the current functionality was implemented around last April/May. If I had to guess, the 8-bit limitation was probably just the simplest way to allow the write_timed() method to be fed with an array of bytes, and the write() method was made to be consistent with the write_timed() method.
It might make sense to implement the 8/12 bit switch as a keyword argument; we have something similar for the I2C class to enable both 8- and 16-bit addresses for mem_read() and mem_write(): http://micropython.readthedocs.org/en/l ... c.mem_read
The trick is that the buffer fed to the write_timed method will need to be properly aligned for 12-bit data, maybe you have an idea how to handle that.
-Bryan

Damien
Site Admin
Posts: 647
Joined: Mon Dec 09, 2013 5:02 pm

Post by Damien » Thu Feb 19, 2015 8:05 am

Yes better to use a keyword argument to write_timed rather than another function. This would also provide scope for other MCUs that have different bit widths.

The tricky thing is as blmorris says: you'll need to have the data correctly aligned for the DMA to be able to simply read the data from the buffer.

ADC.read_timed has similar difficulties, and we should make the API consistent between these two.

ul5255
Posts: 11
Joined: Thu Oct 23, 2014 9:08 am

Re: Request for Comments: DAC Interface

Post by ul5255 » Thu Feb 19, 2015 8:34 am

I have the cross compiler working and can build MicroPython for the PyBoardV10, just need to test
how to upload to the board with the DFU utility.
I will first make some changes to hardcode usage of 12bit values in write and write_timed. As for
alignment I already checked a little bit yesterday and the DMA has this 'DMA_PDATAALIGN_12B_R' and
'DMA_MDATAALIGN_12B_R' so my idea is that the user fills buffers of arrray.array('H', ...) like in
my proof of concept above. This data will naturally be right-aligned. The resolution of the DAC could
already be specified in the DAC.__init__(), no? We could have then either a DAC.FULL_SCALE_VALUE or
DAC.RESOLUTION property.

ul5255
Posts: 11
Joined: Thu Oct 23, 2014 9:08 am

DAC.write w/ 12bit resolution is working

Post by ul5255 » Thu Feb 19, 2015 11:03 am

so as a first test I changed the implementation of DAC.write in dac.c from:

Code: Select all

HAL_DAC_SetValue(&DAC_Handle, self->dac_channel, DAC_ALIGN_8B_R, mp_obj_get_int(val));
to

Code: Select all

HAL_DAC_SetValue(&DAC_Handle, self->dac_channel, DAC_ALIGN_12B_R, mp_obj_get_int(val)&0xFFF);
After I uploaded the new firmware to the board I placed a jumper (okay ... paper clip) between pinx X6 and X19.
This is the session:

Code: Select all

Micro Python v1.3.10-40-g6904583-dirty on 2015-02-19; PYBv1.0 with STM32F405RG
Type "help()" for more information.
>>> from pyb import DAC, ADC
>>> d = DAC(2)
>>> a = ADC(pyb.Pin.board.X19)
>>> d.write(0)
>>> a.read()
2
>>> d.write(4095)
>>> a.read()
4094
I'm pretty sure the bit masking with 0xFFF in the code above is not strictly necessary. I will go ahead and try the equivalent change in the implementation of DAC.write_timed().

Post Reply