signalk-auto-polar
Version:
Aggregates boat performance data into running averages per wind condition (with a capped count) and displays the results via a static HTML/JS interface with CSV download.
207 lines (187 loc) • 7.45 kB
JavaScript
/**
* SignalK Polar Performance Aggregator Plugin
*
* This plugin listens for SignalK delta messages, extracts:
* - environment.wind.speedTrue (in m/s; converted to knots)
* - environment.wind.angleTrue (in degrees)
* - navigation.speedThroughWater (in m/s; converted to knots)
*
* It then aggregates the data per wind condition using binned values:
* - Wind speed is rounded to the nearest knot.
* - Wind angle is rounded to the nearest 5°.
*
* For each unique bin (e.g., "10_45" for 10 knots and 45°),
* the plugin stores only:
* - The number of updates (count)
* - A running average boat speed.
*
* When the count reaches the configured maximum (default: 1000), new updates
* are blended in using a fixed update factor so that older data gradually loses influence.
*
* The plugin also provides a web interface at:
* /plugin/polar-performance
* which displays a view that is served from static files (HTML & JS).
*/
const path = require("path");
const fs = require("fs");
module.exports = function(app) {
var plugin = {};
plugin.id = "signalk-auto-polar";
plugin.name = "Automatic Polar Performance Aggregator";
plugin.description = "Aggregates boat performance data into running averages per wind condition (with a capped count) and displays the results via a static HTML/JS interface with CSV download.";
// Default options (can be overridden by configuration)
var defaultOptions = {
maxCount: 1000, // Maximum number of updates per bin before using a fixed update factor
viewType: "diagram" // Default view ("diagram" or "table")
};
var options = defaultOptions;
// The aggregated data is stored in an object keyed by "windSpeed_windAngle" (e.g., "10_45")
// Each entry is an object: { windSpeed, windAngle, count, avgSpeed }
var aggregatedData = {};
// Cache to hold the latest values from incoming deltas.
var latestValues = {
windSpeed: null, // in knots
windAngle: null, // in degrees
boatSpeed: null // in knots
};
// ---------------------------
// Delta Handler
// ---------------------------
// Listens for SignalK deltas, extracts our three values and then
// updates the aggregated data for the appropriate wind condition bin.
function onDelta(delta) {
if (delta.updates) {
delta.updates.forEach(function(update) {
if (update.values) {
update.values.forEach(function(val) {
// Convert environment.wind.speedTrue (m/s) to knots.
if (val.path === "environment.wind.speedTrue") {
latestValues.windSpeed = parseFloat(val.value) * 1.94384;
}
// environment.wind.angleTrue is in degrees.
if (val.path === "environment.wind.angleTrue") {
latestValues.windAngle = parseFloat(val.value);
}
// Convert navigation.speedThroughWater (m/s) to knots.
if (val.path === "navigation.speedThroughWater") {
latestValues.boatSpeed = parseFloat(val.value) * 1.94384;
}
});
}
});
}
// Once all three values are available, update the aggregated data.
if (latestValues.windSpeed !== null &&
latestValues.windAngle !== null &&
latestValues.boatSpeed !== null) {
// Bin the values:
// - Wind speed: round to the nearest integer.
// - Wind angle: round to the nearest 5°.
var binnedWindSpeed = Math.round(latestValues.windSpeed);
var binnedWindAngle = Math.round(latestValues.windAngle / 5) * 5;
var key = binnedWindSpeed + "_" + binnedWindAngle;
if (!aggregatedData[key]) {
aggregatedData[key] = {
windSpeed: binnedWindSpeed,
windAngle: binnedWindAngle,
count: 0,
avgSpeed: 0
};
}
var entry = aggregatedData[key];
var boatSpeed = latestValues.boatSpeed;
// Update the running average.
if (entry.count < options.maxCount) {
entry.avgSpeed = (entry.avgSpeed * entry.count + boatSpeed) / (entry.count + 1);
entry.count++;
} else {
// When count is capped, blend in the new value using a fixed factor.
var factor = 1.0 / options.maxCount;
entry.avgSpeed = entry.avgSpeed * (1 - factor) + boatSpeed * factor;
// count remains at options.maxCount.
}
// Reset the cached values for the next complete update.
latestValues.windSpeed = null;
latestValues.windAngle = null;
latestValues.boatSpeed = null;
}
}
// ---------------------------
// Plugin Start & Stop
// ---------------------------
plugin.start = function(_options) {
options = Object.assign({}, defaultOptions, _options);
app.on("delta", onDelta);
// Serve static files from the public folder.
if (app.expressApp) {
// Static route: /plugin/polar-performance/static
app.expressApp.use("/plugin/polar-performance/static", require("express").static(path.join(__dirname, "public")));
}
// Set up HTTP endpoints.
if (app.registerHttpHandler) {
app.registerHttpHandler("/plugin/polar-performance", handleMainPage);
app.registerHttpHandler("/plugin/polar-performance/data", handleData);
app.registerHttpHandler("/plugin/polar-performance/csv", handleCSV);
} else if (app.expressApp) {
app.expressApp.get("/plugin/polar-performance", handleMainPage);
app.expressApp.get("/plugin/polar-performance/data", handleData);
app.expressApp.get("/plugin/polar-performance/csv", handleCSV);
}
};
plugin.stop = function() {
app.removeListener("delta", onDelta);
};
// ---------------------------
// Plugin Configuration Schema
// ---------------------------
plugin.schema = {
title: "Polar Performance Aggregator",
type: "object",
properties: {
maxCount: {
type: "number",
title: "Maximum Count per Wind Condition",
default: 1000
},
viewType: {
type: "string",
title: "Default View Type",
enum: ["table", "diagram"],
default: "diagram"
}
}
};
// ---------------------------
// Web Endpoints
// ---------------------------
// Main page: Serves the static HTML page.
function handleMainPage(req, res) {
// Read and serve index.html from the public folder.
var filePath = path.join(__dirname, "public", "index.html");
fs.readFile(filePath, "utf8", function(err, data) {
if (err) {
res.statusCode = 500;
res.end("Error loading index.html: " + err);
return;
}
res.setHeader("Content-Type", "text/html");
res.end(data);
});
}
// Data endpoint: Returns the aggregated data as JSON.
function handleData(req, res) {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(Object.values(aggregatedData)));
}
// CSV endpoint: Generates and returns a CSV file for download.
function handleCSV(req, res) {
res.setHeader("Content-Type", "text/csv");
res.setHeader("Content-Disposition", 'attachment; filename="polar_data.csv"');
let csv = "Wind Speed (knots),Wind Angle (°),Count,Avg Boat Speed (knots)\n";
Object.values(aggregatedData).forEach(item => {
csv += item.windSpeed + "," + item.windAngle + "," + item.count + "," + item.avgSpeed.toFixed(2) + "\n";
});
res.end(csv);
}
return plugin;
};