To use with CircuitPython, you need to first install a few libraries, into the lib folder on your CIRCUITPY drive. Then you need to update code.py with the example script.
Thankfully, we can do this in one go. In the example below, click the Download Project Bundle button below to download the necessary libraries and the code.py file in a zip file. Extract the contents of the zip file, open the directory CLUE_Metal_Detector/ and then click on the directory that matches the version of CircuitPython you're using and copy the contents of that directory to your CIRCUITPY drive.
Your CIRCUITPY drive should now look similar to the following image:
# SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries # # SPDX-License-Identifier: MIT # clue-metal-detector v1.6 # A simple metal detector using a minimum number of external components # Tested with an Adafruit CLUE (Alpha) and CircuitPython 5.2.0 # Tested with an Adafruit Circuit Playground Bluefruit with TFT Gizmo # and CircuitPython 5.2.0 # CLUE: Pad P0 is an output and pad P1 is an input # CPB: Pad/STEMMA A1 is an output and Pad/STEMMA A2 is an input # copy this file to CLUE/CPB board as code.py # MIT License # Copyright (c) 2020 Kevin J. Walters # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # pylint: disable=global-statement import time import math import array import os import gc import board import pwmio import analogio import ulab from displayio import Group, CIRCUITPYTHON_TERMINAL import terminalio # These imports works on CLUE, CPB (and CPX on 5.x) from audiocore import RawSample try: from audioio import AudioOut except ImportError: from audiopwmio import PWMAudioOut as AudioOut # displayio graphical objects from adafruit_display_text.label import Label from adafruit_display_shapes.rect import Rect from adafruit_display_shapes.circle import Circle # Assuming CLUE if it's not a Circuit Playround (Bluefruit) clue_less = "Circuit Playground" in os.uname().machine if clue_less: # CPB with TFT Gizmo (240x240) from adafruit_circuitplayground import cp from adafruit_gizmo import tft_gizmo # Outputs display = tft_gizmo.TFT_Gizmo() audio_out = AudioOut(board.SPEAKER) min_audio_frequency = 100 max_audio_frequency = 4000 pixels = cp.pixels board_pin_output = board.A1 # Enable the onboard amplifier for speaker cp._speaker_enable.value = True # pylint: disable=protected-access # Inputs board_pin_input = board.A2 magnetometer = None # This indicates device is not present button_left = lambda: cp.button_b button_right = lambda: cp.button_a else: # CLUE with builtin screen (240x240) from adafruit_clue import clue # Outputs display = board.DISPLAY audio_out = AudioOut(board.SPEAKER) min_audio_frequency = 100 max_audio_frequency = 5000 pixels = clue.pixel board_pin_output = board.P0 # Inputs (buttons reversed as it is used upside-down with Gizmo) board_pin_input = board.P1 magnetometer = lambda: clue.magnetic button_left = lambda: clue.button_a button_right = lambda: clue.button_b # Globals variables used r/w in functions last_frequency = 0 last_negbar_len = None last_posbar_len = None last_mag_radius = None text_overlay_gob = None voltage_barneg_dob = None voltage_sep_dob = None voltage_barpos_dob = None magnet_circ_dob = None # Globals debug = 1 screen_height = display.height screen_width = display.width samples = [] # Other globals quantize_tones = True audio_on = True screen_on = True mu_output = False neopixel_on = True # Used to alternate/flash the NeoPixel neopixel_alternate = True # Some constants used in start_beep() BASE_NOTE = 261.6256 # C4 (middle C) QUANTIZE = 4 # determines the "scale" POSTLOG_FACTOR = QUANTIZE / math.log(2) AUDIO_MIDPOINT = 32768 # There's room for 80 pixels but 60 draws a bit quicker VOLTAGE_BAR_WIDTH = 60 VOLTAGE_BAR_HEIGHT = 118 VOLTAGE_BAR_SEP_HEIGHT = 4 MAG_MAX_RADIUS = 50 VOLTAGE_FMT = "{:6.1f}" MAG_FMT = "{:6.1f}" INFO_FG_COLOR = 0x000080 INFO_BG_COLOR = 0xc0c000 BLACK_TUPLE = (0, 0, 0) RED = 0xff0000 GREEN75 = 0x00c000 BLUE = 0x0000ff WHITE75 = 0xc0c0c0 FONT_WIDTH, FONT_HEIGHT = terminalio.FONT.get_bounding_box() # Thresholds below which audio is silent and NeoPixels are dark threshold_voltage = 0.002 threshold_mag = 2.5 def d_print(level, *args, **kwargs): """A simple conditional print for debugging based on global debug level.""" if not isinstance(level, int): print(level, *args, **kwargs) elif debug >= level: print(*args, **kwargs) # Adapted and borrowed from clue-plotter v1.14 def wait_release(text_func, button_func, menu): """Calls button_func repeatedly waiting for it to return a false value and goes through menu list as time passes. The menu is a list of menu entries where each entry is a two element list of time passed in seconds and text to display for that period. Text is displayed by calling text_func(text). The entries must be in ascending time order.""" start_t_ns = time.monotonic_ns() menu_option = None selected = False for menu_option, menu_entry in enumerate(menu): menu_time_ns = start_t_ns + int(menu_entry[0] * 1e9) menu_text = menu_entry[1] if menu_text: text_func(menu_text) while time.monotonic_ns() < menu_time_ns: if not button_func(): selected = True break if menu_text: text_func("") if selected: break return (menu_option, (time.monotonic_ns() - start_t_ns) * 1e-9) def popup_text(text_func, text, duration=1.0): """Place some text on the screen using info property of Plotter object for duration seconds.""" text_func(text) time.sleep(duration) text_func(None) def show_text(text): """Place text on the screen. Empty string or None clears it.""" global screen_group, text_overlay_gob if text: font_scale = 3 line_spacing = 1.25 text_lines = text.split("\n") max_word_chars = max([len(word) for word in text_lines]) # If too large reduce the scale to 2 and hope! if (max_word_chars * font_scale * FONT_WIDTH > screen_width or (len(text_lines) * font_scale * FONT_HEIGHT * line_spacing) > screen_height): font_scale -= 1 text_overlay_gob = Label(terminalio.FONT, text=text, scale=font_scale, background_color=INFO_FG_COLOR, color=INFO_BG_COLOR) # Centre the (left justified) text text_overlay_gob.x = (screen_width - font_scale * FONT_WIDTH * max_word_chars) // 2 text_overlay_gob.y = screen_height // 2 screen_group.append(text_overlay_gob) else: if text_overlay_gob is not None: screen_group.remove(text_overlay_gob) text_overlay_gob = None def voltage_bar_set(volt_diff): """Draw a bar based on positive or negative values. Width of 60 is performance compromise as more pixels take longer.""" global voltage_sep_dob, voltage_barpos_dob, voltage_barneg_dob global last_negbar_len, last_posbar_len if voltage_sep_dob is None: voltage_sep_dob = Rect(160, VOLTAGE_BAR_HEIGHT, VOLTAGE_BAR_WIDTH, VOLTAGE_BAR_SEP_HEIGHT, fill=WHITE75) screen_group.append(voltage_sep_dob) if volt_diff < 0: negbar_len = max(min(-round(volt_diff * 5e3), VOLTAGE_BAR_HEIGHT), 1) posbar_len = 1 else: negbar_len = 1 posbar_len = max(min(round(volt_diff * 5e3), VOLTAGE_BAR_HEIGHT), 1) if posbar_len == last_posbar_len and negbar_len == last_negbar_len: return if voltage_barpos_dob is not None: screen_group.remove(voltage_barpos_dob) if posbar_len > 0: voltage_barpos_dob = Rect(160, VOLTAGE_BAR_HEIGHT - posbar_len, VOLTAGE_BAR_WIDTH, posbar_len, fill=GREEN75) screen_group.append(voltage_barpos_dob) last_posbar_len = posbar_len if voltage_barneg_dob is not None: screen_group.remove(voltage_barneg_dob) if negbar_len > 0: voltage_barneg_dob = Rect(160, VOLTAGE_BAR_HEIGHT + VOLTAGE_BAR_SEP_HEIGHT, VOLTAGE_BAR_WIDTH, negbar_len, fill=RED) screen_group.append(voltage_barneg_dob) last_negbar_len = negbar_len def magnet_circ_set(mag_ut): """Display a filled circle to represent the magnetic value mag_ut in microteslas.""" global magnet_circ_dob global last_mag_radius # map microteslas to a radius with minimum of 1 and # maximum of MAG_MAX_RADIUS radius = min(max(round(math.sqrt(mag_ut) * 4), 1), MAG_MAX_RADIUS) if radius == last_mag_radius: return if magnet_circ_dob is not None: screen_group.remove(magnet_circ_dob) magnet_circ_dob = Circle(60, 180, radius, fill=BLUE) screen_group.append(magnet_circ_dob) def manual_screen_refresh(disp): """Refresh the screen as immediately as is currently possibly with refresh method.""" refreshed = False while True: try: # 1000fps is fastest library allows - this high value # minimises any delays this refresh() method introduces refreshed = disp.refresh(minimum_frames_per_second=0, target_frames_per_second=1000) except RuntimeError: pass if refreshed: break def neopixel_set(pix, d_volt, mag_ut): """Set all the NeoPixels to an alternating colour based on voltage difference and magnitude of magnetic flux density difference.""" global neopixel_alternate np_r, np_g, np_b = BLACK_TUPLE if neopixel_alternate: # RGB values are 8bit, hence the cap of 255 using min() if abs(d_volt) > threshold_voltage: if d_volt < 0.0: np_r = min(round(-d_volt * 8e3), 255) else: np_g = min(round(d_volt * 8e3), 255) else: if mag_ut > threshold_mag: np_b = min(round(mag_ut * 6), 255) pix.fill((np_r, np_g, np_b)) # Note: double brackets to pass tuple neopixel_alternate = not neopixel_alternate def start_beep(freq, wave, wave_idx): """Start playing a continous beep based on freq and waveform specified by wave_idx. A frequency of 0 will stop the note playing. This quantizes the notes into a scale to make beeping sound more pleasant. This modifies the sample_rate property of the RawSample objects. """ global last_frequency if freq == 0: if last_frequency != 0: audio_out.stop() last_frequency = 0 return if quantize_tones: note_freq = BASE_NOTE * 2**((round(math.log(freq / BASE_NOTE) * POSTLOG_FACTOR)) / QUANTIZE) d_print(3, "Quantize", freq, note_freq) else: note_freq = freq (waveform, wave_samples_n) = wave[wave_idx] new_freq = round(note_freq * wave_samples_n) # Only set the new frequency if it's not the same as last one if new_freq != last_frequency: waveform.sample_rate = new_freq audio_out.play(waveform, loop=True) last_frequency = new_freq def make_sample_list(levels=10, volume=32767, range_l=24, start_l=8): """Make a list of tuples of (RawSample, sample_length) with a sine wave of varying resolution from high to low. The lower resolutions sound crunchier and louder on the CLUE.""" # Make a range of sample lengths, default is between 32 and 8 sample_lens = [int((x*(range_l + .99)/(levels - 1)) + start_l) for x in range(0, levels)] sample_lens.reverse() wavefs = [] for s_len in sample_lens: raw_samples = array.array("H", [round(volume * math.sin(2 * math.pi * (idx / s_len))) + AUDIO_MIDPOINT for idx in range(s_len)]) sound_samples = RawSample(raw_samples) wavefs.append((sound_samples, s_len)) return wavefs waveforms = make_sample_list() # For testing the waveforms if debug >= 4: for idx in range(len(waveforms)): start_beep(440, waveforms, idx) time.sleep(0.1) start_beep(0, waveforms, 0) # This silences it # See https://forums.adafruit.com/viewtopic.php?f=60&t=164758 for # a comparison and performance analysis of alternate techniques for this def sample_sum(pin, num): """Sample the analogue value from pin num times and return the sum of the values.""" global samples # Not strictly needed - indicative of r/w use samples[:] = [pin.value for _ in range(num)] return sum(samples) # Initialise detector display # The units are created as separate text objects as they are static # and this reduces the amount of redrawing for the dynamic numbers FONT_SCALE = 3 if magnetometer is not None: magnet_value_dob = Label(font=terminalio.FONT, text="----.-", scale=FONT_SCALE, color=0xc0c000) magnet_value_dob.y = 90 magnet_units_dob = Label(font=terminalio.FONT, text="uT", scale=FONT_SCALE, color=0xc0c000) magnet_units_dob.x = len(magnet_value_dob.text) * FONT_WIDTH * FONT_SCALE magnet_units_dob.y = magnet_value_dob.y voltage_value_dob = Label(font=terminalio.FONT, text="----.-", scale=FONT_SCALE, color=0x00c0c0) voltage_value_dob.y = 30 voltage_units_dob = Label(font=terminalio.FONT, text="mV", scale=FONT_SCALE, color=0x00c0c0) voltage_units_dob.y = voltage_value_dob.y voltage_units_dob.x = len(voltage_value_dob.text) * FONT_WIDTH * FONT_SCALE screen_group = Group() if magnetometer is not None: screen_group.append(magnet_value_dob) screen_group.append(magnet_units_dob) screen_group.append(voltage_value_dob) screen_group.append(voltage_units_dob) # Initialise some displayio objects and append them # The following four variables are set by these two functions # voltage_barneg_dob, voltage_sep_dob, voltage_barpos_dob # magnet_circ_dob voltage_bar_set(0) if magnetometer is not None: magnet_circ_set(0) # Start-up splash screen display.root_group = screen_group # Start-up splash screen popup_text(show_text, "\n".join(["Button Guide", "Left: audio", " 2secs: NeoPixel", " 4s: screen", " 6s: Mu output", "Right: recalibrate"]), duration=10) # P1 or A2 for analogue input pin_input = analogio.AnalogIn(board_pin_input) CONV_FACTOR = pin_input.reference_voltage / 65535 # Start pwm output on P0 or A1 # 400kHz and 55000 (84%) duty_cycle were chosen empirically to maximise # the voltage and the voltage drop detecting a small pair of metal scissors pwm = pwmio.PWMOut(board_pin_output, frequency=400 * 1000, duty_cycle=0, variable_frequency=True) pwm.duty_cycle = 55000 # Get a baseline value for magnetometer totals = [0.0] * 3 mag_samples_n = 10 if magnetometer is not None: for _ in range(mag_samples_n): mx, my, mz = magnetometer() totals[0] += mx totals[1] += my totals[2] += mz time.sleep(0.05) base_mx = totals[0] / mag_samples_n base_my = totals[1] / mag_samples_n base_mz = totals[2] / mag_samples_n # Wait a bit for P1/A2 input to stabilise _ = sample_sum(pin_input, 3000) / 3000 * CONV_FACTOR base_voltage = sample_sum(pin_input, 1000) / 1000 * CONV_FACTOR voltage_value_dob.text = "{:6.1f}".format(base_voltage * 1000.0) # Auto refresh off display.auto_refresh = False # Store two previous values of voltage to make a simple # filtered value voltage_zm1 = None voltage_zm2 = None filt_voltage = None # Initialise the magnitude of the # magnetic flux density difference from its baseline mag_mag = 0.0 # Keep some historical voltage data to calculate median for re-baselining # aiming for about 10 reads per second so this gives # 20 seconds voltage_hist = ulab.numpy.zeros(20 * 10 + 1, dtype=ulab.numpy.float) voltage_hist_idx = 0 voltage_hist_complete = False voltage_hist_median = None # Reduce the frequency of the more heavyweight graphical changes update_basic_graphics_period = 2 update_complex_graphics_period = 4 update_median_period = 5 counter = 0 while True: # Garbage collect now to reduce likelihood it occurs # during sample reading gc.collect() if debug >=2: d_print(2, "mem_free=" + str(gc.mem_free())) screen_updates = 0 # Used to determine if the screen needs a refresh # Take arithmetic mean of 500 samples but take a few more samples # if the loop isn't doing other work samples_to_read = 500 # About 23ms worth on CLUE update_basic_graphics = (screen_on and counter % update_basic_graphics_period == 0) if not update_basic_graphics: samples_to_read += 150 update_complex_graphics = (screen_on and counter % update_complex_graphics_period == 0) if not update_complex_graphics: samples_to_read += 400 update_median = counter % update_median_period == 0 if not update_median: samples_to_read += 50 # Read the analogue values from P1/A2 sample_start_time_ns = time.monotonic_ns() voltage = (sample_sum(pin_input, samples_to_read) / samples_to_read * CONV_FACTOR) # Store the previous two voltage values voltage_zm2 = voltage_zm1 voltage_zm1 = voltage if voltage_zm1 is None: voltage_zm1 = voltage if voltage_zm2 is None: voltage_zm2 = voltage filt_voltage = (voltage * 0.4 + voltage_zm1 * 0.3 + voltage_zm2 * 0.3) update_basic_graphics = counter % update_basic_graphics_period == 0 update_complex_graphics = counter % update_complex_graphics_period == 0 # Update text if update_basic_graphics: voltage_value_dob.text = VOLTAGE_FMT.format(filt_voltage * 1000.0) screen_updates += 1 # Read magnetometer if magnetometer is not None: mx, my, mz = magnetometer() diff_x = mx - base_mx diff_y = my - base_my diff_z = mz - base_mz # Use the z value as a crude measure as this is # constant if the device is rotated and kept level mag_mag = math.sqrt(diff_z * diff_z) else: mag_mag = 0.0 # Calculate a new audio frequency based on the absolute difference # in voltage being read - turn small voltages into 0 for silence # between 100Hz (won't be audible) # and 5000 (loud on CLUE's miniscule speaker) diff_v = filt_voltage - base_voltage abs_diff_v = abs(diff_v) if audio_on: if abs_diff_v > threshold_voltage or mag_mag > threshold_mag: frequency = min(min_audio_frequency + abs_diff_v * 5e5, max_audio_frequency) else: frequency = 0 # silence start_beep(frequency, waveforms, min(int(mag_mag / 2), len(waveforms) - 1)) # Update the NeoPixel(s) if enabled if neopixel_on: neopixel_set(pixels, diff_v, mag_mag) # Update voltage bargraph if update_complex_graphics: voltage_bar_set(diff_v) screen_updates += 1 # Update the magnetometer text value and the filled circle representation if magnetometer is not None: if update_basic_graphics: magnet_value_dob.text = MAG_FMT.format(mag_mag) screen_updates += 1 if update_complex_graphics: magnet_circ_set(mag_mag) screen_updates += 1 # Update the screen with a refresh if needed if screen_updates: manual_screen_refresh(display) # Send output to Mu in tuple format if mu_output: print((diff_v, mag_mag)) # Check for buttons and just for this section of code turn back on # the screen auto-refresh so the menus actually appear! display.auto_refresh = True if button_left(): opt, _ = wait_release(show_text, button_left, [(2, "Audio " + ("off" if audio_on else "on")), (4, "NeoPixel " + ("off" if neopixel_on else "on")), (6, "Screen " + ("off" if screen_on else "on")), (8, "Mu output " + ("off" if mu_output else "on")) ]) if not screen_on or opt == 2: # Screen toggle screen_on = not screen_on if screen_on: display.root_group = screen_group display.brightness = 1.0 else: display.root_group = CIRCUITPYTHON_TERMINAL display.brightness = 0.0 elif opt == 0: # Audio toggle audio_on = not audio_on if not audio_on: start_beep(0, waveforms, 0) # Silence elif opt == 1: # NeoPixel toggle neopixel_on = not neopixel_on if not neopixel_on: neopixel_set(pixels, 0.0, 0.0) else: # Mu toggle mu_output = not mu_output # Set new baseline voltage and magnetometer on right button press if button_right(): wait_release(show_text, button_right, [(2, "Recalibrate")]) d_print(1, "Recalibrate") base_voltage = voltage voltage_hist_idx = 0 voltage_hist_complete = False voltage_hist_median = None if magnetometer is not None: base_mx, base_my, base_mz = mx, my, mz display.auto_refresh = False # Add the current voltage to the historical list voltage_hist[voltage_hist_idx] = voltage if voltage_hist_idx >= len(voltage_hist) - 1: voltage_hist_idx = 0 voltage_hist_complete = True else: voltage_hist_idx += 1 # Adjust the reference base_voltage to the median of historical values if voltage_hist_complete and update_median: voltage_hist_median = ulab.numpy.sort(voltage_hist)[len(voltage_hist) // 2] base_voltage = voltage_hist_median d_print(2, counter, sample_start_time_ns / 1e9, voltage * 1000.0, mag_mag, filt_voltage * 1000.0, base_voltage, voltage_hist_median) counter += 1
The video shows the CLUE version powered by a lithium polymer battery similar to the Adafruit 1200mAh Lithium Ion Polymer battery. Note: the CLUE and the CPB do not have an integrated charger.
In the video, when no object is being sensed, the voltage shown on the screen is around 1474mV and magnitude of the magnetic flux density difference is 0uT. The five hidden objects, in order, show the following voltages:
- Through a large hardback book
- a large metallic sticker, 1467mV.
- Through a magazine
- another Adafruit CLUE board, 1463mV;
- a ferrite core from an inductor, 1477mV (note the value has increased);
- a neodymium magnet, 1474mV and 28uT;
- a large silver coin 1469mV.
The voltage will vary based on the inductance of the coil created for the metal detector. It will be about 300mV less if a rectifier diode like a 1N1004 is used. The voltage is about 200mV less on the Circuit Playground Bluefruit with TFT Gizmo for the same coil.
Troubleshooting
If the metal detector is not working, here's some tips based on observing the voltage.
- Around 2950mV: the coil is not connected or the connection is hampered by insulation left on the enamelled wire.
- Around 0mV: diode may be the wrong way around or something is not connected properly.
- A few tens of mV: the yellow connection is probably from a high (3.3V) pin.
- Voltage jumps around: probably a loose connection and/or ground is not attached. Wiggle and re-insert connections to find problematic one. Using alternate holes/rows on the breadboard can help sometimes.
Operation
The mV value across the capacitor is shown on screen. This value represents the inductance value. The detection of metal is based on a positive or negative change from the baseline value when no object is being sensed. A difference is indicated by a beeping sound, a bar graph with green for positive and red for negative and flashing of the NeoPixel(s) with a matching colour. The baseline value is assigned when the code first starts. It will also follow any changes after about ten seconds.
The uT reading (CLUE board only) is the magnitude of the difference between the magnetometer's z component only and the first value measured at start-up. This value is also shown as a filled blue circle, a slightly different beeping sound and flashing of the NeoPixel(s) in blue alternating with any mV related colour.
The use of the z component only is a crude approach to make the detector ignore the Earth's magnetic field. This allows the detector to be rotated as this changes the x and y values but not the z value. Tilting the device, as seen in the video when the metal detector is at the top of the screen, will unfortunately increase the value slightly.
The right button can be used to immediately reset the baseline for the voltage and the magnetic flux density. The left button toggles the audio, NeoPixel(s), screen and Mu output on and off depending on the duration of the button press.
Code
A code discussion follows the code.
# SPDX-FileCopyrightText: 2020 Kevin J Walters for Adafruit Industries # # SPDX-License-Identifier: MIT # clue-metal-detector v1.6 # A simple metal detector using a minimum number of external components # Tested with an Adafruit CLUE (Alpha) and CircuitPython 5.2.0 # Tested with an Adafruit Circuit Playground Bluefruit with TFT Gizmo # and CircuitPython 5.2.0 # CLUE: Pad P0 is an output and pad P1 is an input # CPB: Pad/STEMMA A1 is an output and Pad/STEMMA A2 is an input # copy this file to CLUE/CPB board as code.py # MIT License # Copyright (c) 2020 Kevin J. Walters # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # pylint: disable=global-statement import time import math import array import os import gc import board import pwmio import analogio import ulab from displayio import Group, CIRCUITPYTHON_TERMINAL import terminalio # These imports works on CLUE, CPB (and CPX on 5.x) from audiocore import RawSample try: from audioio import AudioOut except ImportError: from audiopwmio import PWMAudioOut as AudioOut # displayio graphical objects from adafruit_display_text.label import Label from adafruit_display_shapes.rect import Rect from adafruit_display_shapes.circle import Circle # Assuming CLUE if it's not a Circuit Playround (Bluefruit) clue_less = "Circuit Playground" in os.uname().machine if clue_less: # CPB with TFT Gizmo (240x240) from adafruit_circuitplayground import cp from adafruit_gizmo import tft_gizmo # Outputs display = tft_gizmo.TFT_Gizmo() audio_out = AudioOut(board.SPEAKER) min_audio_frequency = 100 max_audio_frequency = 4000 pixels = cp.pixels board_pin_output = board.A1 # Enable the onboard amplifier for speaker cp._speaker_enable.value = True # pylint: disable=protected-access # Inputs board_pin_input = board.A2 magnetometer = None # This indicates device is not present button_left = lambda: cp.button_b button_right = lambda: cp.button_a else: # CLUE with builtin screen (240x240) from adafruit_clue import clue # Outputs display = board.DISPLAY audio_out = AudioOut(board.SPEAKER) min_audio_frequency = 100 max_audio_frequency = 5000 pixels = clue.pixel board_pin_output = board.P0 # Inputs (buttons reversed as it is used upside-down with Gizmo) board_pin_input = board.P1 magnetometer = lambda: clue.magnetic button_left = lambda: clue.button_a button_right = lambda: clue.button_b # Globals variables used r/w in functions last_frequency = 0 last_negbar_len = None last_posbar_len = None last_mag_radius = None text_overlay_gob = None voltage_barneg_dob = None voltage_sep_dob = None voltage_barpos_dob = None magnet_circ_dob = None # Globals debug = 1 screen_height = display.height screen_width = display.width samples = [] # Other globals quantize_tones = True audio_on = True screen_on = True mu_output = False neopixel_on = True # Used to alternate/flash the NeoPixel neopixel_alternate = True # Some constants used in start_beep() BASE_NOTE = 261.6256 # C4 (middle C) QUANTIZE = 4 # determines the "scale" POSTLOG_FACTOR = QUANTIZE / math.log(2) AUDIO_MIDPOINT = 32768 # There's room for 80 pixels but 60 draws a bit quicker VOLTAGE_BAR_WIDTH = 60 VOLTAGE_BAR_HEIGHT = 118 VOLTAGE_BAR_SEP_HEIGHT = 4 MAG_MAX_RADIUS = 50 VOLTAGE_FMT = "{:6.1f}" MAG_FMT = "{:6.1f}" INFO_FG_COLOR = 0x000080 INFO_BG_COLOR = 0xc0c000 BLACK_TUPLE = (0, 0, 0) RED = 0xff0000 GREEN75 = 0x00c000 BLUE = 0x0000ff WHITE75 = 0xc0c0c0 FONT_WIDTH, FONT_HEIGHT = terminalio.FONT.get_bounding_box() # Thresholds below which audio is silent and NeoPixels are dark threshold_voltage = 0.002 threshold_mag = 2.5 def d_print(level, *args, **kwargs): """A simple conditional print for debugging based on global debug level.""" if not isinstance(level, int): print(level, *args, **kwargs) elif debug >= level: print(*args, **kwargs) # Adapted and borrowed from clue-plotter v1.14 def wait_release(text_func, button_func, menu): """Calls button_func repeatedly waiting for it to return a false value and goes through menu list as time passes. The menu is a list of menu entries where each entry is a two element list of time passed in seconds and text to display for that period. Text is displayed by calling text_func(text). The entries must be in ascending time order.""" start_t_ns = time.monotonic_ns() menu_option = None selected = False for menu_option, menu_entry in enumerate(menu): menu_time_ns = start_t_ns + int(menu_entry[0] * 1e9) menu_text = menu_entry[1] if menu_text: text_func(menu_text) while time.monotonic_ns() < menu_time_ns: if not button_func(): selected = True break if menu_text: text_func("") if selected: break return (menu_option, (time.monotonic_ns() - start_t_ns) * 1e-9) def popup_text(text_func, text, duration=1.0): """Place some text on the screen using info property of Plotter object for duration seconds.""" text_func(text) time.sleep(duration) text_func(None) def show_text(text): """Place text on the screen. Empty string or None clears it.""" global screen_group, text_overlay_gob if text: font_scale = 3 line_spacing = 1.25 text_lines = text.split("\n") max_word_chars = max([len(word) for word in text_lines]) # If too large reduce the scale to 2 and hope! if (max_word_chars * font_scale * FONT_WIDTH > screen_width or (len(text_lines) * font_scale * FONT_HEIGHT * line_spacing) > screen_height): font_scale -= 1 text_overlay_gob = Label(terminalio.FONT, text=text, scale=font_scale, background_color=INFO_FG_COLOR, color=INFO_BG_COLOR) # Centre the (left justified) text text_overlay_gob.x = (screen_width - font_scale * FONT_WIDTH * max_word_chars) // 2 text_overlay_gob.y = screen_height // 2 screen_group.append(text_overlay_gob) else: if text_overlay_gob is not None: screen_group.remove(text_overlay_gob) text_overlay_gob = None def voltage_bar_set(volt_diff): """Draw a bar based on positive or negative values. Width of 60 is performance compromise as more pixels take longer.""" global voltage_sep_dob, voltage_barpos_dob, voltage_barneg_dob global last_negbar_len, last_posbar_len if voltage_sep_dob is None: voltage_sep_dob = Rect(160, VOLTAGE_BAR_HEIGHT, VOLTAGE_BAR_WIDTH, VOLTAGE_BAR_SEP_HEIGHT, fill=WHITE75) screen_group.append(voltage_sep_dob) if volt_diff < 0: negbar_len = max(min(-round(volt_diff * 5e3), VOLTAGE_BAR_HEIGHT), 1) posbar_len = 1 else: negbar_len = 1 posbar_len = max(min(round(volt_diff * 5e3), VOLTAGE_BAR_HEIGHT), 1) if posbar_len == last_posbar_len and negbar_len == last_negbar_len: return if voltage_barpos_dob is not None: screen_group.remove(voltage_barpos_dob) if posbar_len > 0: voltage_barpos_dob = Rect(160, VOLTAGE_BAR_HEIGHT - posbar_len, VOLTAGE_BAR_WIDTH, posbar_len, fill=GREEN75) screen_group.append(voltage_barpos_dob) last_posbar_len = posbar_len if voltage_barneg_dob is not None: screen_group.remove(voltage_barneg_dob) if negbar_len > 0: voltage_barneg_dob = Rect(160, VOLTAGE_BAR_HEIGHT + VOLTAGE_BAR_SEP_HEIGHT, VOLTAGE_BAR_WIDTH, negbar_len, fill=RED) screen_group.append(voltage_barneg_dob) last_negbar_len = negbar_len def magnet_circ_set(mag_ut): """Display a filled circle to represent the magnetic value mag_ut in microteslas.""" global magnet_circ_dob global last_mag_radius # map microteslas to a radius with minimum of 1 and # maximum of MAG_MAX_RADIUS radius = min(max(round(math.sqrt(mag_ut) * 4), 1), MAG_MAX_RADIUS) if radius == last_mag_radius: return if magnet_circ_dob is not None: screen_group.remove(magnet_circ_dob) magnet_circ_dob = Circle(60, 180, radius, fill=BLUE) screen_group.append(magnet_circ_dob) def manual_screen_refresh(disp): """Refresh the screen as immediately as is currently possibly with refresh method.""" refreshed = False while True: try: # 1000fps is fastest library allows - this high value # minimises any delays this refresh() method introduces refreshed = disp.refresh(minimum_frames_per_second=0, target_frames_per_second=1000) except RuntimeError: pass if refreshed: break def neopixel_set(pix, d_volt, mag_ut): """Set all the NeoPixels to an alternating colour based on voltage difference and magnitude of magnetic flux density difference.""" global neopixel_alternate np_r, np_g, np_b = BLACK_TUPLE if neopixel_alternate: # RGB values are 8bit, hence the cap of 255 using min() if abs(d_volt) > threshold_voltage: if d_volt < 0.0: np_r = min(round(-d_volt * 8e3), 255) else: np_g = min(round(d_volt * 8e3), 255) else: if mag_ut > threshold_mag: np_b = min(round(mag_ut * 6), 255) pix.fill((np_r, np_g, np_b)) # Note: double brackets to pass tuple neopixel_alternate = not neopixel_alternate def start_beep(freq, wave, wave_idx): """Start playing a continous beep based on freq and waveform specified by wave_idx. A frequency of 0 will stop the note playing. This quantizes the notes into a scale to make beeping sound more pleasant. This modifies the sample_rate property of the RawSample objects. """ global last_frequency if freq == 0: if last_frequency != 0: audio_out.stop() last_frequency = 0 return if quantize_tones: note_freq = BASE_NOTE * 2**((round(math.log(freq / BASE_NOTE) * POSTLOG_FACTOR)) / QUANTIZE) d_print(3, "Quantize", freq, note_freq) else: note_freq = freq (waveform, wave_samples_n) = wave[wave_idx] new_freq = round(note_freq * wave_samples_n) # Only set the new frequency if it's not the same as last one if new_freq != last_frequency: waveform.sample_rate = new_freq audio_out.play(waveform, loop=True) last_frequency = new_freq def make_sample_list(levels=10, volume=32767, range_l=24, start_l=8): """Make a list of tuples of (RawSample, sample_length) with a sine wave of varying resolution from high to low. The lower resolutions sound crunchier and louder on the CLUE.""" # Make a range of sample lengths, default is between 32 and 8 sample_lens = [int((x*(range_l + .99)/(levels - 1)) + start_l) for x in range(0, levels)] sample_lens.reverse() wavefs = [] for s_len in sample_lens: raw_samples = array.array("H", [round(volume * math.sin(2 * math.pi * (idx / s_len))) + AUDIO_MIDPOINT for idx in range(s_len)]) sound_samples = RawSample(raw_samples) wavefs.append((sound_samples, s_len)) return wavefs waveforms = make_sample_list() # For testing the waveforms if debug >= 4: for idx in range(len(waveforms)): start_beep(440, waveforms, idx) time.sleep(0.1) start_beep(0, waveforms, 0) # This silences it # See https://forums.adafruit.com/viewtopic.php?f=60&t=164758 for # a comparison and performance analysis of alternate techniques for this def sample_sum(pin, num): """Sample the analogue value from pin num times and return the sum of the values.""" global samples # Not strictly needed - indicative of r/w use samples[:] = [pin.value for _ in range(num)] return sum(samples) # Initialise detector display # The units are created as separate text objects as they are static # and this reduces the amount of redrawing for the dynamic numbers FONT_SCALE = 3 if magnetometer is not None: magnet_value_dob = Label(font=terminalio.FONT, text="----.-", scale=FONT_SCALE, color=0xc0c000) magnet_value_dob.y = 90 magnet_units_dob = Label(font=terminalio.FONT, text="uT", scale=FONT_SCALE, color=0xc0c000) magnet_units_dob.x = len(magnet_value_dob.text) * FONT_WIDTH * FONT_SCALE magnet_units_dob.y = magnet_value_dob.y voltage_value_dob = Label(font=terminalio.FONT, text="----.-", scale=FONT_SCALE, color=0x00c0c0) voltage_value_dob.y = 30 voltage_units_dob = Label(font=terminalio.FONT, text="mV", scale=FONT_SCALE, color=0x00c0c0) voltage_units_dob.y = voltage_value_dob.y voltage_units_dob.x = len(voltage_value_dob.text) * FONT_WIDTH * FONT_SCALE screen_group = Group() if magnetometer is not None: screen_group.append(magnet_value_dob) screen_group.append(magnet_units_dob) screen_group.append(voltage_value_dob) screen_group.append(voltage_units_dob) # Initialise some displayio objects and append them # The following four variables are set by these two functions # voltage_barneg_dob, voltage_sep_dob, voltage_barpos_dob # magnet_circ_dob voltage_bar_set(0) if magnetometer is not None: magnet_circ_set(0) # Start-up splash screen display.root_group = screen_group # Start-up splash screen popup_text(show_text, "\n".join(["Button Guide", "Left: audio", " 2secs: NeoPixel", " 4s: screen", " 6s: Mu output", "Right: recalibrate"]), duration=10) # P1 or A2 for analogue input pin_input = analogio.AnalogIn(board_pin_input) CONV_FACTOR = pin_input.reference_voltage / 65535 # Start pwm output on P0 or A1 # 400kHz and 55000 (84%) duty_cycle were chosen empirically to maximise # the voltage and the voltage drop detecting a small pair of metal scissors pwm = pwmio.PWMOut(board_pin_output, frequency=400 * 1000, duty_cycle=0, variable_frequency=True) pwm.duty_cycle = 55000 # Get a baseline value for magnetometer totals = [0.0] * 3 mag_samples_n = 10 if magnetometer is not None: for _ in range(mag_samples_n): mx, my, mz = magnetometer() totals[0] += mx totals[1] += my totals[2] += mz time.sleep(0.05) base_mx = totals[0] / mag_samples_n base_my = totals[1] / mag_samples_n base_mz = totals[2] / mag_samples_n # Wait a bit for P1/A2 input to stabilise _ = sample_sum(pin_input, 3000) / 3000 * CONV_FACTOR base_voltage = sample_sum(pin_input, 1000) / 1000 * CONV_FACTOR voltage_value_dob.text = "{:6.1f}".format(base_voltage * 1000.0) # Auto refresh off display.auto_refresh = False # Store two previous values of voltage to make a simple # filtered value voltage_zm1 = None voltage_zm2 = None filt_voltage = None # Initialise the magnitude of the # magnetic flux density difference from its baseline mag_mag = 0.0 # Keep some historical voltage data to calculate median for re-baselining # aiming for about 10 reads per second so this gives # 20 seconds voltage_hist = ulab.numpy.zeros(20 * 10 + 1, dtype=ulab.numpy.float) voltage_hist_idx = 0 voltage_hist_complete = False voltage_hist_median = None # Reduce the frequency of the more heavyweight graphical changes update_basic_graphics_period = 2 update_complex_graphics_period = 4 update_median_period = 5 counter = 0 while True: # Garbage collect now to reduce likelihood it occurs # during sample reading gc.collect() if debug >=2: d_print(2, "mem_free=" + str(gc.mem_free())) screen_updates = 0 # Used to determine if the screen needs a refresh # Take arithmetic mean of 500 samples but take a few more samples # if the loop isn't doing other work samples_to_read = 500 # About 23ms worth on CLUE update_basic_graphics = (screen_on and counter % update_basic_graphics_period == 0) if not update_basic_graphics: samples_to_read += 150 update_complex_graphics = (screen_on and counter % update_complex_graphics_period == 0) if not update_complex_graphics: samples_to_read += 400 update_median = counter % update_median_period == 0 if not update_median: samples_to_read += 50 # Read the analogue values from P1/A2 sample_start_time_ns = time.monotonic_ns() voltage = (sample_sum(pin_input, samples_to_read) / samples_to_read * CONV_FACTOR) # Store the previous two voltage values voltage_zm2 = voltage_zm1 voltage_zm1 = voltage if voltage_zm1 is None: voltage_zm1 = voltage if voltage_zm2 is None: voltage_zm2 = voltage filt_voltage = (voltage * 0.4 + voltage_zm1 * 0.3 + voltage_zm2 * 0.3) update_basic_graphics = counter % update_basic_graphics_period == 0 update_complex_graphics = counter % update_complex_graphics_period == 0 # Update text if update_basic_graphics: voltage_value_dob.text = VOLTAGE_FMT.format(filt_voltage * 1000.0) screen_updates += 1 # Read magnetometer if magnetometer is not None: mx, my, mz = magnetometer() diff_x = mx - base_mx diff_y = my - base_my diff_z = mz - base_mz # Use the z value as a crude measure as this is # constant if the device is rotated and kept level mag_mag = math.sqrt(diff_z * diff_z) else: mag_mag = 0.0 # Calculate a new audio frequency based on the absolute difference # in voltage being read - turn small voltages into 0 for silence # between 100Hz (won't be audible) # and 5000 (loud on CLUE's miniscule speaker) diff_v = filt_voltage - base_voltage abs_diff_v = abs(diff_v) if audio_on: if abs_diff_v > threshold_voltage or mag_mag > threshold_mag: frequency = min(min_audio_frequency + abs_diff_v * 5e5, max_audio_frequency) else: frequency = 0 # silence start_beep(frequency, waveforms, min(int(mag_mag / 2), len(waveforms) - 1)) # Update the NeoPixel(s) if enabled if neopixel_on: neopixel_set(pixels, diff_v, mag_mag) # Update voltage bargraph if update_complex_graphics: voltage_bar_set(diff_v) screen_updates += 1 # Update the magnetometer text value and the filled circle representation if magnetometer is not None: if update_basic_graphics: magnet_value_dob.text = MAG_FMT.format(mag_mag) screen_updates += 1 if update_complex_graphics: magnet_circ_set(mag_mag) screen_updates += 1 # Update the screen with a refresh if needed if screen_updates: manual_screen_refresh(display) # Send output to Mu in tuple format if mu_output: print((diff_v, mag_mag)) # Check for buttons and just for this section of code turn back on # the screen auto-refresh so the menus actually appear! display.auto_refresh = True if button_left(): opt, _ = wait_release(show_text, button_left, [(2, "Audio " + ("off" if audio_on else "on")), (4, "NeoPixel " + ("off" if neopixel_on else "on")), (6, "Screen " + ("off" if screen_on else "on")), (8, "Mu output " + ("off" if mu_output else "on")) ]) if not screen_on or opt == 2: # Screen toggle screen_on = not screen_on if screen_on: display.root_group = screen_group display.brightness = 1.0 else: display.root_group = CIRCUITPYTHON_TERMINAL display.brightness = 0.0 elif opt == 0: # Audio toggle audio_on = not audio_on if not audio_on: start_beep(0, waveforms, 0) # Silence elif opt == 1: # NeoPixel toggle neopixel_on = not neopixel_on if not neopixel_on: neopixel_set(pixels, 0.0, 0.0) else: # Mu toggle mu_output = not mu_output # Set new baseline voltage and magnetometer on right button press if button_right(): wait_release(show_text, button_right, [(2, "Recalibrate")]) d_print(1, "Recalibrate") base_voltage = voltage voltage_hist_idx = 0 voltage_hist_complete = False voltage_hist_median = None if magnetometer is not None: base_mx, base_my, base_mz = mx, my, mz display.auto_refresh = False # Add the current voltage to the historical list voltage_hist[voltage_hist_idx] = voltage if voltage_hist_idx >= len(voltage_hist) - 1: voltage_hist_idx = 0 voltage_hist_complete = True else: voltage_hist_idx += 1 # Adjust the reference base_voltage to the median of historical values if voltage_hist_complete and update_median: voltage_hist_median = ulab.numpy.sort(voltage_hist)[len(voltage_hist) // 2] base_voltage = voltage_hist_median d_print(2, counter, sample_start_time_ns / 1e9, voltage * 1000.0, mag_mag, filt_voltage * 1000.0, base_voltage, voltage_hist_median) counter += 1
Code Discussion
The high level design is straightforward.
- Output a square wave on a pin.
- Store a baseline value from the other pin configured as an analogue input which is measuring the voltage across the capacitor.
- Store a baseline value for the z component of the magnetometer (if present).
- Take the difference from the current analogue input and the baseline and present this value to the user.
- Take the magnitude of the difference from the current z component of the magnetometer and the baseline and present this value to the user.
- Check the two buttons for user inputs.
- Go to step 4.
Only the buttons are used for the user interface on the CLUE. There is one spare touch capable pad but this isn't really accessible if an edge connector is used.
Voltage from ADC Values
The ADC values are easily read in CircuitPython using an AnalogIn object's value
property. This value ranges from 0 to 65535 (a 16bit value) regardless of the number of bits returned by the ADC. The nRF52840 is configured in 12bit ADC mode by the CircuitPython interpreter. This means values will always be multiples of 16.
One surprise is these values can vary even with a stable voltage source like a battery. An extreme example from some real data for consecutive values is:
- 25152 = 1266.5mV
- 28848 = 1452.6mV
- 28608 = 1440.5mV
In the case of this metal detector, a 3mV difference represents a small metallic object, but the ADC is infrequently producing output which hugely deviates from the actual value. Even the second and third values have a 12.1mV difference.
A common approach is to take multiple samples and then take the average (arithmetic mean) of those values with the aim of reducing the effect of this variance. The sample_sum()
function below does most of this job, it leaves the division by num
to the caller.
def sample_sum(pin, num): """Sample the analogue value from pin num times and return the sum of the values.""" global samples samples[:] = [pin.value for _ in range(num)] return sum(samples)
This is one of the most efficient ways to read multiple samples with a rate of around 21-22 thousand samples per second (ksps) on an nRF52840. It also stores them in case further data analysis is required. The use of global
here isn't strictly required but arguably it's useful to indicate the function changes the global list samples
. The values are intentionally processed here as int
and not float
to improve the performance. The use of slice assignment is an attempt. probably unsuccessful, to stop the interpreter generating a temporary list to store all the sample values.
The performance of different approaches to reading many samples is shown in Adafruit Forums: Analogue Sampling at high rates plus ulab.
The validity of using the average of a number of consecutive samples to accurately represent the real voltage is examined on the next page.
Using Global Variables in Python
In Python, global must be used inside a function (or method) to declare usage of a variable if assignment occurs. This prevents Python from creating a new local variable. An example from the program is shown below.
def magnet_circ_set(mag_ut): """Display a filled circle to represent the magnetic value mag_ut in microteslas.""" global magnet_circ_dob global last_mag_radius radius = min(max(round(math.sqrt(mag_ut) * 4), 1), MAG_MAX_RADIUS) if radius == last_mag_radius: return if magnet_circ_dob is not None: screen_group.remove(magnet_circ_dob) magnet_circ_dob = Circle(60, 180, radius, fill=BLUE) screen_group.append(magnet_circ_dob)
Pylint picks up on use of global
and issues a W0603: Using the global statement (global-statement)
warning. Variables with a large scope which are not truly constant can make a program difficult to understand and lead to bugs - global variables are the most extreme version of this. In a small program they tend not to be problematic but small programs can gradually become much larger ones. In the above case the variables have:
- a clear, specific, semi-documented purpose
- and a very low probability of being used elsewhere in the code in the future.
The current code does limit the display to a single circle/value. If the program was likely to grow over time or there was a potential need to display multiple circles/values then creating a new class would be an attractive option to encapsulate this data replacing the use of global variables.
In other languages, global variables can cause limitations or bugs from ill-considered use due to multi-threading or re-entrancy issues. The evolution of errno is one important example of a global variable used by UNIX libraries which had to be enhanced to support true multi-threading by conversion into a function.
Positional Arguments
The majority of programming languages use positional arguments (parameters) to functions. An example from the code is show below with the body of the procedure not shown for brevity.
def neopixel_set(pix, d_volt, mag_ut): """Set all the NeoPixels to an alternating colour based on voltage difference and magnitude of magnetic flux density difference."""
The three values are clearly very different:
-
pix
- an object for the NeoPixels, thefill()
method is used on it. -
d_volt
- a difference value which may be positive or negative in volts. -
mag_ut
- a magnetic value in microteslas which happens to be a magnitude of a difference value so is always non-negative.
A scientist would clearly see there are two quantities with very different units. Python traditionally didn't have any typing that would indicate if the procedure was used with the arguments in the wrong order and during development the numerical arguments were briefly reversed by accident. The use of keyword (named) arguments can make this less likely to occur, particularly with functions which take a huge number of arguments. Keyword arguments are only mandatory in Python after *
in the argument list.
CircuitPython supports type hints (PEP-484) which improves the results from static analysis tools like pylint. This can reduce bugs in this area but will not eliminate them.
Practical Issues with displayio Graphics
Drawing items on the TFT LCD screen on these boards is a slow process compared to a modern desktop computer. This is particularly noticeable when drawing large objects using the adafruit_display_shapes library.
The program uses a variety of techniques to try and keep the main loop executing at a reasonable and approximately constant rate both especially when a significant object is detected.
- The default automatic screen refresh is replaced by a manual refresh once per loop to CPU cycles are not spent on interim, fruitless, partial screen updates.
- The
MAG_MAX_RADIUS
seen in themagnet_circ_set()
procedure above serves to ensure the filled circle fits on screen. It's set slightly smaller than the screen area it occupies to reduce the performance impact of drawing very large circles. - Screen objects which are slow to update are reduced in frequency with an "only every N times" approach in the main loop.
- The number of samples read adapts to other balance other activity in the loop to keep the execution rate more constant.
- Graphical objects are not updated if the screen has been turned off in the program by the user.
- The numerical values on screen are split into two Label objects to separate the dynamic value and the static units (
"uT"
and"mV"
).
The third, fourth and fifth optimisations are shown in an excerpt below from the main loop.
# An excerpt from main loop samples_to_read = 500 # About 23ms worth on CLUE update_basic_graphics = (screen_on and counter % update_basic_graphics_period == 0) if not update_basic_graphics: samples_to_read += 150 update_complex_graphics = (screen_on and counter % update_complex_graphics_period == 0) if not update_complex_graphics: samples_to_read += 400 update_median = counter % update_median_period == 0 if not update_median: samples_to_read += 50
This is setting three boolean variables, update_basic_graphics
, update_complex_graphics
and update_median
, which are used to selectively execute certain computationally expensive parts of the loop and to increase the amount of sample reading if those operations are not taking place to balance the loop time and make practical use of this time. The first two values are calculated using screen_on
to ensure they are False
if the screen is not being used.
The displayio
library has a builtin optimisation. Only areas of the screen which have been changed are sent to the TFT LCD screen. Internally these are processed as rectangular areas and marked as "dirty" when they've been changed to indicate the need to send them to the screen on the next refresh.
Filters with and without ulab Library
The main loop also has an extra level of filtering to try to further reduce any brief, transient variations of voltage - these could give a distracting, false indication. The simple code below shows how two previous voltage
values can be stored in simple variables. The _zm1
suffix refers to z-1 which represents the unit delay in digital filter implementations.
# Store the previous two voltage values voltage_zm2 = voltage_zm1 voltage_zm1 = voltage
These are then used to make a "filtered" version of the voltage by a multiplication by weights (coefficients) and summation.
# Make a filtered voltage from three values filt_voltage = (voltage * 0.4 + voltage_zm1 * 0.3 + voltage_zm2 * 0.3)
This tiny low-pass, causal filter was improvised rather than designed but appears to work reasonably well to reduce the effect of transient spikes without introducing obvious delay.
CircuitPython 5.1.0 introduced the ulab library for boards with larger CPUs like the nRF52840 on the CLUE/CPB. This library is a cut-down version of numpy, providing very fast vector operations and efficient, flexible storage for arrays. The ulab approach for this can be seen on Low pass filtering: Measuring barometric Pressure. This type of filter is know as a Finite Impulse Response (FIR) filter. There is also a convolve function in ulab
which can be used to perform this type of filtering across arrays.
The program does make some use of ulab
. The unfiltered voltage
values are continually stored in a fixed size 201 element float
-based ulab
ndarray
. This is used in the style of a circular buffer storing the most recent 201 values. These values are then used to calculate the median voltage with the code shown below.
# Adjust the reference base_voltage to the median of historical values if voltage_hist_complete and update_median: voltage_hist_median = ulab.numpy.sort(voltage_hist)[len(voltage_hist) // 2] base_voltage = voltage_hist_median
The code is updating the baseline voltage used as the datum for calculating the voltage difference used to indicate metal. This allows the code to deal with gradual shifts in the voltage level. An inevitable side-effect of this approach is the detector will incorrectly adjust the baseline if held over a metal object constantly for about ten seconds.
Magnetometer Baseline and Code Reviews
An informal code review by Jeff Epler highlighted an inconsistency in the program for setting the baseline value for the magnetometer. The code which initialises the values is shown below.
# Get a baseline value for magnetometer totals = [0.0] * 3 mag_samples_n = 10 if magnetometer is not None: for _ in range(mag_samples_n): mx, my, mz = magnetometer() totals[0] += mx totals[1] += my totals[2] += mz time.sleep(0.05) base_mx = totals[0] / mag_samples_n base_my = totals[1] / mag_samples_n base_mz = totals[2] / mag_samples_n
The code used within the loop if the user pressed the right button to "Recalibrate" is a much simpler affair, shown below.
# Excerpt from main loop inside if button_right(): if magnetometer is not None: base_mx, base_my, base_mz = mx, my, mz
The issues here could be summarised as:
- There's no explanation in comments or documentation for this inconsistency.
- There's no explanation for the
0.05
(50ms) pause in thefor
loop. - A developer working on this code in the future is left to guess the reasons for this and possibly duplicate them without being able to justify the difference.
The actual reason for the difference is the calibration feature was added very late in the development process and was not part of any initial design. The metal detector automatically adjusts the baseline for the voltage which represents the inductance and presence of metal. It does not do this for the magnetometer as this is a more stable value. In testing it turned out to be useful sometimes to set a new baseline for the magnetometer so this was added as a feature initiated by pressing the right button.
The small delay in the first code sample was based on prior observations whilst developing the code for CLUE Sensor Plotter in CircuitPython. The magnetometer issues duplicate values if read as fast as possible (~230Hz) in CircuitPython. This suggests it has a fixed rate for producing new values and the library does not wait (block) for a new value to be produced. The adafruit_lis3mdl library shows a set of different rates but does not document the default (the code shows it as 155Hz). The adafruit_clue
library does not set an explicit rate which explains the duplication of results.
There's no particular reason for the difference in the number of samples. This is worth checking particularly on power-up to see if the sensor takes time to stabilise. The use case for user-initiated recalibration may specify it occurs within a certain amount of time - that would limit how many samples could be taken. In practical use, the magnetometer value is fairly stable for tenths of microteslas (uT).
This could be enhanced with:
- A concise explanation in the comments and any documentation.
- For both uses, call a single function which includes a parameter for the number of samples. This also ensures any future modifications (software maintenance) to the code are applied to both.
Text editor powered by tinymce.