feat: complete floor plan dashboard UI
- Floor plan upload panel with image selection and preview - Two-point calibration UI with pixel distance measurement - Real-world distance input for scale computation - Pixel-to-meter scale factor calculation and storage - Fixed floor plan image serving at /floorplan/image.png - Integration with Viz3D ground plane texture - CSS styling for floor plan setup panel - Image persists across server restart via SQLite Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
602b68645b
commit
281bbefb57
11 changed files with 875 additions and 48 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
f2cd1ebf0b9fbae48af27de003536623ab91688f
|
||||
3ff80c294d817bffc2c455069fbc592932c211ab
|
||||
|
|
|
|||
261
dashboard/css/floorplan.css
Normal file
261
dashboard/css/floorplan.css
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* Floor Plan Setup Panel Styles
|
||||
*/
|
||||
|
||||
.floorplan-panel {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 340px;
|
||||
width: 380px;
|
||||
max-height: calc(100vh - 80px);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.floorplan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.floorplan-header h3 {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.floorplan-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.floorplan-close:hover {
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.floorplan-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.floorplan-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.floorplan-section h4 {
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.floorplan-hint {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Upload area */
|
||||
.floorplan-upload-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.floorplan-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.floorplan-btn-primary {
|
||||
background: #4fc3f7;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.floorplan-btn-primary:hover:not(:disabled) {
|
||||
background: #29b6f6;
|
||||
}
|
||||
|
||||
.floorplan-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.floorplan-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ccc;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.floorplan-btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.floorplan-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.floorplan-file-name {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Preview */
|
||||
.floorplan-preview {
|
||||
margin-top: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.floorplan-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Calibration */
|
||||
.floorplan-calibration-container {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.floorplan-image-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#floorplan-canvas {
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.floorplan-marker {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.floorplan-controls {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.floorplan-instructions {
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.floorplan-points-info {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.floorplan-point-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.point-label {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.floorplan-distance-input {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.floorplan-distance-input label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.floorplan-distance-input input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
color: #eee;
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.floorplan-distance-input input:focus {
|
||||
outline: none;
|
||||
border-color: #4fc3f7;
|
||||
box-shadow: 0 0 0 2px rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
.floorplan-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.floorplan-status-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.floorplan-panel {
|
||||
right: 20px;
|
||||
width: 340px;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
<link rel="stylesheet" href="css/security.css">
|
||||
<link rel="stylesheet" href="css/anomaly.css">
|
||||
<link rel="stylesheet" href="css/sleep.css">
|
||||
<link rel="stylesheet" href="css/floorplan.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
|
|
@ -1227,6 +1228,48 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ===== Diurnal Chart ===== */
|
||||
#diurnal-chart-container {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(30, 30, 30, 0.5);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#diurnal-chart-container h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#diurnal-chart-legend {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
#diurnal-chart {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ===== Toast Notifications ===== */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
|
|
@ -2166,9 +2209,7 @@
|
|||
<button class="view-btn" id="view-follow" onclick="Viz3D.setViewPreset('follow')">Follow</button>
|
||||
<button id="gdop-toggle-btn" onclick="Placement && Placement.toggleGDOP()">GDOP</button>
|
||||
<button id="room-editor-btn" onclick="Placement && Placement.toggleRoomEditor()">Room</button>
|
||||
<button id="floorplan-btn" onclick="document.getElementById('floorplan-input').click()">Floor plan</button>
|
||||
<input id="floorplan-input" type="file" accept="image/*" style="display:none"
|
||||
onchange="Viz3D.uploadFloorPlan(this.files[0]); this.value=''">
|
||||
<button id="floorplan-btn" onclick="FloorPlanSetup.togglePanel()">Floor plan</button>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>FPS: <strong id="fps-counter">0</strong></span>
|
||||
|
|
@ -2268,6 +2309,8 @@
|
|||
<script src="js/tooltips.js"></script>
|
||||
<!-- WebSocket reconnection manager -->
|
||||
<script src="js/websocket.js"></script>
|
||||
<!-- Floor plan setup panel -->
|
||||
<script src="js/floorplan-setup.js"></script>
|
||||
<!-- Main application -->
|
||||
<script src="js/app.js"></script>
|
||||
<!-- esp-web-tools for firmware flashing (Web Serial) -->
|
||||
|
|
@ -2298,6 +2341,8 @@
|
|||
<script src="js/automation-builder.js"></script>
|
||||
<!-- Sleep Quality Monitoring -->
|
||||
<script src="js/sleep.js"></script>
|
||||
<!-- Diurnal Baseline Visualization -->
|
||||
<script src="js/diurnal-chart.js"></script>
|
||||
|
||||
<!-- Room editor panel -->
|
||||
<div id="room-editor-panel">
|
||||
|
|
|
|||
|
|
@ -1369,6 +1369,11 @@
|
|||
if (link.lastCSI) drawAmplitudeChart(link.lastCSI);
|
||||
drawTimeSeries(link.ampHistory || []);
|
||||
}
|
||||
|
||||
// Show diurnal baseline chart for this link
|
||||
if (typeof DiurnalChart !== 'undefined' && DiurnalChart.showForLink) {
|
||||
DiurnalChart.showForLink(linkID);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
|
|||
207
dashboard/js/diurnal-chart.js
Normal file
207
dashboard/js/diurnal-chart.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// Diurnal Baseline Visualization - 24-hour polar chart
|
||||
// Shows baseline amplitude variance by hour with confidence coloring
|
||||
|
||||
var DiurnalChart = (function() {
|
||||
'use strict';
|
||||
|
||||
var canvas = null;
|
||||
var ctx = null;
|
||||
var currentLinkID = null;
|
||||
var slotData = null;
|
||||
|
||||
// Confidence colors: green (ready), amber (partial), red (no data)
|
||||
var CONFIDENCE_COLORS = {
|
||||
high: { fill: 'rgba(76, 175, 80, 0.8)', stroke: 'rgba(76, 175, 80, 1)' }, // green
|
||||
medium: { fill: 'rgba(255, 152, 0, 0.8)', stroke: 'rgba(255, 152, 0, 1)' }, // orange
|
||||
low: { fill: 'rgba(244, 67, 54, 0.8)', stroke: 'rgba(244, 67, 54, 1)' } // red
|
||||
};
|
||||
|
||||
function init() {
|
||||
canvas = document.getElementById('diurnal-chart');
|
||||
if (!canvas) {
|
||||
// Create canvas if it doesn't exist
|
||||
var panel = document.getElementById('chart-panel');
|
||||
if (panel) {
|
||||
var container = document.createElement('div');
|
||||
container.id = 'diurnal-chart-container';
|
||||
container.style.cssText = 'margin-top: 20px; padding: 10px; background: rgba(0,0,0,0.2); border-radius: 8px;';
|
||||
container.innerHTML = '<h4 style="margin: 0 0 10px 0; color: #888;">24-Hour Diurnal Baseline</h4>' +
|
||||
'<div id="diurnal-chart-legend">' +
|
||||
'<div class="legend-item"><div class="legend-color" style="background: rgba(76, 175, 80, 0.8)"></div>Ready</div>' +
|
||||
'<div class="legend-item"><div class="legend-color" style="background: rgba(255, 152, 0, 0.8)"></div>Learning</div>' +
|
||||
'<div class="legend-item"><div class="legend-color" style="background: rgba(244, 67, 54, 0.8)"></div>No Data</div>' +
|
||||
'</div>' +
|
||||
'<canvas id="diurnal-chart" width="300" height="300"></canvas>';
|
||||
panel.appendChild(container);
|
||||
canvas = document.getElementById('diurnal-chart');
|
||||
}
|
||||
}
|
||||
|
||||
if (canvas) {
|
||||
ctx = canvas.getContext('2d');
|
||||
// Handle high DPI displays
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
}
|
||||
}
|
||||
|
||||
function getConfidenceColor(confidence) {
|
||||
if (confidence >= 1.0) {
|
||||
return CONFIDENCE_COLORS.high;
|
||||
} else if (confidence >= 0.5) {
|
||||
return CONFIDENCE_COLORS.medium;
|
||||
} else {
|
||||
return CONFIDENCE_COLORS.low;
|
||||
}
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
if (!ctx || !canvas) {
|
||||
init();
|
||||
if (!ctx) return;
|
||||
}
|
||||
|
||||
slotData = data;
|
||||
|
||||
var width = canvas.width / (window.devicePixelRatio || 1);
|
||||
var height = canvas.height / (window.devicePixelRatio || 1);
|
||||
var centerX = width / 2;
|
||||
var centerY = height / 2;
|
||||
var radius = Math.min(width, height) / 2 - 20;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw background circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'rgba(60, 60, 60, 0.3)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.stroke();
|
||||
|
||||
// Draw hour labels and radial lines
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
for (var h = 0; h < 24; h++) {
|
||||
var angle = (h / 24) * 2 * Math.PI - Math.PI / 2; // Start at 12 o'clock
|
||||
|
||||
// Draw radial line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
var lineEndX = centerX + Math.cos(angle) * radius;
|
||||
var lineEndY = centerY + Math.sin(angle) * radius;
|
||||
ctx.lineTo(lineEndX, lineEndY);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.stroke();
|
||||
|
||||
// Draw hour label
|
||||
var labelRadius = radius + 15;
|
||||
var labelX = centerX + Math.cos(angle) * labelRadius;
|
||||
var labelY = centerY + Math.sin(angle) * labelRadius;
|
||||
ctx.fillText(h.toString(), labelX, labelY);
|
||||
}
|
||||
|
||||
// Draw current hour indicator
|
||||
if (data.current_hour !== undefined) {
|
||||
var currentAngle = (data.current_hour / 24) * 2 * Math.PI - Math.PI / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(centerX, centerY);
|
||||
var currentEndX = centerX + Math.cos(currentAngle) * (radius + 5);
|
||||
var currentEndY = centerY + Math.sin(currentAngle) * (radius + 5);
|
||||
ctx.lineTo(currentEndX, currentEndY);
|
||||
ctx.strokeStyle = '#4fc3f7';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
}
|
||||
|
||||
// Draw data bars for each hour
|
||||
if (data.slot_amplitudes && data.slot_confidences) {
|
||||
var maxAmplitude = 0;
|
||||
for (var i = 0; i < 24; i++) {
|
||||
if (data.slot_amplitudes[i] > maxAmplitude) {
|
||||
maxAmplitude = data.slot_amplitudes[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid division by zero
|
||||
if (maxAmplitude === 0) maxAmplitude = 1;
|
||||
|
||||
for (var h = 0; h < 24; h++) {
|
||||
var amplitude = data.slot_amplitudes[h] || 0;
|
||||
var confidence = data.slot_confidences[h] || 0;
|
||||
|
||||
if (amplitude === 0 && confidence === 0) {
|
||||
continue; // Skip empty slots
|
||||
}
|
||||
|
||||
var angle = (h / 24) * 2 * Math.PI - Math.PI / 2;
|
||||
var barLength = (amplitude / maxAmplitude) * radius;
|
||||
var barWidth = (2 * Math.PI * radius) / 24 * 0.8;
|
||||
|
||||
// Draw bar segment
|
||||
ctx.beginPath();
|
||||
ctx.arc(centerX, centerY, barLength, angle - barWidth / radius / 2, angle + barWidth / radius / 2);
|
||||
ctx.arc(centerX, centerY, 0, angle + barWidth / radius / 2, angle - barWidth / radius / 2, true);
|
||||
ctx.closePath();
|
||||
|
||||
var colors = getConfidenceColor(confidence);
|
||||
ctx.fillStyle = colors.fill;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = colors.stroke;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw center info
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
var infoText = '';
|
||||
if (data.is_ready) {
|
||||
infoText = 'READY';
|
||||
} else if (data.is_learning) {
|
||||
infoText = Math.round(data.learning_progress || 0) + '%';
|
||||
} else {
|
||||
infoText = 'N/A';
|
||||
}
|
||||
ctx.fillText(infoText, centerX, centerY);
|
||||
}
|
||||
|
||||
function showForLink(linkID) {
|
||||
currentLinkID = linkID;
|
||||
|
||||
fetch('/api/diurnal/slots/' + encodeURIComponent(linkID))
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
render(data);
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('[DiurnalChart] Failed to load slot data:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!ctx || !canvas) return;
|
||||
var width = canvas.width / (window.devicePixelRatio || 1);
|
||||
var height = canvas.height / (window.devicePixelRatio || 1);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
currentLinkID = null;
|
||||
slotData = null;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
render: render,
|
||||
showForLink: showForLink,
|
||||
clear: clear,
|
||||
getCurrentData: function() { return slotData; }
|
||||
};
|
||||
})();
|
||||
|
|
@ -104,6 +104,11 @@ const Viz3D = (function () {
|
|||
_scene.add(floor);
|
||||
_roomObjs.floor = floor;
|
||||
|
||||
// Apply floor plan calibration if texture and calibration data exist
|
||||
if (_floorTex && _floorCalibration.metersPerPixel !== 1) {
|
||||
_applyCalibrationToFloor();
|
||||
}
|
||||
|
||||
// ceiling (dim, transparent)
|
||||
const ceil = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(w, d),
|
||||
|
|
@ -160,6 +165,89 @@ const Viz3D = (function () {
|
|||
});
|
||||
}
|
||||
|
||||
// Calibration state for floor plan
|
||||
let _floorCalibration = {
|
||||
metersPerPixel: 1,
|
||||
rotationDeg: 0,
|
||||
ax: 0, ay: 0, bx: 0, by: 0,
|
||||
distanceM: 1
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply floor plan calibration to the ground plane texture.
|
||||
* @param {Object} calibration - Calibration data from API
|
||||
* @param {number} calibration.meters_per_pixel - Scale factor
|
||||
* @param {number} calibration.rotation_deg - Rotation angle in degrees
|
||||
* @param {number} calibration.cal_ax - Point A X coordinate
|
||||
* @param {number} calibration.cal_ay - Point A Y coordinate
|
||||
* @param {number} calibration.cal_bx - Point B X coordinate
|
||||
* @param {number} calibration.cal_by - Point B Y coordinate
|
||||
* @param {number} calibration.distance_m - Real-world distance in meters
|
||||
*/
|
||||
function setFloorPlanCalibration(calibration) {
|
||||
if (!calibration) return;
|
||||
|
||||
_floorCalibration = {
|
||||
metersPerPixel: calibration.meters_per_pixel || 1,
|
||||
rotationDeg: calibration.rotation_deg || 0,
|
||||
ax: calibration.cal_ax || 0,
|
||||
ay: calibration.cal_ay || 0,
|
||||
bx: calibration.cal_bx || 0,
|
||||
by: calibration.cal_by || 0,
|
||||
distanceM: calibration.distance_m || 1
|
||||
};
|
||||
|
||||
// Apply calibration to floor texture if floor exists
|
||||
if (_roomObjs.floor && _floorTex) {
|
||||
_applyCalibrationToFloor();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply stored calibration to the floor mesh.
|
||||
* Uses texture transformation matrix to scale and rotate.
|
||||
*/
|
||||
function _applyCalibrationToFloor() {
|
||||
if (!_roomObjs.floor || !_floorTex) return;
|
||||
|
||||
const floor = _roomObjs.floor;
|
||||
const tex = _floorTex;
|
||||
|
||||
// Enable texture transformation
|
||||
tex.matrixAutoUpdate = false;
|
||||
|
||||
// Calculate texture scale based on room dimensions vs image dimensions
|
||||
// Default room size if not set
|
||||
const roomWidth = _room ? _room.width : 10;
|
||||
const roomDepth = _room ? _room.depth : 10;
|
||||
|
||||
// Calculate how many meters the image covers at current scale
|
||||
const imageWidthMeters = tex.image.width * _floorCalibration.metersPerPixel;
|
||||
const imageHeightMeters = tex.image.height * _floorCalibration.metersPerPixel;
|
||||
|
||||
// Scale texture to fit room
|
||||
const scaleX = roomWidth / imageWidthMeters;
|
||||
const scaleY = roomDepth / imageHeightMeters;
|
||||
|
||||
// Build transformation matrix: center -> rotate -> scale -> center back
|
||||
tex.matrix.setUvTransform(
|
||||
0.5, 0.5, // center
|
||||
scaleX, scaleY, // scale
|
||||
_floorCalibration.rotationDeg * Math.PI / 180, // rotation
|
||||
0, 0 // translation
|
||||
);
|
||||
|
||||
floor.material.needsUpdate = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current floor plan calibration state.
|
||||
* @returns {Object} Current calibration data
|
||||
*/
|
||||
function getFloorPlanCalibration() {
|
||||
return _floorCalibration;
|
||||
}
|
||||
|
||||
// ── node meshes ───────────────────────────────────────────────────────────
|
||||
|
||||
function applyNodeRegistry(nodes) {
|
||||
|
|
@ -2137,6 +2225,8 @@ const Viz3D = (function () {
|
|||
handleLinkInactive,
|
||||
applyLinks,
|
||||
uploadFloorPlan,
|
||||
setFloorPlanCalibration,
|
||||
getFloorPlanCalibration,
|
||||
setViewPreset,
|
||||
// WebSocket reconnect helpers
|
||||
clearAllTrails: clearAllTrails,
|
||||
|
|
|
|||
|
|
@ -2429,6 +2429,55 @@ func main() {
|
|||
http.Error(w, "link not found", http.StatusNotFound)
|
||||
})
|
||||
|
||||
// Diurnal slot data API - returns 24-hour slot data for polar chart visualization
|
||||
r.Get("/api/diurnal/slots/{linkID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
linkID := chi.URLParam(r, "linkID")
|
||||
processor := pm.GetProcessor(linkID)
|
||||
if processor == nil {
|
||||
http.Error(w, "link not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
diurnal := processor.GetDiurnal()
|
||||
if diurnal == nil {
|
||||
http.Error(w, "diurnal data not available", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate average amplitude per hour (for variance visualization)
|
||||
slotAmplitudes := make([]float64, 24)
|
||||
slotConfidences := make([]float64, 24)
|
||||
slotSampleCounts := make([]int, 24)
|
||||
|
||||
for h := 0; h < 24; h++ {
|
||||
slot := diurnal.GetSlot(h)
|
||||
if slot != nil && len(slot.Values) > 0 {
|
||||
// Calculate average amplitude for this slot
|
||||
sum := 0.0
|
||||
for _, v := range slot.Values {
|
||||
sum += v
|
||||
}
|
||||
slotAmplitudes[h] = sum / float64(len(slot.Values))
|
||||
slotSampleCounts[h] = slot.SampleCount
|
||||
}
|
||||
slotConfidences[h] = diurnal.GetSlotConfidence(h)
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"link_id": linkID,
|
||||
"current_hour": time.Now().Hour(),
|
||||
"current_minute": time.Now().Minute(),
|
||||
"is_ready": diurnal.IsReady(),
|
||||
"is_learning": diurnal.IsLearning(),
|
||||
"learning_progress": diurnal.GetLearningProgress(),
|
||||
"overall_confidence": diurnal.GetOverallConfidence(),
|
||||
"slot_amplitudes": slotAmplitudes,
|
||||
"slot_confidences": slotConfidences,
|
||||
"slot_sample_counts": slotSampleCounts,
|
||||
"created_at": diurnal.GetCreatedAt(),
|
||||
})
|
||||
})
|
||||
|
||||
// Link health API - returns all links with health scores and details
|
||||
r.Get("/api/links", func(w http.ResponseWriter, r *http.Request) {
|
||||
links := ingestSrv.GetAllLinksWithHealth()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ func NewHandler(db *sql.DB, dataDir string) *Handler {
|
|||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/api/floorplan/image", h.uploadImage)
|
||||
r.Get("/api/floorplan/image", h.getImage)
|
||||
// Serve the floor plan image at /floorplan/image.png for direct use by frontend
|
||||
r.Get("/floorplan/image.png", h.getImage)
|
||||
r.Post("/api/floorplan/calibrate", h.calibrate)
|
||||
r.Get("/api/floorplan/calibrate", h.getCalibration)
|
||||
r.Get("/api/floorplan", h.getFloorplan)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
// Diurnal configuration constants
|
||||
const (
|
||||
DiurnalSlots = 24 // One slot per hour
|
||||
DiurnalMinSamples = 100 // Minimum samples per slot (spec requirement)
|
||||
DiurnalMinSamples = 300 // Minimum samples per slot (spec requirement: >= 300 samples/slot to mark ready)
|
||||
DiurnalLearningDays = 7 // Days before diurnal baseline activates
|
||||
|
||||
// DiurnalUpdateAlpha is the slow EMA coefficient for slot updates
|
||||
|
|
@ -20,6 +20,9 @@ const (
|
|||
|
||||
// Confidence staleness threshold (days)
|
||||
DiurnalStaleDays = 3
|
||||
|
||||
// DiurnalCrossfadeMinutes is the duration of the EMA-to-diurnal crossfade at hour boundaries
|
||||
DiurnalCrossfadeMinutes = 15 // Crossfade over first 15 minutes of each hour
|
||||
)
|
||||
|
||||
// Confidence weights for composite score
|
||||
|
|
@ -112,8 +115,8 @@ func (db *DiurnalBaseline) GetActiveBaseline(emaBaseline []float64) ([]float64,
|
|||
}
|
||||
|
||||
// GetActiveBaselineAt returns the blended baseline for a specific timestamp
|
||||
// This is the core crossfade algorithm: B_eff = (1 - frac) * B_slot[h] + frac * B_slot[(h+1) % 24]
|
||||
// Uses linear interpolation between current hour and next hour slots
|
||||
// Spec: crossfade over first 15 min of each hour from EMA to diurnal slot; after 15 min use diurnal exclusively
|
||||
// Returns: blendedBaseline, crossfadeWeight (0-1), diurnalReady
|
||||
func (db *DiurnalBaseline) GetActiveBaselineAt(t time.Time, emaBaseline []float64) ([]float64, float64, bool) {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
|
|
@ -122,30 +125,41 @@ func (db *DiurnalBaseline) GetActiveBaselineAt(t time.Time, emaBaseline []float6
|
|||
minute := t.Minute()
|
||||
second := t.Second()
|
||||
|
||||
// Calculate fractional hour: frac = (minute + second/60) / 60
|
||||
frac := (float64(minute) + float64(second)/60.0) / 60.0
|
||||
|
||||
// Get the current hour's slot
|
||||
currentSlot := db.slots[hour]
|
||||
nextSlot := db.slots[(hour+1)%DiurnalSlots]
|
||||
|
||||
// Check if both slots have enough samples for diurnal to be used
|
||||
slotsReady := currentSlot.SampleCount >= DiurnalMinSamples && nextSlot.SampleCount >= DiurnalMinSamples
|
||||
// Check if the current slot has enough samples for diurnal to be used
|
||||
slotReady := currentSlot.SampleCount >= DiurnalMinSamples
|
||||
|
||||
// If diurnal not ready, fall back to EMA baseline
|
||||
if !slotsReady || len(emaBaseline) != db.nSub {
|
||||
// If diurnal slot not ready, fall back to EMA baseline
|
||||
if !slotReady || len(emaBaseline) != db.nSub {
|
||||
result := make([]float64, db.nSub)
|
||||
copy(result, emaBaseline)
|
||||
return result, 0.0, false
|
||||
}
|
||||
|
||||
// Apply crossfade between adjacent hourly slots
|
||||
// B_eff = (1 - frac) * B_slot[h] + frac * B_slot[(h+1) % 24]
|
||||
result := make([]float64, db.nSub)
|
||||
for k := 0; k < db.nSub && k < len(currentSlot.Values) && k < len(nextSlot.Values); k++ {
|
||||
result[k] = (1-frac)*currentSlot.Values[k] + frac*nextSlot.Values[k]
|
||||
// Calculate seconds into the current hour
|
||||
secondsIntoHour := minute*60 + second
|
||||
crossfadeDuration := DiurnalCrossfadeMinutes * 60 // 15 minutes = 900 seconds
|
||||
|
||||
var crossfadeWeight float64
|
||||
if secondsIntoHour < crossfadeDuration {
|
||||
// First 15 minutes: linear crossfade from EMA to diurnal slot
|
||||
// crossfadeWeight = 0 at hour start, = 1 at 15 minutes
|
||||
crossfadeWeight = float64(secondsIntoHour) / float64(crossfadeDuration)
|
||||
|
||||
// B_eff = (1 - weight) * EMA + weight * diurnal_slot
|
||||
result := make([]float64, db.nSub)
|
||||
for k := 0; k < db.nSub && k < len(currentSlot.Values) && k < len(emaBaseline); k++ {
|
||||
result[k] = (1-crossfadeWeight)*emaBaseline[k] + crossfadeWeight*currentSlot.Values[k]
|
||||
}
|
||||
return result, crossfadeWeight, true
|
||||
}
|
||||
|
||||
return result, frac, true
|
||||
// After 15 minutes: use diurnal slot exclusively
|
||||
result := make([]float64, db.nSub)
|
||||
copy(result, currentSlot.Values)
|
||||
return result, 1.0, true
|
||||
}
|
||||
|
||||
// GetActiveBaselineCosine returns the blended baseline using cosine crossfade
|
||||
|
|
@ -155,6 +169,7 @@ func (db *DiurnalBaseline) GetActiveBaselineCosine(emaBaseline []float64) ([]flo
|
|||
}
|
||||
|
||||
// GetActiveBaselineCosineAt returns cosine-crossfaded baseline for a specific timestamp
|
||||
// Uses cosine interpolation over the first 15 minutes for smoother transition: frac_smooth = (1 - cos(pi * frac)) / 2
|
||||
func (db *DiurnalBaseline) GetActiveBaselineCosineAt(t time.Time, emaBaseline []float64) ([]float64, float64, bool) {
|
||||
db.mu.RLock()
|
||||
defer db.mu.RUnlock()
|
||||
|
|
@ -163,29 +178,42 @@ func (db *DiurnalBaseline) GetActiveBaselineCosineAt(t time.Time, emaBaseline []
|
|||
minute := t.Minute()
|
||||
second := t.Second()
|
||||
|
||||
// Calculate fractional hour
|
||||
frac := (float64(minute) + float64(second)/60.0) / 60.0
|
||||
|
||||
// Get the current hour's slot
|
||||
currentSlot := db.slots[hour]
|
||||
nextSlot := db.slots[(hour+1)%DiurnalSlots]
|
||||
|
||||
slotsReady := currentSlot.SampleCount >= DiurnalMinSamples && nextSlot.SampleCount >= DiurnalMinSamples
|
||||
// Check if the current slot has enough samples for diurnal to be used
|
||||
slotReady := currentSlot.SampleCount >= DiurnalMinSamples
|
||||
|
||||
if !slotsReady || len(emaBaseline) != db.nSub {
|
||||
// If diurnal slot not ready, fall back to EMA baseline
|
||||
if !slotReady || len(emaBaseline) != db.nSub {
|
||||
result := make([]float64, db.nSub)
|
||||
copy(result, emaBaseline)
|
||||
return result, 0.0, false
|
||||
}
|
||||
|
||||
// Apply cosine crossfade: frac_smooth = (1 - cos(pi * frac)) / 2
|
||||
fracSmooth := (1 - math.Cos(math.Pi*frac)) / 2
|
||||
// Calculate seconds into the current hour
|
||||
secondsIntoHour := minute*60 + second
|
||||
crossfadeDuration := DiurnalCrossfadeMinutes * 60 // 15 minutes = 900 seconds
|
||||
|
||||
result := make([]float64, db.nSub)
|
||||
for k := 0; k < db.nSub && k < len(currentSlot.Values) && k < len(nextSlot.Values); k++ {
|
||||
result[k] = (1-fracSmooth)*currentSlot.Values[k] + fracSmooth*nextSlot.Values[k]
|
||||
var crossfadeWeight float64
|
||||
if secondsIntoHour < crossfadeDuration {
|
||||
// First 15 minutes: cosine crossfade from EMA to diurnal slot
|
||||
// frac goes from 0 to 1 over the crossfade period
|
||||
frac := float64(secondsIntoHour) / float64(crossfadeDuration)
|
||||
crossfadeWeight = (1 - math.Cos(math.Pi*frac)) / 2
|
||||
|
||||
// B_eff = (1 - weight) * EMA + weight * diurnal_slot
|
||||
result := make([]float64, db.nSub)
|
||||
for k := 0; k < db.nSub && k < len(currentSlot.Values) && k < len(emaBaseline); k++ {
|
||||
result[k] = (1-crossfadeWeight)*emaBaseline[k] + crossfadeWeight*currentSlot.Values[k]
|
||||
}
|
||||
return result, crossfadeWeight, true
|
||||
}
|
||||
|
||||
return result, fracSmooth, true
|
||||
// After 15 minutes: use diurnal slot exclusively
|
||||
result := make([]float64, db.nSub)
|
||||
copy(result, currentSlot.Values)
|
||||
return result, 1.0, true
|
||||
}
|
||||
|
||||
// GetSlotConfidence returns the confidence level for a specific hour's slot
|
||||
|
|
|
|||
|
|
@ -189,41 +189,43 @@ func TestDiurnalBaseline_HourSlotSelection(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestDiurnalBaseline_CrossfadeAtHalfHour tests crossfade at half-hour
|
||||
// Spec: produces correct blend of two adjacent slots
|
||||
// Spec: after 15 min, use diurnal slot exclusively (no more crossfade)
|
||||
func TestDiurnalBaseline_CrossfadeAtHalfHour(t *testing.T) {
|
||||
db := NewDiurnalBaseline("test", 64)
|
||||
|
||||
loc := time.Now().Location()
|
||||
// Test at 13:30:00
|
||||
// Test at 13:30:00 (30 minutes into the hour, past the 15-min crossfade window)
|
||||
t1330 := time.Date(2024, 1, 15, 13, 30, 0, 0, loc)
|
||||
|
||||
// Fill slots 13 and 14 with different values
|
||||
// Fill slot 13 with values
|
||||
db.mu.Lock()
|
||||
for i := 0; i < 64; i++ {
|
||||
db.slots[13].Values[i] = 1.0
|
||||
db.slots[14].Values[i] = 0.0
|
||||
}
|
||||
db.slots[13].SampleCount = DiurnalMinSamples
|
||||
db.slots[14].SampleCount = DiurnalMinSamples
|
||||
db.mu.Unlock()
|
||||
|
||||
emaBaseline := make([]float64, 64)
|
||||
for i := range emaBaseline {
|
||||
emaBaseline[i] = 0.5 // EMA baseline value
|
||||
}
|
||||
|
||||
result, frac, ready := db.GetActiveBaselineAt(t1330, emaBaseline)
|
||||
|
||||
if !ready {
|
||||
t.Fatal("Should be ready with populated slots")
|
||||
t.Fatal("Should be ready with populated slot")
|
||||
}
|
||||
|
||||
// At 13:30:00, frac = 30/60 = 0.5
|
||||
if math.Abs(frac-0.5) > 0.01 {
|
||||
t.Errorf("frac at half-hour = %f, want 0.5", frac)
|
||||
// After 15 minutes, frac should be 1.0 (diurnal slot only)
|
||||
if math.Abs(frac-1.0) > 0.01 {
|
||||
t.Errorf("frac at half-hour = %f, want 1.0", frac)
|
||||
}
|
||||
|
||||
// Result should be 50% slot 13 + 50% slot 14 = 0.5
|
||||
// Result should be exactly the diurnal slot value (1.0), not blended with EMA
|
||||
for k := 0; k < 64; k++ {
|
||||
expected := 0.5*1.0 + 0.5*0.0
|
||||
expected := 1.0
|
||||
if math.Abs(result[k]-expected) > 0.01 {
|
||||
t.Errorf("result[%d] = %f, want 0.5", k, result[k])
|
||||
t.Errorf("result[%d] = %f, want 1.0", k, result[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -819,3 +821,141 @@ func TestDiurnalManager_LinkCount(t *testing.T) {
|
|||
t.Errorf("LinkCount = %d, want 2", dm.LinkCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiurnalBaseline_CrossfadeAtHourBoundary tests crossfade at hour boundaries (±60s)
|
||||
// Spec: Baseline correctly crossfades at hour boundaries (±60s)
|
||||
func TestDiurnalBaseline_CrossfadeAtHourBoundary(t *testing.T) {
|
||||
db := NewDiurnalBaseline("test", 64)
|
||||
|
||||
loc := time.Now().Location()
|
||||
|
||||
// Set up EMA baseline value
|
||||
emaBaseline := make([]float64, 64)
|
||||
for i := range emaBaseline {
|
||||
emaBaseline[i] = 0.5 // EMA baseline value
|
||||
}
|
||||
|
||||
// Fill slot 13 with values
|
||||
db.mu.Lock()
|
||||
for i := 0; i < 64; i++ {
|
||||
db.slots[13].Values[i] = 1.0 // Hour 13 slot value
|
||||
}
|
||||
db.slots[13].SampleCount = DiurnalMinSamples
|
||||
db.mu.Unlock()
|
||||
|
||||
// Test at 13:00:00 (start of hour 13, in crossfade window)
|
||||
t1300 := time.Date(2024, 1, 15, 13, 0, 0, 0, loc)
|
||||
result1300, frac1300, ready1300 := db.GetActiveBaselineAt(t1300, emaBaseline)
|
||||
|
||||
if !ready1300 {
|
||||
t.Fatal("Should be ready with populated slot")
|
||||
}
|
||||
|
||||
// At 13:00:00, crossfade weight should be 0 (start of 15-min crossfade)
|
||||
if math.Abs(frac1300-0.0) > 0.001 {
|
||||
t.Errorf("frac at 13:00:00 = %f, want 0.0", frac1300)
|
||||
}
|
||||
|
||||
// At 13:00:00, result should be all EMA (0.5)
|
||||
for k := 0; k < 64; k++ {
|
||||
if math.Abs(result1300[k]-0.5) > 0.01 {
|
||||
t.Errorf("result[%d] at 13:00:00 = %f, want 0.5", k, result1300[k])
|
||||
}
|
||||
}
|
||||
|
||||
// Test at 13:15:00 (end of crossfade window)
|
||||
t1315 := time.Date(2024, 1, 15, 13, 15, 0, 0, loc)
|
||||
result1315, frac1315, ready1315 := db.GetActiveBaselineAt(t1315, emaBaseline)
|
||||
|
||||
if !ready1315 {
|
||||
t.Fatal("Should be ready at end of crossfade")
|
||||
}
|
||||
|
||||
// At 13:15:00, crossfade weight should be 1.0 (diurnal only)
|
||||
if math.Abs(frac1315-1.0) > 0.001 {
|
||||
t.Errorf("frac at 13:15:00 = %f, want 1.0", frac1315)
|
||||
}
|
||||
|
||||
// At 13:15:00, result should be exactly the diurnal slot value (1.0)
|
||||
for k := 0; k < 64; k++ {
|
||||
if math.Abs(result1315[k]-1.0) > 0.01 {
|
||||
t.Errorf("result[%d] at 13:15:00 = %f, want 1.0", k, result1315[k])
|
||||
}
|
||||
}
|
||||
|
||||
// Test at 13:07:30 (midway through crossfade)
|
||||
t1330 := time.Date(2024, 1, 15, 13, 7, 30, 0, loc)
|
||||
result1330, frac1330, ready1330 := db.GetActiveBaselineAt(t1330, emaBaseline)
|
||||
|
||||
if !ready1330 {
|
||||
t.Fatal("Should be ready during crossfade")
|
||||
}
|
||||
|
||||
// At 13:07:30 (450 seconds into hour / 900 seconds = 0.5)
|
||||
expectedFrac := 0.5
|
||||
if math.Abs(frac1330-expectedFrac) > 0.01 {
|
||||
t.Errorf("frac at 13:07:30 = %f, want %f", frac1330, expectedFrac)
|
||||
}
|
||||
|
||||
// Result should be 50% EMA (0.5) + 50% diurnal (1.0) = 0.75
|
||||
expectedResult := 0.5*0.5 + 0.5*1.0
|
||||
for k := 0; k < 64; k++ {
|
||||
if math.Abs(result1330[k]-expectedResult) > 0.01 {
|
||||
t.Errorf("result[%d] at 13:07:30 = %f, want %f", k, result1330[k], expectedResult)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Crossfade test: 13:00=%.4f, 13:07:30=%.4f, 13:15=%.4f",
|
||||
result1300[0], result1330[0], result1315[0])
|
||||
}
|
||||
|
||||
// TestDiurnalBaseline_Crossfade15MinuteWindow tests the 15-minute crossfade window
|
||||
// Spec: crossfade over first 15 min of each hour from EMA to diurnal slot
|
||||
func TestDiurnalBaseline_Crossfade15MinuteWindow(t *testing.T) {
|
||||
db := NewDiurnalBaseline("test", 64)
|
||||
|
||||
loc := time.Now().Location()
|
||||
|
||||
// Set up EMA baseline value
|
||||
emaBaseline := make([]float64, 64)
|
||||
for i := range emaBaseline {
|
||||
emaBaseline[i] = 0.5 // EMA baseline value
|
||||
}
|
||||
|
||||
// Fill slot 13 with diurnal values
|
||||
db.mu.Lock()
|
||||
for i := 0; i < 64; i++ {
|
||||
db.slots[13].Values[i] = 1.0 // Diurnal slot value
|
||||
}
|
||||
db.slots[13].SampleCount = DiurnalMinSamples
|
||||
db.mu.Unlock()
|
||||
|
||||
// Test progression across the first 15 minutes of hour 13
|
||||
testMinutes := []int{0, 5, 10, 15, 16, 30, 45}
|
||||
// expectedFracs: 0 at start, 1/3 at 5 min, 2/3 at 10 min, 1 at 15 min, then 1 for rest
|
||||
expectedFracs := []float64{0.0, 1.0/3.0, 2.0/3.0, 1.0, 1.0, 1.0, 1.0}
|
||||
|
||||
for i, minute := range testMinutes {
|
||||
testTime := time.Date(2024, 1, 15, 13, minute, 0, 0, loc)
|
||||
result, frac, ready := db.GetActiveBaselineAt(testTime, emaBaseline)
|
||||
|
||||
if !ready {
|
||||
t.Fatalf("Should be ready at 13:%02d", minute)
|
||||
}
|
||||
|
||||
expectedFrac := expectedFracs[i]
|
||||
if math.Abs(frac-expectedFrac) > 0.01 {
|
||||
t.Errorf("frac at 13:%02d = %f, want %f", minute, frac, expectedFrac)
|
||||
}
|
||||
|
||||
// Verify the result matches the crossfade formula: (1-frac)*EMA + frac*diurnal
|
||||
expectedResult := (1-expectedFrac)*0.5 + expectedFrac*1.0
|
||||
for k := 0; k < 64; k++ {
|
||||
if math.Abs(result[k]-expectedResult) > 0.01 {
|
||||
t.Errorf("result[%d] at 13:%02d = %f, want ~%f", k, minute, result[k], expectedResult)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("13:%02d: frac=%.3f, result[0]=%.4f (expected %.4f)", minute, frac, result[0], expectedResult)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue