Funktionen laufen, Listing der Sketches und Skripte

Arduino-Sketch

Der Arduino-Sketch ist eine Abwandlung eines Sketches von der Fundinio-Webseite.

#define TRG 8
#define SIG 9

long dauer;
float entfernung;

void setup() {
  Serial.begin(9600);
  pinMode(TRG, OUTPUT);
  pinMode(SIG, INPUT);
}

void loop() {
  digitalWrite(TRG, LOW);
  delay(2);
  digitalWrite(TRG, HIGH);
  delay(10);
  digitalWrite(TRG, LOW);
  dauer = pulseIn(SIG, HIGH);
  entfernung = (dauer/2)*0.03432;
  if (entfernung >= 500 || entfernung <= 0){
    Serial.print("DSTE ");
    Serial.println(entfernung);
  } else {
    Serial.print("DST ");
    Serial.println(entfernung);
  }
}

Der Sketch ist nicht weiter komplex. Einzig der Befehl pulseIn() in Zeile 19 ist interessant. Er gibt die Zeit bis zur Input-Pulsmessung in µs aus. Der Rest ist einfach zu verstehen.

Python-Skripte

kalibrierung.py
'''

Skript zur Kalibrierung des Experiments "Funktionen laufen" zur Langen Nacht der Wissenschaften 2019
Es erstellt die Datei calib.cal mit folgenden Daten:

- port: USB-Port des Arduino
- steps: Wertebereich der Funktion W=[0, steps]
- min: Abstand zum Wert 0
- max: Abstand zum Wert steps

Auswahl des Ports werden (sofern Port-Wahl erfolgreich, d.h. Verbindung ist möglich und der Arduino sendet das richtige Messprotokoll (DST bzw. DSTE) folgende Schritte ausgeführt:
- Auswahl des Maximalwerts des Wertebereichs
- Festlegung von min und max
- Sind min und max festgelegt, schaltet die Entfernungsanzeige auf Funktionswerte um, sodass man leichter die Striche anbringen kann.

Das Skript wurde von Martin Döpel erstellt (post@martin-doepel.de).

Weimar/Jena, Oktober/November 2019

'''

from PyQt5 import QtCore, QtGui, QtWidgets
import serial.tools.list_ports as lipo
import serial
import time
import sys

class Helper():
    def __init__(self):
        self.port = ""
        self.steps = 5
        self.min = -1.0
        self.max = -1.0
        self.ser = ""

        self.connected = False

    def getPorts(self):
        rv = []
        ports = list(lipo.comports())
        for p in ports:
            rv.append(str(p).split(" - "))
        return rv

    def correctProtocol(self):
        try:
            self.ser = serial.Serial(port=self.port, baudrate=9600)
        except serial.serialutil.SerialException:
            return 1
        stime = time.time()
        misses = 0
        self.ser.flushInput()
        while (time.time()-stime < 4) and (misses < 7):
            proto = str(self.ser.readline()).split(" ")[0]
            if (proto == "b\'DST") or (proto == "b\'DSTE"):
                self.ser.flushInput()
                self.connected = True
                return 0
            misses += 1
        return 2

    def saveData(self):
        file = open("calib.cal", "w")
        s = "port "+self.port+":\n"
        s += "steps "+str(self.steps)+"\n"
        s += "min " + str(self.min) + "\n"
        s += "max " + str(self.max)
        file.write(s)
        file.close()
        self.ser.close()

    def getValues(self):
        while True and self.connected:
            try:
                strg = self.ser.readline()
                d = int(float(str(self.ser.readline()).split("\\")[0].split(" ")[1]))
                return d
            except:
                return -1
        return -1


class OFlaeche(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.setupUi()

    def setupUi(self):
        self.h = Helper()
        self.cdst = 0.0
        self.font1 = QtGui.QFont()
        self.font1.setPointSize(12)
        self.font2 = QtGui.QFont()
        self.font2.setPointSize(40)

        self.resize(350, 350)

        self.setWindowTitle("Kalibrierung Funktionen laufen")


        self.HeadArduino = QtWidgets.QLabel(self)
        self.HeadArduino.setText("Arduino-Port")
        self.HeadArduino.setGeometry(QtCore.QRect(30, 10, 131, 16))
        self.HeadArduino.setFont(self.font1)

        self.Head2 = QtWidgets.QLabel(self)
        self.Head2.setText("Maximaler Funktionswert")
        self.Head2.setGeometry(QtCore.QRect(30, 70, 191, 16))
        self.Head2.setFont(self.font1)
        self.Head2.setVisible(False)

        self.Head3 = QtWidgets.QLabel(self)
        self.Head3.setText("Position Extremwerte")
        self.Head3.setGeometry(QtCore.QRect(30, 140, 191, 16))
        self.Head3.setFont(self.font1)
        self.Head3.setVisible(False)

        self.arduinooptions = QtWidgets.QComboBox(self)
        self.arduinooptions.setGeometry(QtCore.QRect(30, 30, 181, 22))
        for p in self.h.getPorts():
            self.arduinooptions.addItem(p[1]+" - "+p[0])

        self.arduinochoose = QtWidgets.QPushButton(self)
        self.arduinochoose.setGeometry(QtCore.QRect(230, 30, 75, 23))
        self.arduinochoose.setObjectName("arduinochoose")
        self.arduinochoose.setText("wählen")
        self.arduinochoose.clicked.connect(self.AChoose)

        self.mindst = QtWidgets.QLabel(self)
        self.mindst.setGeometry(QtCore.QRect(130, 170, 151, 20))
        self.mindst.setObjectName("mindst")
        self.mindst.setText("noch nicht gewählt")
        self.mindst.setVisible(False)

        self.minchoose = QtWidgets.QPushButton(self)
        self.minchoose.setGeometry(QtCore.QRect(30, 170, 75, 23))
        self.minchoose.setObjectName("minchoose")
        self.minchoose.setText("Minimum")
        self.minchoose.clicked.connect(self.MiCAction)
        self.minchoose.setVisible(False)

        self.maxchoose = QtWidgets.QPushButton(self)
        self.maxchoose.setGeometry(QtCore.QRect(30, 210, 75, 23))
        self.maxchoose.setObjectName("maxchoose")
        self.maxchoose.setText("Maximum")
        self.maxchoose.clicked.connect(self.MaCAction)
        self.maxchoose.setVisible(False)

        self.maxdst = QtWidgets.QLabel(self)
        self.maxdst.setGeometry(QtCore.QRect(130, 210, 151, 20))
        self.maxdst.setObjectName("maxdst")
        self.maxdst.setText("noch nicht gewählt")
        self.maxdst.setVisible(False)

        self.schritte = QtWidgets.QComboBox(self)
        self.schritte.setGeometry(QtCore.QRect(30, 100, 69, 22))
        self.schritte.setObjectName("schritte")
        self.schritte.addItem("2")
        self.schritte.addItem("3")
        self.schritte.addItem("4")
        self.schritte.addItem("5")
        self.schritte.addItem("6")
        self.schritte.addItem("7")
        self.schritte.setCurrentIndex(3)
        self.schritte.currentIndexChanged.connect(self.StepChoose)
        self.schritte.setVisible(False)

        self.currdst = QtWidgets.QLabel(self)
        self.currdst.setGeometry(QtCore.QRect(130, 250, 201, 41))
        self.currdst.setFont(self.font2)
        self.currdst.setObjectName("currdst")
        self.currdst.setText("nn")
        self.currdst.setVisible(False)

        self.calibexit = QtWidgets.QPushButton(self)
        self.calibexit.setGeometry(QtCore.QRect(30, 310, 181, 23))
        self.calibexit.setObjectName("calibexit")
        self.calibexit.setText("Kalibrierung beenden")
        self.calibexit.clicked.connect(self.FinishAll)
        self.calibexit.setVisible(False)

        self.timer = QtCore.QTimer(self)
        self.timer.timeout.connect(self.updateDSTValue)
        self.timer.start(20)
        self.show()

    def updateDSTValue(self):
        dst = self.h.getValues()
        if dst == -1:
            self.currdst.setText("Error")
        elif (self.h.min < 0.0 or self.h.max < 0.0):
            self.cdst = dst
            self.currdst.setText(str(dst))
        else:
            if (self.h.min < self.h.max):
                schrittweite = (self.h.max - self.h.min) / self.h.steps
                wert = (dst - self.h.min)/schrittweite
            else:
                schrittweite = (self.h.min - self.h.max) / self.h.steps
                wert = (self.h.min - dst) / schrittweite
            self.currdst.setText(str(wert))
            self.calibexit.setVisible(True)

    def MiCAction(self):
        self.h.min = self.cdst
        self.mindst.setText(str(self.h.min))

    def MaCAction(self):
        self.h.max = self.cdst
        self.maxdst.setText(str(self.h.max))

    def StepChoose(self):
        self.h.steps = int(self.schritte.currentText())

    def AChoose(self):
        self.h.port = str(self.arduinooptions.currentText()).split(" - ")[1]
        if self.h.correctProtocol() == 0:
            self.Head2.setVisible(True)
            self.Head3.setVisible(True)
            self.currdst.setVisible(True)
            self.schritte.setVisible(True)
            self.maxdst.setVisible(True)
            self.maxchoose.setVisible(True)
            self.minchoose.setVisible(True)
            self.mindst.setVisible(True)
            self.schritte.setVisible(True)

        elif self.h.correctProtocol() == 2:
            self.h.ser.close()
            msg = QtWidgets.QMessageBox()
            msg.setIcon(QtWidgets.QMessageBox.Critical)
            msg.setText("Falsches Protokoll")
            msg.setInformativeText("Der angeschlossene Arduino sendet nicht das korrekte Protokoll. Bitte versuchen Sie es noch einmal erneut und prüfen Sie bei nochmaligem Fehler das Programm des Arduino.")
            msg.setWindowTitle("Protokollfehler")
            msg.exec_()
        else:
            msg = QtWidgets.QMessageBox()
            msg.setIcon(QtWidgets.QMessageBox.Critical)
            msg.setText("Hardware-Problem")
            msg.setInformativeText("Verbindung zum Arduino kann nicht hergestellt werden.")
            msg.setWindowTitle("Verbindungsfehler")
            msg.exec_()

    def FinishAll(self):
        self.h
        self.h.saveData()
        self.close()


if __name__== "__main__":
    app = QtWidgets.QApplication([])
    ex = OFlaeche()
    sys.exit(app.exec_())

Im Wesentlichen handelt es sich hierbei um eine PyQt5-Oberfläche und einige Funktionen. Dennoch gibt es drei Funktionen, die ich gern kurz vorstellen würde. Sie sind im Listing hervorgehoben.

getPorts, Zeilen 38-43

Diese Funktion liefert eine Liste aller Ports mit angeschlossenen seriellen Endgeräten, also auch Arduinos. Mit dieser Funktion lassen sich alle zur Verfügung stehenden Ports auflisten.

correctProtocol, Zeilen 45.60

Diese Funktion überprüft, ob der angeschlossene Arduino Daten mit dem richtigen Protokoll sendet. Da in meinem Projekt vorgesehen ist, Arduinos immer wieder mit neuen Programmen zu benutzen, um bestimmte Messaufgaben zu erfüllen, ist dies eine wichtige Funktionalität, da so sichergestellt werden kann, dass immer die richtigen Daten geloggt werden.

getValues, Zeilen 72-80, insbes. Zeile 76

Hier werden die vom Arduino übertragenen Daten als String ausgewertet. Wichtig ist, dass beim Casten des seriellen Bytestreams als String erhält man eine Zeichenfolge wie z.B. b’DST 10.23\r\n‘. Offensichtlich kann durch ein anderes Decoden das b‘ vermieden werden (mehr dazu hier). In künftigen Fassungen wird dieser Fehler behoben (ich habe ihn erst beim Schreiben dieser Seite bemerkt). Problematisch ist jedoch \r\n, deswegen wird der String zunächst beim ersten Backslash geteilt, davon der erste Teil wird dann erneut am Leerzeichen geteilt. Der zweite Teil dieser Trennung ist 10.23. Diese Zahl wird zunächst in eine Gleitkommazahl und diese dann in einen Ganzzahl umgewandelt. So erhält man aus dem Protokoll den Abstand in cm.

updateDSTValue, Zeilen 187-202

Diese Funktion erneuert laufend die Anzeige der Abstandsangabe. Sind der Minimal- und der Maximalwert noch nicht gesetzt, wird nur die Distanz in cm angezeigt (191-193). Ansonsten werden die Werte umgerechnet. Diese Methode wird in Zeile 183 von einem QtTimer aufgerufen.

arduinohelper.py
import serial

class ArduinoHelper():
    def __init__(self, ardport, noe, scmin, scmax):
        self.arduinoport = ardport
        self.number_of_steps = noe
        self.scale_minimum = scmin
        self.scale_maximum = scmax
        self.distance_per_step = abs(scmax-scmin)/self.number_of_steps
        self.arduino = serial.Serial(port=self.arduinoport, baudrate=9600)
        self.min_smaller_max = scmin < scmax

    def getDistance(self):
        self.arduino.flushInput()
        misses = 0
        errorous = True
        while misses < 10 and errorous:
            try:
                dis = int(float(str(self.arduino.readline()).split("\\")[0].split(" ")[1]))
                errorous = False
            except:
                misses += 1
        if errorous:
            return -1
        if self.min_smaller_max:
            rv = (dis - self.scale_minimum)/self.distance_per_step
        else:
            rv = (self.scale_minimum-dis)/self.distance_per_step
        if rv < 0:
            return 0
        if rv > self.number_of_steps:
            return self.number_of_steps
        return rv

Dieses Skript übernimmt für das Spiel die Auswertung der vom Arduino gesendeten Messwerte.

starten.py
import tkinter as tk
from time import time, sleep
import arduinohelper

sublevel = -1
root = tk.Tk()

#Dimensionen bestimmen
cw = root.winfo_screenwidth() #Breite der Diagrammfläche
ch = root.winfo_screenheight()-50 # Höhe der Diagrammfläche

#Kalibrierungsdatei lesen und Werte festlegen
noe = -1 #Wie viele Einheiten auf y-Achse
scalemin = -1 #Ort des 0-Punkts der Skala vor dem Sensor
scalemax = -1 #Ort des Maximal-Punkts auf der Skala vor dem Sensor
arduinoport = "" #Portname des Arduinoports

file = open("calib.cal", "r")
for line in file.readlines():
    l = line.split(" ")
    if l[0] == "port":
        arduinoport = l[1].split(":")[0]
    elif l[0] == "steps":
        noe = int(l[1])
    elif l[0] == "min":
        scalemin = int(l[1])
    elif l[0] == "max":
        scalemax = int(l[1])
t = 10 #Zeit in s
ey = round(ch-100)/noe-5 #Länge einer Einheit auf der y-Achse
ex = round(cw-100)/t-2 #Länge einer Einheit auf der x-Achse

arduino = arduinohelper.ArduinoHelper(arduinoport, noe, scalemin, scalemax)

def disable_event(): #Wird aufgerufen, wenn der Schließen-Button gedrückt wird, verhindert den Abbruch des Experiments
    pass

def close_program(event): #Bricht Experiment ab
    root.destroy()

def getFunktion(name): #holt die Funktion aus der Datei <name>.txt, die kritische Punkte enthält
    file = open(name+".txt", "r")
    rv = []
    for line in file.readlines():
        ll = line.split(",")
        rv.append((float(ll[0]), float(ll[1])))
    return rv

def getFktValue(locp, x): #gibt den Wert der Zielfunktion für einen bestimmten x-Wert aus. locp: List of critical points
    getmn = False
    for i in range(len(locp)-1):
        if (locp[i][0] <= x) and (locp[i+1][0] > 0):
            m = (locp[i+1][1]-locp[i][1])/(locp[i+1][0]-locp[i][0])
            n = locp[i+1][1]-m*locp[i+1][0]
            getmn = True
    if not getmn:
        return -1
    return m*x+n

def getError(locp, point, maxy): #Berechnet die derzeitige Abweichung und die maximal mögliche Abweichung von der Zielfunktion
    fy = getFktValue(locp, point[0])
    if fy > maxy/2:
        maxe = fy
    else:
        maxe = maxy - fy
    return(round(abs(fy-point[1]),8), round(maxe, 8))

def calculateScore(maximal_possible_score): #Berechnet die Score
    global positions
    accumulated_error = 0
    maximal_error = 0
    for p in positions:
        fehler = getError(targetfunction, p, noe)
        accumulated_error += fehler[0]
        maximal_error += fehler[1]
    return (1-accumulated_error/maximal_error)*maximal_possible_score

def drawTargetFunction(name): #Holt und zeichnet die Zielfunktion
    global tarfunc_graph
    rv=getFunktion(name)
    for i in range(0, len(tarfunc_graph)):
        canvas.delete(tarfunc_graph[i])
    for i in range(len(rv)-1):
        p1 = getKoord(rv[i], False)
        p2 = getKoord(rv[i+1], False)
        tarfunc_graph.append(canvas.create_line(p1[0], p1[1], p2[0], p2[1], fill="#BBBBBB", width=4))
    return rv

def getKoord(k, protocol): #Errechnet die Koordinaten zur Darstellung, bei protocol=True werden die Ergebnisse protokolliert
    if protocol:
        positions.append(k)
    return (50+round(k[0]*ex), ch-50-round(k[1]*ey))

def updatePosition(op, xval=0,protocol=False): #Holt die derzeitige Position vom Arduino, bewegt den Punkt und zeichnet (bei Protokollierung) auch den Strich
    global pos, canvas, graph
    nk=getKoord((xval, arduino.getDistance()), protocol)
    canvas.move(pos, nk[0]-op[0], nk[1]-op[1])
    if protocol:
        graph.append(canvas.create_line(op[0], op[1], nk[0], nk[1], fill="blue", width=3))
    canvas.pack()
    canvas.update()
    return nk

def countdown(l): #Zeigt Countdown der Länge l (in sek) an
    global canvas, pos
    secs = canvas.create_text(cw/2, ch/2, text=str(l), font=("Arial", 80, "bold"), fill="red")
    op = (0,0)
    pos = canvas.create_oval(op[0] - 8, op[1] - 8, op[0] + 8, op[1] + 8, fill="blue")
    canvas.update()
    for i in range(0, l):
        canvas.itemconfigure(secs, text=str(l-i))
        canvas.update()
        stime = time()
        ctime = time()
        while ctime-stime < 1:
            op = updatePosition(op)
            ctime = time()
    canvas.delete(secs)
    sleep(0.2)
    return op

def lauflos(): #Hier wird die Funktion gelaufen
    global pos
    koord = countdown(5)
    starttime = time()
    currenttime = time()
    while currenttime - starttime < t:
        currenttime = time()
        koord = updatePosition(koord, xval=currenttime-starttime, protocol=True)
        sleep(0.01)
    canvas.delete(pos)

def chooseLvlClick(event): #Event-Handler LevelChooser
    global lvlc, cl, sublevel
    p1=(cw/2-200, ch/2-75)
    p2=(cw/2+50, ch/2-75)
    if cl and (event.x >= p1[0]) and (event.x <= p1[0]+150) and (event.y >=p1[1]) and (event.y <= p1[1]+150):
        sublevel += 1
        lvlc = "f1"+str(sublevel%3)
    if cl and (event.x >= p2[0]) and (event.x <= p2[0]+150) and (event.y >=p2[1]) and (event.y <= p2[1]+150):
        sublevel += 1
        lvlc = "f2"+str(sublevel%3)

def chooseLevel(): #Level-Chooser
    global lvlc, cl
    cl = True
    headline = canvas.create_text(cw/2, ch/2-200, text="Level wählen", font=("Arial", 40, "bold"), fill="black")
    #easybutton = tk.Button(text="1", command=chooseLvlClick1, anchor="w")
    #easybutton.configure(width=150, activebackground="#106010")
    #easybutton.window = canvas.create_window(cw/2-200, ch/2-20, anchor="nw", window=easybutton)
    easybutton = canvas.create_rectangle(cw/2-200, ch/2-75, cw/2-50, ch/2+75, fill="#106010", tags=('easy'))
    easytext = canvas.create_text(cw/2-125, ch/2, text="1", font=("Arial", 30, "bold"), fill="white")
    diffbutton = canvas.create_rectangle(cw/2+50, ch/2-75, cw/2+200, ch / 2 + 75, fill="#6e0e10", tags=('diff'))
    difftext = canvas.create_text(cw / 2 + 125, ch / 2, text="2", font=("Arial", 30, "bold"), fill="white")
    canvas.bind("<Button-1>", chooseLvlClick)
    canvas.pack()
    while lvlc not in ["f10", "f11", "f12", "f20", "f21", "f22"]:
        canvas.update()
    rv = lvlc
    lvlc = 0
    cl = False
    canvas.delete(headline)
    canvas.delete(easybutton)
    canvas.delete(easytext)
    canvas.delete(diffbutton)
    canvas.delete(difftext)
    canvas.update()
    return rv

def startbutton(event): #Event-Handler Startbutton
    global showscore
    showscore = False

def loescheVerlauf(): #Löscht den Verlauf des Trials
    global graph, positions, tarfunc_graph, targetfunction
    positions = []
    for i in range(len(graph)):
        canvas.delete(graph[i])
        if i in range(len(tarfunc_graph)):
            canvas.delete(tarfunc_graph[i])
    canvas.update()
    targetfunction = []

def showscoreText(): #Zeigt den Punktwert an
    global showscore
    t = canvas.create_text(cw/2, ch/2, text="Berechne die Punktzahl", font=("Arial", 60, "bold"))
    canvas.update()
    sleep(1)
    canvas.delete(t)
    canvas.update()
    t = canvas.create_text(cw / 2, ch / 2-80, text="Punkte: "+str(int(calculateScore(10000))), font=("Arial", 60, "bold"))
    t2 = canvas.create_text(cw / 2, ch / 2+80, text="Zum Fortfahren Leertaste drücken!", font=("Arial", 40, "bold"), fill = "red")
    showscore = True
    while showscore:
        canvas.update()
    canvas.delete(t)
    canvas.delete(t2)
    canvas.update()


root.title("Funktionen laufen")

canvas = tk.Canvas(root, bg="white", width=cw, height=ch)
canvas.pack()


# Diagramm initialisieren
sysItems = []
sysItems.append(canvas.create_line(50, 50, 50, ch-50, fill="black", width=2))
sysItems.append(canvas.create_line(50, ch-50, cw-50, ch-50, fill="black", width=2))

#Grid zeichnen
for i in range(1,noe+1):
    sysItems.append(canvas.create_line(cw-50, ch-50-i*ey, 45, ch-50-i*ey, fill="#aaaaaa", width=1, dash=(2,2)))
    sysItems.append(canvas.create_text(30, ch-50-i*ey, text=str(i), font=("Arial", 20)))

for i in range(1,t+1):
    sysItems.append(canvas.create_line(50+i*ex, 50, 50+i*ex, ch-50, fill="#aaaaaa", width=1, dash=(2, 2)))
    sysItems.append(canvas.create_text(50+i*ex, ch-30, text=str(i), font=("Arial", 20)))

#Einstellen, dass das Programm nur mit der Tastenkombination <F10><q><x> geschlossen werden kann
root.protocol("WM_DELETE_WINDOW", disable_event)
root.bind("<F10><q><x>", close_program)

#Event-Bindings für Level-Chooser und Startbutton
root.bind("<space>", startbutton)
canvas.bind("<Button-1>", chooseLvlClick)

#Loslaufen
positions = [] #gemessene Funktionswerte
graph = [] #gelaufene Funktion, Canvas-Objekte

tarfunc_graph = [] #Zielfunktion, Canvas-Objekte
pos = "" #später wird darauf der Punkt initialisiert

lvlc = "" #Variable für Level-Choose-Handler
cl = True #gibt an, ob gerade ein Level ausgewählt werden kann (für Level-Choose-Event-Handler)
showscore = False #gibt an, ob gerade ein Punktwert angezeigt wird (für Punktanzeige-Event-Handler)

while True:
    targetfunction = drawTargetFunction(chooseLevel())
    lauflos()
    showscoreText()
    loescheVerlauf()
root.mainloop()

Das Spiel selbst wurde als tkinter-Oberfläche realisiert. Das Skript ist hoffentlich selbsterklärend.