first commit

This commit is contained in:
fototeddy
2026-09-04 21:30:51 +02:00
commit da64b1b6bd
160 changed files with 20664 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
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)
}
}