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.
317 lines (286 loc) • 11.2 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.";
let unsubscribes = [];
let saveInterval = null;
// 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")
pathWindAngle: "environment.wind.angleTrueWater",
pathWindSpeed: "environment.wind.speedTrue",
pathBoatSpeed: "navigation.speedThroughWater",
smoothingFactor: 10, // Number of datapoints to average before processing
updatePeriod: 100 // Update period in milliseconds
};
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: [], // in knots
windAngle: [], // in degrees
boatSpeed: [] // in knots
};
const dataFilePath = path.join(__dirname, "aggregatedData.json");
// Function to save aggregatedData to a JSON file
function saveAggregatedData() {
fs.writeFile(dataFilePath, JSON.stringify(aggregatedData), (err) => {
if (err) {
app.error('[signalk-auto-polar] Error saving aggregated data:', err);
} else {
app.debug('[signalk-auto-polar] Aggregated data saved.');
}
});
}
// Function to load aggregatedData from a JSON file
function loadAggregatedData() {
if (fs.existsSync(dataFilePath)) {
fs.readFile(dataFilePath, "utf8", (err, data) => {
if (err) {
app.error('[signalk-auto-polar] Error loading aggregated data:', err);
} else {
aggregatedData = JSON.parse(data);
app.debug('[signalk-auto-polar] Aggregated data loaded.');
}
});
}
}
// ---------------------------
// Delta Handler
// ---------------------------
// Listens for SignalK deltas, extracts our three values and then
// updates the aggregated data for the appropriate wind condition bin.
function average(values) {
return values.reduce((a, b) => a + b, 0) / values.length;
}
function onDeltaUpdate(update) {
if (update.values) {
update.values.forEach(function(val) {
// Convert environment.wind.speedTrue (m/s) to knots.
if (val.path === options.pathWindSpeed) {
latestValues.windSpeed.push(parseFloat(val.value) * 1.94384);
if (latestValues.windSpeed.length > options.smoothingFactor) {
latestValues.windSpeed.shift();
}
}
// Convert environment.wind.angleTrue radians to degrees.
if (val.path === options.pathWindAngle) {
latestValues.windAngle.push(parseFloat(val.value) * 180 / Math.PI);
if (latestValues.windAngle.length > options.smoothingFactor) {
latestValues.windAngle.shift();
}
}
// Convert navigation.speedThroughWater (m/s) to knots.
if (val.path === options.pathBoatSpeed) {
latestValues.boatSpeed.push(parseFloat(val.value) * 1.94384);
if (latestValues.boatSpeed.length > options.smoothingFactor) {
latestValues.boatSpeed.shift();
}
}
});
}
// Once all three values have enough data points, update the aggregated data.
if (latestValues.windSpeed.length === options.smoothingFactor &&
latestValues.windAngle.length === options.smoothingFactor &&
latestValues.boatSpeed.length === options.smoothingFactor) {
// Average the values
var avgWindSpeed = average(latestValues.windSpeed);
var avgWindAngle = average(latestValues.windAngle);
var avgBoatSpeed = average(latestValues.boatSpeed);
// Bin the values:
// - Wind speed: round to the nearest integer.
// - Wind angle: round to the nearest 5°.
var binnedWindSpeed = Math.round(avgWindSpeed);
var binnedWindAngle = Math.round(avgWindAngle / 5) * 5;
var key = binnedWindSpeed + "_" + binnedWindAngle;
if (!aggregatedData[key]) {
aggregatedData[key] = {
windSpeed: binnedWindSpeed,
windAngle: binnedWindAngle,
count: 0,
avgSpeed: 0
};
}
var entry = aggregatedData[key];
// Update the running average.
if (entry.count < options.maxCount) {
entry.avgSpeed = entry.avgSpeed + (avgBoatSpeed - entry.avgSpeed) / (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 += factor * (avgBoatSpeed - entry.avgSpeed);
}
// Reset the cached values for the next complete update.
latestValues.windSpeed = [];
latestValues.windAngle = [];
latestValues.boatSpeed = [];
}
}
// ---------------------------
// Plugin Start & Stop
// ---------------------------
plugin.start = function(settings) {
app.debug('[signalk-auto-polar] start');
options = Object.assign({}, defaultOptions, settings);
// Reset in-memory state, then restore from file if available
aggregatedData = {};
latestValues = { windSpeed: [], windAngle: [], boatSpeed: [] };
loadAggregatedData();
// Set interval to save aggregated data every 10 seconds
saveInterval = setInterval(saveAggregatedData, 10000);
let localSubscription = {
context: 'vessels.self',
subscribe: [
{ path: options.pathWindAngle, period: options.updatePeriod },
{ path: options.pathWindSpeed, period: options.updatePeriod },
{ path: options.pathBoatSpeed, period: options.updatePeriod }
]
};
app.subscriptionmanager.subscribe(
localSubscription,
unsubscribes,
subscriptionError => {
app.error('Error:' + subscriptionError);
},
delta => {
delta.updates.forEach(onDeltaUpdate);
}
);
// 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.debug('[signalk-auto-polar] stop');
if (saveInterval) {
clearInterval(saveInterval);
saveInterval = null;
}
unsubscribes.forEach(f => f());
unsubscribes = [];
// Save aggregated data when stopping the plugin
saveAggregatedData();
};
// ---------------------------
// Plugin Configuration Schema
// ---------------------------
plugin.schema = {
title: "Polar Performance Aggregator",
type: "object",
properties: {
maxCount: {
type: "number",
title: "Maximum Count per Wind Condition",
default: defaultOptions.maxCount
},
pathWindAngle: {
type: "string",
title: "Path to Wind Angle",
default: defaultOptions.pathWindAngle
},
pathWindSpeed: {
type: "string",
title: "Path to Wind Speed",
default: defaultOptions.pathWindSpeed
},
pathBoatSpeed: {
type: "string",
title: "Path to Boat Speed",
default: defaultOptions.pathBoatSpeed
},
viewType: {
type: "string",
title: "Default View Type",
enum: ["table", "diagram"],
default: defaultOptions.viewType
},
smoothingFactor: {
type: "number",
title: "Number of Datapoints to Average",
default: defaultOptions.smoothingFactor
},
updatePeriod: {
type: "number",
title: "Update Period (ms)",
default: defaultOptions.updatePeriod
}
}
};
// ---------------------------
// Web Endpoints
// ---------------------------
// Main page: Serves the static HTML page.
function handleMainPage(req, res) {
// Serve different HTML files based on the viewType option
var fileName = options.viewType === "table" ? "table.html" : "index.html";
var filePath = path.join(__dirname, "public", fileName);
fs.readFile(filePath, "utf8", function(err, data) {
if (err) {
res.statusCode = 500;
res.end("Error loading " + fileName + ": " + 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;
};