Einführung in die Vehikel-Programmiersprache
Im Folgenden
wird der Aufbau der Vehikel-Programmiersprache aus der Sicht
eines Anwenders erklärt.
Was ist die
Vehikel-Programmiersprache?
Diese Programmiersprache
ermöglicht es verhaltensbasierte Algorithmen für einfache
mobile Roboter zu erstellen. Sie ist auf diese Aufgabe hin
optimiert und gehört deshalb zur Gruppe der domänenspezifischen
Sprachen (DSL). Für allgemeine Programmieraufgaben ist sie
dagegen weniger geeignet. Jedoch enthält sie, wie die
general-purpose Programmiersprachen Schlüsselworte, Variablen
und Anweisungen. Diese grundlegenden Elemente werden im folgenden
Teil beschrieben. Anschließend folgt die Beschreibung der
fachspezifischen Fähigkeiten der Programmiersprache. Ein
ausführliches Beispiel ist
hier zu finden.
Programmstruktur der
Vehikel-Programmiersprache
Struktur des
Quelltextes
Die Notation der DSL ist aus der
Programmiersprache Python übernommen. Dies bezieht sich jedoch
nur auf elementare Dinge wie die Zeilenstruktur, die Gruppierung
von Anweisungen und einfache Kontrollstrukturen. Die DSL ist
nicht objektorientiert und schlägt bei der Typbindung der
Variablen einen anderen Weg ein als das Vorbild.
Zeilen
Für jede Anweisung
der DSL wird eine eigene Zeile benötigt. Steht in einer Zeile
das Zeichen
#, so werden
die restlichen Zeichen als Kommentar betrachtet und vom Compiler
nicht ausgewertet. Sollte eine Zeile unüberschaubar lang werden,
kann am Zeilenende mit dem Zeichen
\\ die nächste Zeile als
Fortsetzungszeile angehängt werden. Pro logische Zeile kann
genau eine Anweisung notiert werden. Strukturelle Anweisungen
werden wie in Python mit einem Doppelpunkt abgeschlossen. Auf
diese Besonderheit wird bei der Beschreibung der betroffenen
Anweisungen hingewiesen
Blöcke
Durch die
Einrückungstiefe wird vorgegeben, wie Anweisungen zu Blöcken
zusammengefasst werden. Um Anweisungen zu gruppieren, werden
keine Schlüsselworte wie
begin,
end oder eine andere Form der
Klammerung benötigt. Für die Zugehörigkeit zu einem Block ist
stattdessen die Einrückungstiefe maßgebend. Alle Anweisungen
eines Blocks beginnen in derselben Spalte.
Beispiel für Anweisungen und Blöcke:
# Ein Kommentar.
var a = 11
var b = 2
# Die Einrueckungstiefe gibt die Blockstruktur vor.
if a > 10:
a = 10 # Diese beiden Anweisungen bilden
b = b / 2 # den ''Then''-Block der Bedingung a > 10.
a = a * b # Erste Anweisung nach dem Then-Block.
Schlüsselwörter und
Namen
In der DSL gibt es reservierte Schlüsselworte. Diese
haben unabhängig vom Kontext immer dieselbe Bedeutung. Auf ihre
Verwendung wird im restlichen Kapitel näher eingegangen.
Schlüsselworte der DSL:
and, arbiter, array, assert, bool, configuration, const, def, delay, else, for, if, in, int, message, mod, not, or, pass, persistent, return, rule, sensor, system, var, while
Namen dagegen werden vom Anwender eingeführt. Zulässig sind
dabei Kombinationen aus Buchstaben und Ziffern, wobei ein Name
mit einem Buchstaben beginnen muss und keine Leerzeichen
enthalten darf. Groß- und Kleinschreibung ist für die
Unterscheidung der Namen relevant. Schlüsselworte sind als Namen
nicht zulässig.
Beispiele für Namen: name, i1, IF
Datentypen
Für
Berechnungen werden die Datentypen Logikwert und
vorzeichenbehaftet Ganzzahl angeboten. Die Datentypen müssen in
der DSL nicht explizit notiert werden. Der Compiler ermittelt sie
aus dem Kontext.
Variablen und
Konstanten
Eine Variable wird durch einen Namen referenziert.
Ihr ist ein Datentyp und ein Wert zugeordnet. Durch eine
Wertzuweisung kann ihr Wert geändert werden, aber nie ihr
Datentyp. Konstanten unterscheiden sich von Variablen dadurch,
dass sie ihren Wert nach der Initialisierung nicht mehr ändern
können.
Definition
Variable werden
mit dem Schlüsselwort
var definiert, Konstante werden mit
const beschrieben. Bei
der Definition muss außer dem Namen auch ein Initialwert
angegeben werden. Wird eine Konstante durch einen arithmetischen
Ausdruck initialisiert, so muss dieser bereits während der
Compilierung vollständig berechnet werden können. Er darf
deshalb keine Variable enthalten.
Beispiel: Definitionen von Variablen und Konstanten
var a = 1 # a ist eine Ganzzahlvariable,
# da sie mit der Zahl 1 initialisiert wird.
# Der Datentyp wird automatisch ermittelt.
var b = a >= 1 # b ist eine Boolesche Variable.
# Sie wird hier mit True initialisiert.
const speedR = 50 # Zwei ganzzahlig Konstanten.
const speedL = speedR - 5
Felder und
Strukturen
Felder und Strukturen können in der DSL vom
Anwender nicht selbst definiert werden. Im fachspezifischen
Bereich der Sprache kommen jedoch Felder vor. Diese werden dem
Anwendungsprogramm vom Laufzeitsystem zur Verfügung gestellt.
Diese Datenstrukturen werden im fachspezifischen Teil erläutert.
Ein Beispiel für eine Struktur sind die
self-Variablen der
Regeln. Felder kommen kommen als
Übergabeparameter der
Koordinatoren zum Einsatz. Auf die
Elemente eines Feldes wird mit der
for Anweisung zugegriffen.
Sichtbarkeitsbereiche
Variablen
und Konstanten sind im Allgemeinen nur in dem Block, in dem sie
definiert wurden und in den untergeordneten Blöcken sichtbar.
Die Ausnahmen von dieser Regel werden im fachspezifischen Teil
beschrieben. Vereinfacht kann am sich merken, dass es global
sichtbare Konstante geben kann, dies aber für Variablen nicht
möglich ist.
Arithmetische
Ausdrücke
Für Berechnungen stehen in der Programmiersprache
Ganzzahlarithmetik und Boolesche Algebra zur Verfügung.
Grundrechenarten
Für
numerische Berechnungen werden die Grundrechenarten (
+,
-,
*,
/) und die Berechnung des
Teilerrestes (
mod)
unterstützt.
Beispiel: Arithmetische Ausdrücke
var a = 2 * (3 + 4) # a erhaelt den Wert 14
var rest = a mod 3 # der Operator mod berechnet den Teilerrest.
var rest2 = (a mod 3) == 2 # Hier wird die Boolesche Variable rest2
# auf True gesetzt. Das Ergebnis eines
# Vergleichsoperators ist ein logischer
# Wert.
Besonderheiten bei der
Ganzzahlarithmetik
Bei Berechnungen mit ganzzahligen
Variablen müssen zwei Dinge beachtet werden: Erstens ist der
Wertebereich dieses Datentyps auf das Intervall -32768 bis 32767
beschränkt. Zweitens können im Ergebnis einer Division keine
Nachkommastellen dargestellt werden. Diese werden vom Prozessor
nicht berechnet. Deshalb ist es teilweise nicht möglich,
physikalische Formeln direkt in die DSL zu übertragen.
Beispiel: Dividieren ohne Gleitkommaunterstützung
var a = 10
var b = 2
var c = a / b # hier erhaelt c wie erwartet den Wert 5.
c = b / a # 0.2 entspricht in Ganzzahlarithmetik dem Wert 0.
c = 15 * (b / a) # Achtung: c ist 0, weil b / a bereits 0 ergibt.
c = (15 * b) / a # Jetzt erhaelt c den Wert 3. Es wurde zuerst
# multipliziert. Die Division kommt ''zu spaet'',
# um die Rechengenauigkeit negativ zu beeinflussen.
Werte
vergleichen
Vergleichsoperatoren akzeptieren Zahlenwerte und
liefern einen logischen Wert.
a ==
b |
Prüft auf Gleichheit von a und b. |
a !=
b |
Prüft auf Ungleichheit von a und b. |
a <
b |
Prüft ob a kleiner als b ist. |
a <=
b |
Prüft ob a kleiner oder gleich
b ist. |
a >=
b |
Prüft ob a größer oder gleich
b ist. |
a >
b |
Prüft ob a größer als b ist. |
Rechnen mit logischen
Werten
In der DSL kann mit logischen Variablen gerechnet
werden. Es wird eine Teilmenge der Booleschen Algebra
unterstützt. Als vordefinierte Konstanten stehen
True und
False zur Verfügung. Gearbeitet
wird mit den Operatoren
and,
or und
not. Auch die Vergleichsoperatoren
können auf logische Variable angewendet werden. Es wird davon
ausgegangen, dass
False
kleiner als
True ist.
Beispiele für Boolesche Algebra:
#value1, value2 und firstError seinen bereits weiter oben definiert.
const limit = 100 # Eine ganzzahlige Konstante.
var over1 = value1 > limit
var over2 = value2 > limit
var both = over1 or over2
if both and not firstError:
firstError = True
message value1, value2
Schreibweise |
Funktion |
not |
Verneinung |
and |
Und-Verknüpfung |
or |
Oder-Verknüpfung |
== |
Äquivalenz |
!= |
Exklusives Oder |
Typsicherheit
Bei der
Berechnung wird vom Compiler geprüft, ob die Variablen und
Operatoren typstreng miteinander verknüpft werden. Auch wenn,
wie oben beschrieben, der Datentyp nicht explizit angegeben
werden muss, wird jeder Variablen bei der Übersetzung ein
Datentyp zugeordnet. Dieser kann nicht zur Laufzeit, zum Beispiel
als Seiteneffekt einer Wertzuweisung, geändert werden.
Rangfolge der
Operatoren
Kommen in einem arithmetischen Ausdruck mehrere
Operatoren vor, so werden höher rangige Operatoren vor den
tiefer in der Rangfolge stehen ausgewertet. Operatoren desselben
Rangs werden von links nach rechts abgearbeitet. In einem
Ausdruck kann die Reihenfolge der Berechnung durch Klammern der
Terme vorgegeben werden. Ist man sich über die Priorität
unsicher, so empfiehlt es sich zusätzliche Klammern zu setzen.
Die Lesbarkeit des Programms wird dadurch deutlich erhöht. Im
Ausdruck
i mod 2 != 0
wird zuerst der Teilerrest bestimmt und dann der rangniedere
Vergleichsoperator angewendet. Klarer ist jedoch die Schreibweise
(i mod 2) != 0. Die
zusätzliche Klammerung hat dabei keine negative Auswirkung auf
das Laufzeitverhalten des Programms.
In folgender Tabelle ist die Rangfolge der Operatoren
aufgelistet:
Schreibweise |
Funktion |
Rang |
(
) |
Klammerung |
1 |
. |
Membervariable |
1 |
[
] |
Arrayindex |
1 |
not |
logische Verneinung |
2 |
- |
unaeres Minus |
2 |
+ |
unaeres Plus |
2 |
* |
Multiplikation |
3 |
/ |
Division |
3 |
mod |
Teilerrest |
3 |
and |
Logische Undverknüpfung |
3 |
+ |
Addition |
4 |
- |
Subtraktion |
4 |
or |
logische Oderverknüpfung |
4 |
== |
Gleichheit |
5 |
!= |
Ungleichheit |
5 |
< |
kleiner |
5 |
<= |
kleiner oder gleich |
5 |
>= |
groesser oder gleich |
5 |
> |
groesser |
5 |
Werden Vergleiche mit logischen Operatoren verknüpft, so
müssen Klammern gesetzt werden. Lässt man in dem Ausdruck
(left > 10) or (right >
20) die Klammern weg, würde die Zahl 10 mit der Ganzzahlvariable
right eine
Oder-Verknüpfung eingehen. Der Grund dafür ist, dass die
Vergleichsoperatoren einen niedrigeren Rang als das logische Oder
haben. Den Ausdruck so auszuwerten ist weder die Absicht des
Programmierers, noch wird er vom Compiler akzeptiert. Zwei
ganzzahlige Werte können nicht mit einem booleschen Operator
verknüpft werden.
Anweisungen
Die DSL
gehört zur Familie der imperativen Programmiersprachen. Dies
bedeutet, dass in einzelnen Anweisungen beschrieben wird welche
Rechenschritte nacheinander ausgeführt werden sollen.
Definitionen
Elemente, die
der Anwender in das Programm einbringt, müssen explizit durch
eine Definitionsanweisung erzeugt werden. Hierzu gehören die
oben beschrieben Variablen- und Konstantendefinitionen. Als
fachspezifisches Element kommt das weiter unten beschriebene
Verzögerungsglied hinzu.
Zuweisungen
In einer
Anweisung kann einer Variablen ein neuer Wert zugewiesen werden.
Nachdem der arithmetische Ausdruck, der rechts vom
Gleichheitszeichen steht, ausgewertet wurde, wird das Ergebnis
der Variablen links vom Gleichheitszeichen zugewiesen. Bei
Wertzuweisung müssen der Ergebnistyp des arithmetischen
Ausdrucks und der Datentyp der Variable auf der linken Seite
übereinstimmen. Ansonsten wird vom Compiler eine Fehlermeldung
ausgegeben.
Beispiel: Wertzuweisung an Variable:
i = 2 * j + 1 # In dieser Berechnung sind i und j ganzzahlige Variable.
decision = i > j # desicion ist eine Boolesche Variable, das Ergebnis
# der rechten Seite ist ein Logikwert, welcher in der
# Booleschen Variable decision gespeichert wird.
decision = decision or b # b sei ein bereits definierter Logikwert.
Nachrichten senden
Mit der
message-Anweisung können
ganzzahlige Werte an die Entwicklungsumgebung gesendet werden.
Diese Anweisung ist als Hilfsmittel für die Fehlersuche gedacht.
message Irleft, IrRight # die Werte der IR-Abstandssensoren protokollieren
Diese Nachrichten werden über die Prozesdatenverbindung an
die Entwicklungsumgebung gesendet und dort in der Console-Anschicht
protokolliert. Dazu muss der Roboter mit dem PC verbunden sein.
Leere Anweisung
Die
pass Anweisung bewirkt
nichts. Diese exotische Anweisung wird als Platzhalter an Stellen
eingefügt, an denen die Syntax der DSL eine Anweisung fordert,
aber keine Aktion ausgelöst werden soll. Der Einsatz der
''leeren'' Anweisung ist hier nur der Vollständigkeit halber
ausgeführt. Ein Beispiel für ihre Verwendung ist im folgenden
Beispiel über
bedingte Anweisungen zu
finden.
Kontrollstrukturen
Kontrollstrukturen
werden benötigt, um alternative Pfade durch den Algorithmus oder
die Wiederholung von Anweisungen zu beschreiben.
Kontrollstrukturen wirken nicht auf einzelnen Anweisungen sondern
auf Blöcke. Die unter Kontrolle stehenden inneren Blöcke werden
durch tiefere Einrückung gekennzeichnet.
Bedingte Anweisungen
Mit
der
if - else Anweisung
können Anweisungsblöcke bedingt abgearbeitet werden. Sowohl die
if-Bedingung als auch die
optionale
else Klausel
werden mit einem Doppelpunkt abgeschlossen.
Beispiele für bedingte Anweisungen:
# Variable i durch einen oberen Grenzwert beschraenken.
if i > 100: # i sei eine ganzzahlige Variable.
i = 100
var max = 0
var iWars = False
if i > j: # i und j seien als Variablen fuer ganze Zahlen definiert.
max = i # Der then-Block besteht hier aus zwei Anweisungen.
iWars = True
else:
max = j # else-Block
var maxPlus = max + 2 # Diese Anweisung wird in jedem Fall ausgefuehrt.
# Nur im else-Zweig eine Berechnung durchfuehren.
if (j1 > 100) or (j2 < 0):
pass # nichts tun
else:
i = j1 * j2
Schleifen
Beliebige Wiederholungen eines Anweisungsblock sind
in der DSL nicht erwünscht. Dies liegt daran, dass alle Regeln
innerhalb einer definierten Zeitspanne abgearbeitet werden
müssen. Schleifen gibt es deshalb in zwei unterschiedlichen
Formen.
Ein Iterator durchläuft alle Elemente eines Feldes. Dadurch
ist sichergestellt, dass diese Form der Schleife beendet wird,
sobald alle Elemente genau einmal besucht wurden. Die
Iterator-Anweisung wird mit dem Schlüsselwort for eingeleitet. Anschließend folgt
die Schleifenvariable, die in jedem Durchlauf einen neuen Wert
annimmt. Nach dem Schlüsselwort in wird angegeben aus welchem Feld
die Elemente sukzessiv entnommen werden.
var count = 0
# Diese Schleife zaehlt,
# wie viele Elemente im Feld rules enthalten sind.
for r in rules:
count = count + 1
Die Schleifenanweisung wird mit dem Schlüsselwort while eingeleitet. Anschließend
folgt ein logischer Ausdruck. Die Anweisungen im Schleifenrumpf
werden ausgeführt, solange diese Bedingung True ergibt. Im Rumpf der Schleife
muss deshalb etwas geschehen, das die Bedingung verändert. Die
Schleife wird sonst endlos lange ausgeführt.
var n = 4
# Diese Schleife berechnet die Fakultaet von 4.
var fakultaet = 1
while n > 1:
fakultaet = fakultaet * n
n = n - 1
while-Schleifen
bringen das Risiko mit sich, dass die Regeln und Koordinatoren zu
lange für ihre Berechnung benötigen. Der mobile Roboter kann
dadurch unmanövrierbar werden. Es wird empfohlen, wo immer
möglich, den for
Iterator anstelle der while-Schleife zu verwenden. Zur
Laufzeit werden while-Schleifen auf
Zeitüberschreitung überwacht. Der DSL-Compiler fügt dazu in
jede Schleife eine Anweisung ein, welche die Anzahl der seit
Zyklusbeginn aufgetretenen Zeitinterrupts gegen ein vorgegebenes
Limit vergleicht. Eine Überschreitung der Zykluszeit führt
dazu, dass die Motoren gestoppt werden und das Programm
abgebrochen wird. Der Fehlerzustand wird an die
Entwicklungsumgebung gemeldet.
Als Strukturanweisungen werden for- und while-Schleifen mit einem
Doppelpunkt abgeschlossen.
Funktionen
Funktionen
werden verwendet, um identische Anweisungsfolgen nur an einer
Stelle im Programmtext notieren zu müssen. Jede Funktion trägt
einen Namen, hat Parameter und gibt einen Wert zurück. Im
zugehörigen Rumpf beschreiben Anweisungen, wie die Funktion ihre
Aufgabe im Detail berechnet. Um die DSL-Programme verständlich
zu halten, sollte der Name die Berechnung im Funktionsrumpf und
den Rückgabewert treffend beschreiben.
Definition von
Funktionen
Funktionen werden mit der Anweisung
def gefolgt von Name und
Parameterliste definiert. Die Definitionsanweisung wird mit einem
Doppelpunkt abgeschlossen. Übergebene Parameter stehen innerhalb
der Funktion für Berechnungen zur Verfügung. Ändert man ihre
Werte, so hat dies keine Auswirkungen außerhalb des
Funktionsrumpfes.
Beispiel für eine Funktion, welche ihren Übergabeparameter
im Wertebereich begrenzt bevor sie ihn zurückgibt:
# Begrenzt value auf den Bereich 0 bis 100.
def range0to100 (var value):
if value > 100:
value = 100
if value < 0:
value = 0
return value
Beispiel für eine Funktion, die dafür sorgt, dass niemals
die rote und die grüne Diagnose LED zur selben Zeit leuchten.
Allerdings dürfen dann beide LEDs nicht mehr direkt angesteuert
werden. Interessiert ist man hier nicht an einer Funktion im
mathematischen Sinn, sondern am Seiteneffekt, den diese
Codesequenz auslöst. Da es sich formal um eine Funktion handelt,
muss ein Wert zurückgegeben werden. Im Beispiel wird die Zahl 0
zurückgegeben. Es wäre auch möglich den Übergabeparameter mit
return rotLeuchtet als
Rückgabewert zu verwenden.
# Zwei Diagnose LEDs komplementaer ansteuern.
def toggleLEDs (var rotLeuchtet):
LedRed = rotLeuchtet
LedGreen = not rotLeuchtet
return 0
Aufruf
Funktionen werden
innerhalb von arithmetischen Ausdrücken aufgerufen. Ihr Ergebnis
fließt dann direkt in die Berechnung ein. Im Aufruf kann als
Parameter ein arithmetischer Ausdruck stehen. Dieser wird vor dem
Aufruf berechnet und sein Ergebnis an die Funktion übergeben.
# i, j und i10 seien als Variablen fuer ganze Zahlen definiert.
i = range0to100(i)
i10 = 10 * range0to100(j)
Die Rückgabewerte von Funktionen müssen nicht verwendet
werden. Aus Funktionen werden so Prozeduren, die einen
Seiteneffekt auslösen sollen.
toggleLEDs(errorCount >= 1)
Funktionen für
Typwandlung
Benötigt man Typwandlungen, so lässt sich dies
durch Funktionen ausdrücken. Der Typ des Rückgabewertes einer
Funktion muss nicht dem Typ des Eingangsparameters
übereinstimmen.
Zwei Beispiele für Funktionen die eine Typwandlung
ausführen:
# Alle ganzzahligen Werte die ungleich Null sind
# werden als True betrachtet.
def asBoolean(var i):
return i != 0
# Ermitteln, ob der Uebergabeparameter eine ungerade Zahl enthaelt.
def ungerade(var i):
if asBoolean(i mod 2):
return True
return False
Dieses Beispiel kann natürlich einfacher als arithmetischer
Ausdruck dargestellt werden. Der Teilerrest von i geteilt durch 2 wird auf ungleich
Null verglichen, um zu entscheiden ob i ungerade ist.
var ungerade = (i mod 2) != 0
Fachspezifischer Anteil der
Vehikel-Programmiersprache
Typisch für die
verhaltensbasierte Programmierung sind Regeln, mit denen
Messwerte auf Antriebe, im Folgend als Aktoren bezeichnet,
abgebildet werden. In einem verhaltensbasierten Algorithmus
dürfen mehrere Regeln für dieselben Aktoren Sollwerte
vorschlagen. Ein Koordinator übernimmt zwischen den
widersprüchliche Vorgaben die Funktionen eines Schiedsrichters.
Da der mobile Roboter auf die Umwelt reagieren muss, kommt die
Zeit mit ins Spiel. Diese Anforderungen bestimmen den Aufbau der
DSL. Zusätzlich zu den oben beschrieben typischen Elementen
einer Programmiersprachen kommen fachspezifische Erweiterungen
hinzu. Sie werden in den folgenden Abschnitten beschrieben.
Trennung des Systemteils von
der Anwendungslogik
Ein DSL-Programm besteht aus einem
Systemteil und einem problemspezifischen Teil. Im Systemteil wird
die Anbindung an die Aktoren und Sensoren beschrieben. Im zweiten
Teil wird die Anwendungslogik formuliert.
Systemteil
Konfiguration
Abhängig vom
Typ des mobilen Roboter stehen dem Anwender unterschiedliche
Sensoren und Aktoren zur Verfügung. Diese werden in einer
externen Konfigurationsdatei definiert. Als
Anwendungsprogrammierer greift man auf eine fertige
Konfigurationsdatei zurück. Diese legt die symbolischen Namen
der Aktoren, Sensoren und der On-Board-Diagnose fest. Aus der
Bitbreite der E/A Kanäle wird der Datentyp ermittelt. Ein (1)
Bit breiten Kanälen werden Booleschen Variablen zugeordnet. Die
mit Analog/Digital-Wandlern oder der Pulsweitenmodulation
verknüpfte E/A Kanäle werden als 16 Bit breite
vorzeichenbehaftete Ganzzahlen behandelt. Typisch sind 10 Bit A/D
Wandler. Diese liefern Werte im Bereich 0 bis 1023.
Gültigkeitsbereich eines
Namens in der DSL
Namen, die im Systemteil oder der
Konfiguration definiert werden, sind im gesamten DSL-Programm
sichtbar. Alle anderen Namen gelten im jeweiligen
Sichtbarkeitsbereich. Sichtbarkeitsbereiche können geschachtelt
werden. Wird ein Name nicht im innersten Sichtbarkeitsbereich
gefunden wird im nächstäußeren gesucht. Die Suche wird so
lange fortgesetzt, bis der äußere globale Bereich erreicht ist.
Prozessabbild
Im Systemteil
wird das verhaltensbasierte Programm mit den Sensoren und Aktoren
des mobilen Roboters verbunden. Dazu gibt man mit dem
Schlüsselwort
configuration an, für welche
Roboterkonfiguration das Programm erstellt wurde. Die importierte
Konfiguration bestimmt den Inhalt des Prozessabbildes. Über
dieses greift die Anwendungslogik auf die Messwerte der Sensoren,
die letzten an die Aktoren ausgegebenen Werte und die
Diagnose-LEDs zu.
Zugriff auf die
Sensoren
Die Sensoren stehen als Variablen im Programm zur
Verfügung. Man kann sich diese Variablen als Briefkästen
vorstellen, in denen vor dem Start des Zyklus eine Nachricht
hinterlegt wurde. Während eines Zyklus kann jede Nachricht
mehrfach gelesen werden, selbstverständlich ändert sie dadurch
ihren Wert nicht. Ein realer Sensor würde bei mehrmaligem Lesen
nicht denselben Wert liefern, ein Effekt für den bereits das
Rauschen sorgt.
Sensor-Fusion und virtuelle Sensoren.
Zusätzlich können
virtuelle Sensoren definiert werden. Diese werden wie
parameterlose Funktionen definiert. Sie lesen einen oder mehrere
Sensorwerte aus dem Prozessabbild und geben diesen an den
Logikteil weiter. Damit ist es möglich, die Messwerte zu
skalieren. Wenn ein virtueller Sensor den Input mehrerer Sensoren
zusammenfasst, wird dies als Sensor-Fusion bezeichnet.
Im folgenden Beispiel für einen Systemteil mit virtuellen
Sensoren sind LdrLeft,
LdrRight und IrLeft symbolische Namen für
Sensoren. Sie werden von der Konfiguration simpleCtBot vorgegeben.
system:
configuration ctBot.simpleCtBot
# Sensor Fusion, aus den zwei realen Lichtsensoren LdrLeft und LdrRight
# wird ein virtueller Richtungssensor berechnet.
sensor diffLight:
return (1023 - LdrLeft) - (1023 - LdrRight)
# Linearisierung des IR-Abstandsensor IrLeft auf den Bereich 0 bis 800.
sensor distanceLeft:
const maxDistance = 800
var value = 4833 / (IrLeft / 10)
if value > maxDistance:
return maxDistance
if value < 0:
return 0
return value
Anwendungslogik
Die
Anwendungslogik wird mit Hilfe von Regeln, Koordinatoren und
Verzögerungsgliedern ausgedrückt.
Regeln
Eine Regel liefert Sollwerte für die Aktoren.
Allerdings werden diese Sollwerte für jede Regel getrennt
zwischengespeichert. Es ist sehr wohl möglich, dass mehrere
Regeln für dieselben Aktoren unterschiedliche Sollwerte
vorgeben. Jede Regel besitzt dazu einen Zwischenspeicher, der mit
dem Schlüsselwort
self
angesprochen wird. Die folgenden zwei Regeln werden tragen beide
ihren Teil dazu bei, den Roboter in Richtung einer Lichtquelle zu
steuern.
Beispiel: Regeln für ein fototrophes Verhalten.
# Die Konstanten forwardSpeed und steeringSpeed sind
# im Systemteil definiert.
# 1. Regel: vorwaerts fahren.
rule driveForward:
self.SpeedLeft = forwardSpeed
self.SpeedRight = forwardSpeed
# 2. Regel: Kurs abhaengig von der linken und
# rechten Beleuchtungsstaerke korrigieren.
rule navigate:
if LdrLeft > 2+LdrRight:
self.SpeedLeft = -steeringSpeed
self.SpeedRight = steeringSpeed
if LdrLeft+2 < LdrRight:
self.SpeedLeft = steeringSpeed
self.SpeedRight = -steeringSpeed
Variablen einer Regel
Mit
jeder Regel sind Variablen verknüpft. Diese werden über das
Schlüsselwort
self
angesprochen. Grundsätzlich sind die Aktoren als Variablen
verfügbar. Im obigen Beispiel sind dies
self.SpeedLeft und
self.SpeedRight. Weist man diesen
Variablen einen Wert zu, wird dies pro Variable in einem Flag
notiert. Damit kann später ermittelt werden, ob eine Regel in
der momentanen Situation einen Aktor beeinflussen will. Jede
Wertzuweisung an einen Aktor wird vom Compiler automatisch
ergänzt. Dabei wird die Anweisung
self.Aktor = ....
als
self.Aktor = ....
self.Aktor.isContributing = True
interpretiert.
Regel |
Aktor |
Membervariable |
driveForward |
SpeedLeft |
value |
|
|
isContributing |
|
SpeedRight |
value |
|
|
isContributing |
navigate |
SpeedLeft |
value |
|
|
isContributing |
|
SpeedRight |
value |
|
|
isContributing |
Die
Ergebnisse der Regeln enthalten sowohl die konkreten Werte für
die Aktoren, als auch die Information, ob eine Regel momentan
den Aktor beeinflussen will.
Zusätzlich ist in jeder Regel die Variablen self.strength definiert. Mit der
ganzzahligen Variable self.strength wird ausgedrückt, wie
stark der Koordinator die Sollwerte dieser Regel berücksichtigen
soll.
Koordinatoren
Die Regeln einer Gruppe werden immer von genau
einem Schiedsrichter überwacht. Ein Koordinator bekommt dazu
die, sicherlich unterschiedlichen, Sollwerte und Gewichtungen der
Regeln übergeben. Bei der Ausführung der Regeln werden die
self-Werte gesammelt und
anschließend dem Koordinator als Feld übergeben. Koordinatoren
haben immer genau einen Parameter, der den lesenden Zugriff auf
diese Werte ermöglicht.
Auf welche Weise Koordinatoren diese unterschiedlichen Werte
ausgleichen, unterscheidet sich von Aufgabenstellung zu
Aufgabenstellung. Bei einem fototrophen Verhalten können die
Geschwindigkeitswünsche überlagert werden. Der Koordinator
iteriert in diesem Fall pro Motor über die Sollwerte und bildet
daraus den Mittelwert.
Beispiel: Koordinator zur Mittelwertbildung von n Regeln
# Koordinator fuer das fototrophe Verhalten.
# Eine ''Vektoraddition'' ueberlagert driveForward und navigate.
arbiter vectorField(array rules):
var count = 0
var left = 0
var right = 0
for r in rules:
left = left + r.SpeedLeft
right = right + r.SpeedRight
count = count + 1
assert count > 0
self.SpeedLeft = left / count
self.SpeedRight = right / count
Beispiel:
Stength Speed Speed Stength Speed Speed
Left Right Koordinator Left Right
Regel1 n/a 50 50 ----------> n/a 27 22
Regel2 n/a 5 -5
Auch der Koordinator gibt die gemittelten Werte nicht direkt
an die Motoren weiter. Er könnte selbst nur ein untergeordnetes
Blatt innerhalb einer Gruppe von Regeln sein.
Die Syntax der DSL schreibt vor, dass den Regeln immer genau
ein Koordinator folgen muss. In Ausnahmefällen kann es nötig
sein einen leeren Koordinator zu definieren. Dies wird durch die
pass Anweisung im Rumpf
des Koordinators ausgedrückt.
# Leerer Koordinator
arbiter vectorField(array rules):
pass
Variablen und Parameter eines
Koordinators
Die Ergebnisse der Regeln, für die ein
Koordinator zuständig ist, werden ihm als Eingangsparameter
übergeben. Über die Elemente dieses Feldes kann der Koordinator
mit einer
for Schleife
iterieren. Für die Ausgabe seines Ergebnisses enthält der
Koordinator genau denselben Satz von Aktoren wie die Regeln.
Formal besteht seine Aufgabe darin, die Sollwerte aller Regeln
der Gruppe zusammenzufassen und das Ergebnis als seinen Sollwert
zu speichern. Welche Strategie Koordinator dabei anwendet, bleibt
dem Anwender überlassen.
Gruppieren von Regeln
Eine
Regelgruppe enthält zumindest eine Regel und genau einen
Koordinator. Anstelle einer Regel kann wieder eine Regelgruppe
eingesetzt werden. Der Koordinator der inneren Regelgruppe gibt
seine Entscheidung eine Stufe nach außen bekannt. Dadurch wirkt
eine eingeschobene Regelgruppe im ausrufenden Kontext wie
einzelne Regel. Der Koordinator in der untergeordneten Gruppe
sieht dabei nur die Regeln der lokalen Gruppe. Regelsysteme
können damit hierarchisch organisiert werden. Ausgedrückt wird
dies allein durch die Einrückungstiefe. Die dezentrale
Koordination wird im Artikel
Regel und Koordination detailierter beschrieben.
Felder, Indexvariable und
Schleifen
Auf die Elemente eines Felder kann über einen
Index zugegriffen werden. Das erste Element hat dabei den Index
0. Zur Laufzeit wird überprüft, ob der Index innerhalb der
Größe des Feldes liegt. Man kann sich vorstellen, dass jeder
Zugriff intern durch die Zusicherung
assert (index >= 0) and (index <
array.length) geprüft wird. Beim Auswerten von Regeln im
Koordinator fährt man häufig besser, wenn man auf die Elemente
des Feldes mit der
for
Anweisung zugreift. Nur bei sehr übersichlichen Regelgruppen
kann der Koordinator prägnater in der Indexschreibweise notiert
werden.
Bei den folgenden drei Beispielen wird davon ausgegangen, dass
genau zwei Regeln koordiniert werden müssen. Am wenigsten
Wartungsaufwand erfordert die erste Variante, sie muss nicht
geändert werden, falls eine weitere Regel
hinzukommt.
Beispiel 1:
var left = 0
var right = 0
for r in ruiles:
left = left + r.SpeedLeft
right = right + r.SpeedRight
Beispiel 2:
var index = 0
var left = 0
var right = 0
while index < 2:
left = left + rules[index].SpeedLeft
right = right + rules[index].SpeedRight
index = index + 1
Beispiel 3:
var left = rules[0].SpeedLeft + rules[1].SpeedLeft
var right = rules[0].SpeedRight + rules[1].SpeedRight
Indirekter Zugriff auf die
Aktoren
Aktoren werden nie direkt aus dem Programm
angesprochen. Die Ansteuerung der Aktoren nimmt in der
verhaltensbasierten Programmierung eine Sonderstellung ein, die
sich direkt in der Struktur der Programmiersprache
niederschlägt. Der weiter unten beschriebene Mechanismus der
Regeln und Koordinatoren übernimmt diese Aufgabe. Hier
unterscheidet sich die DSL von einer general-purpose
Programmiersprache.
Direkter Zugriff auf die
Diagnose LEDs
Anders sieht es bei den Diagnose-LEDs aus. Sie
können direkt vom Programm angesteuert werden. Dabei werden sie
nicht nur im Prozessabbild aktualisiert, sondern auch sofort an
die E/A weitergeleitet. Die OnBoard-Diagnose umgeht
grundsätzlich die Koordinatoren. Damit ist es möglich
Fehlerzustände ungefiltert zu signalisieren. Diagnose LEDs
können an beliebiger Stelle im Programm gesetzt werden. Wird ein
und die selbe LED in einem Zyklus mehrfach beschrieben, so
gewinnt der letzte Schreibzugriff. Der Zugriff auf LEDs wird ohne
self notiert um zu
signalsieren, dass man direkt mit dem Prozessabbild arbeitet.
Dies hat zur Folge, dass man selbst dafür verantwortlich ist die
LEDs konfliktfrei zu verwenden
In folgendem Beispiel wird der Sensor Irleft und die Diagnose-LED
LedRed direkt aus dem
Prozessabbild verwendet. Die Sollvorgabe für die Geschwindigkeit
werden mit self.SpeedLeft
und self.SpeedRight
temporär gespeichert um später koordiniert zu werden.
if IrLeft > 100:
# muss stoppen
self.SpeedLeft = 0
self.SpeedRight = 0
LedRed = True
Zyklische Bearbeitung
Quasiparalleität mit einfachen
Mitteln
Im Laufzeitsystem werden pro Zyklus alle Regeln genau
einmal ausgeführt. Für einen Zyklus stehen 125 Millisekunden
zur Verfügung. Von außen gesehen erweckt dies den Eindruck,
dass die Regeln parallel arbeiten. Um dieses Arbeitsschema nicht
zu stören, darf keine der Regeln durch aktives Warten den
Prozessor belegen, bis das erwartete Ereignis ein tritt. Die
Programmiersprache ist deshalb so aufgebaut, dass Schleifen nur
eingeschränkt unterstützt werden.
Darstellung der Zeit durch Verzögerungsglieder
Durch den
getakteten Ablauf taucht im Programm implizit die Zeit auf. Man
darf sich vorstellen, dass es einen Zyklenzähler gibt. Aus
seinem Fortschreiten kann abgeleitet werden, wie lange ein
Zustand anhält. Ein solcher absoluter Zähler läuft auf einem
realen Prozessor natürlich irgendwann über. Deshalb wird in der
DSL mit relativen Zählern gearbeitet. Diese werden mit einem
Initialwert gestartet und zählen dann abwärts bis Null.
Dieses Beispiel lässt eine LED blinken. Es handelt sich dabei
aber um ein eher untypisches verhaltensbasiertes Programm, denn
die Wertzuweisung an die Diagnose-LED wirkt sich sofort auf die
Hardware aus. Die Regel blink wirkt über diesen
Seiteneffekt. Der Koordinator ist nur als leere Hülle
vorhanden.
# blink sample
system:
configuration ctBot.simpleCtBot
delay takt = 50 # Takt: Zaehler ''takt'' mit Startwert definieren.
delay hell = 5 # Hellphase: Zaehler ''hell'' mit Startwert definieren.
takt.trigger() # Zaehler ''takt'' starten.
rule blink:
if takt == 0: # Falls Zaehler ''takt'' ablaeuft,
takt.trigger() # beide Zaehler neu starten.
hell.trigger()
LedOrange = hell > 0 # Diagnose LED soll in der Hellphase leuchten.
arbiter dummy(array rules):
pass
Methoden eines
Verzögerungsgliedes
Die Verzögerungsglieder werden über
Methoden gesteuert. Es gibt zwei Strategien, um mit diesen
Zykluszählern umzugehen. Zum einen können bereits abgelaufene
Zähler neu gestartet werden. Dies geschieht mit dem
Methodenaufruf
trigger().
Hat der Zähler noch nicht Null erreicht, ist dieser
Methodenaufruf wirkungslos. Ein laufender Zähler kann durch
retrigger() wieder auf
seinen Initialwert gesetzt werden. Dadurch wird die Laufzeit des
Zählers verlängert. Mit
cancel() kann ein Verzögerungsglied
vorzeitig gestoppt werden.
Lebensdauer und
Sichtbarkeitsbereich
Variablen und Konstanten sind im
Allgemeinen nur in der Funktion oder Regel, in der sie definiert
wurden, sichtbar. Sofern Konstanten und Verzögerungsglieder im
Abschnitt
system
definiert werden sind sie global sichtbar. Dann können alle
Funktionen, Regeln und Koordinatoren auf diese Daten zugreifen.
Zusätzlich muss man noch die Lebensdauer einer Variable in
Betracht zu ziehen. So kann auf die Werte der
Verzögerungsgliedern über mehrere Zyklen hinweg zugegriffen
werden. Variable innerhalb einer Regel oder Funktion werden
dagegen bei jedem Aufruf neu erzeugt und initialisiert. Sie
speichern Werte nur während eines Durchlaufes. Sollen Variable
Werte speichern, die in der nächsten Zyklus wieder benötigt
werden, so muss das Attribut
persistent in der
Variablendefinition angegeben werden. Die persistenten Variablen
sind jedoch weiterhin für anderen Regeln und Funktionen nicht
sichtbar. Lebensdauer und Sichtbarkeitsbereich sind in der DSL
getrennt. Die Möglichkeiten sind in folgender Tabelle
dargestellt:
Definition |
Sichtbarkeit |
Lebensdauer |
Zugriff |
Initialisierung |
system: const c = 10
|
global |
andauerend |
nur lesend |
Durch die Compilierung. |
system: delay d = 50
|
global |
andauerend |
schreibend nur über Funktionen |
Durch die Funktion trigger() und retrigger(). |
|
rule r1: var l = 1
|
lokal |
temporär |
schreiben und lesen |
Jedesmal wenn der Block betreten wird. |
rule r2: persistent var p = 5
|
lokal |
andauerend |
schreiben und lesen |
Beim Programmstart. |
def f(var n):
|
lokal |
temporär |
schreiben und lesen
Veränderungen sind aussen nicht
sichtbar. |
Beim Aufruf der Funktion. Die Funktion erhält eine
Kopie. |
arbiter a(array r):
|
lokal |
temporär |
schreiben und lesen
Die Funktion erhält Zugriff auf die Elemente des Feldes.
Veränderungen sind aussen sichtbar. |
Beim Aufruf der Funktion. |
Lebensdauer und Sichtbarkeitsbereich
von Konstanten, Variablen, Parametern und
Verzögerungsgliedern.
Fehlersuche
Man lernt aus
Fehlern. Allerdings ist die Fehlersuche in eingebetteten Systemen
aufwändig, da die Software in diesen Systemen nicht so leicht
zugänglich ist, wie Software die für den Desktop entwickelt
wird. Damit die Fehler doch gefunden werden, sollen hier einige
Hinweise gegeben werden.
Syntaxfehler im
Editor
Editiert man die DSL-Quelltexte in der
Entwicklungsumgebung, so wird der Text ständig auf Fehler
überprüft. Die betroffenen Stellen werden direkt im Editor
markiert. Gefunden werden allerdings nur Verstöße gegen die
Syntax, die Verwendung nicht definierter Namen und der falsche
Umgang mit den Datentypen. Diese Verstöße werden durch
statische Analyse des Quelltextes aufgedeckt. Dies stellt nur
sicher, dass ein Programm korrekt notiert ist. Ob das Programm
auf dem mobilen Roboter sinnvoll reagiert, ist damit nicht
sichergestellt.
Laufzeitfehler
Laufzeitfehler
bedeuten, dass sich das Programm bei der Ausführung auf dem
mobilen Roboter nicht wie erwartet verhält. Um diese Fehler
einzugrenzen, ist mehr Aufwand nötig. Hierzu werden
Zusicherungen und Meldungen als Hilfsmittel angeboten.
Zusicherung
Zusammen mit
der
assert-Anweisung wird
ein logischer Ausdruck angegeben. Ergibt eine Berechnung dieses
Ausdrucks den Wert
True,
so wird das Programm fortgesetzt. Andernfalls wird der mobile
Roboter gestoppt und der Fehlerzustand an die
Entwicklungsumgebung gesandt.
- Alle Aktoren werden angehalten.
- Die weitere Abarbeitung der Verhaltensregeln und
Koordinatoren wird unterbunden.
- Das Auftreten des Fehlers wird über die rote On Board
Diagnose LED signalisiert.
- Die Fehlernummer und das Prozessabbild wird an die
Entwicklungsumgebung gesendet.
Nach dem eine Zusicherung einen Fehlerzustand erkannt hat,
wird der momentane Zustand des Prozessabbildes zyklisch an die
Entwicklungsumgebung gesendet. Um diesen Zustand auszulesen,
genügt es, wenn der Roboter erst später wieder in Verbindung
mit der Entwicklungsumgebung steht. Der Fehlerzustand wird durch
elektrisches Rücksetzten oder neu Programmieren gelöscht.
Nachricht
Um konkrete Werte
zu protokollieren, wird die
message-Anweisung verwendet. Pro
Nachricht können 1 bis 8 Werte übergeben werden. Die gesendeten
Nachrichten werden von der Entwicklungsumgebung mitprotokolliert.
Dies kann man verwenden, um die Reaktionen des Programms
nachzuvollziehen.
Beispiel: Zusicherung und Nachricht
# Obeflaechlich betrachtet gilt hier max > min
var max = 100 * IrLeft
var min = 10 * IrLeft
message min, IrLeft, max
assert max > min # Aber trotzdem gibt es sporadisch einen Fehler.
# Immer dann, wenn IrLeft den Wert 0 liefert.
Werte der Sensoren
protokollieren
Eine häufige Fehlerquelle ist die falsche
Interpretation der Sensorwerte. Die Sensoren sind über einen
10-Bit Analog/Digital-Wandler eingebunden. Dies bedeutet aber
nicht, dass die gemessenen Werte den Zahlenbereich von
0 bis
2^10-1 = 1023 vollständig
ausnutzen. Die Interpretation der Messwerte ist von der
elektronischen Beschaltung abhängig. Die lichtempfindlichen
Widerstände des c''t-Bots liefern bei hellem Licht kleine Werte,
bei Dunkelheit Große. Ein höherer Messwert bedeutet hier nicht
hellere Lichteinstrahlung. Um die Zusammenhänge zu verstehen,
wird empfohlen das Verhalten der einzelnen Sensoren im
Prozessdatenrekorder der Entwicklungsumgebung aufzuzeichnen.