Skip to content

Commit ed77505

Browse files
authored
fix: smooth fan curve cooldown behavior (#16)
make fan curve hysteresis follow rising temperatures immediately while holding cooldown inside the deadband, add optional fall_slew_rate for gentler RPM drops, and update the cooler profile guidance.
1 parent f7b34d5 commit ed77505

6 files changed

Lines changed: 112 additions & 36 deletions

File tree

Sources/PolicyEngine/PolicyEngine.swift

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -330,14 +330,16 @@ public struct FanCurve: Codable, Equatable, Sendable {
330330
public var points: [FanCurvePoint]
331331
public var hysteresisCelsius: Double
332332
public var slewRateRPMPerSecond: Double?
333+
public var fallSlewRateRPMPerSecond: Double?
333334

334335
public init(
335336
name: String,
336337
sensors: [String] = [],
337338
sensorWeights: [String: Double] = [:],
338339
points: [FanCurvePoint],
339340
hysteresisCelsius: Double = 0,
340-
slewRateRPMPerSecond: Double? = nil
341+
slewRateRPMPerSecond: Double? = nil,
342+
fallSlewRateRPMPerSecond: Double? = nil
341343
) throws {
342344
let sortedPoints = points.sorted { $0.temperatureCelsius < $1.temperatureCelsius }
343345
guard sortedPoints.count >= 2 else {
@@ -352,6 +354,7 @@ public struct FanCurve: Codable, Equatable, Sendable {
352354
self.points = sortedPoints
353355
self.hysteresisCelsius = max(0, hysteresisCelsius)
354356
self.slewRateRPMPerSecond = slewRateRPMPerSecond.map { max(0, $0) }
357+
self.fallSlewRateRPMPerSecond = fallSlewRateRPMPerSecond.map { max(0, $0) }
355358
}
356359

357360
public static func quiet(maxRPM: Double) -> FanCurve {
@@ -368,7 +371,8 @@ public struct FanCurve: Codable, Equatable, Sendable {
368371
FanCurvePoint(105, maxRPM)
369372
],
370373
hysteresisCelsius: 3,
371-
slewRateRPMPerSecond: 400
374+
slewRateRPMPerSecond: 400,
375+
fallSlewRateRPMPerSecond: 180
372376
)
373377
}
374378

@@ -416,18 +420,19 @@ public struct FanCurveEngine: Sendable {
416420
now: Date
417421
) throws -> FanCurveEvaluation {
418422
let aggregated = try Self.aggregate(samples: samples, sensors: curve.sensors, weights: curve.sensorWeights)
419-
let effectiveTemperature: Double
420-
if
421-
let previous = lastEffectiveTemperature,
422-
abs(aggregated - previous) <= curve.hysteresisCelsius
423-
{
424-
effectiveTemperature = previous
425-
} else {
426-
effectiveTemperature = aggregated
427-
}
423+
let effectiveTemperature = Self.hysteresisAdjustedTemperature(
424+
aggregated: aggregated,
425+
previous: lastEffectiveTemperature,
426+
hysteresis: curve.hysteresisCelsius
427+
)
428428

429429
let interpolated = Self.interpolate(points: curve.points, temperature: effectiveTemperature)
430-
let target = slewLimitedTarget(rawTarget: interpolated, slewRate: curve.slewRateRPMPerSecond, now: now)
430+
let target = slewLimitedTarget(
431+
rawTarget: interpolated,
432+
riseSlewRate: curve.slewRateRPMPerSecond,
433+
fallSlewRate: curve.fallSlewRateRPMPerSecond,
434+
now: now
435+
)
431436

432437
lastEffectiveTemperature = effectiveTemperature
433438
lastRPM = target
@@ -440,6 +445,19 @@ public struct FanCurveEngine: Sendable {
440445
)
441446
}
442447

448+
static func hysteresisAdjustedTemperature(aggregated: Double, previous: Double?, hysteresis: Double) -> Double {
449+
guard let previous, hysteresis > 0 else {
450+
return aggregated
451+
}
452+
if aggregated >= previous {
453+
return aggregated
454+
}
455+
if aggregated >= previous - hysteresis {
456+
return previous
457+
}
458+
return aggregated
459+
}
460+
443461
public static func interpolate(points: [FanCurvePoint], temperature: Double) -> Double {
444462
let sorted = points.sorted { $0.temperatureCelsius < $1.temperatureCelsius }
445463
guard let first = sorted.first, let last = sorted.last else {
@@ -497,19 +515,26 @@ public struct FanCurveEngine: Sendable {
497515
return selected.map(\.celsius).max() ?? 0
498516
}
499517

500-
private func slewLimitedTarget(rawTarget: Double, slewRate: Double?, now: Date) -> Double {
518+
private func slewLimitedTarget(rawTarget: Double, riseSlewRate: Double?, fallSlewRate: Double?, now: Date) -> Double {
501519
guard
502-
let slewRate,
503-
slewRate > 0,
504520
let lastRPM,
505521
let lastEvaluationDate
506522
else {
507523
return rawTarget
508524
}
509-
let maxDelta = slewRate * max(0, now.timeIntervalSince(lastEvaluationDate))
525+
let elapsed = max(0, now.timeIntervalSince(lastEvaluationDate))
510526
if rawTarget > lastRPM {
527+
guard let slewRate = riseSlewRate, slewRate > 0 else {
528+
return rawTarget
529+
}
530+
let maxDelta = slewRate * elapsed
511531
return min(rawTarget, lastRPM + maxDelta)
512532
}
533+
let slewRate = fallSlewRate ?? riseSlewRate
534+
guard let slewRate, slewRate > 0 else {
535+
return rawTarget
536+
}
537+
let maxDelta = slewRate * elapsed
513538
return max(rawTarget, lastRPM - maxDelta)
514539
}
515540
}

Sources/SMCtlDaemonCore/Daemon.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ struct FanCurveConfig: Codable, Equatable, Sendable {
149149
var points: [[FanPointValue]]
150150
var hysteresis: Double
151151
var slew_rate: Double?
152+
var fall_slew_rate: Double?
152153
var weights: [String: Double]?
153154

154155
init(
@@ -157,29 +158,32 @@ struct FanCurveConfig: Codable, Equatable, Sendable {
157158
points: [[FanPointValue]],
158159
hysteresis: Double = 0,
159160
slew_rate: Double? = nil,
161+
fall_slew_rate: Double? = nil,
160162
weights: [String: Double]? = nil
161163
) {
162164
self.name = name
163165
self.sensors = sensors
164166
self.points = points
165167
self.hysteresis = hysteresis
166168
self.slew_rate = slew_rate
169+
self.fall_slew_rate = fall_slew_rate
167170
self.weights = weights
168171
}
169172

170173
enum CodingKeys: String, CodingKey {
171-
case name, sensors, points, hysteresis, slew_rate, weights
174+
case name, sensors, points, hysteresis, slew_rate, fall_slew_rate, weights
172175
}
173176

174-
// Custom decoding so `hysteresis`/`slew_rate` are optional (default rather than
175-
// keyNotFound) and accept TOML integers — see decodeLenientDoubleIfPresent (#9).
177+
// Custom decoding so optional numeric tuning fields default rather than throwing
178+
// keyNotFound and accept TOML integers — see decodeLenientDoubleIfPresent (#9).
176179
init(from decoder: Decoder) throws {
177180
let container = try decoder.container(keyedBy: CodingKeys.self)
178181
name = try container.decode(String.self, forKey: .name)
179182
sensors = try container.decodeIfPresent([String].self, forKey: .sensors) ?? []
180183
points = try container.decode([[FanPointValue]].self, forKey: .points)
181184
hysteresis = try container.decodeLenientDoubleIfPresent(forKey: .hysteresis) ?? 0
182185
slew_rate = try container.decodeLenientDoubleIfPresent(forKey: .slew_rate)
186+
fall_slew_rate = try container.decodeLenientDoubleIfPresent(forKey: .fall_slew_rate)
183187
weights = try container.decodeLenientDoubleDictionaryIfPresent(forKey: .weights)
184188
}
185189
}
@@ -1019,7 +1023,8 @@ public final class SmctlDaemon: @unchecked Sendable {
10191023
sensorWeights: curve.weights ?? [:],
10201024
points: points,
10211025
hysteresisCelsius: curve.hysteresis,
1022-
slewRateRPMPerSecond: curve.slew_rate
1026+
slewRateRPMPerSecond: curve.slew_rate,
1027+
fallSlewRateRPMPerSecond: curve.fall_slew_rate
10231028
)
10241029
}
10251030
}
@@ -1232,6 +1237,9 @@ public final class SmctlDaemon: @unchecked Sendable {
12321237
if let slewRate = curve.slew_rate {
12331238
text += "slew_rate = \(slewRate)\n"
12341239
}
1240+
if let fallSlewRate = curve.fall_slew_rate {
1241+
text += "fall_slew_rate = \(fallSlewRate)\n"
1242+
}
12351243
if let weights = curve.weights, !weights.isEmpty {
12361244
let pairs = weights.sorted { $0.key < $1.key }.map { "\"\($0.key)\" = \($0.value)" }.joined(separator: ", ")
12371245
text += "weights = { \(pairs) }\n"

Tests/PolicyEngineTests/FanCurveEngineTests.swift

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ final class FanCurveEngineTests: XCTestCase {
3333
)
3434
}
3535

36-
func testHysteresisIsBidirectionalDeadZone() throws {
36+
func testHysteresisFollowsHeatingAndHoldsCooldown() throws {
3737
let curve = try FanCurve(
3838
name: "custom",
3939
points: [
@@ -53,7 +53,7 @@ final class FanCurveEngineTests: XCTestCase {
5353
samples: [FanTemperatureSample(sensor: "cpu", celsius: 62.9)],
5454
now: Date(timeIntervalSince1970: 1)
5555
)
56-
let outsideUp = try engine.evaluate(
56+
let furtherUp = try engine.evaluate(
5757
curve: curve,
5858
samples: [FanTemperatureSample(sensor: "cpu", celsius: 63.1)],
5959
now: Date(timeIntervalSince1970: 2)
@@ -65,15 +65,15 @@ final class FanCurveEngineTests: XCTestCase {
6565
)
6666
let outsideDown = try engine.evaluate(
6767
curve: curve,
68-
samples: [FanTemperatureSample(sensor: "cpu", celsius: 59)],
68+
samples: [FanTemperatureSample(sensor: "cpu", celsius: 59.9)],
6969
now: Date(timeIntervalSince1970: 4)
7070
)
7171

7272
XCTAssertEqual(first.effectiveTemperature, 60, accuracy: 0.01)
73-
XCTAssertEqual(insideUp.effectiveTemperature, 60, accuracy: 0.01)
74-
XCTAssertEqual(outsideUp.effectiveTemperature, 63.1, accuracy: 0.01)
73+
XCTAssertEqual(insideUp.effectiveTemperature, 62.9, accuracy: 0.01)
74+
XCTAssertEqual(furtherUp.effectiveTemperature, 63.1, accuracy: 0.01)
7575
XCTAssertEqual(insideDown.effectiveTemperature, 63.1, accuracy: 0.01)
76-
XCTAssertEqual(outsideDown.effectiveTemperature, 59, accuracy: 0.01)
76+
XCTAssertEqual(outsideDown.effectiveTemperature, 59.9, accuracy: 0.01)
7777
}
7878

7979
func testSlewRateLimitsRpmChangesPerSecondInBothDirections() throws {
@@ -110,4 +110,37 @@ final class FanCurveEngineTests: XCTestCase {
110110
XCTAssertEqual(down.unclampedRPM, 1000, accuracy: 0.01)
111111
XCTAssertEqual(down.targetRPM, 1500, accuracy: 0.01)
112112
}
113+
114+
func testFallSlewRateCanBeSlowerThanRiseSlewRate() throws {
115+
let curve = try FanCurve(
116+
name: "custom",
117+
points: [
118+
FanCurvePoint(50, 1000),
119+
FanCurvePoint(90, 5000)
120+
],
121+
slewRateRPMPerSecond: 1000,
122+
fallSlewRateRPMPerSecond: 200
123+
)
124+
var engine = FanCurveEngine()
125+
let start = Date(timeIntervalSince1970: 0)
126+
127+
_ = try engine.evaluate(
128+
curve: curve,
129+
samples: [FanTemperatureSample(sensor: "cpu", celsius: 50)],
130+
now: start
131+
)
132+
let up = try engine.evaluate(
133+
curve: curve,
134+
samples: [FanTemperatureSample(sensor: "cpu", celsius: 90)],
135+
now: start.addingTimeInterval(1)
136+
)
137+
let down = try engine.evaluate(
138+
curve: curve,
139+
samples: [FanTemperatureSample(sensor: "cpu", celsius: 50)],
140+
now: start.addingTimeInterval(2)
141+
)
142+
143+
XCTAssertEqual(up.targetRPM, 2000, accuracy: 0.01)
144+
XCTAssertEqual(down.targetRPM, 1800, accuracy: 0.01)
145+
}
113146
}

Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ final class SmctlDaemonTests: XCTestCase {
357357
sensors = []
358358
points = [[0, "max"], [105, 2000]]
359359
hysteresis = 3
360+
slew_rate = 600
361+
fall_slew_rate = 180
360362
""".write(toFile: configPath, atomically: true, encoding: .utf8)
361363

362364
let daemon = makeDaemon(backend: RecordingBackend(), capabilities: .oneFan)
@@ -368,6 +370,8 @@ final class SmctlDaemonTests: XCTestCase {
368370
XCTAssertEqual(daemon.config.safety.temp_ceiling, 95)
369371
let curve = try XCTUnwrap(daemon.config.fan.curves.first)
370372
XCTAssertEqual(curve.hysteresis, 3)
373+
XCTAssertEqual(curve.slew_rate, 600)
374+
XCTAssertEqual(curve.fall_slew_rate, 180)
371375
XCTAssertEqual(curve.points, [[.number(0), .maximum], [.number(105), .number(2000)]])
372376
}
373377

docs/design.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ locked ──直写 modeKey=1 成功──► manual (M1/M5 路径)
124124
### 5.4 风扇曲线引擎(PolicyEngine)
125125

126126
- 曲线 = 折线段:`[(温度, RPM), ...]`,输入为**多传感器加权聚合值**(可配权重,默认取 max)
127-
- **滞回**双阈值死区(升温越过 T 才升档,降温越过 T−delta 才降档),杜绝转速抖动——与充电状态机共用同一个 hysteresis 抽象
128-
- **变化率限制**:RPM 目标变化做 slew rate 限制,避免风扇忽高忽低的体感噪音
127+
- **滞回**升温及时跟随,降温必须低于上次有效温度一段 `hysteresis` 后才下调,避免临界点附近反复升降
128+
- **变化率限制**:RPM 目标变化做 slew rate 限制,支持升速与降速分开配置;降速通常更慢,避免“刚转起来又马上掉下去”的体感噪音
129129
- profile:`quiet` / `auto` / `full` / 自定义曲线,TOML 定义
130130

131131
### 5.5 唤醒恢复
@@ -207,6 +207,8 @@ name = "custom"
207207
sensors = ["cpu", "gpu"] # 聚合:默认 max,可加权
208208
points = [[50, 0], [65, 2000], [80, 4000], [95, "max"]]
209209
hysteresis = 3 #
210+
slew_rate = 600 # RPM/s, rising target
211+
fall_slew_rate = 180 # RPM/s, falling target; defaults to slew_rate when omitted
210212

211213
[safety]
212214
temp_ceiling = 100 # 温度护栏(℃),不可关闭,硬上限 105;连续 2 tick 超限才触发

docs/fan-profiles.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ Apple 没有公开 SMC 温度 key 的稳定语义。下面的前缀只能当经
6060
name = "cooler"
6161
sensors = ["Tp3P", "Tp0E", "Tp06"]
6262
points = [[75, 1000], [85, 1600], [95, 2600], [105, 4200], [110, "max"]]
63-
hysteresis = 2
64-
slew_rate = 800
63+
hysteresis = 4
64+
slew_rate = 600
65+
fall_slew_rate = 180
6566
```
6667

6768
daemon 每秒执行一次曲线:
@@ -73,8 +74,9 @@ daemon 每秒执行一次曲线:
7374
5. 如果配置了 `weights`,就按权重算加权平均。只有正权重会生效。
7475
6.`points` 做线性插值,得到目标 RPM。
7576
7. daemon 会把目标 RPM 限制在风扇上报的最小/最大转速内。`"max"` 会展开成每个风扇自己的最大转速。
76-
8. `hysteresis` 控制温度死区,避免风扇在临界点来回抖动。
77-
9. `slew_rate` 控制每秒最大 RPM 变化,避免转速突然跳变。
77+
8. `hysteresis` 控制降温死区。温度升高时曲线会及时跟随;温度下降时,必须比上次有效温度低出这段余量,才会开始降低目标转速。
78+
9. `slew_rate` 控制升速时每秒最大 RPM 变化,避免目标转速突然上跳。
79+
10. `fall_slew_rate` 控制降速时每秒最大 RPM 变化。它通常应该小于 `slew_rate`,让风扇把热量继续带走一会儿,而不是刚碰到临界温度就立刻降速。
7880

7981
曲线启用后,`smctl fan status` 里风扇模式会显示 `manual`。这是正常现象。曲线本质上是 daemon 托管的手动目标转速,不是一次性的 `smctl fan set`
8082

@@ -90,8 +92,9 @@ profile = "cooler"
9092
name = "cooler"
9193
sensors = ["Tp3P", "Tp0E", "Tp06", "Tp02", "Tp09", "Tp05", "Tp01"]
9294
points = [[75, 1000], [85, 1600], [95, 2600], [105, 4200], [110, "max"]]
93-
hysteresis = 2
94-
slew_rate = 800
95+
hysteresis = 4
96+
slew_rate = 600
97+
fall_slew_rate = 180
9598
```
9699

97100
这些点的含义:
@@ -101,8 +104,9 @@ slew_rate = 800
101104
- `95C -> 2600 RPM`:持续负载下开始真正带走热量,但不直接拉满。
102105
- `105C -> 4200 RPM`:接近热点高位时快速加强冷却。
103106
- `110C -> max`:接近热点硬上限时不再保留余量。
104-
- `hysteresis = 2`:温度小幅波动时不频繁调整目标转速。
105-
- `slew_rate = 800`:允许风扇足够快地跟上热点变化,但不会瞬间大跳。
107+
- `hysteresis = 4`:温度下降时保留 4C 的回落余量,避免在 95C、105C 这类临界点附近反复升降。
108+
- `slew_rate = 600`:升速仍然足够快,热点真的升高时不会拖太久。
109+
- `fall_slew_rate = 180`:降速更慢。风扇会继续带走热量,听感也不会出现“刚转起来又马上掉下去”。
106110

107111
保留原有 `[safety]`。不要通过提高安全阈值来掩盖曲线太慢的问题。根本方案是更早从正确传感器提速,让安全护栏只做最后兜底。
108112

0 commit comments

Comments
 (0)