====== 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).