Motivation

Bei einem Projekt geht es darum mehrere Steppermotoren gleichzeitig mit einem ESP32 anzusteuern. Bei der Bibliothek zur Ansteuerung eines einzelnen Schrittmotor vom Typ 28BYJ-48 werden die Schritte über einen Timer-Interrupt erzeugt. Die zugehörige ISR muss nun so erweitert werden, dass mehrere Motoren gleichzeitig mit passendem Schrittverhältnis angesteuert werden können.


Version Anpassungen
1.0 (2026-05-09) Initiale Version
1.1 (2026-06-04) Implementierung für eine Arduino- (ESP32-) Bibliothek.
1.2 (2026-06-09) Fehler bei der Sortierung der Motoren behoben.
1.3 (2026-06-19) Funktion hasFinished hinzugefügt.
1.3 (2026-06-28) Algorithmus zur Rasterung beliebiger Funktionen und Rasterung in Polarkoordinaten

Motivation

Linien-Algorithmus (Duale Motorsteuerung)

Bewertung

Methode MotorControl

Download Projekt DualMotorControl

Multi-Motorsteuerung

Struktur

Klasse Motor

Klasse MotorControl

Methode MultiMotorControl

Algorithmus

Implementierung in C++

Klasse MultiMotorControl

Klasse MotorControlTicker

Download Projekt MultiMotorControl

Arduino- (ESP32-) Bibliothek

Steuerung von Schrittmotoren vom Typ 28BVJ-48

Rasterung beliebiger Funktionen

Beispiele

Polarkoordinaten

Funktionen mit mehr als einem Parameter

Beispiel Ellipsen

Schrittmotorsteuerung (C#)

Zeitverhalten

Linien-Algorithmus (Duale Motorsteuerung)

Im einfachsten Fall müssen zwei Motoren so synchronisiert werden, dass sie die geforderten unterschiedlichen Schrittzahlen innerhalb der gleichen Zeit ausführen. Dies entspricht einem Linien-Algorithmus. Zur Prüfung habe ich zunächst ein kleines C#-Programm (DualMotorControl) geschrieben, das mit verschiedenen Algorithmen eine Linie zeichnet.

 Verglichen werden drei Varianten:

Ausgabe des Programms 'DualMotorControl'

Das Programm zeichnet die Linien in verschiedenen Farben und zeigt die Zielpositionen an.

Die Ergebnisse liegen recht nah beieinander, wie die folgenden Vergrößerungen zeigen (Line: Rot, Bresenham: Grün, MotorControl: Braun):

Vergleich der Algorithmen   Vergleich der Algorithmen

Bewertung

Der DDA-Algorithmus ist recht einfach aufgebaut und lässt sich leicht auf weitere Motoren erweitern. Er arbeitet jedoch mit Fließkommazahlen die auf μCs relativ aufwändig sind und zu Problemen führen können.

Der Bresenham-Algorithmus kommt mit Ganzzahladditionen aus, lässt sich aber schwer erweitern.

MotorControl kommt ebenfalls mit Ganzzahladditionen aus und lässt sich leicht auf weitere Motoren erweitern.

Methode MotorControl

Die Aufgabe besteht darin, eine gerade Linie mit diskreten Schritten zu approximieren. Die Idee ist, den Motor, der die meisten Schritte ausführen muss, bei jedem Takt einen Schritt ausführen zu lassen. Der zweite Motor mit der geringeren Schrittzahl wird nur dann angesteuert, wenn ein Schritt in die andere Richtung notwendig ist.

Approximation der Ziellinie

Um die Steuerung zu vereinfachen, werden zunächst die Schrittrichtungen separiert. Dadurch ist es möglich, ausschließlich mit positiven Werten weiterzuarbeiten:

int Steps1 = StepsX; 
int Steps2 = StepsY;

int dir1 = Steps1 >= 0 ? 1 : -1; // Richtung für Motor in X-Richtung
Steps1 = Abs(Steps1);
int dir2 = Steps2 >= 0 ? 1 : -1; // Richtung für Motor in Y-Richtung
Steps2 = Abs(Steps2);

Zur weiteren Vereinfachung werden die Motoren (MotorX und MotorY) nicht direkt, sondern über Referenzen (motor1 und motor2) angesteuert. Dadurch ist es möglich die Motoren so zu vertauschen, dass motor1 immer der mit der größeren Schrittzahl ist und trotzdem der richtige physikalische Motor angesteuert wird.

Motor motor1 = motorX;  // Vertauschbare Referenz. Motor 1 ist der Motor mit den meisten Schritten
Motor motor2 = motorY;

if (Steps1 < Steps2) { // Motor 2 hat mehr Schritte als Motor 1, also tauschen
   Swap(ref motor1, ref motor2);
   Swap(ref Steps1, ref Steps2);
   Swap(ref dir1, ref dir2);
}

Das Ergebnis ist, dass sich die notwendige Mathematik auf die untere Hälfte des ersten Quadranten beschränkt, wie in der obigen Grafik gezeigt. Der Winkel der Ziellinie ist kleiner oder gleich 45° und ihre Steigung ist ≤ 1. Nun muss bei jedem Takt motor1 um einen Schritt weiter gedreht werden, was man einfach mit einer Schleife erledigen kann. Zu überlegen, wann der zweite Motor angesteuert werden muss.

Die Steigung der Ziellinie ist S = StepsX / StepsY. Die Sollposition für y ist * x. Ein Schritt in Y-Richtung muss immer dann ausgeführt werden, wenn die Ist-Position um mindestens einen Schritt von der Soll-Position abweicht: S * x - y ≥ 1. Also

Die Formel lässt sich umformen zu

   (Formel 1)

Die Multiplikation lässt sich vermeiden, indem man sie durch fortgesetzte Addition ersetzt. Es werden Variablen prod1 und prod2 für die Produkte mitgeführt und bei bei jedem ausgeführten Schritt um StepsX bzw. StepsY erhöht:

motor1.Step(dir1);
prod1 += Steps2; // Führt das Produkt x * StepsY mit (ggf. getauscht)

 if (prod1 >= prod2) {
   motor2.Step(dir2); Führt das Produkt y * StepsX mit (ggf. getauscht)
   prod2 += Steps1;
 }

Ein kleines Problem gibt es noch zu lösen. Zu Beginn sind x und y Null. Sobald der erste Schritt in X-Richtung ausgeführt wird, ist StepsY*x = StepsY immer größer als StepsX*y = 0. D.h. der zweite Motor wird direkt beim 1. Takt angesteuert und würde damit der gelben Kurve folgen:

Falsche Ansteuerung des nachlaufenden Motors

Dies lässt sich vermeiden, in dem man prod2 mit dem Wert von StepsX vorbelegt:

int prod1 = 0, prod2 = Steps2; // Es werden die Produkte der aktuellen Position mit der Anzahl der Schritte
                               // des anderen Motors benötigt. Dies entspricht i.W. der Steigung der Linie.

Download Projekt DualMotorControl

Das DualMotorControl.zip enthält die Visual Studio-Solution zu diesem Programm. Es enthält im Projekt DualMotorControlCS das C# Programm und im Projekt DualMotorControlCpp die für μC einsetzbare Methode DualMotorControl in C++.

Multi-Motorsteuerung

Etwas anspruchsvoller wird es, wenn weitere Motoren gleichzeitig angesteuert werden sollen. Der oben gezeigte Algorithmus lässt sich erweitern. Im Bespiel werden fünf Motoren angesteuert:

Multi-Motorsteuerung   Darstellung der Steuerung

Struktur

Klasse Motor

Wie im vorhergehenden Beispiel dient die Klasse Motor als Schnittstelle zum anzusteuernden Motor:

Klasse Motor

Für das Beispiel nur die Methode Step benötigt, die den Motor veranlasst, eine angegebene Anzahl von Schritten auszuführen. Bei der synchronisierten Ansteuerung wird immer nur ein einzelner Schritt ausgeführt:

/// <summary>
/// Bewegt den Motor um die angegebene Anzahl von Schritten. 
/// Positive Werte bewegen den Motor vorwärts, negative Werte rückwärts.
/// </summary>
/// <param name="step">Die Anzahl der Schritte, um die der Motor bewegt werden soll.</param>
public void Step(int step) {
   Pos += step;
}

Klasse MotorControl

Zur Verwaltung der Motoren wird die Hilfsklasse MotorControl verwendet. In ihr werden alle prozessrelevanten Daten gespeichert, die bei der dualen Steuerung als eigenständige, motorbezogene Variablen geführt wurden.

Klasse MotorControl

Die Felder Dir und Steps enthalten die Richtung und absolut durchzuführende Schrittzahl. motor ist die Referenz auf den anzusteuernden Motor. prod1 und prod2 sind die Produkte der aktuellen Position mit der Anzahl der Schritte des Referenzmotors benötigt. Dies entspricht i.W. der Steigung der Linie. Die Methode Step leitet die Schrittanweisung an den betroffenen Motor weiter.

Methode MultiMotorControl

Die Methode MultiMotorControl übernimmt die Berechnung der Motorschritte. Sie ist direkt im Code-Teil der Windows.Form-Klasse implementiert. Dies erleichtert erheblich die Anzeige der berechneten Pfade.

Algorithmus

Die Synchronisation der Motoren übernimmt die Funktion MultiMotorControl in der Klasse Form1. Die wurde dort implementiert, weil dass die die Anzeige der berechneten Pfade erheblich erleichtert. Die enthält eine Reihe von Anweisungen zur Anzeige, die in einer realen Implementierung nicht benötig werden. Dies ist eine bereinigte Version:

/// <summary>
/// Steuert mehrere Motoren gleichzeitig, damit sie alle gleichzeitig an ihrem Ziel ankommen.
/// </summary>
/// <param name="Motors">Ein Array von MotorControl-Objekten, die gesteuert werden sollen.</param>
void MultiMotorControl(MotorControl[] Motors) {

   // Der Motor mit den meisten Schritten muss zuerst bearbeitet werden.
   // Daher wird der Motor mit den meisten Schritten an die erste Stelle des Arrays verschoben.
   for (int i = 1; i < Motors.Length; i++) {
      if (Motors[i].Steps > Motors[0].Steps) {
         Swap(ref Motors[0], ref Motors[i]);
      }
   }

   // Vorbelegung von prod2, damit die Motoren mit weniger Schritten nicht sofort starten.
   for (int i = 1; i < Motors.Length; i++) {
      Motors[i].prod2 = Motors[0].Steps/2;
   }

   // Motoren ansteuern
   for (int i = 0; i < Motors[0].Steps; i++) { // Anzahl Takte, die der Motor mit den meisten Schritten benötigt.


   // ----- TAKT ----->>>>

      Motors[0].Step(); // Der Motor mit den meisten Schritten wird in jedem Takt bewegt.

      for (int k = 1; k < Motors.Length; k++) { // Alle anderen Motoren prüfen, ob sie in diesem Takt bewegt werden müssen.
         Motors[k].prod1 += Motors[k].Steps;

         if (Motors[k].prod1 > Motors[k].prod2) { // Nachlaufender Motor muss bewegt werden.
            Motors[k].Step();
            Motors[k].prod2 += Motors[0].Steps;
         }
      }
   }
}

In einer echten Motorsteuerung muss diese Methode geteilt werden. Der obere Teil dient der Initialisierung. Der untere Teil (ab „TAKT”) muss durch einen Zeitgeber angesteuert werden, der die Schrittfrequenz für den am schnellsten laufenden Motor vorgibt, d. h. für den Motor, der die meisten Schritte ausführen muss.

Angesteuert wird die Methode mit einem Array von MotorControl-Objekten:

MultiMotorControl([new MotorControl(motorX, x),
                   new MotorControl(motorU, u),
                   new MotorControl(motorV, v),
                   new MotorControl(motorW, w),
                   new MotorControl(motorQ, q)
                   ]);

motorX, motorU etc. sind die zu synchronisierenden Motoren. x, u etc. die jeweils auszuführenden Schritte.

Implementierung in C++

Die benutzbare Implementierung in C++ ist etwas aufwändiger. Die Klassen Motor und MotorControl entsprechenden den in C# (s.o.).

Klasse MultiMotorControl

Die Klasse MultiMotorControl stellt die Methoden zur Steuerung der Methoden bereit:

Klasse MultiMotorControl

Der Methode init wird ein Array mit den zu steuernden Motoren übergeben. Diese werden jeweils durch ein MotorControl-Objekt repräsentiert. init sortiert das Array und berechnet die konstanten Prozesswerte. Alternativ kann init mit einem std::vector<MotorControl> versorgt werden.

Die Methode start startet den intern definierten Timer vom Typ MotorControlTicker, der die gewünschte Anzahl an Taktimpulsen liefert. Der Methode wird die Verzögerungszeit zwischen den einzelnen Schritten übergeben. Alternativ könnte eine Frequenzangabe implementiert werden.

isRunning und getRemainingSteps liefern Informationen über den Fortschritt. Mit stop kann der Vorgang vorzeitig beendet werden.

Das interne Feld motors speichert die übergebenen MotorControl-Objekte. motorControlTicker ist der Timer für die Taktimpulse.

Beim Start wird dem Timer ein Verweis auf die eigene Instanz übergeben. Er hat die Aufgabe, bei jedem Taktimpuls die Methode step aufzurufen, die bei jedem Aufruf einen Schritt beim Motor ausführt, der am schnellsten laufen muss, d. h. der mit den meisten auszuführenden Schritten. Falls notwendig, wird auch ein Schritt bei den weiteren Motoren ausgeführt.

Über die Methode hasFinished kann einmalig abgerufen werden, ob die aktuelle Aufgabe beendet ist. Nach Erledigung der Aufgabe wird intern ein Flag gesetzt, dass bei Abruf über hasFinished wieder zurück gesetzt wird. Wenn hasFinished true zurück liefert, kann z.B. ein Ereignis ausgelöst werden oder mit einem nächsten Programmschritt fortgefahren werden.

Klasse MotorControlTicker

Ein Objekt dieser Klasse liefert die notwendigen Taktimpulse. Der zu ergänzende physikalische Timer hängt von der benutzten Hardware und deren Firmware ab. Der Methode start wird die Verzögerungszeit zwischen den einzelnen Schritten, die Anzahl der auszuführenden Schritte (ableitet von der Schrittzahl des Motors mit den meisten auszuführenden Schritten) und ein Verweis auf das zugehörige MultiMotorControl-Objekt, dessen Methode step bei den Taktimpulsen aufgerufen werden soll.

/**
* Startet den Ticker mit der angegebenen Verzögerungszeit und Anzahl der Schritte.
*
* @param delay Die Verzögerungszeit in Millisekunden.
* @param StepCount Die Anzahl der Schritte, die der Ticker ausführen soll.
* @param callback Zeiger auf die Callback-Funktion, die aufgerufen wird, wenn der Ticker abläuft.
*/
void MotorControlTicker::start(unsigned int delay, unsigned int stepCount, MultiMotorControl* controller) {
   this->delay = delay;
   this->stepCount = stepCount;
   this->controller = controller;
   running = true; // Ticker als laufend markieren.
   elapsedSteps = 0; // Zurücksetzen der bereits abgelaufenen Schritte.

   // Hier muss ein Timer oder eine Schleife implementiert werden, der die tick()-Methode regelmäßig aufruft.
   // -------------------------------------------------------------------------------------------------------
   // RealTimer.Start(...);
}

Der reale Timer muss bei jedem Taktimpuls die Methode tick aufrufen, die, wie oben gefordert step aufruft und weiterhin die Abbruchbedingung überprüft.

void MotorControlTicker::tick() {
   controller->step(); // Führt einen Schritt in der Steuerung der Motoren aus.
   elapsedSteps++; // Erhöhen der Anzahl der abgelaufenen Schritte.
   if (elapsedSteps >= stepCount) {
      stop(); // Stoppen des Tickers, wenn die gewünschte Anzahl von Schritten erreicht ist.
   }
}

Download Projekt MultiMotorControl

Das MultiMotorControl.zip enthält die Visual Studio-Solution zu diesem Programm. Es enthält im Projekt MultiMotorControlCS das C# Programm und im Projekt MultiMotorControlCpp die für μC einsetzbare Methode DualMotorControl in C++.

Arduino- (ESP32-) Bibliothek

Das ZIP-Archiv UrsMultiMotorControl.zip enthält den Code für die Ansteuerung mehrerer Schrittmethoden. Beim Praxiseinsatz hat sich gezeigt, dass einige Änderungen an der Struktur notwendig waren:

Steuerung von Schrittmotoren vom Typ 28BVJ-48

Im Projekt Android steuert einen Schrittmotor über einen ESP32 wird eine Bibliothek vorgestellt, die einen Schrittmotor vom Typ 28BVJ-48 ansteuert.

Schrittmotor vom Typ 28BYJ-48

Um mehrere Motoren dieses Typs anzusteuern wird eine Schnittstelle (Adapter, Wrapper) benötigt, die die beiden Bibliotheken miteinander verbindet. Dies erledigt die Klasse MotorWrapper. Sie wird überall anstatt der Klassen M28BYJ_48 oder Motor verwendet.

Klasse MotorWrapper

Es wird eine interne Wrapper-Klasse M28BYJ_48Wrapper für die Klasse M28BYJ_48 verwendet, die den Zugriff auf die geschützten Methoden M28BYJ_48::internalStepCW und M28BYJ_48::internalStepCCW ermöglicht. Diese sind bereits für die Nutzung in einer ISR vorbereitet.

Klasse M28BYJ_48Wrapper
class M28BYJ_48Wrapper : public M28BYJ_48 {
public:
   M28BYJ_48Wrapper(uint8_t pin1, uint8_t pin2, uint8_t pin3, uint8_t pin4, uint8_t gearClearance)
      : M28BYJ_48(pin1, pin2, pin3, pin4, gearClearance) {
   }

   void ARDUINO_ISR_ATTR stepCW() {
      M28BYJ_48::internalStepCW();
   }

   void ARDUINO_ISR_ATTR stepCCW() {
      M28BYJ_48::internalStepCCW();
   }
};

Die Klasse MotorWrapper selbst ist von der Klasse Motor abgeleitet, die elementaren Funktionen für die Bibliothek UrsMultiMotorControl bereit stellt. Dies sind die Methoden setDirection und step. Diese werden so überschrieben, dass sie einerseits die entsprechende Methode in der Basisklasse und die in der Klasse M28BYJ_48 bedienen. Hier das Beispiel für die Methode step:

// Bewegt den Motor um einen Schritt in die angegebene Richtung
virtual void ARDUINO_ISR_ATTR step() override {
   Motor::step(); // Aktualisiert die Position basierend auf der Richtung
   if (direction > 0) {
      motor->stepCW();
   }
   else if (direction < 0) {
      motor->stepCCW();
   }
}

Darüber hinaus stellt sie sämtliche Methoden bereit, die für den Betrieb eines 28BYJ-48 notwendig sind. Zusätzlich ist eine Umkehrung der Bewegungsrichtung möglich. Normalerweise ist die Drehung im Uhrzeigersinn mit einer negativen, die im Gegenuhrzeigersinn mit einem positiven Wert verbunden. Dies kann umgekehrt werden.

// Wrapper-Klasse, die die Motor-Klasse auf die Klasse M28BYJ_48 abbildet.
// Diese Klasse ermöglicht es, die Motor-Klasse als Abstraktion zu verwenden, 
// während die tatsächliche Steuerung des Motors über die M28BYJ_48-Klasse erfolgt.
class MotorWrapper : public Motor {
public:
   class M28BYJ_48Wrapper : public M28BYJ_48 {
   // ... (s.o.)
   };

   /** Konstruktor, der die Motor-Instanz mit den entsprechenden Pins und Einstellungen initialisiert.
   * @param pin1..pin4 Die Pins des Motors.
   * @param gearClearance Das Getriebeübersetzungsverhältnis des Motors.
   * @param dirChange Gibt an, ob die Richtung des Motors umgekehrt werden soll (true) oder nicht (false).  
   */
   MotorWrapper(uint8_t pin1, uint8_t pin2, uint8_t pin3, uint8_t pin4, uint8_t gearClearance, bool dirChange) {
      motor = new M28BYJ_48Wrapper(pin1, pin2, pin3, pin4, gearClearance);
      directionChange = dirChange;
   }

   // Initialisiert den Motor
   void begin() {
      motor->begin();
   }

   // Deinitialisiert den Motor
   void end() {
      motor->end();
   }

   // Setzt die Richtung des Motors und aktualisiert die Richtung in der M28BYJ_48-Instanz entsprechend.
   void setDirection(int dir) override {
      if (directionChange)
         dir = -dir;

      Motor::setDirection(dir); // Aktualisiert die Richtung in der Basisklasse
      if (direction >= 0) 
         motor->setDirection(Direction::CW);
      else 
         motor->setDirection(Direction::CCW);
   }

   // Bewegt den Motor um einen Schritt in die angegebene Richtung
   virtual void ARDUINO_ISR_ATTR step() override {
      Motor::step(); // Aktualisiert die Position basierend auf der Richtung
      if (direction > 0)
          motor->stepCW();
      else 
          motor->stepCCW();
   }

   // Setzt die Position des Motors zurück auf 0.
   void resetPosition() {
      Motor::resetPosition();
   }

protected:
   M28BYJ_48Wrapper* motor;
   bool directionChange; // Gibt an, ob die Richtung des Motors umgekehrt werden soll (true) oder nicht (false).
};

Rasterung beliebiger Funktionen

Die obigen Algorithmen sind optimiert für das Zeichnen von geraden Linien. Es gibt ähnlich optimierte Methoden für Kreise, Ellipsen, Bezier-Kurven oder Splines. Algorithmen dazu findet man bei Alois Zingl: A Rasterizing Algorithm for Drawing Curves. Alle lassen sich auf einfachen Mikrocontrollern ausführen.

Wenn man beliebige Funktionen rastern möchte, kommt man nicht umhin, die Funktionswerte zu berechnen. Je nach Funktion ist der Berechnungsaufwand erheblich und übersteigt die Fähigkeiten eines µC.

Die folgende Grafiken zeigen das Prinzip:

Rasterung einer beliebigen Funktion Die Schritte in X-Richtung haben immer den Wert 1.

Zu jeder Stelle x wird der Funktionswert f(x) berechnet und auf die nächste Ganzzahl gerundet. Sowohl der X- als auch der Y-Wert liegen nun als Ganzzahlen vor und lassen sich eindeutig einem Pixel zuordnen. Ist die Steigung der Funktion an der Stelle x größer als 1, werden Pixel in Y-Richtung übersprungen. Es entstehen Lücken (grüner Kreis). Diese müssen gefüllt werden.
Algorithmus zur Rasterung Es wird untersucht, wie sich der Funktionswert ändert, wenn x um eine Position weiter geschoben wird.

Im einfachsten Fall ändert sich y nicht (Situation an den Punkten A). Es wird das Pixel neben dem aktuellen Pixel gesetzt (A0→A1).

Andernfalls muss y je nach Steigung (y1-y) ggf. mehrfach erhöht oder erniedrigt werden, bis der Funktionswert erreicht wird.

Der C#-Code hierzu ist nicht kompliziert:

 public delegate int Function(int x); // Deklaration der Funktionsstruktur


// Zeichnet die Funktion f im Intervall [x0, xe]
void drawFunction(Function f, int x0, int xe, Color color) {
   if (xe < x0)
      throw new Exception("Endwert < Startwert");

   int x = x0; int y = f(x0);
   setPixel(x, y, color); // Startpunkt

   while (x < xe) { // Über alle X-Werte
      x += 1; int y1 = f(x);

      if (y1 - y == 0) {  // Funktionswert ist gleich geblieben
         setPixel(x, y, color);
         continue;
      }

      int dy = Math.Sign(y1 - y); // dy ist +/- 1, je nach Steigung der Funktion
      while (y != y1) { // y solange erhöhen, bis Funktionswert erreicht.
         y += dy;
         setPixel(x, y, color);
      }
   }
}

Beispiel:

Funktionen:

int Parabel(int x) {
   return x * x / 100;
}

int Sinus(int x) {
   return (int)(100 * Math.Sin((double)x / 50) + 150);
}

int SinusDown(int x) { // Abklinger Sinus
   return (int)(100 * Math.Sin((double)x / 5) * Math.Exp((double)-x / 100) + 150);
}

int SawtoothWave(int x) { // Sägezahn
   return x % 20 + 50;
}

Die konstanten Faktoren und Summanden dienen der Anpassung an die Größe der Bitmap (300 x 300 Pixel).

drawFunction(Parabel, 0, bmp.Width, Color.Black);
drawFunction(Sinus, 0, bmp.Width, Color.Red);
drawFunction(SinusDown, 0, bmp.Width, Color.Green);
drawFunction(SawtoothWave, 0, bmp.Width, Color.Orange);

Ergebnis:

Rasterung der Besipielfunktionen (vergrößert)

Polarkoordinaten

In einem anderen Projekt wird der Winkel eines Arms und dessen Länge über zwei Schrittmotoren geregelt.

Projekt Zeichnen mit Poarkoordinaten

Hier ist es sinnvoll mit Polarkoordinaten zu arbeiten. Die Laufvariable ist in diesem Fall der Winkel und die Abhängige Variable ist der Radius: r = f(φ).

Bei Jürgen Köller (Mathematische Basteleien) findet man unter Kurven im Polarkoordinatensystem eine Menge Anregungen. Zum Beispiel liefert die Funktion f(φ) = sin(5 * φ) folgendes Bild:

Rasterung der Funktion f(φ) = sin(5 * φ)

Man kann sehen, dass die Linien hier Lücken aufweisen. Das liegt an der Umrechnung von Polar- in kartesische Koordinaten.

Funktionen mit mehr als einem Parameter

Die oben benutzte Funktion kann man schrittweise drehen. Dann erhält man eine schöne Rosette:

Rosette

Die zugehörige Funktion ist f(φ) = sin(5 * φ + η), mit η = Phasenwinkel. Am einfachsten lässt sich dies realisieren indem Funktion und Parameter in einer eigenen Klasse implementiert:

// Funktionsklasse zum Zeichnen einer Rosette
class Rosette {
   double phase = 0; // Phasenverschiebung

   // Pahse festlegen
   public void SetPhase(double newPhase) {
      phase = newPhase;
   }

   // Funktionswert berechnen
   public int F(int phi) {
      double phiRad = (double)phi * Math.PI / 180; // Umrechnung von Grad in Bogenmaß
      return (int)(Math.Sin(5 * phiRad + phase) * 150);  // sin²(x + phase);
   }
}

Der Aufruf erfolgt dann innerhalb einer Schleife, in der die Parameter variiert werden:

Rosette rosette = new Rosette();

int counts = 5;
for (int phaseIndex = 0; phaseIndex < counts; phaseIndex++) {
   rosette.setPhase(phaseIndex * 360 / counts * Math.PI / 180); // Phasenverschiebung in Bogenmaß
   DrawPolarFunction(rosette.F, 0, 360, Color.Yellow, bmp3);
}

Beispiel Ellipsen

Ellipsen lasen sich in Polarkoordinaten ebenfalls gut darstellen. Die Klasse Ellipse stellt die Funktionalitäten dazu bereit:

/// <summary>
/// Erzeugt die Funktionswerte für eine Ellipse
/// </summary>
/// <remarks>https://www.aleph1.info/?call=Puc&permalink=ellipsen1_1_6_Z1</remarks>
public class Ellipse {
   double phase = 0; // Phasenverschiebung
   double b = 0;     // kleine Halbachse

   double equad = 0; // e = a² - b²
   double ε = 0;     // ε = √e / a

   /// <summary>
   /// Initialisiert eine neue Instanz der Klasse Ellipse.
   /// </summary>
   /// <param name="a">Große Halbachse.</param>
   /// <param name="b">Kleine Halbachse</param>
   /// <param name="phase">Phase in Grad.</param>
   public Ellipse(double a, double b, int phase) {
      if (a <= 0 || b <= 0 || b > a)
         throw new Exception("ungültige Paramter");

      this.phase = (double)phase * Math.PI / 180;
      this.b = b;

      equad = a * a - b * b;
      ε = Math.Sqrt(equad) / a;
   }

   /// <summary>
   /// Berechnet den Funktionwert (Radius) zum angegebenen Winkel.
   /// </summary>
   /// <param name="phi">Winkel in Grad.</param>
   /// <returns>Funktionwert (Radius) zum angegebenen Winkel.</returns>
   public int F(int phi) {
      double phiRad = (double)phi * Math.PI / 180; // Umrechnung von Grad in Bogenmaß
      double sinPhi = Math.Sin(phiRad + phase);
      double r = b / Math.Sqrt(1 - ε * ε * sinPhi * sinPhi);
      return (int)r;
   }
}

Die Laufvariable ist wieder der Winkel. Dem Konstruktor der Klasse werden die weiteren Parameter übergeben: die lange und die kurze Halbachse sowie ein Phasenwert, mit dem sich die Ellipse drehen lässt. Beide Achsen müssen einen Wert größer als 0 besitzen, wobei die lange Halbachse größer als die kurze sein muss.

Dort werden auch einige Werte vorab ermittelt, die für die weitere Berechnung notwendig sind.

Ellipse   Rosette aus Ellipsen
Ellipse ellipse = new Ellipse(100, 50, 0);
DrawPolarFunction(ellipse.F, 0, 360);
counts = 5;
for (int phaseIndex = 0; phaseIndex < counts; phaseIndex++) {
   ellipse = new Ellipse(100, 50, phaseIndex * 360 / counts);
   DrawPolarFunction(ellipse.F, 0, 360);
}

Die Rosette, die mit den trigonometrischen Funktionen gezeichnet werden, gehen durch den Mittelpunkt, während die, die mit der Ellipsenfunktion gezeichnet werden, den zentralen Bereich frei lassen.

Schrittmotorsteuerung (C#)

Um die Schrittmotorsteuerung zu realisieren, muss die Funktion wieder in einen Initialisierungsteil und eine Schrittfunktion zerlegt werden. Letztere wird von einem Timer mit der passenden Frequenz aufgerufen. Die Schrittfunktion liefert außerdem eine Rückmeldung, ob die Zielposition bereits erreicht wurde.

Die Realisierung erfolgt über die separate Klasse Stepper. Der Konstruktor übernimmt die Parameter der zu zeichnenden Funktion. Dies ist ein Zeiger (Delegat) auf eine Funktion vom Typ int f(int x). Auch hier sind wieder nur ganzzahlige Werte zulässig. Zudem werden der Start- und der Endwert für die Laufvariable benötigt. Für die grafische Version müssen außerdem die Farbe, mit der gezeichnet werden soll, und die Bitmap, auf die gezeichnet werden soll, angegeben werden.

/// <summary>
/// Dient zum Zeichnen einer Funktion auf einer Bitmap.
/// </summary>
public class Stepper {
   public delegate int Function(int x); // Definition des Funktionstyps

   Function f;  // Funktion, die gezeichnet werden soll.
   int x;       // Laufvariable.
   int xe;      // Endwert.
   int y;       // Aktueller y-Wert.
   int y1;      // Zielwert für y bei aktuellem x.

   /// <summary>
   /// Initialisiert eine neue Instanz der Stepper-Klasse.
   /// </summary>
   /// <param name="f">Die zu zeichnende Funktion.</param>
   /// <param name="x0">Startwert für x.</param>
   /// <param name="xe">Endwert für x.</param>
   /// <exception cref="Exception">Startwert ist größer als der Endwert.</exception>
   public Stepper(Function f, int x0, int xe) {
      if (xe < x0)
         throw new Exception("Endwert < Startwert");

      this.f = f;
      x = x0;
      this.xe = xe;
      y = f(x); y1 = y; // Ausgangsposition
   }

Die Funktion Step approximiert die Funktionswerte und steuert die Schrittmotoren an.

/// <summary>
/// Führt einen einzelnen Schritt aus.
/// </summary>
/// <returns>false, wenn noch weiter Schritte auszuführen sind.</returns>
public bool Step() {
   int dy;

   if (y1 - y == 0) {
      x += 1; 
      y1 = f(x); // Schritt in x-Richtung
      MotorX.Step(1);
   }

   dy = Signum(y1 - y); // dy ist 0, +/- 1, je nach Steigung der Funktion
   y += dy;
   MotorY.Step(dy); // dy kann 0 sein!

   return x == xe && y == y1;
}

Zeitverhalten

Das Ganze sieht recht unkompliziert aus. Warum gibt es dann spezielle Funktionen (Stichwort Bresenham) für Linien, Kreise, etc.? Der Casus Knacksus ist die Zeile y1 = f(x). Die Berechnung des Funktionswertes kann recht aufwendig sein. Die Zeit für einen Schrittmotorschritt ist jedoch i.d.R. deutlich größer als die Berechnung des Funktionswerts. Auf einem ESP32 dauert die Ausführung eines einzelnen Schleifendurchlaufs der mit der folgenden, schon recht komplexen Funktion nur 1,72 μs.

for (float x = 0; x < 5.0; x += 0.001) {
   count++;
   result += sin(3.1415 / 180 * x) * exp(x);
}