204 lines
9.4 KiB
Swift
204 lines
9.4 KiB
Swift
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)
|
||
}
|
||
}
|