Ultrasonic Radar (update 2-6-2018)

Figure 1 : System

This project was designed to show that you can't solve every problem in software, that you need to understand a system's limitations and that adding hardware can actually simplify software development. Aim: to implement a system that scans 180 degrees, measure the distance to the nearest obstacle and display these as a polar plot. One system will be implemented in software (as much as possible), another using both software and dedicated hardware. Both systems use a common sensor platform as shown in figure 2. Position control is implemented using a standard analogue model servo (Link), distance measurement via an off the shelf, HC-SR04 ultrasonic module (Link). The 3D printed brackets where obtained from: (Link) (Link), the bottom base bracket was quickly done in openScad (Link). The base was laser cut, layout drawn in LibreCad (Link). All 3D model and dxf files are available here (Local).

Figure 2 : Sensor platform

Figure 3 : 3D printed parts

Figure 4 : Plastic base

The software biased system requires minimal external hardware, as shown in figures 5 and 6. The resistors shown are purely for protection i.e. the Raspberry Pi is not 5V tolerant. Resistors R2 and R4 are not really needed, these are 41 ohms (were to hand), just there to limit the current if i do something silly. Resistors R1 and R3 (4K7) form a potential, shifting the ultrasonic module's output pulse from 5V down to 2.5V i.e. Pi safe.

Figure 5 : Software system interface

Figure 6 : Software interface circuit diagram

The servo and ultrasonic sensor are controlled using a simple python prgram. The ultrasonic sensor control was based on this example: (Link) (Local). The servo control was based on this example: (Link) (Local). The full code listing is shown below:

import RPi.GPIO as GPIO
import time

# Servo position control func
def update(angle):
	duty = float(angle) / 10 + 2.5
	pwm.ChangeDutyCycle(duty)

#IO pin numbers
TRIG = 17
ECHO = 27
SERVO = 18

#Setup IO ports
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)

GPIO.setup(TRIG, GPIO.OUT)
GPIO.setup(ECHO, GPIO.IN)

GPIO.setup(SERVO, GPIO.OUT)

pwm = GPIO.PWM(SERVO, 100)
pwm.start(5)

GPIO.output(TRIG, False)

#Initialise variables
direction = True
angle = 0
pos = 0
pos_prev = 17
pulse_start = 0 
pulse_end = 0 

b = [ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 ]
c = [ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 ]

#Main loop
while True :
	#Send trigger (start) pulse to US sensor
	GPIO.output(TRIG, True)
	time.sleep(0.00001)
	GPIO.output(TRIG, False)

	#Wait until RX signal 0 or max delay
	timeout = 0
	while (GPIO.input(ECHO)==0) and (timeout < 50):
		timeout = timeout + 1	
		pulse_start = time.time() 

	#Wait for RX signal  
	while GPIO.input(ECHO)==1:
		pulse_end = time.time()

	#Calculate time of flight
	pulse_duration = pulse_end - pulse_start

	#convert time to distance
	distance = pulse_duration * 17150
	distance = round(distance, 2)

	#Trap if no pulse recieved
	if distance < 0:
		distance = 0

	distance = distance * 10

	#Set max distance for display
	if distance > 600:
		distance = 600

	print "Distance: ", distance,"mm", "Angle: ", pos

	#Move sensor head
	update(angle)
	time.sleep(0.5)

	#update log file
	file = open('plot_1.dat', 'w')
	outputString = "\n"

	#this array contains a single value i.e. scan line on plot
	c[pos] = 600
	c[pos_prev] = 0

	#update distance string with new value
	for i in range(0,18):
		if pos == i:
			b[i] = distance
			
		outputString = outputString + str(i*10) + "\t" + str(b[i]) + "\t" + str(c[i]) + "\n"

	#write data to file
	file.write(outputString)
	file.close

	#update scan direction and position
	pos_prev = pos

	if direction:
		angle = angle + 10
		pos = pos + 1
	else:
		angle = angle - 10
		pos = pos - 1

	if angle > 180:
		direction = False
		angle = 170
		pos = 17

	if angle < 0:
		direction = True
		angle = 10
		pos = 1

This program produces a log file, three columns, [Angle : Range to Object : Scan Line], as shown below:

0	106.5	0
10	600	0
20	600	0
30	600	0
40	83.7	0
50	73.8	0
60	66.2	0
70	64.8	0
80	88.6	0
90	600	0
100	600	600
110	83.2	0
120	83.4	0
130	69.6	0
140	84.9	0
150	107.2	0
160	106.6	0
170	69.1	0

A separate shell script program then reads this file and plots this data to the screen using GnuPlot. This automatically rereads this file and updates the display every 0.5 seconds. A screen shot of the display is shown in figure 7. RED regions indicate open space, WHITE regions indicate the distance to the closest object, the current sensor position is indicated by the GREEN scan line. A video of the displayed plot is available here: (Video). Figure 8 shows the objects in front of the scanning sensor (circled in red) for this plot, to its right is the stacked tables, to its left open space and the other sensor platform.

File: go.sh

#!/bin/bash

gnuplot plot.gnu

File: plot.gnu

unset border
set polar
set angles degrees

set style line 10 lt 1 lc 0 lw 0.3 

set grid polar 60 
set grid ls 10

set xrange [-700:700]
set yrange [-700:700]

set xtics axis 
set ytics axis

set xtics scale 0
set xtics ("" 100, "" 200, "" 300, "" 400, "" 500, "" 600) 
set ytics 0, -100, -600

set size square 
unset key

set_label(x, text) = sprintf("set label '%s' at (750*cos(%f)), (750*sin(%f)) center", text, x, x) #this places a label on the outside

#here all labels are created
eval set_label(0, "0")
eval set_label(60, "60")
eval set_label(120, "120")
eval set_label(180, "180")
eval set_label(240, "240")
eval set_label(300, "300")

#set style line 11 lt 1 lc 3 lw 2 pt 2 ps 2 #set the line style for the plot

plot "plot.dat" using 1:2 with filledcurves, "plot.dat" using 1:3 with line 
pause 0.5
reread<

Figure 7 : Screen plot

Figure 8 : Table

This program works, but the timing of the servo pulse does vary a little, resulting in servo jitter i.e. delays in receiving the ultrasonic signal, operating system calls, or just simply updating the mouse pointer position etc, means that the servo pulse is not the correct length or is not produced at the correct time, as shown in figure 9. A video of the software controlled servo is available here: (Video).

Figure 9 : Pulse jitter

To remove this jitter the servo can be controlled by dedicated hardware i.e. using a CPLD, as shown in figures 10 - 13. The Raspberry Pi tells the CPLD what position to turn the servo to, low level control is now done in hardware, therefore, any small changes in the software timings no longer affects the servo control pulse. This hardware was based on the following example: (Link) (Local). To further simplify the software, control of the ultrasonic sensor can also be passed to the CPLD. The Raspberry Pi now requests a distance measurement from the CPLD which returns a 7 bit value. Again all low level control / timing is performed in hardware on the CPLD, a simple state machine, coded in VHDL.

Figure 10 : Hardware system interface

Figure 11 : CPLD

Figure 12 : Clock (4MHz) and Buffer

Figure 13 : I2C interface

Figure 14 : I2C interface IC

Figure 15 : I2C interface internals

To allow the Raspberry Pi to communicate to the CPLD it needs a data link. The simplest way to do this is to used three PCF8574 I2C 8bit IO expanders (Local), as shown in figures 13 - 15. Two are set up as outputs (servo position and US sensor control) and one as an input (US sensor data), the Raspbery Pi can then read or write to these I2C mapped addresses. Connected to these parallel ports is the CPLD as shown in figure 16 - 17. This operates from a 4MHz clock, with an internal clock divider generating the timing signals needed for each hardware subsystem. Individual VHDL files and the complete CPLD ISE project is available here:

Figure 16 : Top Level Hardware Controller, VHDL (left), Symbol (right)

Figure 17 : Hardware Controller

Top level control is implemented in another python program, shown below:

import smbus
import time

POS_I2C_ADDR = 0x38
DIS_I2C_ADDR = 0x39
CTL_I2C_ADDR = 0x3A

bus = smbus.SMBus(1)

pos = 0
pos_prev = 31
direction = True

# Distance (37)

b = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
c = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

bus.write_byte(DIS_I2C_ADDR, 0xFF)

while True:
	# Trigger TX

	bus.write_byte(CTL_I2C_ADDR, 0x01)
	bus.write_byte(CTL_I2C_ADDR, 0x00)

	# Time to send start = 2 x 50us	
	# Start pulse 200us 
	# Max RX wait 10ms
	# Software delay 0.1s = 100ms	

	time.sleep(0.1)

	# Time to read 50us

	data = bus.read_byte(DIS_I2C_ADDR)
	
	# print "Distance: " + str(data)

	# Speed of sound 340m/s, clock 64KHz = 15us
	# 1 sec = 340,000mm
	# 1 tick = 2.5mm	
	
	distance = float(data) * 2.5

	# Max distance = 255 x 2.5 = 638mm	

	if distance > 600:
		distance = 600

	# print "Distance: " + str(data)

	file = open('plot.dat', 'w')
	outputString = "\n"

	c[pos] = 600
	c[pos_prev] = 0

	for i in range(0,31):
		if pos == i:
			b[i] = distance
			
		outputString = outputString + str(i*6) + "\t" + str(b[i]) + "\t" + str(c[i]) + "\n"

	file.write(outputString)
	file.close

	pos_prev = pos

	if direction:	
		pos = pos + 1
	else:
		pos = pos - 1

	if pos == 31:
		direction = False
		pos = 29

	if pos < 0:
		direction = True
		pos = 1

	# Print Angle
	print "Angle: " + str( pos ) + " Distance: " + str(data)
 
	bus.write_byte(POS_I2C_ADDR, int(float(pos) * 3.5))
	time.sleep(0.2)

As servo control is implemented in dedicated hardware, timing resolution can be more accurately controlled, allowing more readings to be taken during a scan. A video of the hardware controlled servo is available here: (Video).

Figure 18 : System

Update: Tkinter GUI

The GNUplot display was a bit of a bodge, a quick and dirty solution. Finally got around to getting a nicer GUI working using Tkinter. Do confess GUIs are not my thing, to simplify the job went for a simple gif background (shown in figure 19) and canvas + lines.

Figure 19 : GIF background

Test code is below, current reading is displayed in green, readings recorded during the current sweep are a light blue, readings recorded on the previous sweep a dark blue. A video of the software display random test data value is available here: (Video).

import Tkinter as tk
import math
import random

canvasWidth = 860
canvasHeight = 860

canvasCenterX = (canvasWidth/2)
canvasCenterY = (canvasHeight/2) 

centerRadius = 10
angleOffset = 90

lineWidth = 3
lineLength = 300

lineNumber = 0
lineObjects = []

colourScale = [(0,255,0), (0, 208, 226), (0,190,207), (0,170,188), (0,150,162), (0,130,142), (0,110,121), (0,90,100), (0,75,75), (0,50,50)]

clockwise = True
anticlockwise = False

# Init measurement array (36)

measurement = []
for i in range(0, 185, 5):
	ran_number = random.random()
	distance =  int(ran_number*300)
	measurementData = (i, distance)
	measurement.append( measurementData )

def updateDisplay():
	global lineNumber, lineObjects, clockwise, anticlockwise, measurement

	#print len(measurement), lineNumber
	
	angle = measurement[lineNumber][0] 
	length = measurement[lineNumber][1]

	dx = length * math.sin(math.radians(angleOffset - angle))
	dy = length * math.cos(math.radians(angleOffset - angle))

	colour = '#%02x%02x%02x' % colourScale[0]
	lineObjects.append(canvas.create_line(canvasCenterX, canvasCenterY, dx+canvasCenterX, dy+canvasCenterY, width=lineWidth, fill=colour))

	if lineNumber != 0:
		if clockwise:		
			canvas.delete(lineObjects[lineNumber-1])

			angle = measurement[lineNumber-1][0] 
			length = measurement[lineNumber-1][1]
		
			dx = length * math.sin(math.radians(angleOffset - angle))
			dy = length * math.cos(math.radians(angleOffset - angle))

			colour = '#%02x%02x%02x' % colourScale[1]
			lineObjects[lineNumber-1] = canvas.create_line(canvasCenterX, canvasCenterY, dx+canvasCenterX, dy+canvasCenterY, width=lineWidth, fill=colour)
		else:
			if lineNumber < (len(measurement)-1):
				canvas.delete(lineObjects[lineNumber+1])

				angle = measurement[lineNumber+1][0] 
				length = measurement[lineNumber+1][1]
	
				dx = length * math.sin(math.radians(angleOffset - angle))
				dy = length * math.cos(math.radians(angleOffset - angle))

				colour = '#%02x%02x%02x' % colourScale[1]
				lineObjects[lineNumber+1] = canvas.create_line(canvasCenterX, canvasCenterY, dx+canvasCenterX, dy+canvasCenterY, width=lineWidth, fill=colour)

	if clockwise:
		lineNumber +=1
	else:
		lineNumber -=1

	if ((lineNumber == len(measurement)) and clockwise) or ((lineNumber == -1) and anticlockwise):
		for i in range(0, len(lineObjects)):
			canvas.delete(lineObjects[i])

		lineObjects = []
		for i in range(0, len(measurement)):
			angle = measurement[i][0] 
			length = measurement[i][1]
	
			dx = length * math.sin(math.radians(angleOffset - angle))
			dy = length * math.cos(math.radians(angleOffset - angle))

			colour = '#%02x%02x%02x' % colourScale[5]
			lineObjects.append(canvas.create_line(canvasCenterX, canvasCenterY, dx+canvasCenterX, dy+canvasCenterY, width=lineWidth, fill=colour))

		if clockwise:
			clockwise = False
			anticlockwise = True
			lineNumber -=1
		else:
			clockwise = True
			anticlockwise = False
			lineNumber = 0

		measurement =[]
		for i in range(0, 185, 5):
			ran_number = random.random()
			distance =  int(ran_number*300)
			measurementData = (i, distance)
			measurement.append( measurementData )

	root.after(200, updateDisplay)

root = tk.Tk()
frame = tk.Frame(root)
frame.pack()

canvas = tk.Canvas(frame, width=canvasWidth, height=canvasHeight, bg='black')
canvas.pack(expand=tk.YES, fill=tk.BOTH)                
     
photo=tk.PhotoImage(file='background2.gif')
canvas.create_image(35, 50, image=photo, anchor=tk.NW)

colour = '#%02x%02x%02x' % colourScale[8]
canvas.create_oval(canvasCenterX-centerRadius, canvasCenterY-centerRadius, canvasCenterX+centerRadius, canvasCenterY+centerRadius, width=0, fill=colour)
   
root.after(0, updateDisplay)
root.mainloop()
Creative Commons Licence

This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

Contact details: email - mike.freeman@york.ac.uk, telephone - 01904 32(5473)

Back