Ausrichtungs-Assistent fürs Rangieren

Begleitet das Einparken: verfolgt die Neigung über die Zeit, meldet ob es
besser oder schlechter wird, und erinnert an den flachsten Punkt - "vor 4
Sekunden stand das Fahrzeug 0,8 Grad flacher". Bei Erreichen der Toleranz
gibt es eine Vibration, das Display bleibt währenddessen wach.

Bewusst ohne Positionsbestimmung. Aus einem MEMS-Sensor lässt sich keine
brauchbare Strecke ableiten: der Fehler wächst beim zweifachen Integrieren
quadratisch mit der Zeit, und im Schritttempo gehen die tatsächlichen
Beschleunigungen im Rauschen unter. Gebraucht wird sie auch nicht - beim
Einparken lautet die Frage nie "wo stehe ich", sondern "wird es besser".
Das steckt vollständig im zeitlichen Verlauf der Neigung, ohne jede
Annahme über das Gelände. Die vorhandene Verlaufsaufzeichnung war mit
einem Punkt alle fünf Sekunden zu grob; der Assistent führt einen eigenen
Ringpuffer, der nur läuft solange die Ansicht offen ist.

Sind Spurweite und Radstand hinterlegt, kommt die nötige Höhe der
Auffahrkeile dazu. Das ist reine Geometrie und damit exakt. Die Masse
stehen im Fahrzeugprofil.

Dabei fiel auf, dass sich vorhandene Fahrzeuge praktisch nicht bearbeiten
liessen: das ging nur über eine Wischgeste, die niemand findet - erst
recht nicht, wenn dort jetzt die Masse einzutragen sind. Die Zeile hat nun
einen sichtbaren Knopf dafür, wie bei den WLAN-Einstellungen: antippen
wählt aus, das Zeichen daneben öffnet die Bearbeitung.

Ausserdem übernimmt der Assistent den anliegenden Messwert beim Öffnen.
Sonst stand dort bis zur nächsten Messung "Warte auf Messwerte", obwohl
längst welche vorlagen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
BiasF
2026-08-30 15:52:57 +02:00
co-authored by Claude Opus 5
parent 826a201a36
commit 767fe4e798
9 changed files with 598 additions and 16 deletions
@@ -0,0 +1,183 @@
import Foundation
/// Hilft beim Ausrichten des Fahrzeugs während des Rangierens.
///
/// Bewusst **ohne** Positionsbestimmung: Aus einem MEMS-Beschleunigungssensor
/// lässt sich keine brauchbare Strecke ableiten, weil der Fehler beim
/// zweifachen Integrieren quadratisch mit der Zeit wächst. Beim Rangieren im
/// Schritttempo gehen die tatsächlichen Beschleunigungen ohnehin im Rauschen
/// unter.
///
/// Gebraucht wird das auch gar nicht. Die Frage beim Einparken lautet nie wo
/// stehe ich, sondern wird es besser oder schlechter, und wo war es am
/// besten. Beides steckt bereits im zeitlichen Verlauf der Neigung ganz
/// ohne Annahmen über das Gelände.
struct AlignmentAssistant {
struct Sample: Equatable {
let time: Date
let pitch: Double
let roll: Double
/// Gesamtabweichung von der Waagerechten.
var deviation: Double { (pitch * pitch + roll * roll).squareRoot() }
}
/// Wie lange zurückgeschaut wird.
static let memory: TimeInterval = 90
/// Ab dieser Verbesserung lohnt der Hinweis auf einen früheren Punkt.
static let worthGoingBack = 0.2
/// Unterhalb dieser Änderung gilt die Lage als unverändert.
static let trendThreshold = 0.08
private(set) var samples: [Sample] = []
enum Trend: Equatable {
case improving
case worsening
case steady
case unknown
var text: String {
switch self {
case .improving: return "Wird besser"
case .worsening: return "Wird schlechter"
case .steady: return "Bleibt gleich"
case .unknown: return "Messe…"
}
}
var symbol: String {
switch self {
case .improving: return "arrow.down.right.circle.fill"
case .worsening: return "arrow.up.right.circle.fill"
case .steady: return "equal.circle.fill"
case .unknown: return "hourglass.circle"
}
}
}
mutating func add(pitch: Double, roll: Double, at time: Date = Date()) {
samples.append(Sample(time: time, pitch: pitch, roll: roll))
let cutoff = time.addingTimeInterval(-Self.memory)
samples.removeAll { $0.time < cutoff }
}
mutating func reset() {
samples.removeAll()
}
var current: Sample? { samples.last }
/// Der flachste Punkt, den wir gesehen haben.
var best: Sample? {
samples.min { $0.deviation < $1.deviation }
}
/// Ob und wie stark sich die Lage gerade ändert.
///
/// Verglichen wird das jüngste Drittel mit dem davorliegenden. Einzelne
/// Messwerte wären zu unruhig; das Fahrzeug wippt beim Rangieren.
var trend: Trend {
guard samples.count >= 6 else { return .unknown }
let recent = samples.suffix(3)
let previous = samples.dropLast(3).suffix(3)
guard !previous.isEmpty else { return .unknown }
let now = recent.map(\.deviation).reduce(0, +) / Double(recent.count)
let before = previous.map(\.deviation).reduce(0, +) / Double(previous.count)
let change = now - before
if abs(change) < Self.trendThreshold { return .steady }
return change < 0 ? .improving : .worsening
}
/// Wieviel besser der beste Punkt gegenüber jetzt war nil, wenn es sich
/// nicht lohnt oder wir gerade selbst am besten Punkt stehen.
var improvementAtBest: Double? {
guard let current, let best, best.time < current.time else { return nil }
let gain = current.deviation - best.deviation
return gain >= Self.worthGoingBack ? gain : nil
}
/// Wie lange der beste Punkt zurückliegt.
var timeSinceBest: TimeInterval? {
guard improvementAtBest != nil, let current, let best else { return nil }
return current.time.timeIntervalSince(best.time)
}
/// Was der Fahrer jetzt tun soll.
var advice: String {
guard let current else { return "Warte auf Messwerte…" }
if current.deviation <= LevelState.levelTolerance {
return "Steht eben anhalten"
}
if let seconds = timeSinceBest {
return String(format: "Vor %.0f s stand es besser ein Stück zurück",
seconds.rounded())
}
switch trend {
case .improving: return "Wird besser weiter so"
case .worsening: return "Wird schlechter andere Richtung"
case .steady: return "Ändert sich kaum andere Richtung versuchen"
case .unknown: return "Langsam weiterfahren"
}
}
var hasReachedTarget: Bool {
(current?.deviation ?? .infinity) <= LevelState.levelTolerance
}
}
/// Wie hoch ein Auffahrkeil sein muss, um eine Neigung auszugleichen.
///
/// Rein geometrisch und damit exakt: Höhe = tan(Winkel) × Abstand der Achsen
/// beziehungsweise der Räder.
struct LevelingWedge: Equatable {
/// Wo der Keil hin muss.
let side: Side
/// Höhe in Metern.
let height: Double
/// Der zugrundeliegende Winkel in Grad.
let angle: Double
enum Side: Equatable {
case front, rear, left, right
var text: String {
switch self {
case .front: return "vorne"
case .rear: return "hinten"
case .left: return "links"
case .right: return "rechts"
}
}
}
var heightInCentimetres: Double { height * 100 }
/// Quer: die tieferliegende Seite muss angehoben werden. Positiver Roll
/// heisst, dass rechts höher steht der Keil gehört also nach links.
static func across(roll: Double, trackWidth: Double) -> LevelingWedge? {
wedge(angle: roll, distance: trackWidth,
whenPositive: .left, whenNegative: .right)
}
/// Längs: positiver Pitch heisst, dass das Heck höher steht der Keil
/// gehört unter die Vorderräder.
static func along(pitch: Double, wheelbase: Double) -> LevelingWedge? {
wedge(angle: pitch, distance: wheelbase,
whenPositive: .front, whenNegative: .rear)
}
private static func wedge(angle: Double,
distance: Double,
whenPositive: Side,
whenNegative: Side) -> LevelingWedge? {
guard distance > 0, abs(angle) > LevelState.levelTolerance else { return nil }
let height = tan(abs(angle) * .pi / 180) * distance
return LevelingWedge(side: angle > 0 ? whenPositive : whenNegative,
height: height,
angle: abs(angle))
}
}