Files
Camper-Monitor/Shared/Models/SensorOrientation.swift
BiasFandClaude Opus 5 a9a44b49d6 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>
2026-08-31 19:40:45 +02:00

204 lines
9.4 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// Wie der Neigungsmesser im Fahrzeug sitzt.
///
/// Der Sensor kann quer eingebaut, gedreht oder kopfüber montiert sein. Dann
/// stimmen seine Achsen nicht mit denen des Fahrzeugs überein: was er als
/// Längsneigung meldet, ist womöglich die Querneigung, und Vorzeichen können
/// vertauscht sein.
///
/// Statt das raten zu lassen, ermittelt der Einrichtungsassistent die Lage
/// durch zwei definierte Kippbewegungen.
struct SensorOrientation: Codable, Equatable, Hashable, Sendable {
/// Welche Achse des Sensors die Längsneigung des Fahrzeugs liefert.
enum Source: String, Codable, Hashable, Sendable {
case pitch
case roll
}
/// Woher die Längsneigung kommt. Die Querneigung kommt aus der jeweils
/// anderen Achse.
var longitudinalSource: Source = .pitch
var invertLongitudinal = false
var invertLateral = false
/// Verdrehung des Sensors um die Hochachse, in Grad der Rest, den der
/// Achsentausch nicht abdeckt.
///
/// Sitzt der Sensor schräg im Fahrzeug, verteilt sich eine reine
/// Querneigung auf beide Sensorachsen: Das Fahrzeug kippt zur Seite, die
/// Anzeige meldet zusätzlich Längsneigung. Achsentausch und Vorzeichen
/// helfen dagegen nicht, die springen in 90°-Schritten.
///
/// Der Wert wird beim Bestimmen der Einbaulage mitgemessen und kostet
/// keinen zusätzlichen Handgriff. Beim **Kalibrieren** liesse er sich
/// nicht ermitteln: Das misst eine einzige Lage und zieht sie als Nullpunkt
/// ab eine Drehung um die Hochachse ist darin nicht enthalten, denn eben
/// sieht in jeder Verdrehung gleich aus.
var twist: Double = 0
static let identity = SensorOrientation()
init() {}
/// Von Hand geschrieben, nicht von Swift erzeugt und das ist der Punkt.
///
/// Die erzeugte Fassung verlangt beim Lesen jedes Feld; Standardwerte im
/// Code zählen dabei nicht. Eine Einbaulage, die vor `twist` gespeichert
/// wurde, liesse sich damit nicht mehr lesen, das Gerät dazu ebenso wenig,
/// und die Geräteliste bliebe leer. Jedes neue Feld gehört deshalb hier
/// hinein, mit `decodeIfPresent` und einem Rückfallwert.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
longitudinalSource = try container.decodeIfPresent(Source.self,
forKey: .longitudinalSource) ?? .pitch
invertLongitudinal = try container.decodeIfPresent(Bool.self,
forKey: .invertLongitudinal) ?? false
invertLateral = try container.decodeIfPresent(Bool.self, forKey: .invertLateral) ?? false
twist = try container.decodeIfPresent(Double.self, forKey: .twist) ?? 0
}
var isIdentity: Bool { self == .identity }
/// Rechnet Sensorwerte in Fahrzeugwerte um.
///
/// Zwei Schritte, in dieser Reihenfolge: erst die grobe Zuordnung der
/// Achsen samt Vorzeichen, dann die Verdrehung zurückdrehen. Für kleine
/// Winkel verhält sich das Wertepaar wie ein Vektor in der Ebene genau
/// deshalb lässt sich die Verdrehung überhaupt herausrechnen.
func apply(pitch: Double?, roll: Double?) -> (pitch: Double?, roll: Double?) {
let longitudinal = longitudinalSource == .pitch ? pitch : roll
let lateral = longitudinalSource == .pitch ? roll : pitch
let mappedLongitudinal = longitudinal.map { invertLongitudinal ? -$0 : $0 }
let mappedLateral = lateral.map { invertLateral ? -$0 : $0 }
guard twist != 0, let long = mappedLongitudinal, let lat = mappedLateral else {
return (mappedLongitudinal, mappedLateral)
}
let angle = twist * .pi / 180
return (long * cos(angle) + lat * sin(angle),
-long * sin(angle) + lat * cos(angle))
}
var summary: String {
if isIdentity { return "Achsen unverändert" }
var parts: [String] = []
if longitudinalSource == .roll { parts.append("Achsen getauscht") }
if invertLongitudinal { parts.append("längs umgekehrt") }
if invertLateral { parts.append("quer umgekehrt") }
if twist != 0 { parts.append(String(format: "um %.0f° verdreht", twist)) }
return parts.joined(separator: ", ")
}
}
/// Wertet die Kippbewegungen des Einrichtungsassistenten aus.
///
/// Zweimal wird gekippt: einmal die Front nach unten, einmal die linke Seite
/// nach unten. Welche Sensorachse sich dabei jeweils bewegt und in welche
/// Richtung, ergibt die Einbaulage.
enum OrientationDetection {
/// So weit muss gekippt werden, damit die Bewegung eindeutig ist.
/// Darunter wäre nicht zu unterscheiden, ob überhaupt gekippt wurde.
static let minimumTilt = 5.0
/// Soviel deutlicher muss die gewinnende Deutung sein als die andere.
static let ambiguityMargin = 1.3
/// Ab hier gilt eine Verdrehung als echt und nicht als Wackeln der Hand.
static let minimumTwist = 2.0
struct Reading: Equatable {
let pitch: Double
let roll: Double
static func - (lhs: Reading, rhs: Reading) -> Reading {
Reading(pitch: lhs.pitch - rhs.pitch, roll: lhs.roll - rhs.roll)
}
}
enum Failure: Error, Equatable {
/// Es wurde zu wenig oder gar nicht gekippt.
case tooLittleMovement
/// Beide Achsen haben sich ähnlich stark bewegt dann lässt sich
/// nicht sagen, welche gemeint war.
case ambiguous
var message: String {
switch self {
case .tooLittleMovement:
return "Zu wenig Bewegung. Deutlicher kippen, mindestens eine "
+ "Handbreit."
case .ambiguous:
return "Beide Schritte haben dieselbe Achse bewegt. Im ersten "
+ "Schritt nach vorne kippen, im zweiten zur Seite."
}
}
}
/// Ermittelt aus beiden Kippbewegungen die Einbaulage.
///
/// Beide Bewegungen werden gemeinsam beurteilt. Kippt man von Hand zur
/// Seite, geht die andere Achse fast immer ein Stück mit jede Bewegung
/// für sich betrachtet sähe das nach schrägem Kippen aus. Im Paar ist die
/// Zuordnung trotzdem eindeutig: es gewinnt die Deutung, die beide
/// Bewegungen zusammen am besten erklärt.
///
/// - Parameters:
/// - nose: Änderung beim Kippen der Front nach unten.
/// - side: Änderung beim Kippen der linken Seite nach unten.
static func orientation(nose: Reading,
side: Reading) -> Result<SensorOrientation, Failure> {
guard max(abs(nose.pitch), abs(nose.roll)) >= minimumTilt,
max(abs(side.pitch), abs(side.roll)) >= minimumTilt else {
return .failure(.tooLittleMovement)
}
// Zwei mögliche Deutungen, bewertet danach, wieviel Bewegung sie
// erklären: die erste Achse längs und die zweite quer oder umgekehrt.
let pitchIsLongitudinal = abs(nose.pitch) + abs(side.roll)
let rollIsLongitudinal = abs(nose.roll) + abs(side.pitch)
let winner = max(pitchIsLongitudinal, rollIsLongitudinal)
let loser = min(pitchIsLongitudinal, rollIsLongitudinal)
// Liegen beide Deutungen dicht beieinander, ist die Zuordnung wirklich
// nicht zu treffen etwa wenn zweimal um dieselbe Achse gekippt wurde.
guard winner >= ambiguityMargin * loser else { return .failure(.ambiguous) }
var orientation = SensorOrientation()
orientation.longitudinalSource = pitchIsLongitudinal > rollIsLongitudinal ? .pitch : .roll
let longitudinal = orientation.longitudinalSource == .pitch ? nose.pitch : nose.roll
let lateral = orientation.longitudinalSource == .pitch ? side.roll : side.pitch
// Die zugeordnete Achse muss in ihrem Schritt auch deutlich gewandert
// sein, sonst stünde das Vorzeichen auf wackligem Grund.
guard abs(longitudinal) >= minimumTilt, abs(lateral) >= minimumTilt else {
return .failure(.tooLittleMovement)
}
// Front nach unten heisst: das Heck steht höher, die Längsneigung des
// Fahrzeugs ist also positiv.
orientation.invertLongitudinal = longitudinal < 0
// Linke Seite nach unten heisst: rechts steht höher, die Querneigung
// ist positiv.
orientation.invertLateral = lateral < 0
// Was nach dem Achsentausch noch übrig ist, ist die Verdrehung um die
// Hochachse. Beim Kippen der Front nach unten dürfte sich nur die
// Längsneigung ändern; wandert die Querneigung mit, sitzt der Sensor
// schräg und zwar um genau diesen Winkel.
//
// Gemessen wird an der Frontbewegung, nicht an der Seitenbewegung: Die
// Front lässt sich am Fahrzeug genauer treffen, und beide Schritte
// gemeinsam auszuwerten brächte hier nichts, weil eine Verdrehung auf
// beide gleich wirkt.
let corrected = orientation.apply(pitch: nose.pitch, roll: nose.roll)
if let long = corrected.pitch, let lat = corrected.roll {
let residual = atan2(lat, long) * 180 / .pi
// Unter zwei Grad ist es Messrauschen. Zwei Kippbewegungen von Hand
// sind nicht genauer, und eine erfundene Verdrehung wäre schlimmer
// als keine.
orientation.twist = abs(residual) >= minimumTwist ? residual : 0
}
return .success(orientation)
}
}