@@ -140,28 +140,50 @@ public final class ThermalMonitor {
140140 let gpuTemp = peakTemp ( status, prefixes: [ " TG " , " Tg " ] )
141141 let maxTemp = max ( cpuTemp, gpuTemp)
142142
143- // Anomaly detection: log significant temperature changes
144- anomalyHistory. append ( maxTemp)
145- if anomalyHistory. count > 15 { anomalyHistory. removeFirst ( ) }
146- if !isCalibrating && anomalyHistory. count >= 15 {
147- let oldest = anomalyHistory. first!
148- let delta = maxTemp - oldest
149- if abs ( delta) > 10 {
150- let direction = delta > 0 ? " spike " : " drop "
151- let fan0 = status. fans. first
152- let topProcs = captureTopProcesses ( )
153- TFLogger . shared. info (
154- " Temperature \( direction) : \( String ( format: " %.1f " , oldest) ) → \( String ( format: " %.1f " , maxTemp) ) °C " +
155- " ( \( String ( format: " %+.1f " , delta) ) °C in 30s) | " +
156- " Fan0: \( fan0? . actualRPM ?? 0 ) RPM ( \( fan0? . mode ?? " ? " ) ) | " +
157- " Profile: \( activeProfile. name) | " +
158- " Processes: \( topProcs) "
159- )
160- // Reset so we don't log the same event repeatedly
161- anomalyHistory. removeAll ( )
143+ // Anomaly detection: two tiers
144+ // Tier 1: instant spike — >5°C between consecutive readings (2 seconds)
145+ // Tier 2: sustained change — >10°C over 30 seconds
146+ if !isCalibrating {
147+ // Tier 1: check against previous reading
148+ if let prevTemp = anomalyHistory. last {
149+ let instantDelta = maxTemp - prevTemp
150+ if abs ( instantDelta) > 5 {
151+ let direction = instantDelta > 0 ? " spike " : " drop "
152+ let fan0 = status. fans. first
153+ let topProcs = captureTopProcesses ( )
154+ TFLogger . shared. info (
155+ " Instant \( direction) : \( String ( format: " %.1f " , prevTemp) ) → \( String ( format: " %.1f " , maxTemp) ) °C " +
156+ " ( \( String ( format: " %+.1f " , instantDelta) ) °C in 2s) | " +
157+ " Fan0: \( fan0? . actualRPM ?? 0 ) RPM ( \( fan0? . mode ?? " ? " ) ) | " +
158+ " Profile: \( activeProfile. name) | " +
159+ " Processes: \( topProcs) "
160+ )
161+ }
162+ }
163+
164+ // Tier 2: check over 30-second window
165+ if anomalyHistory. count >= 15 {
166+ let oldest = anomalyHistory. first!
167+ let sustainedDelta = maxTemp - oldest
168+ if abs ( sustainedDelta) > 10 {
169+ let direction = sustainedDelta > 0 ? " spike " : " drop "
170+ let fan0 = status. fans. first
171+ let topProcs = captureTopProcesses ( )
172+ TFLogger . shared. info (
173+ " Sustained \( direction) : \( String ( format: " %.1f " , oldest) ) → \( String ( format: " %.1f " , maxTemp) ) °C " +
174+ " ( \( String ( format: " %+.1f " , sustainedDelta) ) °C in 30s) | " +
175+ " Fan0: \( fan0? . actualRPM ?? 0 ) RPM ( \( fan0? . mode ?? " ? " ) ) | " +
176+ " Profile: \( activeProfile. name) | " +
177+ " Processes: \( topProcs) "
178+ )
179+ anomalyHistory. removeAll ( )
180+ }
162181 }
163182 }
164183
184+ anomalyHistory. append ( maxTemp)
185+ if anomalyHistory. count > 15 { anomalyHistory. removeFirst ( ) }
186+
165187 // Safety override: any sensor > 95°C
166188 if maxTemp >= FanProfile . safetyTempThreshold {
167189 if state != . safetyOverride {
0 commit comments