Bisher standen dort zwei Angaben nebeneinander, etwa "rechts 8 cm" und "vorne 4 cm". Das liest sich wie zwei Aufgaben, ist aber eine: Unter "rechts" liegen zwei Räder, unter "vorne" auch, und eines davon ist dasselbe. Ausführen lässt sich das so nicht. Gerechnet wird jetzt über alle vier Aufstandspunkte auf einmal. Aus Spurweite, Radstand und den beiden Winkeln ergeben sich deren Höhen; angehoben wird auf das höchststehende Rad, das liegen bleibt. Aus dem Beispiel oben werden damit 12 cm vorne rechts, 8 cm hinten rechts, 4 cm vorne links und nichts hinten links – dieselbe Geometrie, nur richtig zusammengesetzt. Angezeigt wird das als Draufsicht auf das Fahrzeug (WheelLiftPlan), auf iPhone und Uhr dieselbe Ansicht, auf der Uhr kompakt. LevelingWedge entfällt. Abgesichert in run-tests.sh: reine Querneigung, reine Längsneigung, beides zusammen (16,1 / 9,2 / 7,0 / 0 cm), gespiegelte Vorzeichen und die Fälle ohne Fahrzeugmasse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
7.8 KiB
Swift
214 lines
7.8 KiB
Swift
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 jedes einzelne Rad unterlegt werden muss.
|
||
///
|
||
/// Getrennte Angaben wie „rechts 8 cm" und „vorne 4 cm" beschreiben dasselbe
|
||
/// Fahrzeug und lassen sich nicht getrennt ausführen: Unter „rechts" liegen
|
||
/// zwei Räder, unter „vorne" auch, und zwei davon sind dieselben. Gefragt ist
|
||
/// deshalb die Höhe **je Rad**.
|
||
///
|
||
/// Das ist reine Geometrie und damit exakt. Das Fahrzeug steht auf vier
|
||
/// Punkten; deren Höhen ergeben sich aus Spurweite, Radstand und den beiden
|
||
/// Winkeln. Angehoben wird auf die Höhe des höchsten Rades – das bleibt
|
||
/// liegen, die anderen bekommen die Differenz.
|
||
struct LevelingLift: Equatable {
|
||
|
||
enum Wheel: CaseIterable, Hashable {
|
||
case frontLeft, frontRight, rearLeft, rearRight
|
||
|
||
var text: String {
|
||
switch self {
|
||
case .frontLeft: return "vorne links"
|
||
case .frontRight: return "vorne rechts"
|
||
case .rearLeft: return "hinten links"
|
||
case .rearRight: return "hinten rechts"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Höhen in Metern, je Rad. Das höchste Rad steht auf 0.
|
||
let frontLeft: Double
|
||
let frontRight: Double
|
||
let rearLeft: Double
|
||
let rearRight: Double
|
||
|
||
func height(_ wheel: Wheel) -> Double {
|
||
switch wheel {
|
||
case .frontLeft: return frontLeft
|
||
case .frontRight: return frontRight
|
||
case .rearLeft: return rearLeft
|
||
case .rearRight: return rearRight
|
||
}
|
||
}
|
||
|
||
func centimetres(_ wheel: Wheel) -> Double { height(wheel) * 100 }
|
||
|
||
/// Das am tiefsten stehende Rad – dort liegt die grösste Höhe.
|
||
var deepest: Wheel {
|
||
Wheel.allCases.max { height($0) < height($1) } ?? .frontLeft
|
||
}
|
||
|
||
var maximumCentimetres: Double { centimetres(deepest) }
|
||
|
||
/// Unter einem Zentimeter lohnt kein Keil; so genau steht kein Fahrzeug,
|
||
/// und so genau misst der Sensor auch nicht.
|
||
var isNegligible: Bool { maximumCentimetres < 1 }
|
||
|
||
/// `pitch` positiv heisst, das Heck steht höher; `roll` positiv, die rechte
|
||
/// Seite steht höher. Ohne Fahrzeugmasse gibt es nichts zu rechnen.
|
||
static func compute(pitch: Double,
|
||
roll: Double,
|
||
trackWidth: Double,
|
||
wheelbase: Double) -> LevelingLift? {
|
||
guard trackWidth > 0, wheelbase > 0 else { return nil }
|
||
|
||
// Wie viel höher die rechte Seite und wie viel höher das Heck steht.
|
||
// Vorzeichen inbegriffen: tan(-2°) ist negativ, dann steht links höher.
|
||
let rightRise = tan(roll * .pi / 180) * trackWidth
|
||
let rearRise = tan(pitch * .pi / 180) * wheelbase
|
||
|
||
// Höhen der vier Aufstandspunkte, bezogen auf vorne links.
|
||
let heights: [Wheel: Double] = [
|
||
.frontLeft: 0,
|
||
.frontRight: rightRise,
|
||
.rearLeft: rearRise,
|
||
.rearRight: rightRise + rearRise,
|
||
]
|
||
let top = heights.values.max() ?? 0
|
||
|
||
return LevelingLift(frontLeft: top - heights[.frontLeft]!,
|
||
frontRight: top - heights[.frontRight]!,
|
||
rearLeft: top - heights[.rearLeft]!,
|
||
rearRight: top - heights[.rearRight]!)
|
||
}
|
||
}
|