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 = '
Select a link to view diagnostics
';
+ 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 = '' +
+ '';
+
+ // Composite score gauge
+ var scoreClass = healthScore >= 0.7 ? 'score-good' : (healthScore >= 0.4 ? 'score-fair' : 'score-poor');
+ html += '
' +
+ '
' +
+ '
' + Math.round(healthScore * 100) + '%' +
+ '
Overall Health' +
+ '
';
+
+ // Per-metric breakdown
+ html += '
';
+ html += renderMetricGauge('SNR', healthDetails.snr || 0, 'Signal-to-noise ratio');
+ html += renderMetricGauge('Phase', healthDetails.phase_stability || 0, 'Phase stability');
+ html += renderMetricGauge('Rate', healthDetails.packet_rate || 0, 'Packet rate health');
+ html += renderMetricGauge('Drift', healthDetails.baseline_drift || 0, 'Baseline stability');
+ html += '
';
+
+ // "Why is this low?" contextual hint
+ var hint = getWhyLowHint(healthDetails, healthScore);
+ if (hint) {
+ html += '
' +
+ '' +
+ '
' + escapeHtml(hint.metric) + ' at ' + Math.round(hint.value * 100) + '%
' +
+ '
' + escapeHtml(hint.hint) + '
' +
+ '
';
+ }
+
+ // 24-hour health history sparkline
+ html += '
' +
+ '24-Hour Trend' +
+ renderHealthSparkline(linkID) +
+ '
';
+
+ // Weekly sparkline
+ html += '
' +
+ '7-Day Trend' +
+ renderSparkline(weeklyTrend) +
+ '' + renderSparklineAnnotations(weeklyTrend) + '' +
+ '
';
+
+ // Diagnoses list
+ if (diagnoses.length === 0) {
+ html += '
' +
+ '✓' +
+ 'No issues detected' +
+ '
';
+ } else {
+ html += '
';
+ diagnoses.forEach(function (d) {
+ html += renderDiagnosisCard(d);
+ });
+ html += '
';
+ }
+
+ 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 = '' +
+ '' +
+ '
' + 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")
+ }
+}