Skip to content
This repository was archived by the owner on Dec 9, 2025. It is now read-only.

Commit a34826f

Browse files
committed
Switch Climb view to Mach above FL180
1 parent 631dd2b commit a34826f

File tree

5 files changed

+119
-20
lines changed

5 files changed

+119
-20
lines changed

SF50 Shared/Helpers/formatters.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ extension FormatStyle where Self == FloatingPointFormatStyle<Double> {
1111
public static var rateOfClimb: Self { .number.rounded(increment: 1) }
1212
public static var gradient: Self { .number.rounded(increment: 1) }
1313
public static var speed: Self { .number.rounded(increment: 1) }
14+
public static var mach: Self { .number.precision(.fractionLength(3)) }
1415
public static var temperature: Self { .number.rounded(increment: 1) }
1516
public static var airPressure: Self { .number.rounded(increment: 0.01) }
1617
public static var heading: Self { .number.rounded(increment: 1) }

SF50 Shared/Performance/ViewModel/ClimbPerformanceViewModel.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,29 @@ public final class ClimbPerformanceViewModel {
6969
public private(set) var climbRate: Value<Measurement<UnitSpeed>>
7070
public private(set) var climbGradient: Value<Measurement<UnitSlope>>
7171

72+
/// Approximates TAS from IAS using altitude (pressure ratio)
73+
public var climbSpeedTAS: Value<Measurement<UnitSpeed>> {
74+
climbSpeed.map { IAS, uncertainty in
75+
let altFeet = altitude.converted(to: .feet).value
76+
// TAS ≈ IAS / sqrt(σ), where σ ≈ (1 - altFeet/145442)^4.255876
77+
let sigma = pow(1.0 - altFeet / 145442.0, 4.255876)
78+
let TASMultiplier = 1.0 / sqrt(sigma)
79+
let TAS = Measurement(value: IAS.value * TASMultiplier, unit: IAS.unit)
80+
let uncert = uncertainty.map { Measurement(value: $0.value * TASMultiplier, unit: $0.unit) }
81+
return (TAS, uncert)
82+
}
83+
}
84+
85+
/// Mach number for current climb speed
86+
public var climbMach: Value<Double> {
87+
climbSpeedTAS.flatMap { TAS in
88+
let tempKelvin = OAT.converted(to: .kelvin).value
89+
let speedOfSound = 38.967854 * sqrt(tempKelvin)
90+
let TASKnots = TAS.converted(to: .knots).value
91+
return .value(TASKnots / speedOfSound)
92+
}
93+
}
94+
7295
// MARK: Private
7396

7497
private var model: PerformanceModel?

SF50 TOLD/Localizable.xcstrings

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
"comment" : "Label to display when a value is not available.",
1313
"isCommentAutoGenerated" : true
1414
},
15+
"(%@)" : {
16+
"comment" : "A small, secondary text label below the main climb speed display, showing the value of the secondary airspeed measurement.",
17+
"isCommentAutoGenerated" : true
18+
},
1519
"(turf)" : {
1620

1721
},
@@ -650,6 +654,10 @@
650654
"comment" : "Label for \"Landing Weight\" column in the landing performance table.",
651655
"isCommentAutoGenerated" : true
652656
},
657+
"M" : {
658+
"comment" : "The letter \"M\" used in the Mach display.",
659+
"isCommentAutoGenerated" : true
660+
},
653661
"Meets G/A Req?" : {
654662
"comment" : "Header for the column in the landing performance table that indicates whether the landing meets the required go-around distance.",
655663
"isCommentAutoGenerated" : true

SF50 TOLD/Views/Performance/Climb/ClimbResultsView.swift

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ struct ClimbResultsView: View {
99
@Default(.speedUnit)
1010
private var speedUnit
1111

12+
private var showMach: Bool {
13+
performance.altitude.converted(to: .feet).value >= 18400
14+
}
15+
1216
var body: some View {
1317
Section {
1418
// Prominent climb speed display
@@ -17,14 +21,44 @@ struct ClimbResultsView: View {
1721
.font(.headline)
1822
.foregroundStyle(.secondary)
1923

20-
InterpolationView(
21-
value: performance.climbSpeed,
22-
displayValue: { speed in
23-
Text(speed.converted(to: speedUnit), format: .speed)
24-
.font(.system(size: 48, weight: .bold, design: .rounded))
24+
if showMach {
25+
// Mach display with secondary airspeed
26+
InterpolationView(
27+
value: performance.climbMach,
28+
displayValue: { mach in
29+
HStack(alignment: .firstTextBaseline, spacing: 4) {
30+
Text("M")
31+
.font(.system(size: 48, weight: .light, design: .rounded))
32+
Text(mach, format: .mach)
33+
.font(.system(size: 48, weight: .bold, design: .rounded))
34+
.padding(.trailing, 4)
35+
36+
// Secondary IAS display
37+
if case .value(let speed) = performance.climbSpeed {
38+
Text(speed.converted(to: speedUnit), format: .speed)
39+
.font(.title3)
40+
.foregroundStyle(.secondary)
41+
} else if case .valueWithUncertainty(let speed, _) = performance.climbSpeed {
42+
Text("(\(speed.converted(to: speedUnit), format: .speed))")
43+
.font(.title3)
44+
.foregroundStyle(.secondary)
45+
}
46+
}
47+
.accessibilityElement(children: .combine)
2548
.accessibilityIdentifier("climbSpeedValue")
26-
}
27-
)
49+
}
50+
)
51+
} else {
52+
// Standard IAS display
53+
InterpolationView(
54+
value: performance.climbSpeed,
55+
displayValue: { speed in
56+
Text(speed.converted(to: speedUnit), format: .speed)
57+
.font(.system(size: 48, weight: .bold, design: .rounded))
58+
.accessibilityIdentifier("climbSpeedValue")
59+
}
60+
)
61+
}
2862
}
2963
.frame(maxWidth: .infinity)
3064

@@ -67,17 +101,43 @@ struct ClimbResultsView: View {
67101
}
68102
}
69103

70-
#Preview {
104+
#Preview("Offscale Low") {
71105
PreviewView { preview in
106+
// Set defaults BEFORE creating view model (it observes these)
107+
Defaults[.takeoffFuel] = .init(value: 50, unit: .gallons)
108+
72109
let performance = ClimbPerformanceViewModel(container: preview.container)
110+
// Low fuel causes offscale low
111+
performance.altitude = .init(value: 3000, unit: .feet)
112+
113+
return List { ClimbResultsView() }
114+
.environment(performance)
115+
}
116+
}
117+
118+
#Preview("IAS Display") {
119+
PreviewView { preview in
120+
// Set defaults BEFORE creating view model (it observes these)
121+
Defaults[.takeoffFuel] = .init(value: 180, unit: .gallons)
73122

74-
// Set realistic mid-climb conditions
75-
// Need at least 150 gal fuel to reach min weight of 4500 lbs
76-
// (3550 empty + 0 payload + 150 * 6.71 = 4556 lbs)
77-
performance.fuel = .init(value: 180, unit: .gallons)
123+
let performance = ClimbPerformanceViewModel(container: preview.container)
124+
// Mid-altitude climb (below 18,400 ft) - shows IAS
78125
performance.altitude = .init(value: 10000, unit: .feet)
79-
performance.ISADeviation = .init(value: 0, unit: .celsius) // Standard ISA conditions
80-
performance.iceProtection = false
126+
127+
return List { ClimbResultsView() }
128+
.environment(performance)
129+
}
130+
}
131+
132+
#Preview("Mach Display") {
133+
PreviewView { preview in
134+
// Set defaults BEFORE creating view model (it observes these)
135+
Defaults[.takeoffFuel] = .init(value: 180, unit: .gallons)
136+
137+
let performance = ClimbPerformanceViewModel(container: preview.container)
138+
// High altitude climb (above 18,400 ft) - shows Mach
139+
performance.altitude = .init(value: 25000, unit: .feet)
140+
performance.ISADeviation = .init(value: -15, unit: .celsius)
81141

82142
return List { ClimbResultsView() }
83143
.environment(performance)

SF50 TOLDUITests/SF50_TOLDUITests.swift

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,16 +1225,23 @@ final class SF50_TOLDUITests: XCTestCase {
12251225
)
12261226
safetyFactorDryField.clearAndType("1.1", app: app)
12271227

1228-
// Dismiss keyboard if it's up
1229-
app.tap()
1230-
Thread.sleep(forTimeInterval: 0.3)
1228+
// Dismiss keyboard by tapping outside the text field
1229+
if !app.keyboards.isEmpty {
1230+
// Tap on the navigation bar area to dismiss keyboard without affecting navigation
1231+
app.navigationBars.firstMatch.tap()
1232+
Thread.sleep(forTimeInterval: 0.3)
1233+
}
12311234

1232-
// Return to Takeoff tab
1233-
app.tapTab("Takeoff")
1235+
// Return to Takeoff tab - wait for tab bar to be ready
1236+
let takeoffTabButton = app.tabBars.buttons["Takeoff"]
1237+
XCTAssertTrue(
1238+
takeoffTabButton.waitForExistence(timeout: 2),
1239+
"Takeoff tab button should exist in tab bar"
1240+
)
1241+
takeoffTabButton.tap()
12341242
waitForNavigation()
12351243

12361244
// Verify we're actually on the Takeoff tab by checking for tab bar selection
1237-
let takeoffTabButton = app.tabBars.buttons["Takeoff"]
12381245
XCTAssertTrue(takeoffTabButton.isSelected, "Should be on Takeoff tab")
12391246

12401247
// Give extra time for the tab to fully switch and recalculations to complete

0 commit comments

Comments
 (0)