UNPKG

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
/** * 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; };