====== 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. → [[lyx_-_programmiersprache:units:astro|std.astro]] (vorgesehen: Bahnmechanik, Lage, Guidance) · [[lyx_-_programmiersprache:units:quat|std.quat]] · [[lyx_-_programmiersprache:units:matrix|std.matrix]] · [[lyx_-_programmiersprache:units:mathematik|Mathematik-Units]] · [[lyx_-_programmiersprache:guides:aerospace-safety|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** ([[https://github.com/SEOLizer/LyX-Compiler/issues/1886|#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** ([[https://github.com/SEOLizer/LyX-Compiler/issues/1886|#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 [[lyx_-_programmiersprache:sprache:datentypen|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 ([[https://github.com/SEOLizer/LyX-Compiler/issues/1886|#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).