feat(signal): add ambient confidence score and link health monitoring
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 <noreply@anthropic.com>
This commit is contained in:
parent
90e230f9d9
commit
66e640522d
4 changed files with 2044 additions and 0 deletions
560
dashboard/js/linkhealth.js
Normal file
560
dashboard/js/linkhealth.js
Normal file
|
|
@ -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 = '<div class="link-health-empty">Select a link to view diagnostics</div>';
|
||||
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 = '<div class="link-health-content">' +
|
||||
'<div class="link-health-header">' +
|
||||
'<h3>Link Health</h3>' +
|
||||
'<span class="link-health-id">' + escapeHtml(abbreviateLinkID(linkID)) + '</span>' +
|
||||
'</div>';
|
||||
|
||||
// Composite score gauge
|
||||
var scoreClass = healthScore >= 0.7 ? 'score-good' : (healthScore >= 0.4 ? 'score-fair' : 'score-poor');
|
||||
html += '<div class="link-health-composite">' +
|
||||
'<div class="composite-gauge">' +
|
||||
'<div class="gauge-fill ' + scoreClass + '" style="width: ' + (healthScore * 100) + '%"></div>' +
|
||||
'</div>' +
|
||||
'<span class="composite-score">' + Math.round(healthScore * 100) + '%</span>' +
|
||||
'<span class="composite-label">Overall Health</span>' +
|
||||
'</div>';
|
||||
|
||||
// Per-metric breakdown
|
||||
html += '<div class="link-health-metrics">';
|
||||
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 += '</div>';
|
||||
|
||||
// "Why is this low?" contextual hint
|
||||
var hint = getWhyLowHint(healthDetails, healthScore);
|
||||
if (hint) {
|
||||
html += '<div class="link-health-hint">' +
|
||||
'<div class="hint-header">Why is this low?</div>' +
|
||||
'<div class="hint-metric">' + escapeHtml(hint.metric) + ' at ' + Math.round(hint.value * 100) + '%</div>' +
|
||||
'<div class="hint-text">' + escapeHtml(hint.hint) + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 24-hour health history sparkline
|
||||
html += '<div class="link-health-sparkline-section">' +
|
||||
'<span class="sparkline-label">24-Hour Trend</span>' +
|
||||
renderHealthSparkline(linkID) +
|
||||
'</div>';
|
||||
|
||||
// Weekly sparkline
|
||||
html += '<div class="link-health-sparkline-section">' +
|
||||
'<span class="sparkline-label">7-Day Trend</span>' +
|
||||
renderSparkline(weeklyTrend) +
|
||||
'<span class="sparkline-annotations">' + renderSparklineAnnotations(weeklyTrend) + '</span>' +
|
||||
'</div>';
|
||||
|
||||
// Diagnoses list
|
||||
if (diagnoses.length === 0) {
|
||||
html += '<div class="link-health-no-issues">' +
|
||||
'<span class="no-issues-icon">✓</span>' +
|
||||
'<span>No issues detected</span>' +
|
||||
'</div>';
|
||||
} else {
|
||||
html += '<div class="link-health-diagnoses">';
|
||||
diagnoses.forEach(function (d) {
|
||||
html += renderDiagnosisCard(d);
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
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 '<div class="metric-gauge" title="' + escapeHtml(tooltip) + '">' +
|
||||
'<div class="metric-bar">' +
|
||||
'<div class="metric-fill ' + interp.class + '" style="width: ' + (value * 100) + '%"></div>' +
|
||||
'</div>' +
|
||||
'<div class="metric-info">' +
|
||||
'<span class="metric-name">' + escapeHtml(name) + '</span>' +
|
||||
'<span class="metric-value">' + Math.round(value * 100) + '%</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderHealthSparkline(linkID) {
|
||||
var history = state.healthHistory[linkID] || [];
|
||||
if (history.length === 0) {
|
||||
return '<canvas class="sparkline-canvas" width="' + CONFIG.sparklineWidth + '" height="' + CONFIG.sparklineHeight + '" data-empty="true"></canvas>';
|
||||
}
|
||||
|
||||
// Use composite_score from history
|
||||
var points = history.map(function (entry) {
|
||||
return entry.composite_score || entry.CompositeScore || 0.5;
|
||||
});
|
||||
|
||||
return '<canvas class="sparkline-canvas" width="' + CONFIG.sparklineWidth + '" height="' + CONFIG.sparklineHeight + '" data-points="' + points.join(',') + '"></canvas>';
|
||||
}
|
||||
|
||||
function renderSparkline(weeklyTrend) {
|
||||
if (!weeklyTrend || weeklyTrend.length === 0) {
|
||||
return '<canvas class="sparkline-canvas" width="' + CONFIG.sparklineWidth + '" height="' + CONFIG.sparklineHeight + '" data-empty="true"></canvas>';
|
||||
}
|
||||
|
||||
var canvas = '<canvas class="sparkline-canvas" width="' + CONFIG.sparklineWidth + '" height="' + CONFIG.sparklineHeight + '" data-points="';
|
||||
var points = weeklyTrend.map(function (d) {
|
||||
return d.avg_health || d.mean_health || 0.5;
|
||||
});
|
||||
canvas += points.join(',') + '"></canvas>';
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function renderSparklineAnnotations(weeklyTrend) {
|
||||
if (!weeklyTrend || weeklyTrend.length === 0) {
|
||||
return '<span class="sparkline-empty">No data</span>';
|
||||
}
|
||||
|
||||
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('<span class="sparkline-best" title="Best day">Best: ' + (bestDate.toString().substring(0, 10) || 'N/A') + '</span>');
|
||||
}
|
||||
if (weeklyTrend[minIdx] && minIdx !== maxIdx) {
|
||||
var worstDate = weeklyTrend[minIdx].date || '';
|
||||
annotations.push('<span class="sparkline-worst" title="Worst day">Worst: ' + (worstDate.toString().substring(0, 10) || 'N/A') + '</span>');
|
||||
}
|
||||
|
||||
return annotations.join(' ');
|
||||
}
|
||||
|
||||
function renderDiagnosisCard(d) {
|
||||
var severityClass = 'severity-' + d.severity.toLowerCase();
|
||||
var severityIcon = getSeverityIcon(d.severity);
|
||||
|
||||
var html = '<div class="diagnosis-card ' + severityClass + '">' +
|
||||
'<div class="diagnosis-header">' +
|
||||
'<span class="diagnosis-icon">' + severityIcon + '</span>' +
|
||||
'<span class="diagnosis-title">' + escapeHtml(d.title || '') + '</span>' +
|
||||
'<span class="diagnosis-confidence">' + Math.round((d.confidence_score || 0) * 100) + '%</span>' +
|
||||
'</div>' +
|
||||
'<div class="diagnosis-detail">' + escapeHtml(d.detail || '') + '</div>' +
|
||||
'<div class="diagnosis-advice">' + escapeHtml(d.advice || '') + '</div>';
|
||||
|
||||
// Repositioning target button
|
||||
if (d.repositioning_target && d.repositioning_node_mac) {
|
||||
var target = d.repositioning_target;
|
||||
html += '<div class="diagnosis-reposition">' +
|
||||
'<div class="reposition-target">' +
|
||||
'<span>Move to: X=' + target.x.toFixed(2) + 'm, Z=' + target.z.toFixed(2) + 'm</span>' +
|
||||
(d.gdop_improvement ? '<span class="gdop-improvement">GDOP improvement: +' + (d.gdop_improvement * 100).toFixed(0) + '%</span>' : '') +
|
||||
'</div>' +
|
||||
'<button class="reposition-apply-btn" data-node-mac="' + escapeHtml(d.repositioning_node_mac) + '" data-x="' + target.x + '" data-z="' + target.z + '">' +
|
||||
'Show in 3D' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
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, '>').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);
|
||||
}
|
||||
})();
|
||||
664
mothership/internal/signal/ambient.go
Normal file
664
mothership/internal/signal/ambient.go
Normal file
|
|
@ -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
|
||||
}
|
||||
401
mothership/internal/signal/ambient_test.go
Normal file
401
mothership/internal/signal/ambient_test.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
419
mothership/internal/signal/breathing_test.go
Normal file
419
mothership/internal/signal/breathing_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue