diff --git a/Sources/ThermalForgeCore/FanOffGate.swift b/Sources/ThermalForgeCore/FanOffGate.swift new file mode 100644 index 0000000..f023f34 --- /dev/null +++ b/Sources/ThermalForgeCore/FanOffGate.swift @@ -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 + } +} diff --git a/Sources/ThermalForgeCore/Profile.swift b/Sources/ThermalForgeCore/Profile.swift index d4e8176..f9a0332 100644 --- a/Sources/ThermalForgeCore/Profile.swift +++ b/Sources/ThermalForgeCore/Profile.swift @@ -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 } diff --git a/Sources/ThermalForgeCore/ThermalMonitor.swift b/Sources/ThermalForgeCore/ThermalMonitor.swift index 39b0d94..dddb879 100644 --- a/Sources/ThermalForgeCore/ThermalMonitor.swift +++ b/Sources/ThermalForgeCore/ThermalMonitor.swift @@ -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 @@ -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 @@ -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. diff --git a/Tests/ThermalForgeTests/FanOffGateTests.swift b/Tests/ThermalForgeTests/FanOffGateTests.swift new file mode 100644 index 0000000..8615b6a --- /dev/null +++ b/Tests/ThermalForgeTests/FanOffGateTests.swift @@ -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) + } +}