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:
jedarden 2026-04-09 08:35:57 -04:00
parent 602b68645b
commit 281bbefb57
11 changed files with 875 additions and 48 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
f2cd1ebf0b9fbae48af27de003536623ab91688f
3ff80c294d817bffc2c455069fbc592932c211ab

261
dashboard/css/floorplan.css Normal file
View 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;
}
}

View file

@ -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">

View file

@ -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);
}
}
// ============================================

View 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; }
};
})();

View file

@ -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,

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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)
}
}