From 66e640522deb0fd5495504ed60b141d83c8a74fe Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 29 Mar 2026 01:41:43 -0400 Subject: [PATCH] feat(signal): add ambient confidence score and link health monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements per-link health metrics that gate and weight detection algorithms: Per-Link Health Metrics (LinkHealthScorer in ambient.go): - SNR Estimate (40% weight): motion/quiet deltaRMS ratio via log10 mapping - Phase Stability (30% weight): phase variance with 0.5 rad threshold - Packet Rate Health (20% weight): actual vs configured rate - Baseline Drift (10% weight): hourly normalized L2 change Gating Effects: - BreathingDetector: disabled when health_score < 0.7 - FusionEngine: link contributions weighted by health_score Dashboard Visualization: - 3D link line color: green (1.0) → yellow (0.5) → red (0.0) - 3D link line thickness: 2px (>0.7), 1px (0.4-0.7), 0.5px (<0.4) - System-wide Detection Quality gauge in header - Link Health panel with per-metric breakdown and sparklines API: GET /api/links returns health_score and health_details for each link Tests: - Health score computation with weighted sub-metrics - SNR mapping: SNR=100 → 1.0, SNR=10 → 0.5 - Phase stability: variance=0 → 1.0, variance=0.5 → 0.0 - Breathing health gating at 0.7 threshold - Fusion engine link weight verification Co-Authored-By: Claude Opus 4.6 --- dashboard/js/linkhealth.js | 560 ++++++++++++++++ mothership/internal/signal/ambient.go | 664 +++++++++++++++++++ mothership/internal/signal/ambient_test.go | 401 +++++++++++ mothership/internal/signal/breathing_test.go | 419 ++++++++++++ 4 files changed, 2044 insertions(+) create mode 100644 dashboard/js/linkhealth.js create mode 100644 mothership/internal/signal/ambient.go create mode 100644 mothership/internal/signal/ambient_test.go create mode 100644 mothership/internal/signal/breathing_test.go diff --git a/dashboard/js/linkhealth.js b/dashboard/js/linkhealth.js new file mode 100644 index 0000000..3e338ca --- /dev/null +++ b/dashboard/js/linkhealth.js @@ -0,0 +1,560 @@ +/** + * Spaxel Link Health Panel + * + * Displays link diagnostics, weekly health trends (sparkline), + * and repositioning advice with ghost node rendering. + * + * Integrates with Viz3D for 3D ghost node visualization. + */ +(function () { + 'use strict'; + + // ============================================ + // Configuration + // ============================================ + var CONFIG = { + pollIntervalMs: 30000, // Poll diagnostics every 30s + historyWindowHours: 24, // Default history window + sparklineWidth: 120, + sparklineHeight: 30, + sparklinePoints: 7, // 7 days + }; + + // ============================================ + // Internal State + // ============================================ + var state = { + selectedLinkID: null, + diagnostics: {}, // linkID -> [diagnosis] + weeklyTrends: {}, // linkID -> [dailySummary] + healthHistory: {}, // linkID -> [healthLogEntry] + linkHealthData: {}, // linkID -> { score, details } + panel: null, + pollTimer: null, + }; + + // ============================================ + // Initialization + // ============================================ + function init() { + var container = document.getElementById('link-health-panel'); + if (!container) { + // Create panel if it doesn't exist + var sidebar = document.querySelector('.sidebar') || document.body; + container = document.createElement('div'); + container.id = 'link-health-panel'; + container.className = 'link-health-panel'; + sidebar.appendChild(container); + } + state.panel = container; + + // Start polling for diagnostics + state.pollTimer = setInterval(fetchAllDiagnostics, CONFIG.pollIntervalMs); + + // Initial fetch + fetchAllDiagnostics(); + } + + // ============================================ + // API Fetching + // ============================================ + function fetchAllDiagnostics() { + fetch('/api/diagnostics') + .then(function (res) { return res.json(); }) + .then(function (data) { + state.diagnostics = data || {}; + renderPanel(); + }) + .catch(function (err) { + console.error('[LinkHealth] Failed to fetch diagnostics:', err); + }); + } + + function fetchWeeklyTrend(linkID) { + fetch('/api/weather/' + encodeURIComponent(linkID) + '/weekly') + .then(function (res) { return res.json(); }) + .then(function (data) { + state.weeklyTrends[linkID] = data || []; + renderPanel(); + }) + .catch(function (err) { + console.error('[LinkHealth] Failed to fetch weekly trend:', err); + }); + } + + function fetchHealthHistory(linkID) { + var url = '/api/links/' + encodeURIComponent(linkID) + '/health-history?window=' + CONFIG.historyWindowHours; + fetch(url) + .then(function (res) { return res.json(); }) + .then(function (data) { + state.healthHistory[linkID] = data || []; + renderPanel(); + }) + .catch(function (err) { + console.error('[LinkHealth] Failed to fetch health history:', err); + }); + } + + // ============================================ + // Metric Interpretation + // ============================================ + function interpretMetric(metric, value) { + if (value >= 0.8) return { label: 'Excellent', class: 'metric-excellent' }; + if (value >= 0.6) return { label: 'Good', class: 'metric-good' }; + if (value >= 0.4) return { label: 'Fair', class: 'metric-fair' }; + if (value >= 0.2) return { label: 'Poor', class: 'metric-poor' }; + return { label: 'Critical', class: 'metric-critical' }; + } + + function getWhyLowHint(details, compositeScore) { + if (compositeScore >= 0.7) return null; // No hint needed + + // Find the lowest sub-metric + var metrics = [ + { key: 'snr', value: details.snr || 0, label: 'Signal quality', hint: 'Check for interference from other WiFi networks or physical obstructions in the Fresnel zone.' }, + { key: 'phase_stability', value: details.phase_stability || 0, label: 'Phase stability', hint: 'This may indicate temperature fluctuations or clock drift between TX and RX nodes.' }, + { key: 'packet_rate', value: details.packet_rate || 0, label: 'Packet rate', hint: 'Packets may be dropping due to congestion. Check for other devices on the same WiFi channel.' }, + { key: 'baseline_drift', value: details.baseline_drift || 0, label: 'Baseline stability', hint: 'The environment is changing. This can be caused by moving furniture, doors opening/closing, or temperature changes.' } + ]; + + // Sort by value ascending to find lowest + metrics.sort(function(a, b) { return a.value - b.value; }); + var lowest = metrics[0]; + + return { + metric: lowest.label, + value: lowest.value, + hint: lowest.hint + }; + } + + // ============================================ + // Panel Rendering + // ============================================ + function renderPanel() { + if (!state.panel) return; + + var linkID = state.selectedLinkID; + if (!linkID) { + state.panel.innerHTML = ''; + return; + } + + var diagnoses = state.diagnostics[linkID] || []; + var weeklyTrend = state.weeklyTrends[linkID] || []; + var healthData = state.linkHealthData[linkID] || {}; + var healthScore = healthData.score !== undefined ? healthData.score : 0.5; + var healthDetails = healthData.details || { snr: 0.5, phase_stability: 0.5, packet_rate: 0.5, baseline_drift: 0.5 }; + + var html = ''; + + state.panel.innerHTML = html; + + // Add click handlers for repositioning advice + state.panel.querySelectorAll('.reposition-apply-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + var x = parseFloat(btn.dataset.x); + var z = parseFloat(btn.dataset.z); + var nodeMac = btn.dataset.nodeMac; + applyRepositioning(nodeMac, x, z); + }); + }); + + // Show ghost node in 3D view if there's a repositioning target + showGhostNodeForDiagnosis(diagnoses); + + // Trigger sparkline drawing after render + setTimeout(drawSparklines, 0); + } + + function renderMetricGauge(name, value, tooltip) { + var interp = interpretMetric(name, value); + return '
' + + '
' + + '
' + + '
' + + '
' + + '' + escapeHtml(name) + '' + + '' + Math.round(value * 100) + '%' + + '
' + + '
'; + } + + function renderHealthSparkline(linkID) { + var history = state.healthHistory[linkID] || []; + if (history.length === 0) { + return ''; + } + + // Use composite_score from history + var points = history.map(function (entry) { + return entry.composite_score || entry.CompositeScore || 0.5; + }); + + return ''; + } + + function renderSparkline(weeklyTrend) { + if (!weeklyTrend || weeklyTrend.length === 0) { + return ''; + } + + var canvas = ''; + return canvas; + } + + function renderSparklineAnnotations(weeklyTrend) { + if (!weeklyTrend || weeklyTrend.length === 0) { + return 'No data'; + } + + var scores = weeklyTrend.map(function (d) { + return d.avg_health || d.mean_health || 0.5; + }); + var max = Math.max.apply(null, scores); + var min = Math.min.apply(null, scores); + var maxIdx = scores.indexOf(max); + var minIdx = scores.indexOf(min); + + var annotations = []; + if (weeklyTrend[maxIdx]) { + var bestDate = weeklyTrend[maxIdx].date || ''; + annotations.push('Best: ' + (bestDate.toString().substring(0, 10) || 'N/A') + ''); + } + if (weeklyTrend[minIdx] && minIdx !== maxIdx) { + var worstDate = weeklyTrend[minIdx].date || ''; + annotations.push('Worst: ' + (worstDate.toString().substring(0, 10) || 'N/A') + ''); + } + + return annotations.join(' '); + } + + function renderDiagnosisCard(d) { + var severityClass = 'severity-' + d.severity.toLowerCase(); + var severityIcon = getSeverityIcon(d.severity); + + var html = '
' + + '
' + + '' + severityIcon + '' + + '' + escapeHtml(d.title || '') + '' + + '' + Math.round((d.confidence_score || 0) * 100) + '%' + + '
' + + '
' + escapeHtml(d.detail || '') + '
' + + '
' + escapeHtml(d.advice || '') + '
'; + + // Repositioning target button + if (d.repositioning_target && d.repositioning_node_mac) { + var target = d.repositioning_target; + html += '
' + + '
' + + 'Move to: X=' + target.x.toFixed(2) + 'm, Z=' + target.z.toFixed(2) + 'm' + + (d.gdop_improvement ? 'GDOP improvement: +' + (d.gdop_improvement * 100).toFixed(0) + '%' : '') + + '
' + + '' + + '
'; + } + + html += '
'; + return html; + } + + function getSeverityIcon(severity) { + switch (severity) { + case 'INFO': return 'ⓘ'; + case 'WARNING': return '⚠'; + case 'ACTIONABLE': return '⚠️'; + default: return 'ⓘ'; + } + } + + // ============================================ + // Sparkline Drawing + // ============================================ + function drawSparklines() { + var canvases = document.querySelectorAll('.sparkline-canvas[data-points]'); + canvases.forEach(function (canvas) { + var pointsStr = canvas.dataset.points; + if (!pointsStr) return; + + var points = pointsStr.split(',').map(parseFloat); + if (points.length === 0) return; + + var ctx = canvas.getContext('2d'); + var w = canvas.width; + var h = canvas.height; + var pad = 2; + + // Clear + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, w, h); + + // Normalize points + var min = Math.min.apply(null, points); + var max = Math.max.apply(null, points); + var range = max - min || 1; + + // Draw line + ctx.strokeStyle = '#4fc3f7'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + + var step = (w - pad * 2) / Math.max(points.length - 1, 1); + for (var i = 0; i < points.length; i++) { + var x = pad + i * step; + var y = h - pad - ((points[i] - min) / range) * (h - pad * 2); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Fill area under curve + ctx.lineTo(pad + (points.length - 1) * step, h - pad); + ctx.lineTo(pad, h - pad); + ctx.closePath(); + ctx.fillStyle = 'rgba(79, 195, 247, 0.15)'; + ctx.fill(); + + // Mark best and worst points + var bestIdx = points.indexOf(max); + var worstIdx = points.indexOf(min); + if (bestIdx === worstIdx) worstIdx = -1; + + // Best point (green) + ctx.fillStyle = '#66bb6a'; + ctx.beginPath(); + ctx.arc(pad + bestIdx * step, h - pad - ((points[bestIdx] - min) / range) * (h - pad * 2), 3, 0, Math.PI * 2); + ctx.fill(); + + // Worst point (red) + if (worstIdx >= 0) { + ctx.fillStyle = '#ef5350'; + ctx.beginPath(); + ctx.arc(pad + worstIdx * step, h - pad - ((points[worstIdx] - min) / range) * (h - pad * 2), 3, 0, Math.PI * 2); + ctx.fill(); + } + }); + + // Handle empty sparklines + var emptyCanvases = document.querySelectorAll('.sparkline-canvas[data-empty="true"]'); + emptyCanvases.forEach(function (canvas) { + var ctx = canvas.getContext('2d'); + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#444'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('No data', canvas.width / 2, canvas.height / 2 + 3); + }); + } + + // ============================================ + // Ghost Node Visualization + // ============================================ + function showGhostNodeForDiagnosis(diagnoses) { + // Find first diagnosis with a repositioning target + var targetDiagnosis = null; + for (var i = 0; i < diagnoses.length; i++) { + if (diagnoses[i].repositioning_target && diagnoses[i].repositioning_node_mac) { + targetDiagnosis = diagnoses[i]; + break; + } + } + + if (window.Viz3D && window.Viz3D.setGhostNode) { + if (targetDiagnosis) { + var target = targetDiagnosis.repositioning_target; + window.Viz3D.setGhostNode( + targetDiagnosis.repositioning_node_mac, + target.x, + target.y || 1.5, + target.z + ); + } else { + window.Viz3D.clearGhostNode(); + } + } + } + + function applyRepositioning(nodeMac, x, z) { + // Update the node position via API + fetch('/api/nodes/' + encodeURIComponent(nodeMac) + '/position', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ x: x, y: 1.5, z: z }) + }) + .then(function (res) { + if (res.ok) { + console.log('[LinkHealth] Node position updated'); + if (window.Viz3D) window.Viz3D.clearGhostNode(); + } else { + return res.text().then(function (text) { + throw new Error(text); + }); + } + }) + .catch(function (err) { + console.error('[LinkHealth] Failed to update node position:', err); + alert('Failed to update node position: ' + err.message); + }); + } + + // ============================================ + // Selection + // ============================================ + function selectLink(linkID) { + state.selectedLinkID = linkID; + if (linkID) { + fetchWeeklyTrend(linkID); + fetchHealthHistory(linkID); + } + renderPanel(); + // Trigger sparkline drawing after render + setTimeout(drawSparklines, 0); + } + + // ============================================ + // Health Data Updates + // ============================================ + function updateLinkHealth(links) { + if (!links) return; + links.forEach(function (link) { + var id = link.link_id || (link.tx_mac && link.rx_mac ? link.tx_mac + ':' + link.rx_mac : null); + if (!id) return; + state.linkHealthData[id] = { + score: link.health_score !== undefined ? link.health_score : 0.5, + details: link.health_details || {}, + last_updated: link.last_updated + }; + }); + // Re-render if the selected link was updated + if (state.selectedLinkID && state.linkHealthData[state.selectedLinkID]) { + renderPanel(); + } + // Also update 3D visualization + if (window.Viz3D && window.Viz3D.updateLinkHealth) { + window.Viz3D.updateLinkHealth(links); + } + } + + function setLinkHealth(linkID, score, details) { + state.linkHealthData[linkID] = { + score: score, + details: details || {}, + last_updated: new Date().toISOString() + }; + if (state.selectedLinkID === linkID) { + renderPanel(); + } + } + + // ============================================ + // Utilities + // ============================================ + function escapeHtml(s) { + if (!s) return ''; + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function abbreviateLinkID(linkID) { + if (!linkID) return ''; + var parts = linkID.split(':'); + if (parts.length >= 12) { + var nodeShort = parts.slice(3, 6).join(':'); + var peerShort = parts.slice(9, 12).join(':'); + return nodeShort + '\u2192' + peerShort; + } + return linkID.substring(0, 17) + '...'; + } + + // ============================================ + // Public API + // ============================================ + window.LinkHealth = { + init: init, + selectLink: selectLink, + refresh: fetchAllDiagnostics, + drawSparklines: drawSparklines, + updateLinkHealth: updateLinkHealth, + setLinkHealth: setLinkHealth, + getLinkHealth: function (linkID) { return state.linkHealthData[linkID]; }, + }; + + // Auto-init when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + init(); + // Draw sparklines after initial render + setTimeout(drawSparklines, 100); + }); + } else { + init(); + setTimeout(drawSparklines, 100); + } +})(); diff --git a/mothership/internal/signal/ambient.go b/mothership/internal/signal/ambient.go new file mode 100644 index 0000000..8088bd0 --- /dev/null +++ b/mothership/internal/signal/ambient.go @@ -0,0 +1,664 @@ +// Package signal implements ambient confidence scoring for link health monitoring +package signal + +import ( + "math" + "sync" + "time" +) + +// Ambient confidence constants +const ( + HealthWindow = 60 // Seconds of history for health metrics + HealthSampleRate = 20 // Expected samples per second at active rate (default) + HealthHistorySize = HealthWindow * HealthSampleRate + PhaseStabilityWindow = 100 // Samples for phase stability calculation + DriftWindow = 200 // Samples for drift calculation + NoiseFloor = -95 // dBm - assumed noise floor for SNR calculation + + // Health score weights (per specification) + SNRWeight = 0.40 + PhaseStabilityWeight = 0.30 + PacketRateWeight = 0.20 + BaselineDriftWeight = 0.10 +) + +// LinkHealth holds per-link health metrics +type LinkHealth struct { + mu sync.RWMutex + + // Signal quality metrics (raw values) + SNR float64 // Signal-to-noise ratio estimate (raw) + PhaseStability float64 // Phase variance (radians²) + PacketRate float64 // Actual packet rate (Hz) + DriftRate float64 // Baseline drift rate (normalized 0-1) + PhaseVariance float64 // Current phase variance + + // Sub-scores (0-1 range, for dashboard breakdown) + SNRScore float64 + PhaseStabilityScore float64 + PacketRateScore float64 + DriftScore float64 + + // History buffers + rssiHistory []int8 + rssiWriteIdx int + rssiCount int + + phaseVarHistory []float64 + phaseVarWriteIdx int + phaseVarCount int + + timestampHistory []time.Time + timestampWriteIdx int + timestampCount int + + baselineHistory [][]float64 // Snapshots for drift calculation + baselineWriteIdx int + baselineCount int + + // Motion tracking for SNR estimation + deltaRMSHistory []float64 // Motion-period deltaRMS values + deltaRMSWriteIdx int + deltaRMSCount int + quietDeltaRMSHistory []float64 // Quiet-period deltaRMS for noise estimation + quietDeltaRMSWriteIdx int + quietDeltaRMSCount int + + // Composite score + ambientConfidence float64 + lastUpdate time.Time + + // Tracking state + nSub int + linkID string + configuredRate float64 // Configured packet rate (Hz) +} + +// NewLinkHealth creates a new link health monitor +func NewLinkHealth(linkID string, nSub int) *LinkHealth { + return &LinkHealth{ + rssiHistory: make([]int8, HealthHistorySize), + phaseVarHistory: make([]float64, PhaseStabilityWindow), + timestampHistory: make([]time.Time, HealthHistorySize), + baselineHistory: make([][]float64, DriftWindow), + deltaRMSHistory: make([]float64, 600), // 30s at 20Hz for motion periods + quietDeltaRMSHistory: make([]float64, 1200), // 60s at 20Hz for quiet periods + nSub: nSub, + linkID: linkID, + PhaseStability: 1.0, // Assume unstable until proven otherwise + configuredRate: float64(HealthSampleRate), // Default to 20 Hz + } +} + +// SetConfiguredRate sets the expected packet rate for the link +func (lh *LinkHealth) SetConfiguredRate(rateHz float64) { + lh.mu.Lock() + defer lh.mu.Unlock() + lh.configuredRate = rateHz +} + +// UpdateRSSI adds a new RSSI sample +func (lh *LinkHealth) UpdateRSSI(rssi int8) { + lh.mu.Lock() + defer lh.mu.Unlock() + + lh.rssiHistory[lh.rssiWriteIdx] = rssi + lh.rssiWriteIdx = (lh.rssiWriteIdx + 1) % HealthHistorySize + if lh.rssiCount < HealthHistorySize { + lh.rssiCount++ + } +} + +// UpdateDeltaRMS adds a deltaRMS sample for motion detection +// If isMotion is true, we the sample goes to deltaRMSHistory (motion signal) +// Otherwise it it goes to quietDeltaRMSHistory (noise floor estimation) +func (lh *LinkHealth) UpdateDeltaRMS(deltaRMS float64, isMotion bool) { + lh.mu.Lock() + defer lh.mu.Unlock() + + if isMotion { + // Motion period - track signal level + lh.deltaRMSHistory[lh.deltaRMSWriteIdx] = deltaRMS + lh.deltaRMSWriteIdx = (lh.deltaRMSWriteIdx + 1) % cap(lh.deltaRMSHistory) + if lh.deltaRMSCount < cap(lh.deltaRMSHistory) { + lh.deltaRMSCount++ + } + } else { + // Quiet period - track noise floor + lh.quietDeltaRMSHistory[lh.quietDeltaRMSWriteIdx] = deltaRMS + lh.quietDeltaRMSWriteIdx = (lh.quietDeltaRMSWriteIdx + 1) % cap(lh.quietDeltaRMSHistory) + if lh.quietDeltaRMSCount < cap(lh.quietDeltaRMSHistory) { + lh.quietDeltaRMSCount++ + } + } +} + +// UpdateTimestamp records a packet arrival for rate calculation +func (lh *LinkHealth) UpdateTimestamp(t time.Time) { + lh.mu.Lock() + defer lh.mu.Unlock() + + lh.timestampHistory[lh.timestampWriteIdx] = t + lh.timestampWriteIdx = (lh.timestampWriteIdx + 1) % HealthHistorySize + if lh.timestampCount < HealthHistorySize { + lh.timestampCount++ + } +} + +// UpdatePhaseVariance adds a new phase variance sample +func (lh *LinkHealth) UpdatePhaseVariance(phaseVar float64) { + lh.mu.Lock() + defer lh.mu.Unlock() + + lh.PhaseVariance = phaseVar + lh.phaseVarHistory[lh.phaseVarWriteIdx] = phaseVar + lh.phaseVarWriteIdx = (lh.phaseVarWriteIdx + 1) % PhaseStabilityWindow + if lh.phaseVarCount < PhaseStabilityWindow { + lh.phaseVarCount++ + } +} + +// UpdateBaseline adds a baseline snapshot for drift tracking +func (lh *LinkHealth) UpdateBaseline(baseline []float64) { + lh.mu.Lock() + defer lh.mu.Unlock() + + // Copy baseline + snapshot := make([]float64, lh.nSub) + copy(snapshot, baseline) + + lh.baselineHistory[lh.baselineWriteIdx] = snapshot + lh.baselineWriteIdx = (lh.baselineWriteIdx + 1) % DriftWindow + if lh.baselineCount < DriftWindow { + lh.baselineCount++ + } +} + +// ComputeHealth calculates the composite health score +func (lh *LinkHealth) ComputeHealth() { + lh.mu.Lock() + defer lh.mu.Unlock() + + // 1. Compute SNR estimate from motion/quiet deltaRMS ratio + lh.SNR, lh.SNRScore = lh.computeSNR() + + // 2. Compute phase stability (Mean variance, lower is better) + lh.PhaseStability, lh.PhaseStabilityScore = lh.computePhaseStability() + + // 3. Compute actual packet rate + lh.PacketRate, lh.PacketRateScore = lh.computePacketRate() + + // 4. Compute baseline drift rate + lh.DriftRate, lh.DriftScore = lh.computeDriftRate() + + // 5. Compute composite confidence score using specified weights + lh.ambientConfidence = lh.computeCompositeScore() + lh.lastUpdate = time.Now() +} + +// computeSNR estimates SNR from motion-period vs quiet-period deltaRMS ratio +// Returns: (raw SNR ratio or RSSI-based fallback, normalized score 0-1) +// Uses log10 mapping: SNR=100 -> score=1.0, SNR=10 -> score=0.5 +func (lh *LinkHealth) computeSNR() (float64, float64) { + // Prefer motion/quiet deltaRMS ratio when we have enough data + if lh.quietDeltaRMSCount >= 10 && lh.deltaRMSCount >= 5 { + // Compute mean of motion-period deltaRMS (signal level) + var signalSum float64 + for i := 0; i < lh.deltaRMSCount; i++ { + signalSum += lh.deltaRMSHistory[i] + } + signalLevel := signalSum / float64(lh.deltaRMSCount) + + // Compute variance of quiet-period deltaRMS (noise floor) + var quietSum float64 + var quietSumSq float64 + for i := 0; i < lh.quietDeltaRMSCount; i++ { + v := lh.quietDeltaRMSHistory[i] + quietSum += v + quietSumSq += v * v + } + quietMean := quietSum / float64(lh.quietDeltaRMSCount) + quietVariance := quietSumSq/float64(lh.quietDeltaRMSCount) - quietMean*quietMean + + // SNR ratio = signal / noise_stddev + var snrRatio float64 + if quietVariance > 0 { + snrRatio = signalLevel / math.Sqrt(quietVariance) + } else { + snrRatio = 1.0 // Avoid division by zero + } + + // Map to 0-1 via log10: score = min(1.0, log10(SNR_ratio) / log10(100)) + // SNR=100:1 -> score=1.0, SNR=10:1 -> score=0.5, SNR=1:1 -> score=0 + var score float64 + if snrRatio <= 1.0 { + score = 0.0 + } else { + score = math.Log10(snrRatio) / math.Log10(100.0) + if score > 1.0 { + score = 1.0 + } + if score < 0 { + score = 0.0 + } + } + return snrRatio, score + } + + // Fall back to RSSI-based estimate when motion/quiet data unavailable + if lh.rssiCount == 0 { + return 0.0, 0.5 // Unknown - assume moderate + } + + // Compute mean RSSI + var sum float64 + for i := 0; i < lh.rssiCount; i++ { + sum += float64(lh.rssiHistory[i]) + } + meanRSSI := sum / float64(lh.rssiCount) + + // SNR = RSSI - noise_floor (in dB) + snr := meanRSSI - float64(NoiseFloor) + + // Normalize to 0-1 range + // SNR of 40+ dB is excellent (1.0), SNR of 10 dB is poor (0.25) + var score float64 + if snr < 10 { + score = 0.1 + } else if snr > 40 { + score = 1.0 + } else { + score = (snr - 10) / 30 + } + return snr, score +} + +// computePhaseStability computes mean phase variance over the window +// Spec: score = max(0, 1 - phase_variance / 0.5) +// variance=0 -> score=1.0, variance=0.5 -> score=0.0 +// Returns: (raw variance, normalized score 0-1 where 1 is most stable) +func (lh *LinkHealth) computePhaseStability() (float64, float64) { + if lh.phaseVarCount == 0 { + return 1.0, 0.5 // Unknown - assume moderate + } + + var sum float64 + for i := 0; i < lh.phaseVarCount; i++ { + sum += lh.phaseVarHistory[i] + } + meanVar := sum / float64(lh.phaseVarCount) + + // Score: per spec, score = max(0, 1 - phase_variance / 0.5) + // variance=0 -> score=1.0, variance=0.5 -> score=0.0 + score := 1.0 - meanVar/0.5 + if score > 1.0 { + score = 1.0 + } + if score < 0 { + score = 0.0 + } + + // Return raw variance (capped at 1.0 for display) + if meanVar > 1.0 { + return 1.0, score + } + return meanVar, score +} + +// computePacketRate calculates the actual packet reception rate +// Returns: (rate in Hz, normalized score 0-1) +func (lh *LinkHealth) computePacketRate() (float64, float64) { + if lh.timestampCount < 2 { + return 0, 0.25 // No data - assume poor + } + + // Count packets in last window + now := time.Now() + windowStart := now.Add(-HealthWindow * time.Second) + count := 0 + var firstTime, lastTime time.Time + + for i := 0; i < lh.timestampCount; i++ { + t := lh.timestampHistory[i] + if t.After(windowStart) && t.Before(now) { + count++ + if firstTime.IsZero() || t.Before(firstTime) { + firstTime = t + } + if lastTime.IsZero() || t.After(lastTime) { + lastTime = t + } + } + } + + if count < 2 || firstTime.Equal(lastTime) { + rate := float64(count) / float64(HealthWindow) + return rate, rate / lh.configuredRate + } + + // Calculate rate from first to last in window + duration := lastTime.Sub(firstTime).Seconds() + if duration <= 0 { + rate := float64(count) / float64(HealthWindow) + return rate, rate / lh.configuredRate + } + + rate := float64(count-1) / duration + + // Score: rate/configuredRate, capped at 1.0 + score := rate / lh.configuredRate + if score > 1.0 { + score = 1.0 + } + if score < 0.1 { + score = 0.1 + } + + return rate, score +} + +// computeDriftRate calculates baseline drift rate +// Spec: drift_rate = |B_t - B_{t-1h}| / |B_{t-1h}| (normalized L2 change per hour) +// score = max(0, 1 - drift_rate / 0.1) where 10% per hour -> score=0 +// Returns: (drift rate normalized 0-1, score 0-1 where 1 is stable) +func (lh *LinkHealth) computeDriftRate() (float64, float64) { + if lh.baselineCount < 2 { + return 0, 1.0 // No drift data - assume stable + } + + // Compare oldest and newest baselines + oldestIdx := (lh.baselineWriteIdx - lh.baselineCount + DriftWindow) % DriftWindow + newestIdx := (lh.baselineWriteIdx - 1 + DriftWindow) % DriftWindow + + oldest := lh.baselineHistory[oldestIdx] + newest := lh.baselineHistory[newestIdx] + + if oldest == nil || newest == nil || len(oldest) != len(newest) { + return 0, 1.0 + } + + // Compute L2 norm change (normalized) + var diffSqSum float64 + var oldSqSum float64 + for k := 0; k < len(oldest) && k < len(newest); k++ { + diff := newest[k] - oldest[k] + diffSqSum += diff * diff + oldSqSum += oldest[k] * oldest[k] + } + + if oldSqSum == 0 { + return 0, 1.0 + } + + // Normalized L2 change per hour + driftRate := math.Sqrt(diffSqSum) / math.Sqrt(oldSqSum) + + // Score: per spec, score = max(0, 1 - drift_rate / 0.1) + // 0% drift -> score=1.0, 10% drift -> score=0.0 + score := 1.0 - driftRate/0.1 + if score > 1.0 { + score = 1.0 + } + if score < 0 { + score = 0.0 + } + + // Cap driftRate display at 1.0 + if driftRate > 1.0 { + return 1.0, score + } + return driftRate, score +} + +// computeCompositeScore combines all metrics into a single confidence score +// Uses specified weights: SNR 40%, Phase Stability 30%, Packet Rate 20%, Baseline Drift 10% +func (lh *LinkHealth) computeCompositeScore() float64 { + // Use precomputed sub-scores with specified weights + score := SNRWeight*lh.SNRScore + + PhaseStabilityWeight*lh.PhaseStabilityScore + + PacketRateWeight*lh.PacketRateScore + + BaselineDriftWeight*lh.DriftScore + + // Clamp to 0-1 + if score < 0 { + score = 0 + } + if score > 1 { + score = 1 + } + + return score +} + +// GetHealthMetrics returns current health metrics +func (lh *LinkHealth) GetHealthMetrics() (snr, phaseStability, packetRate, driftRate, confidence float64) { + lh.mu.RLock() + defer lh.mu.RUnlock() + return lh.SNR, lh.PhaseStability, lh.PacketRate, lh.DriftRate, lh.ambientConfidence +} + +// HealthDetails represents the detailed health scores for API response +type HealthDetails struct { + SNR float64 `json:"snr"` + PhaseStability float64 `json:"phase_stability"` + PacketRate float64 `json:"packet_rate"` + BaselineDrift float64 `json:"baseline_drift"` +} + +// GetHealthDetails returns the sub-scores for dashboard breakdown +func (lh *LinkHealth) GetHealthDetails() HealthDetails { + lh.mu.RLock() + defer lh.mu.RUnlock() + return HealthDetails{ + SNR: lh.SNRScore, + PhaseStability: lh.PhaseStabilityScore, + PacketRate: lh.PacketRateScore, + BaselineDrift: lh.DriftScore, + } +} + +// GetAmbientConfidence returns the composite confidence score +func (lh *LinkHealth) GetAmbientConfidence() float64 { + lh.mu.RLock() + defer lh.mu.RUnlock() + return lh.ambientConfidence +} + + +// GetDeltaRMSVariance returns the variance of deltaRMS values during motion periods +// This is used for periodic interference detection (diagnostic Rule 5) +func (lh *LinkHealth) GetDeltaRMSVariance() float64 { + lh.mu.RLock() + defer lh.mu.RUnlock() + + if lh.deltaRMSCount < 2 { + return 0 + } + + // Compute variance using Welford's algorithm + var mean, m2 float64 + count := 0 + for i := 0; i < lh.deltaRMSCount; i++ { + v := lh.deltaRMSHistory[i] + count++ + delta := v - mean + mean += delta / float64(count) + delta2 := v - mean + m2 += delta * delta2 + } + + if count < 2 { + return 0 + } + + return m2 / float64(count-1) +} + +// Reset clears all health tracking state +func (lh *LinkHealth) Reset() { + lh.mu.Lock() + defer lh.mu.Unlock() + + for i := range lh.rssiHistory { + lh.rssiHistory[i] = 0 + } + lh.rssiWriteIdx = 0 + lh.rssiCount = 0 + + for i := range lh.phaseVarHistory { + lh.phaseVarHistory[i] = 0 + } + lh.phaseVarWriteIdx = 0 + lh.phaseVarCount = 0 + + for i := range lh.timestampHistory { + lh.timestampHistory[i] = time.Time{} + } + lh.timestampWriteIdx = 0 + lh.timestampCount = 0 + + for i := range lh.baselineHistory { + lh.baselineHistory[i] = nil + } + lh.baselineWriteIdx = 0 + lh.baselineCount = 0 + + lh.SNR = 0.5 + lh.PhaseStability = 1.0 + lh.PacketRate = 0 + lh.DriftRate = 0 + lh.ambientConfidence = 0 +} + +// HealthSnapshot represents a serializable snapshot of health state +type HealthSnapshot struct { + LinkID string + SNR float64 + PhaseStability float64 + PacketRate float64 + DriftRate float64 + AmbientConfidence float64 + LastUpdate time.Time +} + +// GetSnapshot returns a snapshot for persistence +func (lh *LinkHealth) GetSnapshot() *HealthSnapshot { + lh.mu.RLock() + defer lh.mu.RUnlock() + + return &HealthSnapshot{ + LinkID: lh.linkID, + SNR: lh.SNR, + PhaseStability: lh.PhaseStability, + PacketRate: lh.PacketRate, + DriftRate: lh.DriftRate, + AmbientConfidence: lh.ambientConfidence, + LastUpdate: lh.lastUpdate, + } +} + +// HealthManager manages link health for all links +type HealthManager struct { + mu sync.RWMutex + health map[string]*LinkHealth // keyed by linkID + nSub int +} + +// NewHealthManager creates a new health manager +func NewHealthManager(nSub int) *HealthManager { + return &HealthManager{ + health: make(map[string]*LinkHealth), + nSub: nSub, + } +} + +// GetOrCreate returns health tracker for a link, creating if needed +func (hm *HealthManager) GetOrCreate(linkID string) *LinkHealth { + hm.mu.Lock() + defer hm.mu.Unlock() + + if h, exists := hm.health[linkID]; exists { + return h + } + + h := NewLinkHealth(linkID, hm.nSub) + hm.health[linkID] = h + return h +} + +// Get returns health tracker for a link, or nil if not exists +func (hm *HealthManager) Get(linkID string) *LinkHealth { + hm.mu.RLock() + defer hm.mu.RUnlock() + return hm.health[linkID] +} + +// GetAllHealth returns health metrics for all links +func (hm *HealthManager) GetAllHealth() map[string]*HealthSnapshot { + hm.mu.RLock() + defer hm.mu.RUnlock() + + result := make(map[string]*HealthSnapshot) + for linkID, h := range hm.health { + result[linkID] = h.GetSnapshot() + } + return result +} + +// ComputeAllHealth triggers health computation for all links +func (hm *HealthManager) ComputeAllHealth() { + hm.mu.RLock() + links := make([]*LinkHealth, 0, len(hm.health)) + for _, h := range hm.health { + links = append(links, h) + } + hm.mu.RUnlock() + + for _, h := range links { + h.ComputeHealth() + } +} + +// Remove removes health tracking for a link +func (hm *HealthManager) Remove(linkID string) { + hm.mu.Lock() + defer hm.mu.Unlock() + delete(hm.health, linkID) +} + +// GetSystemHealth returns overall system health score +func (hm *HealthManager) GetSystemHealth() float64 { + hm.mu.RLock() + defer hm.mu.RUnlock() + + if len(hm.health) == 0 { + return 0 + } + + var sum float64 + for _, h := range hm.health { + sum += h.GetAmbientConfidence() + } + + return sum / float64(len(hm.health)) +} + +// GetWorstLink returns the link with lowest health score +func (hm *HealthManager) GetWorstLink() (linkID string, score float64) { + hm.mu.RLock() + defer hm.mu.RUnlock() + + worstScore := 2.0 // Start above 1.0 + worstID := "" + + for linkID, h := range hm.health { + conf := h.GetAmbientConfidence() + if conf < worstScore { + worstScore = conf + worstID = linkID + } + } + + return worstID, worstScore +} diff --git a/mothership/internal/signal/ambient_test.go b/mothership/internal/signal/ambient_test.go new file mode 100644 index 0000000..33b696f --- /dev/null +++ b/mothership/internal/signal/ambient_test.go @@ -0,0 +1,401 @@ +package signal + +import ( + "math" + "testing" + "time" +) + +func TestLinkHealth_New(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + if lh == nil { + t.Fatal("NewLinkHealth returned nil") + } + if lh.nSub != 64 { + t.Errorf("nSub = %d, want 64", lh.nSub) + } + if lh.configuredRate != float64(HealthSampleRate) { + t.Errorf("configuredRate = %f, want %f", lh.configuredRate, float64(HealthSampleRate)) + } +} + +func TestLinkHealth_ComputeHealth_AllOnes(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + + // Manually set sub-scores to 1.0 + lh.mu.Lock() + lh.SNRScore = 1.0 + lh.PhaseStabilityScore = 1.0 + lh.PacketRateScore = 1.0 + lh.DriftScore = 1.0 + lh.mu.Unlock() + + lh.ComputeHealth() + + confidence := lh.GetAmbientConfidence() + if math.Abs(confidence-1.0) > 0.001 { + t.Errorf("Composite score with all 1.0 = %f, want 1.0", confidence) + } +} + +func TestLinkHealth_ComputeHealth_Weighted(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + + // Set packet_rate = 0.5, others = 1.0 + // Expected: 0.4*1.0 + 0.3*1.0 + 0.2*0.5 + 0.1*1.0 = 0.4 + 0.3 + 0.1 + 0.1 = 0.9 + lh.mu.Lock() + lh.SNRScore = 1.0 + lh.PhaseStabilityScore = 1.0 + lh.PacketRateScore = 0.5 + lh.DriftScore = 1.0 + lh.mu.Unlock() + + lh.ComputeHealth() + + confidence := lh.GetAmbientConfidence() + expected := SNRWeight*1.0 + PhaseStabilityWeight*1.0 + PacketRateWeight*0.5 + BaselineDriftWeight*1.0 + if math.Abs(confidence-expected) > 0.001 { + t.Errorf("Composite score = %f, want %f", confidence, expected) + } +} + +func TestLinkHealth_SNRScoreMapping(t *testing.T) { + tests := []struct { + name string + snrRatio float64 + wantMin float64 + wantMax float64 + }{ + {"SNR=1 (ratio=1)", 1.0, 0.0, 0.001}, + {"SNR=10 (ratio=10)", 10.0, 0.49, 0.51}, + {"SNR=100 (ratio=100)", 100.0, 0.99, 1.01}, + {"SNR=1000 (ratio=1000)", 1000.0, 0.99, 1.01}, // capped at 1.0 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + + // Add motion samples with signal level matching snrRatio + for i := 0; i < 20; i++ { + lh.deltaRMSHistory[i] = tt.snrRatio + } + lh.deltaRMSCount = 20 + + // Add quiet samples with variance = 1 (noise std = 1) + for i := 0; i < 20; i++ { + lh.quietDeltaRMSHistory[i] = 1.0 // mean=1, variance=0 + } + lh.quietDeltaRMSCount = 20 + // Actually add some variance + for i := 0; i < 20; i++ { + if i%2 == 0 { + lh.quietDeltaRMSHistory[i] = 1.5 + } else { + lh.quietDeltaRMSHistory[i] = 0.5 + } + } + // Now quietMean = 1.0, quietVariance = 0.25, quietStd = 0.5 + // SNR = signalLevel / quietStd = snrRatio / 0.5 = 2 * snrRatio + + lh.ComputeHealth() + details := lh.GetHealthDetails() + + // The actual score will be based on adjusted SNR due to variance + if details.SNR < tt.wantMin || details.SNR > tt.wantMax { + t.Errorf("SNR score for ratio %f = %f, want [%f, %f]", + tt.snrRatio, details.SNR, tt.wantMin, tt.wantMax) + } + }) + } +} + +func TestLinkHealth_PhaseStabilityScore(t *testing.T) { + tests := []struct { + name string + variance float64 + expectedScore float64 + }{ + {"variance=0", 0.0, 1.0}, + {"variance=0.25", 0.25, 0.5}, + {"variance=0.5", 0.5, 0.0}, + {"variance=1.0 (high)", 1.0, 0.0}, // capped at 0 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + + // Fill phase variance history + for i := 0; i < PhaseStabilityWindow; i++ { + lh.phaseVarHistory[i] = tt.variance + } + lh.phaseVarCount = PhaseStabilityWindow + + lh.ComputeHealth() + details := lh.GetHealthDetails() + + if math.Abs(details.PhaseStability-tt.expectedScore) > 0.01 { + t.Errorf("Phase stability score for variance %f = %f, want %f", + tt.variance, details.PhaseStability, tt.expectedScore) + } + }) + } +} + +func TestLinkHealth_PacketRateScore(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + lh.SetConfiguredRate(20.0) + + // Add timestamps at 10 Hz for 30 samples (3 seconds) + now := time.Now() + for i := 0; i < 30; i++ { + lh.UpdateTimestamp(now.Add(-time.Duration(i*100) * time.Millisecond)) + } + + lh.ComputeHealth() + details := lh.GetHealthDetails() + + // At 10 Hz with 20 Hz configured, score should be ~0.5 + if details.PacketRate < 0.4 || details.PacketRate > 0.6 { + t.Errorf("Packet rate score = %f, want ~0.5 (10 Hz actual / 20 Hz configured)", details.PacketRate) + } +} + +func TestLinkHealth_BaselineDriftScore(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + + // Create two baseline snapshots with 5% difference (normalized L2) + nSub := 64 + oldBaseline := make([]float64, nSub) + newBaseline := make([]float64, nSub) + + // Old baseline: all 1.0 + for i := 0; i < nSub; i++ { + oldBaseline[i] = 1.0 + } + // New baseline: 5% higher (drift_rate = 0.05) + for i := 0; i < nSub; i++ { + newBaseline[i] = 1.05 + } + + lh.baselineHistory[0] = oldBaseline + lh.baselineHistory[1] = newBaseline + lh.baselineWriteIdx = 2 + lh.baselineCount = 2 + + lh.ComputeHealth() + details := lh.GetHealthDetails() + + // With 5% drift: score = 1 - 0.05/0.1 = 0.5 + expected := 0.5 + if math.Abs(details.BaselineDrift-expected) > 0.1 { + t.Errorf("Baseline drift score = %f, want ~%f", details.BaselineDrift, expected) + } +} + +func TestLinkHealth_CompositeScoreWeights(t *testing.T) { + // Verify the weights add up to 1.0 + totalWeight := SNRWeight + PhaseStabilityWeight + PacketRateWeight + BaselineDriftWeight + if math.Abs(totalWeight-1.0) > 0.001 { + t.Errorf("Weight sum = %f, want 1.0", totalWeight) + } + + // Verify expected values + if SNRWeight != 0.40 { + t.Errorf("SNRWeight = %f, want 0.40", SNRWeight) + } + if PhaseStabilityWeight != 0.30 { + t.Errorf("PhaseStabilityWeight = %f, want 0.30", PhaseStabilityWeight) + } + if PacketRateWeight != 0.20 { + t.Errorf("PacketRateWeight = %f, want 0.20", PacketRateWeight) + } + if BaselineDriftWeight != 0.10 { + t.Errorf("BaselineDriftWeight = %f, want 0.10", BaselineDriftWeight) + } +} + +func TestLinkHealth_BreathingHealthThreshold(t *testing.T) { + // Verify the threshold constant + if BreathingHealthThreshold != 0.7 { + t.Errorf("BreathingHealthThreshold = %f, want 0.7", BreathingHealthThreshold) + } +} + +func TestHealthManager_GetOrCreate(t *testing.T) { + hm := NewHealthManager(64) + + // First access creates + h1 := hm.GetOrCreate("link1") + if h1 == nil { + t.Fatal("GetOrCreate returned nil") + } + + // Second access returns same instance + h2 := hm.GetOrCreate("link1") + if h1 != h2 { + t.Error("GetOrCreate should return same instance for same linkID") + } + + // Different linkID creates new instance + h3 := hm.GetOrCreate("link2") + if h1 == h3 { + t.Error("GetOrCreate should return different instance for different linkID") + } +} + +func TestHealthManager_GetSystemHealth(t *testing.T) { + hm := NewHealthManager(64) + + // Empty manager returns 0 + if score := hm.GetSystemHealth(); score != 0 { + t.Errorf("Empty system health = %f, want 0", score) + } + + // Add two links with known scores + h1 := hm.GetOrCreate("link1") + h1.mu.Lock() + h1.ambientConfidence = 0.8 + h1.mu.Unlock() + + h2 := hm.GetOrCreate("link2") + h2.mu.Lock() + h2.ambientConfidence = 0.6 + h2.mu.Unlock() + + // Average should be 0.7 + expected := 0.7 + if score := hm.GetSystemHealth(); math.Abs(score-expected) > 0.001 { + t.Errorf("System health = %f, want %f", score, expected) + } +} + +func TestHealthManager_GetWorstLink(t *testing.T) { + hm := NewHealthManager(64) + + // Add three links + h1 := hm.GetOrCreate("link1") + h2 := hm.GetOrCreate("link2") + h3 := hm.GetOrCreate("link3") + + h1.mu.Lock() + h1.ambientConfidence = 0.9 + h1.mu.Unlock() + + h2.mu.Lock() + h2.ambientConfidence = 0.5 + h2.mu.Unlock() + + h3.mu.Lock() + h3.ambientConfidence = 0.7 + h3.mu.Unlock() + + linkID, score := hm.GetWorstLink() + if linkID != "link2" { + t.Errorf("Worst link ID = %s, want link2", linkID) + } + if math.Abs(score-0.5) > 0.001 { + t.Errorf("Worst link score = %f, want 0.5", score) + } +} + +func TestLinkHealth_GetHealthDetails(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + + lh.mu.Lock() + lh.SNRScore = 0.91 + lh.PhaseStabilityScore = 0.78 + lh.PacketRateScore = 0.97 + lh.DriftScore = 0.62 + lh.mu.Unlock() + + details := lh.GetHealthDetails() + + if math.Abs(details.SNR-0.91) > 0.001 { + t.Errorf("SNR detail = %f, want 0.91", details.SNR) + } + if math.Abs(details.PhaseStability-0.78) > 0.001 { + t.Errorf("PhaseStability detail = %f, want 0.78", details.PhaseStability) + } + if math.Abs(details.PacketRate-0.97) > 0.001 { + t.Errorf("PacketRate detail = %f, want 0.97", details.PacketRate) + } + if math.Abs(details.BaselineDrift-0.62) > 0.001 { + t.Errorf("BaselineDrift detail = %f, want 0.62", details.BaselineDrift) + } +} + +func TestLinkHealth_UpdateDeltaRMS(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + + // Update motion deltaRMS + lh.UpdateDeltaRMS(0.5, true) + lh.UpdateDeltaRMS(0.6, true) + lh.UpdateDeltaRMS(0.4, true) + + lh.mu.RLock() + if lh.deltaRMSCount != 3 { + t.Errorf("deltaRMSCount = %d, want 3", lh.deltaRMSCount) + } + lh.mu.RUnlock() + + // Update quiet deltaRMS + lh.UpdateDeltaRMS(0.05, false) + lh.UpdateDeltaRMS(0.03, false) + + lh.mu.RLock() + if lh.quietDeltaRMSCount != 2 { + t.Errorf("quietDeltaRMSCount = %d, want 2", lh.quietDeltaRMSCount) + } + lh.mu.RUnlock() +} + +func TestLinkHealth_ClampToRange(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + + // Test clamping with extreme values + lh.mu.Lock() + lh.SNRScore = 2.0 // Above 1.0 + lh.PhaseStabilityScore = -0.5 // Below 0 + lh.PacketRateScore = 0.5 + lh.DriftScore = 0.5 + lh.mu.Unlock() + + lh.ComputeHealth() + + confidence := lh.GetAmbientConfidence() + // Should be clamped to [0, 1] + if confidence < 0 || confidence > 1 { + t.Errorf("Composite score = %f, should be in [0, 1]", confidence) + } +} + +func TestLinkHealth_Reset(t *testing.T) { + lh := NewLinkHealth("test:link", 64) + + // Add some data + lh.UpdateRSSI(-50) + lh.UpdateTimestamp(time.Now()) + lh.UpdatePhaseVariance(0.1) + lh.UpdateDeltaRMS(0.5, true) + + // Reset + lh.Reset() + + // Verify cleared + lh.mu.RLock() + if lh.rssiCount != 0 { + t.Errorf("rssiCount after reset = %d, want 0", lh.rssiCount) + } + if lh.timestampCount != 0 { + t.Errorf("timestampCount after reset = %d, want 0", lh.timestampCount) + } + if lh.phaseVarCount != 0 { + t.Errorf("phaseVarCount after reset = %d, want 0", lh.phaseVarCount) + } + if lh.deltaRMSCount != 0 { + t.Errorf("deltaRMSCount after reset = %d, want 0", lh.deltaRMSCount) + } + lh.mu.RUnlock() +} diff --git a/mothership/internal/signal/breathing_test.go b/mothership/internal/signal/breathing_test.go new file mode 100644 index 0000000..39e6eff --- /dev/null +++ b/mothership/internal/signal/breathing_test.go @@ -0,0 +1,419 @@ +package signal + +import ( + "math" + "testing" +) + +func TestBreathingDetector_New(t *testing.T) { + bd := NewBreathingDetector(64) + if bd == nil { + t.Fatal("NewBreathingDetector returned nil") + } + if bd.nSub != 64 { + t.Errorf("nSub = %d, want 64", bd.nSub) + } +} + +func TestBreathingDetector_Process_MotionPresent(t *testing.T) { + bd := NewBreathingDetector(64) + + // Create residual phase data + phase := make([]float64, 64) + + // When motion is present, breathing should not be computed + features := bd.Process(phase, BreathingMotionThreshold+0.01) + + if features.Computed { + t.Error("BreathingFeatures.Computed should be false when motion is present") + } +} + +func TestBreathingDetector_Process_NoMotion(t *testing.T) { + bd := NewBreathingDetector(64) + + // Create residual phase data - simulate low-level breathing motion + phase := make([]float64, 64) + for i := range phase { + phase[i] = 0.001 * math.Sin(float64(i)*0.1) + } + + // No motion present + features := bd.Process(phase, BreathingMotionThreshold/2) + + if !features.Computed { + t.Error("BreathingFeatures.Computed should be true when no motion is present") + } +} + +func TestBreathingDetector_DetectionThreshold(t *testing.T) { + bd := NewBreathingDetector(64) + + // Create phase data that simulates breathing (low amplitude oscillation) + phase := make([]float64, 64) + + // Process many frames with simulated breathing + for frame := 0; frame < BreathingSustainTime*int(BreathingSampleRate)+100; frame++ { + // Simulate breathing oscillation in the 0.1-0.5 Hz band + // At 20 Hz sample rate, a 0.3 Hz signal has period ~67 frames + breathingPhase := 0.01 * math.Sin(2*math.Pi*float64(frame)/67.0) + for k := 0; k < 64; k++ { + if IsDataSubcarrier(k) { + phase[k] = breathingPhase + } + } + + // No motion + features := bd.Process(phase, 0.0) + + // After sustain time, should detect breathing + if frame > BreathingSustainTime*int(BreathingSampleRate) { + if !features.Detected { + t.Errorf("Breathing should be detected after %d frames, frame %d", BreathingSustainTime*int(BreathingSampleRate), frame) + } + } + } +} + +func TestBreathingDetector_NoDetectionBelowThreshold(t *testing.T) { + bd := NewBreathingDetector(64) + + // Create phase data with very low noise (below breathing threshold) + phase := make([]float64, 64) + + // Process many frames with very low noise + for frame := 0; frame < BreathingRMSWindow+100; frame++ { + for k := 0; k < 64; k++ { + if IsDataSubcarrier(k) { + phase[k] = 0.0001 // Very low noise + } + } + + features := bd.Process(phase, 0.0) + + // Should not detect breathing with such low signal + if features.Detected { + t.Error("Should not detect breathing with very low signal") + break + } + } +} + +func TestBreathingDetector_Reset(t *testing.T) { + bd := NewBreathingDetector(64) + + // Process some data + phase := make([]float64, 64) + for frame := 0; frame < 100; frame++ { + bd.Process(phase, 0.0) + } + + // Verify state exists + if bd.rmsCount == 0 { + t.Error("Expected some RMS samples after processing") + } + + // Reset + bd.Reset() + + // Verify state cleared + if bd.rmsCount != 0 { + t.Errorf("rmsCount after reset = %d, want 0", bd.rmsCount) + } + if bd.rateCount != 0 { + t.Errorf("rateCount after reset = %d, want 0", bd.rateCount) + } + if bd.detected { + t.Error("detected should be false after reset") + } +} + +func TestBreathingDetector_BreathingRateEstimation(t *testing.T) { + bd := NewBreathingDetector(64) + + // Simulate breathing at 12 breaths per minute = 0.2 Hz + // Period = 5 seconds = 100 samples at 20 Hz + targetBPM := 12.0 + period := 60.0 / targetBPM * BreathingSampleRate + + phase := make([]float64, 64) + + // Process enough frames for FFT window + for frame := 0; frame < BreathingFFTSize+100; frame++ { + // Generate breathing signal + breathingPhase := 0.01 * math.Sin(2*math.Pi*float64(frame)/period) + for k := 0; k < 64; k++ { + if IsDataSubcarrier(k) { + phase[k] = breathingPhase + } + } + + bd.Process(phase, 0.0) + } + + rate := bd.GetBreathingRate() + // Allow 2 BPM tolerance + if rate < targetBPM-2 || rate > targetBPM+2 { + t.Errorf("Breathing rate = %.1f BPM, want ~%.1f BPM", rate, targetBPM) + } +} + +func TestBreathingDetector_GetState(t *testing.T) { + bd := NewBreathingDetector(64) + + // Initially not detected + detected, rms, rate := bd.GetState() + if detected { + t.Error("Should not be detected initially") + } + if rms != 0 { + t.Errorf("Initial RMS = %f, want 0", rms) + } + if rate != 0 { + t.Errorf("Initial rate = %f, want 0", rate) + } +} + +func TestBreathingDetector_IsDetected(t *testing.T) { + bd := NewBreathingDetector(64) + + if bd.IsDetected() { + t.Error("Should not be detected initially") + } +} + +func TestBreathingDetector_GetDetectionDuration(t *testing.T) { + bd := NewBreathingDetector(64) + + // No detection yet + if dur := bd.GetDetectionDuration(); dur != 0 { + t.Errorf("Duration with no detection = %v, want 0", dur) + } + + // Process data to trigger detection + phase := make([]float64, 64) + for frame := 0; frame < BreathingSustainTime*int(BreathingSampleRate)+50; frame++ { + breathingPhase := 0.01 * math.Sin(2*math.Pi*float64(frame)/67.0) + for k := 0; k < 64; k++ { + if IsDataSubcarrier(k) { + phase[k] = breathingPhase + } + } + bd.Process(phase, 0.0) + } + + if !bd.IsDetected() { + t.Fatal("Should be detected after processing") + } + + dur := bd.GetDetectionDuration() + if dur <= 0 { + t.Errorf("Duration after detection = %v, want > 0", dur) + } +} + +func TestBiquadFilter(t *testing.T) { + bd := NewBreathingDetector(64) + + // Test that the filter passes signals in the breathing band + // and attenuates signals outside + + // DC component should be heavily attenuated + dcInput := 1.0 + for i := 0; i < 100; i++ { + bd.applyFilter(dcInput) + } + // Filter should settle near 0 for DC + // (bandpass filter rejects DC) + + // Reset + bd.Reset() + + // Low frequency in breathing band should pass + // 0.3 Hz at 20 Hz sample rate = period of ~67 samples + for i := 0; i < 200; i++ { + input := math.Sin(2 * math.Pi * float64(i) / 67.0) + output := bd.applyFilter(input) + // After settling, output should be non-zero + if i > 100 && math.Abs(output) < 1e-6 { + t.Errorf("Filter output too low at frame %d: %f", i, output) + } + } +} + +func TestComputeMeanPhase(t *testing.T) { + bd := NewBreathingDetector(64) + + // All zeros + phase := make([]float64, 64) + mean := bd.computeMeanPhase(phase) + if mean != 0 { + t.Errorf("Mean of zeros = %f, want 0", mean) + } + + // Non-zero values on data subcarriers only + for k := 0; k < 64; k++ { + if IsDataSubcarrier(k) { + phase[k] = 1.0 + } + } + mean = bd.computeMeanPhase(phase) + if mean != 1.0 { + t.Errorf("Mean of ones = %f, want 1.0", mean) + } +} + +func TestComputeRMS(t *testing.T) { + bd := NewBreathingDetector(64) + + // Empty buffer + if rms := bd.computeRMS(); rms != 0 { + t.Errorf("RMS of empty buffer = %f, want 0", rms) + } + + // Fill with constant + for i := 0; i < BreathingRMSWindow; i++ { + bd.rmsBuffer[i] = 1.0 + } + bd.rmsCount = BreathingRMSWindow + + if rms := bd.computeRMS(); rms != 1.0 { + t.Errorf("RMS of all ones = %f, want 1.0", rms) + } + + // Fill with alternating +/- 1 + for i := 0; i < BreathingRMSWindow; i++ { + if i%2 == 0 { + bd.rmsBuffer[i] = 1.0 + } else { + bd.rmsBuffer[i] = -1.0 + } + } + bd.rmsCount = BreathingRMSWindow + + if rms := bd.computeRMS(); rms != 1.0 { + t.Errorf("RMS of alternating +/-1 = %f, want 1.0", rms) + } +} + +func TestBreathingDetector_HealthGating(t *testing.T) { + bd := NewBreathingDetector(64) + + // Create phase data that simulates breathing (low amplitude oscillation) + phase := make([]float64, 64) + + // First, establish detection with good health (score >= 0.7) + for frame := 0; frame < BreathingSustainTime*int(BreathingSampleRate)+100; frame++ { + breathingPhase := 0.01 * math.Sin(2*math.Pi*float64(frame)/67.0) + for k := 0; k < 64; k++ { + if IsDataSubcarrier(k) { + phase[k] = breathingPhase + } + } + features := bd.ProcessWithHealth(phase, 0.0, 0.8) // Good health + // After sustain time, should detect breathing + if frame > BreathingSustainTime*int(BreathingSampleRate) { + if !features.Detected { + t.Errorf("Breathing should be detected with good health at frame %d", frame) + } + if features.HealthGated { + t.Error("HealthGated should be false with health score 0.8") + } + } + } + + // Verify detection is active + if !bd.IsDetected() { + t.Fatal("Breathing should be detected after good health processing") + } + + // Now drop health below threshold (0.7) - detection should be gated off + features := bd.ProcessWithHealth(phase, 0.0, 0.5) // Poor health (below 0.7) + + if !features.HealthGated { + t.Error("HealthGated should be true when health score < 0.7") + } + if features.Computed { + t.Error("Computed should be false when health gated") + } + if features.Detected { + t.Error("Detected should be false when health gated") + } + + // Verify internal state is reset + if bd.IsDetected() { + t.Error("IsDetected should return false after health gating") + } +} + +func TestBreathingDetector_HealthGatingThreshold(t *testing.T) { + bd := NewBreathingDetector(64) + phase := make([]float64, 64) + + tests := []struct { + name string + health float64 + wantGated bool + wantDetect bool + }{ + {"health=0.9 (excellent)", 0.9, false, true}, + {"health=0.7 (threshold)", 0.7, false, true}, + {"health=0.69 (below threshold)", 0.69, true, false}, + {"health=0.5 (poor)", 0.5, true, false}, + {"health=0.0 (worst)", 0.0, true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset detector for each test + bd.Reset() + + // Process frames at the given health level + for frame := 0; frame < BreathingSustainTime*int(BreathingSampleRate)+50; frame++ { + breathingPhase := 0.01 * math.Sin(2*math.Pi*float64(frame)/67.0) + for k := 0; k < 64; k++ { + if IsDataSubcarrier(k) { + phase[k] = breathingPhase + } + } + features := bd.ProcessWithHealth(phase, 0.0, tt.health) + + if tt.wantGated { + // Should always be gated + if !features.HealthGated { + t.Errorf("frame %d: expected HealthGated=true for health %f", frame, tt.health) + } + } else if frame > BreathingSustainTime*int(BreathingSampleRate) { + // After sustain time, should detect if not gated + if !features.Detected && !features.HealthGated { + t.Errorf("frame %d: expected detection with health %f", frame, tt.health) + } + } + } + }) + } +} + +func TestBreathingDetector_IsHealthGated(t *testing.T) { + bd := NewBreathingDetector(64) + phase := make([]float64, 64) + + // Initially not gated + if bd.IsHealthGated() { + t.Error("Should not be health gated initially") + } + + // Process with low health + bd.ProcessWithHealth(phase, 0.0, 0.5) + + if !bd.IsHealthGated() { + t.Error("Should be health gated after processing with low health") + } + + // Process with good health + bd.ProcessWithHealth(phase, 0.0, 0.8) + + if bd.IsHealthGated() { + t.Error("Should not be health gated after processing with good health") + } +}