Einbaulage: Verdrehung um die Hochachse messen – und Geräte dabei nicht verlieren
Sitzt der Sensor schräg statt längs im Fahrzeug, verteilt sich eine reine
Querneigung auf beide Achsen: Das Fahrzeug kippt zur Seite, und die
Längsanzeige kippt sichtbar mit – bei 20° Verdrehung mit gut einem
Drittel des Werts. Achsentausch und Vorzeichen halfen dagegen nicht, die
springen in 90°-Schritten.
SensorOrientation bekommt deshalb "twist", einen stufenlosen Winkel. Der
Einbaulage-Assistent misst ihn ohne zusätzlichen Handgriff mit: Beim
Kippen der Front nach unten dürfte sich nur die Längsneigung ändern;
wandert die Querneigung mit, ist das der gesuchte Winkel. Unter zwei Grad
gilt es als Wackeln der Hand. Angezeigt wird er in der Zusammenfassung
("um 20° verdreht"), herausgerechnet bei jeder Anzeige.
Beim Kalibrieren liesse sich das grundsätzlich nicht ermitteln: Es misst
eine einzige Lage und zieht sie als Nullpunkt ab. Eine Drehung um die
Hochachse steckt darin nicht – eben sieht in jeder Verdrehung gleich aus.
Das neue Feld hat beim Aufspielen sämtliche eingerichteten Geräte
gekostet, und der Grund gehört hierher: Swifts erzeugte Codable-Umsetzung
verlangt beim Lesen jedes Feld, Standardwerte im Code zählen nicht.
Gespeicherte Einbaulagen hatten kein "twist", also scheiterte das Lesen
der Einbaulage, damit des Geräts, damit der ganzen Liste – und das
nächste Speichern schrieb die leere Liste über den Bestand.
Zwei Vorkehrungen dagegen:
* SensorOrientation liest jetzt von Hand und nachsichtig, mit
decodeIfPresent und Rückfallwerten, wie Profile und ConfiguredDevice es
längst tun. Jedes künftige Feld gehört dort hinein.
* DeviceStore liest die Liste zweistufig: scheitert sie als Ganzes, wird
jedes Gerät einzeln versucht und behalten, was lesbar ist. Die
Rohdaten wandern zusätzlich in einen eigenen Schlüssel, statt beim
nächsten Speichern überschrieben zu werden.
Beides in run-tests.sh abgesichert: die Erkennung und Rückrechnung der
Verdrehung samt Gegenprobe ohne Korrektur, und das Lesen gespeicherter
Geräte ohne "twist", ganz ohne Einbaulage und mit unbrauchbarer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -970,6 +970,95 @@ let untouched = SensorOrientation.identity.apply(pitch: 1.5, roll: -0.5)
|
||||
checkEqual("unveränderte Lage lässt längs stehen", untouched.pitch, 1.5)
|
||||
checkEqual("unveränderte Lage lässt quer stehen", untouched.roll, -0.5)
|
||||
|
||||
// Gespeicherte Einrichtungen müssen ältere Fassungen überleben. Ein neues Feld
|
||||
// darf das Lesen nicht scheitern lassen – sonst ist die Geräteliste leer, und
|
||||
// das nächste Speichern schreibt diese Leere über den Bestand.
|
||||
print("\nGespeicherte Geräte lesen")
|
||||
|
||||
func decodeDevice(_ json: String) -> ConfiguredDevice? {
|
||||
try? JSONDecoder().decode(ConfiguredDevice.self, from: Data(json.utf8))
|
||||
}
|
||||
|
||||
let deviceBeforeTwist = """
|
||||
{"role":"leveling","profileID":"00000000-0000-0000-0000-00000000C001",
|
||||
"name":"Nivellierung","id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B5",
|
||||
"advertisedName":"vanalign","fridgeZoneMode":"automatic",
|
||||
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DA",
|
||||
"sensorOrientation":{"invertLongitudinal":false,"invertLateral":true,
|
||||
"longitudinalSource":"roll"}}
|
||||
"""
|
||||
let restoredDevice = decodeDevice(deviceBeforeTwist)
|
||||
checkEqual("Gerät ohne twist lässt sich weiterhin lesen", restoredDevice?.name, "Nivellierung")
|
||||
checkEqual("die bekannten Felder der Einbaulage bleiben erhalten",
|
||||
restoredDevice?.sensorOrientation.longitudinalSource, .roll)
|
||||
checkEqual("das neue Feld fällt auf null zurück", restoredDevice?.sensorOrientation.twist, 0)
|
||||
|
||||
let deviceWithoutOrientation = """
|
||||
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Bulltron",
|
||||
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B6",
|
||||
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DB"}
|
||||
"""
|
||||
checkEqual("Gerät ganz ohne Einbaulage ebenso",
|
||||
decodeDevice(deviceWithoutOrientation)?.sensorOrientation, .identity)
|
||||
|
||||
checkEqual("eine unbrauchbare Einbaulage bleibt ein Fehler",
|
||||
decodeDevice("""
|
||||
{"role":"bms","profileID":"00000000-0000-0000-0000-00000000C001","name":"Kaputt",
|
||||
"id":"7E33D104-BD56-4D3F-A8F9-05B5852E35B7",
|
||||
"peripheralID":"8CA65FA9-8083-7396-FC45-E29F3BE2D9DC",
|
||||
"sensorOrientation":{"longitudinalSource":"quer"}}
|
||||
""") == nil, true)
|
||||
|
||||
// Schräg eingebauter Sensor: um 20 Grad um die Hochachse verdreht. Eine reine
|
||||
// Kippbewegung nach vorne verteilt sich dann auf beide Sensorachsen.
|
||||
let twistAngle = 20.0
|
||||
func twisted(_ longitudinal: Double, _ lateral: Double) -> OrientationDetection.Reading {
|
||||
// Umgekehrter Weg zu SensorOrientation.apply: aus Fahrzeugwerten wird das,
|
||||
// was ein verdrehter Sensor melden würde.
|
||||
let a = twistAngle * .pi / 180
|
||||
return OrientationDetection.Reading(pitch: longitudinal * cos(a) - lateral * sin(a),
|
||||
roll: longitudinal * sin(a) + lateral * cos(a))
|
||||
}
|
||||
|
||||
let noseTwisted = twisted(10, 0) // Front nach unten, sauber um die Querachse
|
||||
let sideTwisted = twisted(0, 10) // linke Seite nach unten
|
||||
|
||||
if case .success(let skew) = detect(nose: noseTwisted, side: sideTwisted) {
|
||||
checkEqual("Verdrehung wird erkannt", (skew.twist * 10).rounded() / 10, 20.0)
|
||||
checkEqual("Achsen bleiben dabei zugeordnet", skew.longitudinalSource, .pitch)
|
||||
|
||||
// Die Probe: zurückgerechnet muss aus der Frontbewegung reine Längsneigung
|
||||
// werden und aus der Seitenbewegung reine Querneigung.
|
||||
let front = skew.apply(pitch: noseTwisted.pitch, roll: noseTwisted.roll)
|
||||
checkEqual("verdreht gemessene Frontbewegung wird reine Längsneigung",
|
||||
front.pitch.map { ($0 * 10).rounded() / 10 }, 10.0)
|
||||
checkEqual("und hat keine Querneigung mehr",
|
||||
front.roll.map { abs($0) < 0.001 }, true)
|
||||
|
||||
let side = skew.apply(pitch: sideTwisted.pitch, roll: sideTwisted.roll)
|
||||
checkEqual("verdreht gemessene Seitenbewegung wird reine Querneigung",
|
||||
side.roll.map { ($0 * 10).rounded() / 10 }, 10.0)
|
||||
checkEqual("und hat keine Längsneigung mehr",
|
||||
side.pitch.map { abs($0) < 0.001 }, true)
|
||||
} else {
|
||||
check("verdrehter Einbau wird ausgewertet", false)
|
||||
}
|
||||
|
||||
// Genau das, was vorher fehlte: ohne Korrektur schlägt eine reine Querneigung
|
||||
// auf die Längsanzeige durch – bei 20 Grad Verdrehung mit gut einem Drittel.
|
||||
let uncorrected = SensorOrientation.identity.apply(pitch: sideTwisted.pitch,
|
||||
roll: sideTwisted.roll)
|
||||
checkEqual("ohne Korrektur kippt die Längsanzeige mit",
|
||||
uncorrected.pitch.map { ($0 * 10).rounded() / 10 }, -3.4)
|
||||
|
||||
// Ein bisschen Wackeln ist keine Verdrehung.
|
||||
if case .success(let steady) = detect(nose: Reading(pitch: 10, roll: 0.2),
|
||||
side: Reading(pitch: 0.2, roll: 10)) {
|
||||
checkEqual("kleiner Rest gilt als Rauschen, nicht als Verdrehung", steady.twist, 0)
|
||||
} else {
|
||||
check("saubere Kippbewegung wird ausgewertet", false)
|
||||
}
|
||||
|
||||
let missing = SensorOrientation.identity.apply(pitch: nil, roll: 2)
|
||||
checkEqual("fehlende Werte bleiben leer", missing.pitch == nil, true)
|
||||
checkEqual("vorhandene Werte kommen durch", missing.roll, 2)
|
||||
|
||||
Reference in New Issue
Block a user