Kurs und Lage im dreidimensionalen Raum

Am Boden reicht eine Zahl: ein Kurs von 0 bis 359 Grad. Im Raum gibt es keine ausgezeichnete Ebene, an der man diese Zahl aufhängen könnte — dort braucht es zwei getrennte Größen:

Größe Frage Darstellung
Richtung Wohin fliegt das Fahrzeug? Einheitsvektor Vec3 im Bezugsrahmen
Lage (Attitude) Wie ist es dabei gedreht? Quaternion Quat

Beides ist verschieden: Ein Raumfahrzeug kann rückwärts fliegen und dabei vorwärts blicken. Wer beides in einer Größe führt, verliert genau diese Unterscheidung.

std.astro (vorgesehen: Bahnmechanik, Lage, Guidance) · std.quat · std.matrix · Mathematik-Units · Aerospace & Safety


1. Zuerst den Bezugsrahmen festlegen

Ein Vektor ohne Bezugsrahmen ist bedeutungslos. Üblich sind:

Rahmen Ursprung Achsen Wofür
ECI (inertial) Erdmittelpunkt x zum Frühlingspunkt, z zum Nordpol Bahnmechanik — dreht sich nicht mit der Erde
ECEF Erdmittelpunkt mitrotierend Positionen über Grund, GNSS
LVLH Fahrzeug z nach unten (Nadir), x in Flugrichtung Lageregelung im Orbit
Body Fahrzeug fest am Rumpf Sensoren, Triebwerke

Der Rahmen gehört in den Namen, nicht in einen Kommentar: pos_eci, nase_body. Eine Verwechslung zwischen ECI und ECEF ist kein Rundungsfehler, sondern ein Kurs, der sich pro Stunde um 15 Grad verdreht.

 
Der Compiler kann die Trennung heute nicht erzwingen — die Namensgebung trägt die Last allein.

* dim/utype prüfen die Dimension (Länge gegen Zeit), nicht den Rahmen: ECI und ECEF sind beide Längen.
* Ein eigener Struct je Rahmen hilft ebenfalls nicht: Struct-Typen werden bei Zuweisung und Argumentübergabe gar nicht geprüft (#1886). var p: PosEci := vel; übersetzt anstandslos, obwohl VelEci etwas anderes bedeutet — und selbst nimmA(c) mit einem völlig anders geformten Struct läuft durch, mit dem Zeiger als Zahl gelesen. Nachgemessen mit 1.1.14A.

Solange das so ist: Rahmen in den Namen (pos_eci, nase_body), Umrechnungen in eine Funktion je Rahmenpaar bündeln, und diese Funktionen prüfen (Testfall mit bekanntem Ergebnis). Sobald #1886 behoben ist, wird der eigene Struct je Rahmen zur tragenden Lösung.

2. Warum keine Euler-Winkel

Die naheliegende Idee — drei Winkel für Roll, Nick und Gier, jeder als wraps 0..359 — bricht an einer Stelle zusammen, die im Raum regelmäßig vorkommt: bei ±90 Grad Nickwinkel fallen zwei Drehachsen zusammen (Gimbal Lock). Gemessen mit lyxc 1.1.14A gegen std.quat:

import std.quat;
import std.io;

fn zeige(name: pchar, q: Quat): void {
    var g: f64 := 180.0 / 3.14159265358979;
    Print(name); Print(" roll="); Print(FloatToStr(QuatRoll(q)*g, 2));
    Print(" pitch="); Print(FloatToStr(QuatPitch(q)*g, 2));
    Print(" yaw="); PrintLn(FloatToStr(QuatYaw(q)*g, 2));
}

fn main(): int64 {
    var r: f64 := 3.14159265358979 / 180.0;
    zeige("(30,45,60) ->", QuatFromEuler(30.0*r, 45.0*r, 60.0*r));
    zeige("(30,89,0)  ->", QuatFromEuler(30.0*r, 89.0*r,  0.0*r));
    zeige("(30,90,0)  ->", QuatFromEuler(30.0*r, 90.0*r,  0.0*r));
    return 0;
}

(30,45,60) -> roll=30.00 pitch=45.00 yaw=60.00
(30,89,0)  -> roll=30.00 pitch=89.00 yaw=0.00
(30,90,0)  -> roll=31.14 pitch=90.00 yaw=2.05

Bis 89 Grad stimmt die Rückrechnung auf die Nachkommastelle. Bei genau 90 Grad kommen aus (30, 90, 0) plötzlich (31.14, 90, 2.05) heraus — nicht weil die Bibliothek rechnet, sondern weil die Zerlegung dort nicht mehr eindeutig ist: Roll und Gier drehen um dieselbe Achse, unendlich viele Winkelpaare beschreiben dieselbe Lage.

Für ein Flugzeug ist das eine Randlage. Für ein Raumfahrzeug ist „Nase senkrecht zur Bahnebene„ ein Betriebszustand.

Euler-Winkel deshalb nur an der Oberfläche — zum Anzeigen, im Protokoll, als Eingabe. Gerechnet wird mit Quaternionen.


3. Lage als Quaternion, Kurs als Vektor

import std.quat;
import std.matrix;
import std.math;
import std.io;

// Bezugsrahmen: ECI, x = Fruehlingspunkt, z = Nordpol, rechtshaendig.

fn richtung(q: Quat): Vec3 {
    // Wohin zeigt die Nase? Koerpereigene x-Achse in den Bezugsrahmen gedreht.
    var r: Quat := QuatRotateVec3(q, 1.0, 0.0, 0.0);
    return Vec3New(r.x, r.y, r.z);
}

fn winkelZwischen(a: Vec3, b: Vec3): f64 {
    var c: f64 := Vec3Dot(Vec3Normalize(a), Vec3Normalize(b));
    if (c > 1.0)  { c := 1.0; }              // Rundung abfangen: acos(1.0000001) ist NaN
    if (c < -1.0) { c := 0.0 - 1.0; }
    return AcosF64(c) * 180.0 / 3.14159265358979;
}

fn zeige(name: pchar, v: Vec3): void {
    Print(name); Print(" ("); Print(FloatToStr(v.x, 3));
    Print(", "); Print(FloatToStr(v.y, 3));
    Print(", "); Print(FloatToStr(v.z, 3)); PrintLn(")");
}

fn main(): int64 {
    var lage: Quat := QuatIdentity();
    zeige("Ist-Richtung :", richtung(lage));

    var soll: Vec3 := Vec3Normalize(Vec3New(0.866, 0.5, 0.174));
    zeige("Soll-Richtung:", soll);
    Print("Winkelabstand: ");
    PrintLn(FloatToStr(winkelZwischen(richtung(lage), soll), 2));

    // Kuerzeste Drehung dorthin, in fuenf Schritten hinuebergedreht
    var ziel: Quat := QuatFromTo(1.0, 0.0, 0.0, soll.x, soll.y, soll.z);
    var i: int64 := 1;
    while (i <= 5) {
        var t: f64 := (i as f64) / 5.0;
        var zwischen: Quat := QuatSlerp(lage, ziel, t);
        Print("  Schritt "); Print(IntToStr(i)); Print(": Restwinkel ");
        PrintLn(FloatToStr(winkelZwischen(richtung(zwischen), soll), 2));
        i := i + 1;
    }
    return 0;
}

Ist-Richtung : (1.000, 0.000, 0.000)
Soll-Richtung: (0.853, 0.493, 0.171)
Winkelabstand: 31.44
  Schritt 1: Restwinkel 25.15
  Schritt 2: Restwinkel 18.86
  Schritt 3: Restwinkel 12.58
  Schritt 4: Restwinkel 6.29
  Schritt 5: Restwinkel 0.00

Der Restwinkel fällt gleichmäßig — das ist die Eigenschaft, für die es QuatSlerp gibt: die Drehung läuft auf dem kürzesten Bogen und mit gleichbleibender Winkelgeschwindigkeit. QuatNlerp ist billiger, aber ungleichmäßig; für eine Lageregelung ist der Unterschied sichtbar.


4. Die Werkzeuge im Überblick

Aufgabe Funktion
Lage aus Achse und Winkel QuatFromAxisAngle(ax, ay, az, winkel)
Kürzeste Drehung von einer Richtung zur anderen QuatFromTo(ax,ay,az, bx,by,bz)
Vektor mit der Lage drehen QuatRotateVec3(q, x, y, z)
Zwei Lagen gleichmäßig überblenden QuatSlerp(a, b, t)
Lagen verketten (erst a, dann b) QuatMul(b, a)Reihenfolge beachten
Drehung rückgängig QuatConj(q) bei Einheitsquaternion, sonst QuatInverse(q)
Nur zum Anzeigen QuatRoll, QuatPitch, QuatYaw
Sind zwei Lagen dieselbe Drehung? QuatSameRotation(a, b, eps)
Richtung, Abstand, Kreuzprodukt Vec3Normalize, Vec3Dot, Vec3Cross aus std.matrix
 
q und −q beschreiben dieselbe Drehung. Ein Vergleich mit QuatApproxEq meldet sie als verschieden; dafür gibt es QuatSameRotation. Das ist keine Eigenart der Bibliothek, sondern die doppelte Überdeckung der Drehgruppe — dieselbe Lage hat zwei Zahlendarstellungen.

4b. Den Zustand als eigenen Datentyp

Richtung und Lage einzeln herumzureichen ist fehleranfällig — beide gehören zusammen und zu einem Zeitpunkt. Ein Zustandsvektor bündelt das:

import std.quat;
import std.matrix;
import std.io;

// Bezugsrahmen im Namen, nicht im Kommentar (siehe Abschnitt 1)
type PosEci = struct { v: Vec3; }
type VelEci = struct { v: Vec3; }

type Zustand = struct {
    pos:   PosEci;      // Meter, ECI
    vel:   VelEci;      // Meter je Sekunde, ECI
    lage:  Quat;        // Body -> ECI, Einheitsquaternion
    t_ns:  int64;       // Gueltigkeitszeitpunkt, monoton

    // Wohin zeigt die Nase? Koerpereigene x-Achse in den Rahmen gedreht.
    fn Richtung(): Vec3 {
        var r: Quat := QuatRotateVec3(self.lage, 1.0, 0.0, 0.0);
        return Vec3New(r.x, r.y, r.z);
    }

    // Selbstprüfung: eine Lage, die keine Einheitslaenge hat, ist keine Drehung.
    fn LageGueltig(): bool { return QuatIsUnit(self.lage, 0.000001); }
}

fn main(): int64 {
    var z: Zustand;
    z.pos.v := Vec3New(7000000.0, 0.0, 0.0);
    z.vel.v := Vec3New(0.0, 7500.0, 0.0);
    z.lage  := QuatIdentity();
    z.t_ns  := 0;

    var r: Vec3 := z.Richtung();
    Print("Richtung x=");   PrintLn(FloatToStr(r.x, 3));
    Print("Lage gueltig="); PrintLn(IntToStr(BoolI(z.LageGueltig())));
    return 0;
}

fn BoolI(b: bool): int64 { if (b) { return 1; } return 0; }

Richtung x=1.000
Lage gueltig=1

Vier Entwurfsentscheidungen dahinter:

  • Der Zeitpunkt gehört in den Zustand. Eine Lage ohne Zeitstempel lässt sich nicht mit einer Messung von einem anderen Sensor zusammenbringen — und genau das ist die Hauptaufgabe einer Lagebestimmung.
  • Die Lage ist ein Quaternion, kein Winkeltripel. Zum Anzeigen rechnet man am Rand um (Abschnitt 2).
  • Die Gültigkeitsprüfung gehört an den Typ, nicht in den Aufrufer: LageGueltig() ist die eine Stelle, an der die Einheitslänge geprüft wird.
  • Struct statt Klasse: kein Heap, keine dispose-Pflicht, Kopie beim Zuweisen — passend für einen Wert, der pro Regelzyklus entsteht.
 
Struct-Typen werden inzwischen geprüft (#1886, nachgemessen mit lyxc 1.2.2B): var b: Q := a; mit zwei gleich aufgebauten, verschieden benannten Structs bricht mit sema error: Initialisierung: anderer benannter Typ ab, und dasselbe gilt beim Übergeben als Argument (Argument: anderer benannter Typ).

Für Bezugsrahmen hilft das trotzdem nur bedingt: wer pos_eci und pos_ecef als denselben Typ führt (etwa beide als AVec3), bekommt weiterhin keine Meldung. Die Trennung entsteht erst, wenn man je Rahmen einen eigenen benannten Typ anlegt — dann trägt sie der Compiler mit.

5. Was in sicherheitskritischem Code dazugehört

  • Normalisieren, und zwar regelmäßig. Jede Multiplikation verschiebt die Länge um ein paar Bit; nach tausend Regelzyklen ist die Lage schief. QuatNormalize nach jeder Verkettung, QuatIsUnit(q, eps) als Prüfung im Selbsttest.
  • acos gegen Rundung absichern. Vec3Dot zweier Einheitsvektoren kann 1.0000000002 ergeben — AcosF64 liefert dann NaN, und NaN pflanzt sich stumm durch die ganze Regelung fort. Das Klemmen auf [−1, 1] im Beispiel oben ist Pflicht, keine Vorsicht.
  • Winkel als Bereichstyp an der Schnittstelle. Was von außen kommt — Sollkurs, Grenzwinkel —, bekommt range 0..359 oder range -180..180; damit prüft der Compiler die Grenzen und der Laufzeit-Wächter bricht bei einem falschen Wert ab, statt ihn weiterzureichen.
  • Die Lage selbst nicht redundant halten wollen. @redundant schützt einzelne Werte; vier Komponenten dreifach zu führen ergibt keine gültige Drehung, wenn eine Kopie kippt. Sinnvoller ist die Prüfung QuatIsUnit plus eine zweite, unabhängig gerechnete Lagequelle (Sternsensor gegen Kreisel) — Redundanz auf Systemebene, nicht auf Bitebene.

Letzte Aktualisierung: 2026-09-05 (#1886, nachgemessen mit lyxc 1.2.2B) — Kasten gezogen: Struct-Typen werden geprüft; die Rahmenverwechslung bleibt nur ungeprüft, solange beide denselben Typ tragen.

Vorherige letzte Aktualisierung: 2026-08-30 — Abschnitt „Den Zustand als eigenen Datentyp“ ergänzt; dabei gemessen, dass Struct-Typen nicht geprüft werden (#1886) — der Rat „eigener Struct je Bezugsrahmen„ ist entsprechend richtiggestellt.

Zuvor 2026-08-30 — Seite neu angelegt; alle Beispiele gegen lyxc 1.1.14A gebaut und ausgeführt, die Gimbal-Lock-Zahlen gemessen (nicht gerechnet).