Neues Kinematics

This commit is contained in:
chk
2026-06-11 07:57:51 +02:00
parent efe04b731f
commit 05355facf1
8 changed files with 678 additions and 27 deletions

View File

@@ -131,7 +131,8 @@ Die Achszuordnung kann in `robot/TelnetSenderGRBL.js` durch Anpassung der Konstr
- `server/InputWS.js` - `server/InputWS.js`
- `server/InfoServer.js` - `server/InfoServer.js`
- `robot/RobotBase.js` — abstrakte Basisklasse / Interface-Vertrag (generische Infrastruktur) - `robot/RobotBase.js` — abstrakte Basisklasse / Interface-Vertrag (generische Infrastruktur)
- `robot/kinematics/Arm3SegmentLinearX.js` — konkrete Kinematik (Modell + Inverse/Vorwärts) - `robot/kinematics/Arm3SegmentLinearX.js` — konkrete Kinematik (Modell + Inverse/Vorwärts), Default-Arm
- `robot/kinematics/Arm3SegmentRotaryBase.js` — Kinematik für den Joy-IT „Grab-It" (Robot02), 5 Achsen + Greifer mit Drehbasis (`ROBOT_KINEMATICS=grabit`)
- `robot/KinematicsFactory.js` — wählt die Kinematik per Umgebungsvariable - `robot/KinematicsFactory.js` — wählt die Kinematik per Umgebungsvariable
- `robot/GCodeParser.js` — wandelt rohe Nachrichten in strukturierte Befehlsobjekte - `robot/GCodeParser.js` — wandelt rohe Nachrichten in strukturierte Befehlsobjekte
- `robot/RobotController.js` — wendet geparste Befehle auf das Modell an (Steuerlogik) - `robot/RobotController.js` — wendet geparste Befehle auf das Modell an (Steuerlogik)

View File

@@ -146,10 +146,29 @@ environment:
Erst wenn ein konkreter zweiter Roboter definiert ist. Erst wenn ein konkreter zweiter Roboter definiert ist.
- [ ] Physikalische Spezifikation dokumentieren (DOF, Achsen, Gelenkreihenfolge) **Umgesetzt: Joy-IT „Grab-It" (Robot02)** als `Arm3SegmentRotaryBase`.
- [ ] `robot/kinematics/<Name>.js` anlegen — nur die zwei Kinematik-Methoden
- [ ] RoundTrip-Tests für die neue Implementierung schreiben - [x] Physikalische Spezifikation dokumentieren (DOF, Achsen, Gelenkreihenfolge)
- [ ] Prüfen ob die 7 Motor-Slots ausreichen; falls nicht → `RobotMotorPosition` anpassen → im JSDoc von `robot/kinematics/Arm3SegmentRotaryBase.js`. 5 Achsen + Greifer:
Basis-Yaw, Schulter, Ellbogen, Handgelenk-Pitch, Handgelenk-Roll, Greifer.
- [x] `robot/kinematics/Arm3SegmentRotaryBase.js` anlegen — nur die zwei Kinematik-Methoden
- [x] RoundTrip-Tests für die neue Implementierung schreiben
(`test/Robot.GrabIt.RoundTrip.test.js`)
- [x] Prüfen ob die 7 Motor-Slots ausreichen → **ja**: 6 Slots belegt
(`xMotor, alpha, beta, a, b, eMotor`), `c` bleibt frei. `RobotMotorPosition`
unverändert.
- [x] In `KinematicsFactory` registriert (Bezeichner `arm3segmentrotarybase`,
Aliase `grabit` / `robot02`). Factory reicht jetzt das vollständige
`params`-Objekt als 4. Konstruktor-Argument durch (für `baseHeight`).
**Offene Punkte / Annahmen (kein Blocker, aber vor Echtbetrieb zu klären):**
- ⚠️ **5-DOF-Constraint:** `phi` (Hand-Azimut) ist an die Position gekoppelt
(= Basis-Drehung) und nicht frei. In der Inversen aus `atan2(y,x)` abgeleitet.
- ⚠️ **Segmentlängen sind Schätzwerte** (l1=105, l2=98, l3=100, baseHeight=110 mm),
abgeleitet aus Reichweite (300 mm) / Höhe (420 mm). Vor Echtbetrieb am Arm
messen und per `ROBOT_KINEMATICS_PARAMS` setzen.
- ⚠️ **Gelenkmodell** (Pitch/Roll am Handgelenk) folgt der Standardkonfiguration
dieser Arm-Klasse; gegen das physische Gerät / die Kalibrieranleitung prüfen.
--- ---

View File

@@ -55,11 +55,11 @@ Quellen: [Serial Protocol](http://wiki.fluidnc.com/en/support/serial_protocol),
## Designentscheidungen (festgeschrieben) ## Designentscheidungen (festgeschrieben)
**B3 — Umkehr-Kinematik ist disambiguierbar.** Global nicht eindeutig, aber im Arbeitsraum **B3 — Umkehr-Kinematik.** *Aktualisiert nach der Analyse (ToDo_9a):* Die **Port→Motor**-Rückrechnung,
dieses Roboters per **dokumentierter physikalischer Zusatzbedingung** auflösbar die der Sync braucht, ist linear und **eindeutig** — keine Zweig-Wahl nötig. Die Ellbogen-oben/unten-
(z. B. „Ellbogen höher als Hand" bzw. „Ellbogen hinter der x-Achse"). `motorStateFromPorts()` Mehrdeutigkeit betrifft nur die **kartesische** Inverskinematik `calculateAngles3D()` (Pose →
bekommt eine feste Zweig-Wahl-Regel; die exakte Vorzeichen-Konvention wird beim Herleiten Gelenkwinkel), die der Sync nicht verwendet. Falls dort je eine Disambiguierung gebraucht wird,
der Umkehrung gepinnt. gilt die physikalische Zusatzbedingung („Ellbogen höher als Hand" bzw. „hinter der x-Achse").
**B5 — Lockstep als abschaltbare Absicherung.** Durch die koordinierte Feedrate (ToDo_6a **B5 — Lockstep als abschaltbare Absicherung.** Durch die koordinierte Feedrate (ToDo_6a
`correct`) treffen ohnehin alle Achsen *gleichzeitig* am nächsten Ziel ein — Lockstep ist `correct`) treffen ohnehin alle Achsen *gleichzeitig* am nächsten Ziel ein — Lockstep ist
@@ -125,26 +125,42 @@ erstmals einen Promise zurückgeben/awaiten können.
--- ---
## Baustein für Paket 4 + 5: Rückabbildung Port → Motorwerte ## Baustein für Paket 4 + 5: Rückabbildung Port → Motorwerte — ✅ DURCHGERECHNET
> **Erledigt als Analyse.** Vollständige Herleitung: **`doc/ToDo_9a_PortRueckrechnung.md`**.
> Verifikation: **`test/Robot.PortInverse.test.js`** (15 Tests grün).
Beide folgenden Pakete brauchen denselben Baustein: aus den von GRBL gemeldeten Beide folgenden Pakete brauchen denselben Baustein: aus den von GRBL gemeldeten
`MPos`-Werten der drei Controller die **sieben Motorwerte des Roboters** rekonstruieren `MPos`-Werten der drei Controller die **sieben Motorwerte des Roboters** rekonstruieren
(`xMotor, alpha, beta, a, b, c, eMotor`). (`xMotor, alpha, beta, a, b, c, eMotor`).
Das ist die **Umkehrung von `portValue()`** (`robot/TelnetSenderGRBL.js`). `portValue()` **Ergebnis:** Für die produktive Verkabelung (`startRobot.js`) ist die Abbildung
bildet *eine Roboter-Achse → einen GRBL-Port-Wert* ab, dabei koppeln einige Ports mehrere Motorwerte → gesendete GRBL-Achswerte **linear und eindeutig umkehrbar** — auf Port-Ebene
Achsen (z. B. der z-Port der Hand mischt `c, b, z, y`). Die Rückrichtung muss diese gibt es **keine** Mehrdeutigkeit. `factorTurnLift`/`handOpenInMM` kommen in der produktiven
Kopplung **explizit auflösen** — sie ergibt sich nicht automatisch. Verkabelung gar nicht vor (nur in nicht-genutzten `portValue`-Zweigen).
- [ ] `motorStateFromPorts(portReadings)` definieren — algebraische Umkehrung von `portValue()` ```js
- Eingang: pro Sender die gelesenen Port-Werte (`{x, y, z}` Base, `{a}` Elbow, `{c, e, b}` Hand) // D = 180/π ; r = { base:{x,y,z}, elbow:{x}, hand:{x,y,z} }
- Ausgang: `{xMotor, alpha, beta, a, b, c, eMotor}` xMotor = r.base.x
- Grad→Rad zurückrechnen, `factorTurnLift`/`handOpenInMM` herausrechnen, gekoppelte Ports auflösen alpha = r.base.y / D
- **Zweig-Wahl (B3):** wo die Lösung mehrdeutig ist, die dokumentierte physikalische beta = (r.base.z + r.base.y) / D
Zusatzbedingung anwenden (z. B. „Ellbogen höher als Hand"). Konvention im Code festhalten. a = r.elbow.x / D
- [ ] **Round-Trip-Invariante** als Test: `portValue(motorStateFromPorts(p)) ≈ p` b = r.hand.z / D
- dasselbe Muster wie `test/Robot.Kinematics.RoundTrip.test.js` c = (r.hand.x + r.hand.z) / D
- schützt die Umkehrfunktion gegen Drift gegenüber `portValue()` eMotor = r.hand.y / D
```
> **B3 ist hier kein Thema.** Die Ellbogen-oben/unten-Mehrdeutigkeit steckt allein in der
> kartesischen Inverskinematik `calculateAngles3D()` (Pose → Gelenkwinkel). Der Sync nutzt
> diese Richtung nicht — er geht `MPos → Motorwerte → Vorwärtskinematik → Pose`, beide
> Schritte eindeutig. Der gesamte Sync-Pfad ist damit eindeutig.
Offen für die spätere **Umsetzung** (Paket 4, nicht mehr Analyse):
- [ ] `motorStateFromPorts()` aus der Analyse in den Produktiv-Code heben (Ort: Sender oder
Kinematik-Helfer) und im Sync verdrahten
- [ ] **Round-Trip-Invariante** als Dauer-Test mitführen: `portValue(motorStateFromPorts(p)) ≈ p`
— schützt gegen Drift, falls sich die Verkabelung in `startRobot.js` ändert
> Hinweis: Gelesen wird auf dem **aktiven** Sender `TelnetSenderGRBL` (im `data`-Handler, > Hinweis: Gelesen wird auf dem **aktiven** Sender `TelnetSenderGRBL` (im `data`-Handler,
> siehe Paket 1) — nicht auf `FluidNCClient.js`. > siehe Paket 1) — nicht auf `FluidNCClient.js`.
@@ -167,7 +183,7 @@ sonst nicht, wo der Roboter physisch wirklich steht.
im bisher synchronen Dispatch-Pfad. im bisher synchronen Dispatch-Pfad.
- [ ] Ablauf des Sync: - [ ] Ablauf des Sync:
1. an alle drei Sender einmalig `?` senden, je `MPos` aus der Antwort parsen (Paket 3) 1. an alle drei Sender einmalig `?` senden, je `MPos` aus der Antwort parsen (Paket 3)
2. `motorStateFromPorts(...)` → sieben Motorwerte rekonstruieren (Baustein oben, inkl. B3-Zweigwahl) 2. `motorStateFromPorts(...)` → sieben Motorwerte rekonstruieren (Baustein oben — linear/eindeutig, ToDo_9a)
3. diese auf den Roboter schreiben: `robot.xMotor/alpha/beta/a/b/c/eMotor = …` 3. diese auf den Roboter schreiben: `robot.xMotor/alpha/beta/a/b/c/eMotor = …`
4. **Vorwärtskinematik** anstoßen: `robot.calculatePositionFromMotorAngles()` 4. **Vorwärtskinematik** anstoßen: `robot.calculatePositionFromMotorAngles()`
→ füllt `robot.x/y/z` und `phi/theta/psi` aus den Hardwarewerten → füllt `robot.x/y/z` und `phi/theta/psi` aus den Hardwarewerten

View File

@@ -0,0 +1,130 @@
# ToDo 9a — Umkehr-Rechnung GRBL-Port → Motorwerte (Herleitung)
> **Status: durchgerechnet und verifiziert.**
> Baustein für ToDo_9 / Paket 4 (Sync-Command). Verifikation:
> `test/Robot.PortInverse.test.js` (15 Tests grün).
## Zweck
Der Sync-Command (ToDo_9, Paket 4) liest die echten Achs-Positionen (`MPos`) der drei
GRBL/FluidNC-Controller und muss daraus die **sieben Motorwerte** des Roboters
rekonstruieren: `xMotor, alpha, beta, a, b, c, eMotor`. Diese werden dann auf den Roboter
geschrieben und per **Vorwärtskinematik** (`calculatePositionFromMotorAngles()`) in die
Pose `x/y/z/phi/theta/psi` überführt.
Dieses Dokument leitet die Umkehrung her und hält das (verifizierte) Ergebnis fest.
## Kernergebnis (vorweg)
**Für die produktive Verkabelung ist die Abbildung Motorwerte → gesendete GRBL-Achswerte
linear und eindeutig umkehrbar.** Es gibt auf Port-Ebene **keine** Mehrdeutigkeit.
> Die B3-Mehrdeutigkeit (Ellbogen oben/unten) steckt ausschließlich in der **kartesischen**
> Inverskinematik `calculateAngles3D()` (Pose → Gelenkwinkel). Der Sync nutzt diese Richtung
> **nicht** — er geht `MPos → Motorwerte → Vorwärtskinematik → Pose`, und beide Schritte sind
> eindeutige Funktionen. **Damit ist der Sync-Pfad als Ganzes eindeutig.**
---
## Ausgangslage
### Produktiv-Verkabelung (`startRobot.js`)
```js
new TelnetSenderClass(baseIP, 2300, 'x', 'y', 'z') // Base
new TelnetSenderClass(elbowIP, 5000, 'a', null, null) // Elbow
new TelnetSenderClass(handIP, 5000, 'c', 'e', 'b') // Hand
```
### Bedeutung der Motor-Felder (`RobotMotorPosition`)
| Feld | Bedeutung |
|---|---|
| `x` | `xMotor` (Schulterposition auf X-Schiene, **mm**) |
| `y` | `alpha` (Schulterwinkel, **rad**) |
| `z` | `beta` (Unterarm-Neigung, **rad**) |
| `a` | `a` (Ellbogen-Dreher, **rad**) |
| `b` | `b` (Handgelenk-Knick, **rad**) |
| `c` | `c` (Hand-Dreher, **rad**) |
| `e` | `eMotor` (Greifer) |
Mit `D = 180/π` (Grad-Faktor).
### Was jeder Controller real sendet (`execCommand`, Produktiv-Verkabelung)
Aus dem Sende-Pfad (`robot/TelnetSenderGRBL.js`) ergeben sich genau **sieben** GRBL-Achswerte:
| Controller | GRBL-Achse | gesendeter Wert | in Motorwerten |
|---|---|---|---|
| **Base** | `x` | `xMotor` | `xMotor` |
| **Base** | `y` | `alpha·D` | `α·D` |
| **Base** | `z` | `(beta alpha)·D` | `(β α)·D` |
| **Elbow** | `x` | `a·D` | `a·D` |
| **Hand** | `x` | `(c b)·D` | `(c b)·D` |
| **Hand** | `y` | `eMotor·D` | `e·D` |
| **Hand** | `z` | `b·D` | `b·D` |
Drei Ports koppeln je zwei Motorwerte (`base.z`, `hand.x`), aber **jeder gekoppelte Wert
wird mit einem unabhängig gelesenen Wert kombiniert** (`alpha` bzw. `b`). Das System ist
damit *unteres Dreieckssystem* → trivial auflösbar.
---
## Herleitung der Umkehrung
Gegeben die GRBL-Readings `base.{x,y,z}`, `elbow.{x}`, `hand.{x,y,z}` (Grad bzw. mm):
```
xMotor = base.x // direkt
alpha = base.y / D
beta = (base.z + base.y) / D // base.z = (β−α)·D ⇒ β = base.z/D + α
a = elbow.x / D
b = hand.z / D
c = (hand.x + hand.z) / D // hand.x = (cb)·D ⇒ c = hand.x/D + b
eMotor = hand.y / D
```
Als Funktion (siehe Test, kann später 1:1 in Paket 4 übernommen werden):
```js
function motorStateFromPorts(r) { // r = { base:{x,y,z}, elbow:{x}, hand:{x,y,z} }
const D = 180 / Math.PI;
return {
xMotor: r.base.x,
alpha: r.base.y / D,
beta: (r.base.z + r.base.y) / D,
a: r.elbow.x / D,
b: r.hand.z / D,
c: (r.hand.x + r.hand.z) / D,
eMotor: r.hand.y / D,
};
}
```
---
## Verifikation
`test/Robot.PortInverse.test.js` — 15 Tests, fünf repräsentative Motorzustände
(Nullstellung, gemischt, negative/große Winkel, gekoppelt `c≈b`):
- **A) Exaktheit gegen `portValue`** (volle Präzision): Rückgewinnung auf 1e-9 genau.
- **B) Gegen den echten Sende-Pfad `execCommand`** (inkl. 2-Dezimal-Rundung der
G-Code-Werte): Rückgewinnung innerhalb der Rundung (Winkel < 1e-3 rad, `xMotor` < 0.02 mm).
- **C) Voll-Kette Sync**: `Ports → motorStateFromPorts → calculatePositionFromMotorAngles`
liefert dieselbe Pose `x/y/z/phi/theta/psi` wie die Original-Motorwerte (auf 1e-6).
---
## Konsequenzen für ToDo_9 / Paket 4
1. **Keine Zweig-Wahl auf Port-Ebene nötig.** Die frühere Annahme (B3-Disambiguierung im
Baustein) trifft auf die Port-Rückrechnung **nicht** zu — sie ist linear und eindeutig.
2. **Rundung beachten.** `MPos` kommt mit endlicher Präzision; gekoppelte Werte (`beta`, `c`)
summieren zwei gerundete Ports → Toleranz im Sub-Promille-Bereich, unkritisch.
3. **Achszahl je Controller.** FluidNC meldet `MPos` für **alle** in seiner Config definierten
Achsen. Genutzt werden nur: Base `x,y,z` · Elbow `x` · Hand `x,y,z`. Beim Parsen die
übrigen (falls vorhanden) ignorieren.
4. **Verkabelungs-Abhängigkeit.** `motorStateFromPorts()` gilt für die aktuelle Verkabelung.
Ändert sich `startRobot.js` (andere Port-Zuordnung), muss die Umkehrung mitgezogen werden —
der Round-Trip-Test (`portValue(motorStateFromPorts(p)) ≈ p`) schützt davor.

View File

@@ -13,9 +13,13 @@
const DEFAULT_KINEMATICS = 'arm3segmentlinearx'; const DEFAULT_KINEMATICS = 'arm3segmentlinearx';
// Registry: Bezeichner (lowercase) → Modulpfad relativ zu dieser Datei. // Registry: Bezeichner (lowercase) → Modulpfad relativ zu dieser Datei.
// Neue Kinematiken hier eintragen (siehe Phase 3). // Neue Kinematiken hier eintragen (siehe Phase 3). Mehrere Bezeichner dürfen auf
// dasselbe Modul zeigen (Alias, z. B. Produktname).
const KINEMATICS_REGISTRY = { const KINEMATICS_REGISTRY = {
arm3segmentlinearx: './kinematics/Arm3SegmentLinearX', arm3segmentlinearx: './kinematics/Arm3SegmentLinearX',
arm3segmentrotarybase: './kinematics/Arm3SegmentRotaryBase',
grabit: './kinematics/Arm3SegmentRotaryBase', // Alias: Joy-IT „Grab-It" (Robot02)
robot02: './kinematics/Arm3SegmentRotaryBase', // Alias: Joy-IT-Modellbezeichnung
}; };
/** /**
@@ -64,7 +68,11 @@ function createRobotFromEnv(env = process.env, defaultParams = {}) {
const identifier = env.ROBOT_KINEMATICS || DEFAULT_KINEMATICS; const identifier = env.ROBOT_KINEMATICS || DEFAULT_KINEMATICS;
const params = { ...defaultParams, ...parseParams(env.ROBOT_KINEMATICS_PARAMS) }; const params = { ...defaultParams, ...parseParams(env.ROBOT_KINEMATICS_PARAMS) };
const KinematicsClass = loadKinematicsClass(identifier); const KinematicsClass = loadKinematicsClass(identifier);
return new KinematicsClass(params.l1, params.l2, params.l3); // l1/l2/l3 positional (Kompatibilität mit Arm3SegmentLinearX); das vollständige
// params-Objekt als 4. Argument für Kinematiken mit weiteren Parametern
// (z. B. baseHeight bei Arm3SegmentRotaryBase). Klassen, die es nicht brauchen,
// ignorieren das Argument.
return new KinematicsClass(params.l1, params.l2, params.l3, params);
} }
module.exports = { module.exports = {

View File

@@ -0,0 +1,187 @@
const RobotBase = require('../RobotBase.js')
/**
* Arm3SegmentRotaryBase — Kinematik für den **Joy-IT „Grab-It" (Robot02)**.
*
* Strukturell wie {@link Arm3SegmentLinearX} (drei Arm-Segmente: Oberarm `l1`,
* Unterarm `l2`, Hand `l3`), aber statt der linearen X-Schiene sitzt der Arm auf
* einer **drehbaren Basis** (Yaw). Dazu kommt eine Handgelenk-Rotation.
*
* ───────────────────────────────────────────────────────────────────────────
* Physische Spezifikation (recherchiert aus dem Joy-IT-Datenblatt / Handbuch):
* - 6 PWM-Servos (20 kg, Metallgetriebe), 360° mech. / 180° Arbeitsbereich
* - 5 Bewegungsachsen + Greifer
* - Reichweite ca. 300 mm ab Mitte Drehteller, Bauhöhe bis 420 mm
*
* Gelenkkette (Basis → Greifer):
* q1 Basis-Drehung (Yaw um die vertikale Z-Achse) → this.xMotor
* q2 Schulter (Pitch) → this.alpha
* q3 Ellbogen (Pitch, relativ zum Oberarm) → this.beta
* q4 Handgelenk (Pitch, relativ zum Unterarm) → this.a
* q5 Handgelenk-Roll (Drehung um die Hand-/Anflugachse)→ this.b
* (ungenutzt) → this.c (= 0)
* q6 Greifer (Öffnungsweite) → this.eMotor
*
* Damit reichen die 7 Motor-Slots des Frameworks aus; ein Slot (`c`) bleibt frei.
*
* ───────────────────────────────────────────────────────────────────────────
* 5-DOF-Einschränkung (wichtig!):
* Ein 5-Achs-Arm kann **nicht** jede beliebige 6-DOF-Pose (x,y,z,phi,theta,psi)
* erreichen. Da die Basis-Drehung den Azimut bestimmt, liegt der gesamte Arm in
* **einer** vertikalen Ebene durch die Basisachse. Folge:
* - `phi` (Hand-Azimut) ist **nicht frei** — er ist an die Position gekoppelt
* und wird in der Inversen aus `atan2(y, x)` abgeleitet. Ein als Eingabe
* gesetztes `this.phi` wird in {@link calculateAngles3D} **ignoriert** und in
* {@link calculatePositionFromMotorAngles} als Ergebnis überschrieben.
* - Frei wählbar sind: Position `x,y,z`, Hand-Neigung `theta` (Polarwinkel von
* +Z, gleiche Konvention wie Arm3SegmentLinearX) und Hand-Rotation `psi`.
*
* ───────────────────────────────────────────────────────────────────────────
* Geometrie-Modell / Annahmen:
* - Schultergelenk sitzt auf der Basis-Drehachse, Höhe `baseHeight` über der
* Grundplatte (radialer Versatz = 0 — vereinfachende Annahme).
* - Winkel `alpha`, `beta`, `a` werden intern als Elevation gegen die
* Horizontale geführt; `alpha` ist absolut, `beta`/`a` relativ zum
* vorhergehenden Segment.
* - Es wird genau **ein** Ellbogen-Zweig gewählt (`beta` ∈ [0, π]).
* - Gelenk-Endanschläge werden **nicht** geprüft (wie bei Arm3SegmentLinearX).
*
* ⚠️ Die Default-Längen sind **Schätzwerte**, abgeleitet aus den
* veröffentlichten Maßen (Σ Segmente ≈ Reichweite 300 mm, `baseHeight` ≈
* Höhe 420 mm Reichweite). Für den Echtbetrieb am physischen Arm sollten sie
* **kalibriert** und per `ROBOT_KINEMATICS_PARAMS` gesetzt werden.
*/
class Arm3SegmentRotaryBase extends RobotBase {
/**
* @param {number} [l1=105] Länge des Oberarms (Schulter→Ellbogen) in mm
* @param {number} [l2=98] Länge des Unterarms (Ellbogen→Handgelenk) in mm
* @param {number} [l3=100] Länge der Hand (Handgelenk→Greiferspitze) in mm
* @param {Object} [params] zusätzliche Parameter
* @param {number} [params.baseHeight=110] Höhe der Schulterachse über der Basis in mm
*/
constructor(l1 = 105, l2 = 98, l3 = 100, params = {}) {
super();
/** @type {number} Länge des Oberarms in mm */
this.l1 = l1;
/** @type {number} Länge des Unterarms in mm */
this.l2 = l2;
/** @type {number} Länge der Hand (Endeffector) in mm */
this.l3 = l3;
/** @type {number} Höhe der Schulter-Drehachse über der Grundplatte in mm */
this.baseHeight = (params && params.baseHeight != null) ? params.baseHeight : 110;
// Neutrale Default-Pose: Hand horizontal nach vorn (theta = π/2 = Polarwinkel).
this.theta = Math.PI / 2;
}
/**
* Inverse Kinematik: Workspace (x, y, z, theta, psi, e) → Gelenkwinkel.
* `phi` wird ignoriert und aus der Position abgeleitet (5-DOF, siehe Klassendoc).
*/
calculateAngles3D(verbose) {
// Winkel normalisieren (Konsistenz mit Arm3SegmentLinearX)
while (this.theta > Math.PI) { this.theta -= 2 * Math.PI }
while (this.theta < -Math.PI) { this.theta += 2 * Math.PI }
// 1) Basis-Drehung aus dem Azimut der Zielposition (phi ist gekoppelt)
const q1 = Math.atan2(this.y, this.x);
this.xMotor = q1;
this.phi = q1;
// 2) Hand-Vektor abziehen → Handgelenk-Punkt
// theta = Polarwinkel von +Z. Hand-Radial = l3·sin(theta), Hand-Z = l3·cos(theta).
const st = Math.sqrt(this.x * this.x + this.y * this.y); // radialer Abstand der Spitze
const zt = this.z;
const sw = st - this.l3 * Math.sin(this.theta); // radial: Handgelenk
const zw = zt - this.l3 * Math.cos(this.theta); // Höhe: Handgelenk
// Handgelenk-Punkt in 3D (für Speed-Berechnung)
this.pX = sw * Math.cos(q1);
this.pY = sw * Math.sin(q1);
this.pZ = zw;
// 3) 2-Gelenk-IK in der Armebene: von Schulter (0, baseHeight) zum Handgelenk (sw, zw)
const dx = sw;
const dz = zw - this.baseHeight;
const R2 = dx * dx + dz * dz;
const R = Math.sqrt(R2);
if (R > (this.l1 + this.l2)) { return; } // unerreichbar — Slots unverändert lassen
if (R === 0) { return; }
// Ellbogen-Relativwinkel (ein Zweig: qe ∈ [0, π])
let cosE = (R2 - this.l1 * this.l1 - this.l2 * this.l2) / (2 * this.l1 * this.l2);
cosE = Math.max(-1, Math.min(1, cosE));
const qe = Math.atan2(Math.sqrt(1 - cosE * cosE), cosE);
// Absoluter Oberarmwinkel (Elevation gegen Horizontale)
const A2 = Math.atan2(dz, dx) - Math.atan2(this.l2 * Math.sin(qe), this.l1 + this.l2 * Math.cos(qe));
const A3 = A2 + qe; // absoluter Unterarmwinkel
const A4 = Math.PI / 2 - this.theta; // absolute Hand-Elevation aus Polarwinkel
// 4) Motor-Slots schreiben
this.alpha = A2; // Schulter (absolut)
this.beta = qe; // Ellbogen (relativ) = A3 A2
this.a = A4 - A3; // Handgelenk-Pitch (relativ)
this.b = this.psi; // Handgelenk-Roll
this.c = 0; // ungenutzt
this.eMotor = this.e; // Greifer
// a auf [-π, π] normalisieren
while (this.a > Math.PI) { this.a -= 2 * Math.PI }
while (this.a < -Math.PI) { this.a += 2 * Math.PI }
if (verbose) {
console.log("IK GrabIt: q1=", q1.toFixed(3), "alpha=", this.alpha.toFixed(3),
"beta=", this.beta.toFixed(3), "a=", this.a.toFixed(3), "b=", this.b.toFixed(3));
}
}
/**
* Vorwärts-Kinematik: Gelenkwinkel → Workspace (x, y, z, phi, theta, psi, e).
* Exakte Umkehrung von {@link calculateAngles3D}.
*/
calculatePositionFromMotorAngles(verbose = false) {
const q1 = this.xMotor; // Basis-Yaw
const A2 = this.alpha; // Oberarm absolut
const A3 = this.alpha + this.beta; // Unterarm absolut
const A4 = this.alpha + this.beta + this.a; // Hand absolut (Elevation)
// Handgelenk-Punkt in der Armebene
const sw = this.l1 * Math.cos(A2) + this.l2 * Math.cos(A3);
const zw = this.baseHeight + this.l1 * Math.sin(A2) + this.l2 * Math.sin(A3);
// Greiferspitze in der Armebene (Hand-Elevation A4)
const st = sw + this.l3 * Math.cos(A4);
const zt = zw + this.l3 * Math.sin(A4);
// In den 3D-Raum drehen
this.pX = sw * Math.cos(q1);
this.pY = sw * Math.sin(q1);
this.pZ = zw;
this.x = st * Math.cos(q1);
this.y = st * Math.sin(q1);
this.z = zt;
this.phi = q1; // Azimut = Basis-Drehung
this.theta = Math.PI / 2 - A4; // Polarwinkel von +Z
this.psi = this.b; // Hand-Roll
while (this.phi > Math.PI) { this.phi -= 2 * Math.PI }
while (this.phi < -Math.PI) { this.phi += 2 * Math.PI }
while (this.theta > Math.PI) { this.theta -= 2 * Math.PI }
while (this.theta < -Math.PI) { this.theta += 2 * Math.PI }
if (verbose) {
console.log("FK GrabIt: x=", this.x.toFixed(2), "y=", this.y.toFixed(2),
"z=", this.z.toFixed(2), "phi=", this.phi.toFixed(3), "theta=", this.theta.toFixed(3));
}
}
}
module.exports = Arm3SegmentRotaryBase

View File

@@ -0,0 +1,140 @@
// Kinematik-RoundTrip-Tests für den Joy-IT „Grab-It" (Robot02),
// implementiert als Arm3SegmentRotaryBase (Roadmap 12, Phase 3).
const Arm3SegmentRotaryBase = require('../robot/kinematics/Arm3SegmentRotaryBase');
const { createRobotFromEnv } = require('../robot/KinematicsFactory');
const RobotBase = require('../robot/RobotBase');
// Default-Geometrie (Schätzwerte): l1=105, l2=98, l3=100, baseHeight=110
const mk = () => new Arm3SegmentRotaryBase();
describe('Arm3SegmentRotaryBase — Workspace → Motor → Workspace', () => {
// Erreichbare Workspace-Punkte mit konsistentem (impliziten) Azimut.
const cases = [
{ x: 200, y: 0, z: 110, theta: Math.PI / 2, psi: 0.0, e: 10 },
{ x: 120, y: 120, z: 200, theta: Math.PI / 3, psi: -0.5, e: 20 },
{ x: -90, y: 90, z: 150, theta: Math.PI / 2, psi: 0.4, e: 5 },
{ x: 80, y: -60, z: 90, theta: 2 * Math.PI / 3, psi: 0.2, e: 0 },
];
test.each(cases)('roundtrip x=$x y=$y z=$z', (c) => {
// A: Inverse (Workspace → Motorwinkel)
const A = mk();
A.x = c.x; A.y = c.y; A.z = c.z;
A.theta = c.theta; A.psi = c.psi; A.e = c.e;
A.calculateAngles3D();
// B: Vorwärts (Motorwinkel → Workspace)
const B = mk();
B.xMotor = A.xMotor;
B.alpha = A.alpha;
B.beta = A.beta;
B.a = A.a;
B.b = A.b;
B.eMotor = A.eMotor;
B.calculatePositionFromMotorAngles();
// Die steuerbaren DOF müssen exakt zurückkommen.
expect(B.x).toBeCloseTo(c.x, 4);
expect(B.y).toBeCloseTo(c.y, 4);
expect(B.z).toBeCloseTo(c.z, 4);
expect(B.theta).toBeCloseTo(c.theta, 5);
expect(B.psi).toBeCloseTo(c.psi, 5);
// phi ist gekoppelt: gleich dem Azimut der Position (5-DOF-Constraint).
expect(B.phi).toBeCloseTo(Math.atan2(c.y, c.x), 5);
});
});
describe('Arm3SegmentRotaryBase — Motor → Workspace → Motor', () => {
// Motorwinkel mit Ellbogen im gewählten Zweig (beta ∈ [0, π]).
const motors = [
{ xMotor: 0.5, alpha: 0.8, beta: 0.6, a: -0.3, b: 0.2, eMotor: 15 },
{ xMotor: -0.7, alpha: 0.4, beta: 1.0, a: 0.5, b: -0.4, eMotor: 30 },
{ xMotor: 1.2, alpha: 1.1, beta: 0.3, a: -0.6, b: 0.1, eMotor: 0 },
];
test.each(motors)('roundtrip alpha=$alpha beta=$beta', (m) => {
// A: Vorwärts (Motor → Workspace)
const A = mk();
Object.assign(A, m);
A.calculatePositionFromMotorAngles();
// B: Inverse (Workspace → Motor)
const B = mk();
B.x = A.x; B.y = A.y; B.z = A.z;
B.theta = A.theta; B.psi = A.psi; B.e = A.eMotor;
B.calculateAngles3D();
expect(B.xMotor).toBeCloseTo(m.xMotor, 5);
expect(B.alpha).toBeCloseTo(m.alpha, 5);
expect(B.beta).toBeCloseTo(m.beta, 5);
expect(B.a).toBeCloseTo(m.a, 5);
expect(B.b).toBeCloseTo(m.b, 5);
expect(B.eMotor).toBeCloseTo(m.eMotor, 5);
});
});
describe('Arm3SegmentRotaryBase — 5-DOF-Eigenschaften', () => {
test('phi-Eingabe wird in der Inversen ignoriert', () => {
const base = { x: 150, y: 60, z: 130, theta: Math.PI / 2, psi: 0.1, e: 0 };
const withPhi = mk();
Object.assign(withPhi, base, { phi: 2.5 }); // bewusst widersprüchliches phi
withPhi.calculateAngles3D();
const noPhi = mk();
Object.assign(noPhi, base, { phi: -1.0 }); // anderes phi
noPhi.calculateAngles3D();
// Trotz unterschiedlicher phi-Eingabe identische Motorwinkel.
for (const k of ['xMotor', 'alpha', 'beta', 'a', 'b', 'eMotor']) {
expect(noPhi[k]).toBeCloseTo(withPhi[k], 9);
}
// phi wird aus der Position abgeleitet.
expect(withPhi.xMotor).toBeCloseTo(Math.atan2(base.y, base.x), 9);
});
test('c-Slot bleibt ungenutzt (= 0)', () => {
const r = mk();
r.x = 180; r.y = 0; r.z = 120; r.theta = Math.PI / 2; r.psi = 0; r.e = 0;
r.calculateAngles3D();
expect(r.c).toBe(0);
});
test('unerreichbarer Punkt lässt die Motor-Slots unverändert', () => {
const r = mk();
r.alpha = 0; r.beta = 0; r.a = 0; // bekannter Startzustand
r.x = 5000; r.y = 0; r.z = 0; r.theta = Math.PI / 2; r.psi = 0; r.e = 0;
r.calculateAngles3D();
// R > l1+l2 → frühzeitiges return, alpha/beta/a unverändert
expect(r.alpha).toBe(0);
expect(r.beta).toBe(0);
expect(r.a).toBe(0);
});
});
describe('KinematicsFactory lädt den Grab-It', () => {
test('Alias "grabit" liefert eine Arm3SegmentRotaryBase-Instanz', () => {
const robot = createRobotFromEnv({ ROBOT_KINEMATICS: 'grabit' });
expect(robot).toBeInstanceOf(Arm3SegmentRotaryBase);
expect(robot).toBeInstanceOf(RobotBase);
});
test('ROBOT_KINEMATICS_PARAMS setzt Längen und baseHeight', () => {
const robot = createRobotFromEnv({
ROBOT_KINEMATICS: 'robot02',
ROBOT_KINEMATICS_PARAMS: '{"l1":110,"l2":100,"l3":95,"baseHeight":130}',
});
expect([robot.l1, robot.l2, robot.l3]).toEqual([110, 100, 95]);
expect(robot.baseHeight).toBe(130);
});
test('Default-baseHeight wenn nicht gesetzt', () => {
const robot = createRobotFromEnv({ ROBOT_KINEMATICS: 'grabit' });
expect(robot.baseHeight).toBe(110);
});
});

View File

@@ -0,0 +1,150 @@
/**
* Verifikation der Port→Motor-Rückrechnung für ToDo_9 / Paket 4 (Sync-Command).
*
* Herleitung: doc/ToDo_9a_PortRueckrechnung.md
*
* Kernaussage: Für die PRODUKTIV-Verkabelung (startRobot.js) ist die Abbildung
* der sieben Motorwerte auf die sieben gesendeten GRBL-Achswerte LINEAR und
* EINDEUTIG umkehrbar. `motorStateFromPorts()` ist diese Umkehrung; die Tests
* beweisen sie gegen den echten Sende-Pfad (`execCommand`) und gegen `portValue`.
*
* Damit ist klar: die B3-Mehrdeutigkeit (Ellbogen oben/unten) betrifft nur die
* kartesische Inverskinematik (`calculateAngles3D`), die der Sync gar nicht nutzt.
*/
const TelnetSender = require('../robot/TelnetSenderGRBL');
const MotorPosition = require('../robot/RobotMotorPosition');
const Robot = require('../robot/kinematics/Arm3SegmentLinearX');
const D = 180 / Math.PI;
/* ---------- Produktiv-Verkabelung (1:1 wie startRobot.js) ---------- */
function buildSenders() {
const mk = (...args) => new TelnetSender('test.test', ...args); // test.test → Fake-tSocket
return {
base: mk(2300, 'x', 'y', 'z'), // GRBL x←xMotor, y←alpha, z←(beta-alpha)
elbow: mk(5000, 'a', null, null), // GRBL x←a
hand: mk(5000, 'c', 'e', 'b'), // GRBL x←(c-b), y←eMotor, z←b
};
}
/* ---------- Die Umkehrung: GRBL-Readings → 7 Motorwerte ---------- */
// r = { base:{x,y,z}, elbow:{x}, hand:{x,y,z} } (GRBL-Achswerte, Grad bzw. mm)
function motorStateFromPorts(r) {
const xMotor = r.base.x; // x-Port = xMotor (mm, direkt)
const alpha = r.base.y / D; // y-Port = alpha·D
const beta = (r.base.z + r.base.y) / D; // z-Port = (beta-alpha)·D ⇒ beta = z/D + alpha
const a = r.elbow.x / D; // Elbow x-Port = a·D
const b = r.hand.z / D; // Hand z-Port = b·D
const c = (r.hand.x + r.hand.z) / D; // Hand x-Port = (c-b)·D ⇒ c = x/D + b
const eMotor = r.hand.y / D; // Hand y-Port = eMotor·D
return { xMotor, alpha, beta, a, b, c, eMotor };
}
/* ---------- Hilfen ---------- */
// {x,y,z,a,b,c,e}-pos aus Motorwerten (Feld-Mapping der RobotMotorPosition)
function posFromMotors(m) {
return { x: m.xMotor, y: m.alpha, z: m.beta, a: m.a, b: m.b, c: m.c, e: m.eMotor };
}
// Port-Readings über die reine Funktion portValue (volle Präzision)
function portsViaPortValue(S, pos) {
return {
base: { x: S.base.portValue('x', 'x', pos), y: S.base.portValue('y', 'y', pos), z: S.base.portValue('z', 'z', pos) },
elbow: { x: S.elbow.portValue('x', 'a', pos) },
hand: { x: S.hand.portValue('x', 'c', pos), y: S.hand.portValue('y', 'e', pos), z: S.hand.portValue('z', 'b', pos) },
};
}
// Achswerte aus einer real gesendeten G-Code-Zeile (z. B. "G1 x12.34 y90.00 z-90.00")
function parseAxes(line) {
const out = {};
const re = /([xyz])(-?\d+(?:\.\d+)?)/g;
let m;
while ((m = re.exec(line)) !== null) out[m[1]] = parseFloat(m[2]);
return out;
}
// Port-Readings über den ECHTEN Sende-Pfad execCommand (inkl. 2-Dezimal-Rundung)
function portsViaExecCommand(S, m) {
const mNew = new MotorPosition(m.xMotor, m.alpha, m.beta, m.a, m.b, m.c, m.eMotor);
for (const f of ['xMotorChanged','yMotorChanged','zMotorChanged','aMotorChanged','bMotorChanged','cMotorChanged','eMotorChanged']) mNew[f] = true;
const mOld = new MotorPosition(0, 0, 0, 0, 0, 0, 0);
S.base.execCommand('G1', mOld, mNew);
S.elbow.execCommand('G1', mOld, mNew);
S.hand.execCommand('G1', mOld, mNew);
return {
base: parseAxes(S.base.tSocket.written),
elbow: parseAxes(S.elbow.tSocket.written),
hand: parseAxes(S.hand.tSocket.written),
};
}
/* ---------- Repräsentative Motorzustände (plausible Bereiche) ---------- */
const CASES = [
{ name: 'Nullstellung', xMotor: 0, alpha: 0, beta: 0, a: 0, b: 0, c: 0, eMotor: 0 },
{ name: 'gemischt klein', xMotor: 100, alpha: 0.30, beta: 0.50, a: 0.20, b: 0.40, c: 0.90, eMotor: 1.0 },
{ name: 'negative Winkel', xMotor: -55, alpha:-0.70, beta:-0.25, a:-1.10, b:-0.60, c:-0.30, eMotor:-0.5 },
{ name: 'große Winkel', xMotor: 250, alpha: 1.20, beta: 2.00, a: 2.90, b: 1.50, c: 3.00, eMotor: 2.5 },
{ name: 'gekoppelt c≈b', xMotor: 42, alpha: 0.10, beta: 0.10, a: 0.05, b: 1.20, c: 1.25, eMotor: 0.0 },
];
beforeAll(() => { jest.spyOn(console, 'log').mockImplementation(() => {}); });
afterAll(() => { jest.restoreAllMocks(); });
describe('Port→Motor-Rückrechnung (ToDo_9, Baustein für Paket 4)', () => {
describe('A) exakte Umkehrung gegen portValue (volle Präzision)', () => {
for (const c of CASES) {
test(c.name, () => {
const S = buildSenders();
const ports = portsViaPortValue(S, posFromMotors(c));
const rec = motorStateFromPorts(ports);
for (const k of ['xMotor','alpha','beta','a','b','c','eMotor']) {
expect(rec[k]).toBeCloseTo(c[k], 9);
}
});
}
});
describe('B) Umkehrung gegen den echten Sende-Pfad execCommand (mit Rundung)', () => {
for (const c of CASES) {
test(c.name, () => {
const S = buildSenders();
const ports = portsViaExecCommand(S, c);
const rec = motorStateFromPorts(ports);
// xMotor in mm (Rundung 0.01) → 0.02 Toleranz; Winkel in rad (Rundung 0.01° je Port) → 1e-3
expect(rec.xMotor).toBeCloseTo(c.xMotor, 2);
for (const k of ['alpha','beta','a','b','c','eMotor']) {
expect(Math.abs(rec[k] - c[k])).toBeLessThan(1e-3);
}
});
}
});
describe('C) Voll-Kette Sync: Ports → Motoren → Vorwärtskinematik → Pose', () => {
// Beweist, dass die rekonstruierten Motorwerte über die Vorwärtskinematik
// exakt dieselbe Pose liefern wie die Originalwerte (= was Sync dem Client meldet).
for (const c of CASES) {
test(c.name, () => {
const S = buildSenders();
// Referenz-Pose direkt aus den Original-Motorwerten
const ref = new Robot(250, 264, 100);
Object.assign(ref, { xMotor: c.xMotor, alpha: c.alpha, beta: c.beta, a: c.a, b: c.b, c: c.c, eMotor: c.eMotor });
ref.calculatePositionFromMotorAngles();
// Sync-Pfad: Ports → Inverse → Motoren setzen → Vorwärtskinematik
const rec = motorStateFromPorts(portsViaPortValue(S, posFromMotors(c)));
const sync = new Robot(250, 264, 100);
Object.assign(sync, { xMotor: rec.xMotor, alpha: rec.alpha, beta: rec.beta, a: rec.a, b: rec.b, c: rec.c, eMotor: rec.eMotor });
sync.calculatePositionFromMotorAngles();
for (const k of ['x','y','z','phi','theta','psi']) {
expect(sync[k]).toBeCloseTo(ref[k], 6);
}
});
}
});
});