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.

207 lines (187 loc) 7.45 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."; // 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; };