Skip to content

Commit a67c2f2

Browse files
Producer GuyProducer Guy
authored andcommitted
Fix all 8 audit issues from unified threshold review
Medium: start() no longer bypasses curve for Max — tick() handles all profiles through proportional curves and sustained trigger. Low: removed dead alwaysOn code from switchProfile(), selectProfile(), and the convenience initializer. Fixed stale default startTemp (60→55). High: README Smart documentation corrected — 53-85°C range (was 60-85), hysteresis 53/50 with 3°C gap (was 60/55 with 5°C gap).
1 parent b1dd58e commit a67c2f2

4 files changed

Lines changed: 10 additions & 34 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ Apple doesn't do this because silence sells in store demos and most users never
127127

128128
### How Smart works
129129

130-
**The curve:** Smart maps temperature to fan speed across a 60–85°C range. Below 60°C, fans stay off. Above 85°C, fans go to max. Between those points, fan speed scales proportionally using an S-curve (gentle at low temps, steeper approaching the ceiling).
130+
**The curve:** Smart maps temperature to fan speed across a 53–85°C range. Below 50°C, fans turn off. Between 50–53°C, fans maintain current state (hysteresis). Above 85°C, fans go to max. Between those points, fan speed scales proportionally using an S-curve (gentle at low temps, steeper approaching the ceiling).
131131

132132
**Rate-of-change awareness:** Smart doesn't just look at where temperature is — it looks at how fast it's moving. If temp is rising at 1°C/sec, Smart boosts fan speed proportionally to get ahead of the climb. If temp is stable or falling, Smart holds steady or eases off gradually.
133133

@@ -137,7 +137,7 @@ Apple doesn't do this because silence sells in store demos and most users never
137137

138138
**Ramp governors:** Fan speed changes are rate-limited to match Apple's hardware behavior. Ramp up at ~400 RPM/sec, ramp down at ~200 RPM/sec. This prevents acoustic shock, reduces mechanical stress, and extends fan bearing lifespan by up to 50% compared to abrupt speed changes (source: [NMB fan engineering](https://nmbtc.com/white-papers/dc-brushless-cooling-fan-behavior/), [Analog Devices ADM1031 datasheet](https://www.onsemi.com/download/data-sheet/pdf/adm1031-d.pdf)).
139139

140-
**Hysteresis:** Fans turn on at 60°C and turn off at 55°C — a 5°C gap. This prevents rapid on/off cycling, which is the #1 cause of fan bearing wear in fluid dynamic bearing fans (source: [Nidec FDB technology](https://www.nidec.com/en/technology/capability/fdb/), [AnandTech fan lifespan discussion](https://forums.anandtech.com/threads/fan-stop-start-effect-on-lifespan.2284098/)).
140+
**Hysteresis:** Fans turn on at 53°C (after 8 seconds sustained) and turn off at 50°C — a 3°C gap. All other profiles use 55°C start with a 5°C gap. This prevents rapid on/off cycling, which is the #1 cause of fan bearing wear in fluid dynamic bearing fans (source: [Nidec FDB technology](https://www.nidec.com/en/technology/capability/fdb/), [AnandTech fan lifespan discussion](https://forums.anandtech.com/threads/fan-stop-start-effect-on-lifespan.2284098/)).
141141

142142
**0 to minimum RPM is binary:** Apple Silicon MacBook fans cannot spin below their minimum RPM (2317 on M5 Max, 1200 on M1 Max). When Smart decides fans should run, they jump directly to minimum — this is a hardware limitation of brushless DC motors that require a startup burst to overcome static friction. Above minimum, all speed changes are smooth and governed.
143143

Sources/ThermalForgeApp/AppState.swift

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,17 +124,14 @@ final class AppState: ObservableObject {
124124
monitor?.switchProfile(profile)
125125
TFLogger.shared.profile("Selected: \(profile.name)")
126126

127-
// Only Max applies fans immediatelyall other profiles
128-
// are handled by tick() curve evaluation each cycle
127+
// All profiles use proportional curvestick() handles fan engagement.
128+
// Reset to auto on profile change so tick() starts from a clean state.
129129
do {
130-
if profile.curve.alwaysOn {
131-
try executor.execute(.setMax)
132-
} else if profile.curve.handsOff || profile.id == "smart" {
133-
// Silent and Smart: tick() handles it, reset to auto first
130+
if profile.curve.handsOff || profile.id == "smart" || profile.id == "silent" {
134131
try executor.execute(.resetAuto)
135132
}
136-
// Balanced/Performance: tick() will ramp proportionally
137-
// based on current temperature — no immediate fan command
133+
// Balanced/Performance/Max: tick() will ramp proportionally
134+
// based on current temperature after sustained trigger is met
138135
} catch {
139136
TFLogger.shared.error("Profile \(profile.name) failed: \(error)")
140137
}

Sources/ThermalForgeCore/Profile.swift

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public struct FanProfile: Codable, Identifiable, Equatable {
4444
/// If true, fans are always at maxRPMPercent regardless of temperature.
4545
public let alwaysOn: Bool
4646

47-
public init(stopTemp: Float = 50, startTemp: Float = 60, ceilingTemp: Float = 70,
47+
public init(stopTemp: Float = 50, startTemp: Float = 55, ceilingTemp: Float = 70,
4848
maxRPMPercent: Float = 0.6, handsOff: Bool = false, alwaysOn: Bool = false) {
4949
self.stopTemp = stopTemp
5050
self.startTemp = startTemp
@@ -209,16 +209,3 @@ extension FanProfile {
209209
public static let hysteresisDegrees: Float = 5.0
210210
}
211211

212-
// MARK: - Curve Initializer Convenience
213-
214-
extension FanProfile.Curve {
215-
/// Convenience for always-on profiles (Max)
216-
public init(alwaysOn: Bool, maxRPMPercent: Float) {
217-
self.stopTemp = 0
218-
self.startTemp = 0
219-
self.ceilingTemp = 0
220-
self.maxRPMPercent = maxRPMPercent
221-
self.handsOff = false
222-
self.alwaysOn = alwaysOn
223-
}
224-
}

Sources/ThermalForgeCore/ThermalMonitor.swift

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,7 @@ public final class ThermalMonitor {
9494
public func start(interval: TimeInterval = 2.0) {
9595
stop()
9696

97-
// If profile is Max, apply immediately
98-
if activeProfile.id == "max" {
99-
applyCommand(.setMax)
100-
state = .active(profileName: "Max")
101-
}
97+
// All profiles use proportional curves now — tick() handles engagement
10298

10399
let timer = DispatchSource.makeTimerSource(queue: queue)
104100
timer.schedule(deadline: .now(), repeating: interval)
@@ -134,11 +130,7 @@ public final class ThermalMonitor {
134130
}
135131
}
136132

137-
if profile.curve.alwaysOn {
138-
state = .active(profileName: profile.name)
139-
} else {
140-
state = .idle
141-
}
133+
state = .idle
142134
}
143135
}
144136

0 commit comments

Comments
 (0)