Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions Sources/ThermalForgeCore/FanOffGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// FanOffGate.swift
// ThermalForge
//
// Pure decision logic for the client monitor's fans-off transition.
// Hardware-free so it unit-tests without SMC — ThermalMonitor performs the
// actual fan commands; this type only decides.
//
// One job: releasing fans to Apple's auto mode is CONFIRMED, not instant.
// The peak sensor is a max over all CPU/GPU cores, and an idle core's
// reading falls off a cliff (20-30°C within a tick or two) the moment its
// workload ends. With stopTemp sitting at idle-core resting level the peak
// oscillates across the threshold on a 100ms scale, and an instant release
// hands the fans to Apple mid-ramp over and over — 2,182 releases in three
// days of logs, fans slamming 3000+→1300 RPM every 3-7 seconds. Cold must
// persist for FanProfile.stopSustainSeconds first; until then the fans HOLD
// at minimum (the same treatment the hysteresis band gives), so a
// dip-and-recover never stops the fans.
//

import Foundation

public struct FanOffGate {
/// Release only after the peak has been at/below stopTemp this long
/// (FanProfile.stopSustainSeconds).
public let sustainSeconds: Float
/// The monitor's thermal tick interval, converting seconds to ticks.
public let tickInterval: Float

private var coldTicks = 0

/// - tickInterval: the monitor's tick (default 100ms).
public init(sustainSeconds: Float = FanProfile.stopSustainSeconds,
tickInterval: Float = 0.1) {
self.sustainSeconds = sustainSeconds
self.tickInterval = tickInterval
}

public enum Action: Equatable {
/// Peak left the stop band — the curve drives again; the streak resets.
case resume
/// Cold, but not yet sustained — hold fans at minimum (hysteresis-band
/// treatment). No mode change.
case holdMinimum
/// Sustained cold confirmed — hand the fans back to Apple now.
case release
}

public mutating func evaluate(temp: Float, stopTemp: Float) -> Action {
if temp <= stopTemp {
coldTicks += 1
} else {
coldTicks = 0
}

if Float(coldTicks) * tickInterval >= sustainSeconds {
coldTicks = 0
return .release
}
return coldTicks > 0 ? .holdMinimum : .resume
}
}
10 changes: 10 additions & 0 deletions Sources/ThermalForgeCore/Profile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,4 +289,14 @@ extension FanProfile {
public static let safetyTempThreshold: Float = 95.0
/// Hysteresis deadband to prevent oscillation
public static let hysteresisDegrees: Float = 5.0
/// How long the peak sensor must STAY at/below stopTemp before fans are
/// handed back to Apple's auto mode. The peak is a max over all CPU/GPU
/// cores, and an idle core's reading drops 20-30°C the instant its workload
/// ends, so with stopTemp at idle-core resting level the peak oscillates
/// across the threshold on a 100ms scale; an instant release there handed
/// the fans to Apple mid-ramp 2,182 times in three days of logs (fans
/// slamming 3000+→1300 RPM every 3-7 seconds — the start/stop cycling that
/// is the #1 bearing wear factor). Until cold is confirmed, fans hold at
/// minimum. See FanOffGate.
public static let stopSustainSeconds: Float = 5.0
}
39 changes: 30 additions & 9 deletions Sources/ThermalForgeCore/ThermalMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ public final class ThermalMonitor {
private var lastAppliedRPMPercent: Float = 0
private var fansCurrentlyRunning = false
private var sustainedAboveCount = 0
/// Confirms the fans-off release with sustained cold instead of releasing
/// on the first below-stopTemp tick. See FanOffGate.
private var fanOffGate = FanOffGate()

// MARK: - Smart Profile State

Expand Down Expand Up @@ -116,6 +119,7 @@ public final class ThermalMonitor {
self.fanControl = fanControl
self.activeProfile = profile
self.tickInterval = 0.1
self.fanOffGate = FanOffGate(tickInterval: tickInterval)
}

// MARK: - Lifecycle
Expand Down Expand Up @@ -427,17 +431,34 @@ public final class ThermalMonitor {
return
}

// Fans-off gate — evaluated EVERY tick so the cold streak resets the
// moment the peak leaves the stop band (an idle core's sensor cliff
// can swing the peak across stopTemp on a 100ms scale).
let offAction = fanOffGate.evaluate(temp: peakTemp, stopTemp: curve.stopTemp)

// Get target from curve (now applies curve shape: easeIn, linear, easeOut, sCurve)
guard let rawTarget = curve.targetPercent(at: peakTemp, fansCurrentlyRunning: fansCurrentlyRunning) else {
// Curve says fans should be off
if fansCurrentlyRunning {
applyCommand(.resetAuto)
fansCurrentlyRunning = false
lastAppliedRPMPercent = 0
state = .idle
TFLogger.shared.fan("Fans off: \(String(format: "%.1f", peakTemp))°C below \(Int(curve.stopTemp))°C [\(activeProfile.name)]")
}
var rawTarget: Float
if let target = curve.targetPercent(at: peakTemp, fansCurrentlyRunning: fansCurrentlyRunning) {
rawTarget = target
} else if !fansCurrentlyRunning {
// Curve says fans should be off — and they already are.
return
} else if offAction == .release {
// Sustained cold confirmed — hand the fans back to Apple now.
// The release is CONFIRMED, not instant: with stopTemp at
// idle-core resting level, an instant release hands the fans to
// Apple mid-ramp over and over (2,182 releases in three days of
// logs, fans slamming 3000+→1300 RPM every 3-7 seconds).
applyCommand(.resetAuto)
fansCurrentlyRunning = false
lastAppliedRPMPercent = 0
state = .idle
TFLogger.shared.fan("Fans off: \(String(format: "%.1f", peakTemp))°C sustained ≤\(Int(curve.stopTemp))°C for \(Int(FanProfile.stopSustainSeconds.rounded()))s [\(activeProfile.name)]")
return
} else {
// Cold not yet sustained — hold at minimum, the same treatment
// the hysteresis band gives. 0.001 signals that below.
rawTarget = 0.001
}

// Sustained trigger: per-profile duration.
Expand Down
86 changes: 86 additions & 0 deletions Tests/ThermalForgeTests/FanOffGateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// FanOffGateTests.swift
// ThermalForge
//
// Client-side fans-off decision, tested hardware-free via the extracted
// gate: a momentary dip below stopTemp HOLDS minimum instead of releasing,
// and the release to Apple auto needs cold sustained for the full window.
// Regression coverage for the stopTemp bang-bang observed in three days of
// logs (2,182 releases, fans slamming 3000+→1300 RPM every 3-7 seconds on
// idle-core sensor cliffs).
//

import Testing

@testable import ThermalForgeCore

@Suite("Client fans-off gate")
struct FanOffGateTests {

@Test("momentary dip below stop holds minimum — never stops the fans")
func momentaryDipHoldsMinimum() {
var gate = FanOffGate() // 5s window / 0.1s tick from FanProfile
// Three seconds of cold: not yet.
for _ in 0..<30 {
#expect(gate.evaluate(temp: 49.5, stopTemp: 50) == .holdMinimum)
}
// The dip ends before the window: back to the curve, streak reset.
#expect(gate.evaluate(temp: 52, stopTemp: 50) == .resume)
#expect(gate.evaluate(temp: 58, stopTemp: 50) == .resume)
}

@Test("sustained cold releases — exactly at the sustained window")
func sustainedColdReleases() {
var gate = FanOffGate()
// 49 ticks = 4.9s: hold.
for _ in 0..<49 {
#expect(gate.evaluate(temp: 50.0, stopTemp: 50) == .holdMinimum)
}
// Tick 50 = 5.0s: release.
#expect(gate.evaluate(temp: 50.0, stopTemp: 50) == .release)
}

@Test("a warm tick anywhere in the window restarts the streak")
func warmTickRestartsStreak() {
var gate = FanOffGate()
for _ in 0..<49 { _ = gate.evaluate(temp: 49.9, stopTemp: 50) }
// The log signature: 49.9↔50.1 crossings every few seconds. One warm
// tick and the whole streak is gone.
#expect(gate.evaluate(temp: 50.1, stopTemp: 50) == .resume)
for _ in 0..<49 {
#expect(gate.evaluate(temp: 49.9, stopTemp: 50) == .holdMinimum)
}
#expect(gate.evaluate(temp: 49.9, stopTemp: 50) == .release)
}

@Test("after release, a new cold episode needs a fresh window")
func releaseResetsStreak() {
var gate = FanOffGate()
// Cold for the full window: releases at tick 50.
for _ in 0..<49 { _ = gate.evaluate(temp: 49, stopTemp: 50) }
#expect(gate.evaluate(temp: 49, stopTemp: 50) == .release)
// Still cold: counting starts over — not an instant re-release.
#expect(gate.evaluate(temp: 49, stopTemp: 50) == .holdMinimum)
// Ticks 2–49 of the new episode: still holding.
for _ in 0..<47 {
#expect(gate.evaluate(temp: 49, stopTemp: 50) == .holdMinimum)
}
#expect(gate.evaluate(temp: 49, stopTemp: 50) == .holdMinimum)
// Tick 50 of the new episode: releases again.
#expect(gate.evaluate(temp: 49, stopTemp: 50) == .release)
}

@Test("stop threshold is inclusive — 50.0 counts as cold")
func stopThresholdInclusive() {
var gate = FanOffGate()
#expect(gate.evaluate(temp: 50.0, stopTemp: 50) == .holdMinimum)
#expect(gate.evaluate(temp: 50.1, stopTemp: 50) == .resume)
}

@Test("window mirrors FanProfile, not hardcoded numbers")
func windowMirrorsProfile() {
let gate = FanOffGate()
#expect(gate.sustainSeconds == FanProfile.stopSustainSeconds)
#expect(gate.tickInterval == 0.1)
}
}