pulse-monitor/backups/backup_20251127/validators.go

145 lines
5.3 KiB
Go

package main
import (
"fmt"
"io"
"time"
)
// Validator functions handle the three-tier exception system
// validateCorruption checks for corrupted digits (Handler #1)
func validateCorruption(reading *Reading, logger io.Writer) (Action, string) {
if reading.IsCorrupted() {
logMessage(LogFile, Info, " [CORRUPTION] Invalid digit detected: SpO2(%d,%d) HR(%d,%d) - skipping frame",
reading.SpO2LeftDigit, reading.SpO2RightDigit,
reading.HRLeftDigit, reading.HRRightDigit)
return ActionContinue, ""
}
return ActionContinue, ""
}
// validateConfidence checks OCR confidence (Handler #2)
func validateConfidence(reading *Reading, state *ProcessingState, logger io.Writer) (Action, string) {
spo2Avg, hrAvg := reading.AvgConfidence()
// High confidence - pass through
if spo2Avg >= 85 && hrAvg >= 85 {
state.InRetry = false // Clear retry flag
return ActionContinue, ""
}
// Low confidence
if !state.InRetry {
// First low confidence - trigger retry
state.InRetry = true
logMessage(LogFile, Info, " [LOW CONFIDENCE] SpO2: %.1f%%, HR: %.1f%% - retrying with layout re-detection",
spo2Avg, hrAvg)
return ActionRetry, ""
}
// Second consecutive low confidence - give up
state.InRetry = false
logMessage(LogFile, Info, " [LOW CONFIDENCE] Still low after retry (SpO2: %.1f%%, HR: %.1f%%) - pausing",
spo2Avg, hrAvg)
return ActionDiscard, ""
}
// validateStability checks for large deltas with hindsight validation (Handler #3)
func validateStability(reading *Reading, state *ProcessingState, logger io.Writer) (Action, string) {
// First reading - just accept it
if !state.HasLastReading {
state.LastReading = *reading
state.HasLastReading = true
state.BaselineReading = *reading
return ActionPost, ""
}
// Calculate deltas from last reading
spo2Delta := abs(reading.SpO2 - state.LastReading.SpO2)
hrDelta := abs(reading.HR - state.LastReading.HR)
// Stable (Δ ≤ 3) - check if we have pending
if spo2Delta <= 3 && hrDelta <= 3 {
if state.PendingReading != nil {
// Check if current reading is closer to baseline or to pending
spo2ToBaseline := abs(reading.SpO2 - state.BaselineReading.SpO2)
hrToBaseline := abs(reading.HR - state.BaselineReading.HR)
spo2ToPending := abs(reading.SpO2 - state.PendingReading.SpO2)
hrToPending := abs(reading.HR - state.PendingReading.HR)
if spo2ToBaseline <= spo2ToPending && hrToBaseline <= hrToPending {
// Closer to baseline - pending was a glitch
logMessage(LogFile, Info, " [STABILITY] Discarding glitch: baseline(%d,%d) -> pending(%d,%d) -> current(%d,%d)",
state.BaselineReading.SpO2, state.BaselineReading.HR,
state.PendingReading.SpO2, state.PendingReading.HR,
reading.SpO2, reading.HR)
state.PendingReading = nil
} else {
// Closer to pending - real trend, post pending first
logMessage(LogFile, Info, " [STABILITY] Confirming trend: baseline(%d,%d) -> pending(%d,%d) -> current(%d,%d)",
state.BaselineReading.SpO2, state.BaselineReading.HR,
state.PendingReading.SpO2, state.PendingReading.HR,
reading.SpO2, reading.HR)
// Will be handled by caller to post pending first
state.PendingReading = nil
}
}
// Accept current reading
state.LastReading = *reading
state.BaselineReading = *reading
return ActionPost, ""
}
// Large delta (Δ > 3) - check if we have pending
if state.PendingReading != nil {
// Check direction vs pending
spo2Direction := reading.SpO2 - state.PendingReading.SpO2
hrDirection := reading.HR - state.PendingReading.HR
pendingSpo2Diff := state.PendingReading.SpO2 - state.LastReading.SpO2
pendingHrDiff := state.PendingReading.HR - state.LastReading.HR
// Same direction if signs match
spo2SameDir := (spo2Direction > 0 && pendingSpo2Diff > 0) || (spo2Direction < 0 && pendingSpo2Diff < 0)
hrSameDir := (hrDirection > 0 && pendingHrDiff > 0) || (hrDirection < 0 && pendingHrDiff < 0)
if spo2SameDir && hrSameDir {
// Trend confirmed - post pending, then current becomes new pending
logMessage(LogFile, Info, " [STABILITY] Trend confirmed: %d->%d->%d (SpO2), %d->%d->%d (HR)",
state.LastReading.SpO2, state.PendingReading.SpO2, reading.SpO2,
state.LastReading.HR, state.PendingReading.HR, reading.HR)
// Post pending (caller handles), then hold current
oldPending := state.PendingReading
state.PendingReading = reading
state.LastReading = *reading
// Return special signal that pending should be posted
_ = oldPending // Will be handled by caller
return ActionHold, ""
} else {
// Opposite directions - discard pending as glitch
logMessage(LogFile, Info, " [STABILITY] Direction mismatch: %d->%d->%d (SpO2), %d->%d->%d (HR)",
state.LastReading.SpO2, state.PendingReading.SpO2, reading.SpO2,
state.LastReading.HR, state.PendingReading.HR, reading.HR)
state.PendingReading = nil
state.LastReading = *reading
return ActionPost, ""
}
}
// No pending yet - hold this unstable reading
logMessage(LogFile, Info, " [STABILITY] Holding unstable reading: SpO2 Δ%d, HR Δ%d", spo2Delta, hrDelta)
state.BaselineReading = state.LastReading
state.PendingReading = reading
state.LastReading = *reading
reason := fmt.Sprintf("[%s] Unstable (SpO2 Δ%d, HR Δ%d)", time.Now().Format("15:04:05.000"), spo2Delta, hrDelta)
return ActionHold, reason
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}