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.
SourceForge.net Logo  
     
 
Creative Commons License
Text and images are licensed under a Creative Commons License.
 
CC-GNU GPL
This software is licensed under the CC-GNU GPL.


 
     
 
Kandid, a genetic art project InnerWorld, a terrain generator for Blender Vehikel, embodied intelligence for mobile robots