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/utypeprü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, obwohlVelEcietwas anderes bedeutet — und selbstnimmA(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 |
qund−qbeschreiben dieselbe Drehung. Ein Vergleich mitQuatApproxEqmeldet sie als verschieden; dafür gibt esQuatSameRotation. 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 mitsema error: Initialisierung: anderer benannter Typab, und dasselbe gilt beim Übergeben als Argument (Argument: anderer benannter Typ).
Für Bezugsrahmen hilft das trotzdem nur bedingt: werpos_eciundpos_ecefals denselben Typ führt (etwa beide alsAVec3), 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.
QuatNormalizenach jeder Verkettung,QuatIsUnit(q, eps)als Prüfung im Selbsttest. acosgegen Rundung absichern.Vec3Dotzweier Einheitsvektoren kann1.0000000002ergeben —AcosF64liefert 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..359oderrange -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.
@redundantschützt einzelne Werte; vier Komponenten dreifach zu führen ergibt keine gültige Drehung, wenn eine Kopie kippt. Sinnvoller ist die PrüfungQuatIsUnitplus 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).
