UNPKG

signalk-polar-performance-plugin

Version:

A plugin that calculates performance information based on a (CSV) polar diagram.

1,004 lines (942 loc) 36.4 kB
const halfPi = Math.PI / 2 var sourceAddress module.exports = function (app) { var plugin = {} var unsubscribes = [] var timers = [] var damping = {} plugin.id = 'signalk-polar-performance-plugin' plugin.name = 'Polar performance plugin' plugin.description = 'A plugin that calculates performance information based on a (CSV) polar diagram.' plugin.uiSchema = { csvTable: { 'ui:widget': 'textarea' } } var schema = { // The plugin schema properties: { useTWSsource: { type: 'string', default: '', title: 'Source (name.id) to filter TWS on' }, beatAngle: { type: 'boolean', title: 'Enable calculation/sending of beat/upwind and run/gybe/downwind angle' }, beatVMG: { type: 'boolean', title: 'Enable calculation/sending of beat/upwind and run/gybe/downwind VMG' }, targetTWA: { type: 'boolean', title: 'Enable sending Target TWA (performance.targetAngle)' }, tackTrue: { type: 'boolean', title: 'Enable calculating Opposite Tack True (performance.tackTrue)' }, optimumWindAngle: { type: 'boolean', title: 'Enable calculation of Optimum Wind Angle (difference between TWA and beat/run angle (depends on beat/run angle)' }, VMG: { type: 'boolean', title: 'Enable calculation of VMG, polarVMG and polar VMG ratio' }, useSOG: { type: 'boolean', title: 'Use speed over ground (SOG) as boat speed.' }, useSOGsource: { type: 'string', default: '', title: 'Source (name.id) to filter SOG on' }, maxSpeed: { type: 'boolean', title: 'Enable writing of maximum speed angle and boat speed for a given TWS' }, perfAdjust: { type: 'number', description: 'This ratio allows you to lower the polar boat speeds in case you are not expecting to meet 100% due to e.g. weight. 1 = 100%, 0.8 = 80% etc', title: 'Performance adjustment ratio', default: 1 }, dampingTWA: { type: 'number', description: 'If data appears erratic or too sensitive, damping may be applied to amke information appear more stable. With damping set to 0, the data is presented in raw form wih no damping applied.', title: 'True Wind Angle damping seconds', default: 1 }, dampingTWS: { type: 'number', title: 'True Wind Speed damping seconds', default: 1 }, dampingBSP: { type: 'number', title: 'Boat speed damping seconds', default: 1 }, csvTable: { type: 'string', title: 'Copy/paste the csv content of the polar in http://jieter.github.io/orc-data/site/ style.' } } } plugin.schema = function () { // updateSchema() return schema } plugin.start = function (options, restartPlugin) { let firstUpdate = true let lastValues = {} // Here we put our plugin logic app.debug('Plugin started') app.debug('Options: %s', JSON.stringify(options)) // Load polar table var polar = csvToPolarObject(options.csvTable) app.debug('polar: %s', JSON.stringify(polar)) plugin.registerWithRouter = function (router) { // Will appear here; plugins/signalk-polar-performance-plugin/ app.debug('registerWithRouter') // Middleware to set Origin-Agent-Cluster on every response from this plugin router.use((req, res, next) => { res.set('Origin-Agent-Cluster', '?1') next() }) router.get('/polar', (req, res) => { res.contentType('application/json') res.send(JSON.stringify(polar)) }) router.get('/chartData', (req, res) => { const chart = getChartData(); if (!chart || !chart.datasets || chart.datasets.length === 0) { return res.status(500).json({ error: "No polar data", reason: "Polar CSV is empty or invalid. Please configure a valid polar in plugin settings." }) } res.contentType('application/json'); res.send(JSON.stringify(chart)); }) } // Global variables var BSP, STW, TWA, targetTWA, TWS, HDG, port // Angles in rad, speed in m/s // Subscribe to paths let localSubscription = { context: '*', subscribe: [ { path: 'navigation.speedThroughWater', policy: 'instant', minPeriod: 500 }, { path: 'environment.wind.speedTrue', policy: 'instant', minPeriod: 500 }, { path: 'environment.wind.angleTrueWater', policy: 'instant', minPeriod: 500 } ] } // Additional subscribes based on options if (options.useSOG == true) { localSubscription.subscribe.push({ path: 'navigation.speedOverGround', policy: 'instant', minPeriod: 500 }) } if (options.tackTrue == true) { localSubscription.subscribe.push({ path: 'navigation.headingTrue', policy: 'instant' }) } app.subscriptionmanager.subscribe( localSubscription, unsubscribes, subscriptionError => { app.error('Error:' + subscriptionError) }, delta => { delta.updates.forEach(u => { // app.debug(u) handleDelta(u.values,u['$source']) }) } ) // Handle delta function handleDelta (deltas, source) { deltas.forEach(delta => { app.debug('handleData (%s): %s', source, JSON.stringify(delta)) if ( delta.path == 'navigation.speedThroughWater' && options.useSOG == false ) { app.debug('STW useSOG: %j', options.useSOG) STW = applyDamping(delta.value, 'STW', options.dampingBSP || 0) BSP = STW app.debug('speedThroughWater (STW): %d', STW) } else if ( delta.path == 'navigation.speedOverGround' && options.useSOG == true ) { app.debug('SOG useSOG: %j', options.useSOG) if (options.useSOGsource == '' || source == options.useSOGsource) { SOG = applyDamping(delta.value, 'SOG', options.dampingBSP || 0) BSP = SOG app.debug('speedOverGround (SOG) (%s): %d', source, SOG) } } else if (delta.path == 'navigation.headingTrue') { HDG = delta.value // app.debug('heading (HDG): %d', HDG) } else if (delta.path == 'environment.wind.speedTrue') { if (options.useTWSsource == '' || source == options.useTWSsource) { TWS = applyDamping(delta.value, 'TWS', options.dampingTWS || 0) // app.debug('(TWS): %d applyDamping: %d', delta.value, TWS) // app.debug('TWS: %d TWA: %d BSP: %d', msToKts(TWS), radToDeg(TWA)*port, msToKts(BSP)) sendUpdates(getPerformanceData(TWS, TWA, BSP)) } } else if (delta.path == 'environment.wind.angleTrueWater') { let TWAtmp = applyDamping(delta.value, 'TWA', options.dampingTWA || 0) if (TWAtmp < 0) { port = -1 } else { port = 1 } TWA = Math.abs(TWAtmp) // app.debug('environment.wind.angleTrueWater (TWA): %s applyDamping: %s port: %d', delta.value, TWA, port) } }) } function sendUpdates (perfObj) { let values = [] let metas = [] function addValue (path, value, meta) { if (lastValues[path] !== value) { values.push({ path: path, value: roundDec(value) }) if (meta) { metas.push({ path: path, value: meta }) } lastValues[path] = value // Update lastValues } } if (BSP) { addValue('performance.boatSpeedDamped', BSP, { units: 'm/s', description: 'Boat speed after applying damping factor (see Polar Performance Plugin settings).' }) } if (TWA) { addValue('environment.wind.angleTrueWaterDamped', TWA * port, { units: 'rad', description: 'True Wind Angle after applying damping factor, negative to port (see Polar Performance Plugin settings).' }) } if (typeof perfObj.beatAngle !== 'undefined') { if (options.beatAngle === true) { addValue('performance.beatAngle', perfObj.beatAngle * port, { units: 'rad', description: 'The optimal beat/upwind angle for current TWS, negative to port.' }) } if (options.targetTWA === true) { addValue('performance.targetAngle', perfObj.beatAngle * port, { units: 'rad', description: 'The combined and automatic switching optimal beat or run angle for current TWS, negative to port.' }) } } if (typeof perfObj.runAngle !== 'undefined') { if (options.beatAngle === true) { addValue('performance.gybeAngle', perfObj.runAngle * port, { units: 'rad', description: 'The optimal run/downwind angle for current TWS, negative to port.' }) } if (options.targetTWA === true) { addValue('performance.targetAngle', perfObj.runAngle * port, { units: 'rad', description: 'The combined and automatic switching optimal beat or run angle for current TWS, negative to port.' }) } } if (typeof perfObj.beatVMG !== 'undefined') { addValue('performance.beatAngleVelocityMadeGood', perfObj.beatVMG, { units: 'm/s', description: 'The beat/upwind Velocity Made Good for current boat speed and heading.' }) if (options.targetTWA === true) { addValue('performance.targetVelocityMadeGood', perfObj.beatVMG, { units: 'm/s', description: 'The combined and automatic switching beat or run Velocity Made Good for current boat speed and heading.' }) } } if (typeof perfObj.runVMG !== 'undefined') { addValue('performance.gybeAngleVelocityMadeGood', perfObj.runVMG, { units: 'm/s', description: 'The run/downwind Velocity Made Good for current boat speed and heading.' }) if (options.targetTWA === true) { addValue('performance.targetVelocityMadeGood', perfObj.runVMG, { units: 'm/s', description: 'The combined and automatic switching beat or run Velocity Made Good for current TWS and TWA.' }) } } if (typeof perfObj.optimumWindAngle !== 'undefined') { addValue('performance.optimumWindAngle', perfObj.optimumWindAngle, { units: 'rad', description: 'The optimum wind angle, negative to port (diff between TWA and environment.wind.directionTrue).' }) } if (typeof perfObj.targetSpeed !== 'undefined') { addValue('performance.targetSpeed', perfObj.targetSpeed, { units: 'm/s', description: 'Target boat speed based on current TWA.' } ) } if (typeof perfObj.polarSpeed !== 'undefined') { addValue('performance.polarSpeed', perfObj.polarSpeed, { units: 'm/s', description: 'The polar chart boat speed as per the polar chart for current TWS and TWA.' }) addValue('performance.polarSpeedRatio', perfObj.polarSpeedRatio, { units: 'ratio', description: 'The ratio between actual boat speed and polar chart boat speed for current TWS and TWA.' }) if (typeof perfObj.velocityMadeGood !== 'undefined') { addValue('performance.velocityMadeGood', perfObj.velocityMadeGood, { units: 'm/s', description: 'The actual Velocity Made Good based on current heading and boat speed.' }) addValue('performance.polarVelocityMadeGood', perfObj.polarVelocityMadeGood, { units: 'm/s', description: 'The polar chart Velocity Made Good indicated in the polar for current TWS and TWA.' }) addValue('performance.polarVelocityMadeGoodRatio', perfObj.polarVelocityMadeGoodRatio, { units: 'ratio', description: 'The ratio between actual Velocity Made Good and polar chart indicated Velocity Made Good for current TWS and TWA.' }) } } if (typeof perfObj.maxSpeed !== 'undefined') { addValue('performance.maxSpeed', perfObj.maxSpeed, { units: 'm/s', description: 'Maximum boat speed as per polar chart.' }) addValue('performance.maxSpeedAngle', perfObj.maxSpeedAngle, { units: 'rad', description: 'The angle to achieve maximum boat speed (not the VMG), based on current TWS, negative to port.' }) } if (options.tackTrue === true) { if (typeof perfObj.tackTrue !== 'undefined') { addValue('performance.tackTrue', perfObj.tackTrue, { units: 'rad', description: 'The Opposite Tack\'s heading relative to True North, based on current TWS.' }) } } if (firstUpdate) { // Send meta updates app.handleMessage(plugin.id, { updates: [ { meta: metas } ] }) firstUpdate = false } // Send values updates app.handleMessage(plugin.id, { updates: [ { values: values } ] }) } function getPerformanceData (TWS, TWA, BSP) { var performance = {} // Use windspeed to find nearest speeds for (let indexTWS = 0; indexTWS < polar.length - 1; indexTWS++) { let lower = polar[indexTWS].tws let upper = polar[indexTWS + 1].tws if (indexTWS == polar.length - 1 && TWS > upper) { // North of polar TWS = upper } if (TWS >= lower && TWS <= upper) { //app.debug('TWS between %d and %d', lower, upper) // Calculate gap ratio let gap = upper - lower let twsGapRatio = (1 / gap) * (TWS - lower) //app.debug('twsGapRatio: %d', twsGapRatio) // Calculate beat/run angle if (TWA < halfPi) { // app.debug('Upwind') // Take beat angle if (typeof polar[indexTWS]['Beat angle'] != 'undefined') { let beatLower = polar[indexTWS]['Beat angle'] let beatUpper = polar[indexTWS + 1]['Beat angle'] //app.debug('beatLower: %s beatUpper: %s', beatLower, beatUpper) performance.beatAngle = beatLower + (beatUpper - beatLower) * twsGapRatio targetTWA = performance.beatAngle } // Calculate optimum wind angle (B&G thing) if (options.optimumWindAngle == true) { if (typeof performance.beatAngle != 'undefined') { performance.optimumWindAngle = (TWA - performance.beatAngle) * port } } // Calculate beat VMG if (typeof polar[indexTWS]['Beat VMG'] != 'undefined') { let VMGLower = polar[indexTWS]['Beat VMG'] let VMGUpper = polar[indexTWS + 1]['Beat VMG'] //app.debug('VMGLower: %s VMGUpper: %s', VMGLower, VMGUpper) performance.beatVMG = VMGLower + (VMGUpper - VMGLower) * twsGapRatio performance.targetVMG = performance.beatVMG performance.targetSpeed = performance.beatVMG / Math.cos(targetTWA) } } else { // app.debug('Downwind') if (typeof polar[indexTWS]['Run angle'] != 'undefined') { // Calculate run angle let runLower = polar[indexTWS]['Run angle'] let runUpper = polar[indexTWS + 1]['Run angle'] //app.debug('runLower: %s runUpper: %s', runLower, runUpper) performance.runAngle = runLower + (runUpper - runLower) * twsGapRatio targetTWA = performance.runAngle } if (options.optimumWindAngle == true) { if (typeof performance.runAngle != 'undefined') { // Calculate optimum wind angle performance.optimumWindAngle = (performance.runAngle - TWA) * port * -1 } } // Calculate run VMG if (typeof polar[indexTWS]['Run VMG'] != 'undefined') { let VMGLower = polar[indexTWS]['Run VMG'] let VMGUpper = polar[indexTWS + 1]['Run VMG'] //app.debug('VMGLower: %s VMGUpper: %s', VMGLower, VMGUpper) performance.runVMG = VMGLower + (VMGUpper - VMGLower) * twsGapRatio performance.targetVMG = performance.runVMG performance.targetSpeed = performance.runVMG / Math.abs(Math.cos(targetTWA)) } } // Calculate opposite Tack True // app.debug('tackTrue: port: %d HDG: %d targetTWA: %d', port, radToDeg(HDG), radToDeg(targetTWA)) if (port < 0) { let tackTrue = HDG - targetTWA if (tackTrue < 0) { tackTrue = tackTrue + 2 * Math.PI } performance.tackTrue = tackTrue } else { let tackTrue = HDG + targetTWA if (tackTrue > 2 * Math.PI) { tackTrue = tackTrue - 2 * Math.PI } performance.tackTrue = tackTrue } // Calculate polar target boat speed // Interpolate Define the 4 near data points let lowerTWA = polar[indexTWS].twa let upperTWA = polar[indexTWS + 1].twa // app.debug('lowerTWA: %s', JSON.stringify(lowerTWA)) // First find lowerTWA for (let indexTWA = 0; indexTWA < lowerTWA.length - 1; indexTWA++) { let lowerTWAlower = lowerTWA[indexTWA] let lowerTWAupper = lowerTWA[indexTWA + 1] if (TWA >= lowerTWAlower.twa && TWA <= lowerTWAupper.twa) { // app.debug('lowerTWAlower: %s lowerTWAupper: %s', JSON.stringify(lowerTWAlower), JSON.stringify(lowerTWAupper)) // Now find upperTWA for ( let indexTWA = 0; indexTWA < upperTWA.length - 1; indexTWA++ ) { let upperTWAlower = upperTWA[indexTWA] let upperTWAupper = upperTWA[indexTWA + 1] if (TWA >= upperTWAlower.twa && TWA <= upperTWAupper.twa) { // Found the 4 points // app.debug('lowerTWAlower: %d TWA: %d lowerTWAupper: %d', radToDeg(lowerTWAlower.twa), radToDeg(TWA), radToDeg(lowerTWAupper.twa)) // Calculate gap ratio let gap = lowerTWAupper.twa - lowerTWAlower.twa let twaGapRatio = (1 / gap) * (TWA - lowerTWAlower.twa) // app.debug('twaGapRatio: %d', twaGapRatio) // Calculate lower tws boat speed let lowerTBS = lowerTWAlower.tbs + (lowerTWAupper.tbs - lowerTWAlower.tbs) * twaGapRatio // Calculate upper tws boat speed let upperTBS = upperTWAlower.tbs + (upperTWAupper.tbs - upperTWAlower.tbs) * twaGapRatio // Calculate polar boat speed performance.polarSpeed = lowerTBS + (upperTBS - lowerTBS) * twsGapRatio // app.debug('lowerTBS: %d TBS: %d upperTBS: %d', msToKts(lowerTBS), performance.polarSpeed, msToKts(upperTBS)) break } } break } } if (typeof performance.polarSpeed == 'undefined') { if (TWA < performance.beatAngle) { // In case TWA < lowest polar angle app.debug('Low angle %d not present in table', radToDeg(TWA)) // performance.polarSpeed = } else if (TWA > performance.runAngle) { // In case TWA > highest polar angle app.debug('High angle %d not present in table', radToDeg(TWA)) // performance.polarSpeed = } } if (options.maxSpeed == true) { let lowerMax = polar[indexTWS]['Max speed'] let upperMax = polar[indexTWS + 1]['Max speed'] let maxSpeed = lowerMax + (upperMax - lowerMax) * twsGapRatio performance.maxSpeed = maxSpeed let lowerMaxAngle = polar[indexTWS]['Max speed angle'] let upperMaxAngle = polar[indexTWS + 1]['Max speed angle'] let maxSpeedAngle = lowerMaxAngle + (upperMaxAngle - lowerMaxAngle) * twsGapRatio performance.maxSpeedAngle = maxSpeedAngle } } else if (indexTWS == 0 && TWS < lower) { app.debug('No data for low wind speed (%d kts)', msToKts(TWS)) } else if (indexTWS == polar.length - 1 && TWS > upper) { app.debug('No data for high wind speed (%d kts)', msToKts(TWS)) } } // Calculate polar performance ratio if (typeof performance.polarSpeed != 'undefined') { // app.debug('performance.polarSpeedRatio = BSP (%s)/ performance.polarSpeed (%s)', msToKts(BSP).toFixed(2), msToKts(performance.polarSpeed).toFixed(2)) performance.polarSpeedRatio = BSP / performance.polarSpeed app.debug('performance.polarSpeedRatio = %s', performance.polarSpeedRatio.toFixed(2)) if (options.VMG == true) { performance.velocityMadeGood = Math.abs(BSP * Math.cos(TWA)) performance.polarVelocityMadeGood = performance.targetVMG performance.polarVelocityMadeGoodRatio = performance.velocityMadeGood / performance.polarVelocityMadeGood } } else { // No value would create stale values performance.polarSpeed = 0 performance.polarSpeedRatio = 0 if (options.VMG == true) { performance.velocityMadeGood = 0 performance.polarVelocityMadeGood = 0 performance.polarVelocityMadeGoodRatio = 0 } } return performance } function csvToPolarObject (csv) { var csvArray = [] var polar = {} var beat = false function upsertTwa (polarIndex, twaObj, angleDeg) { const twaArray = polar[polarIndex].twa const existingIndex = twaArray.findIndex(entry => entry.twa === twaObj.twa) if (existingIndex >= 0) { app.error( 'Duplicate TWA %s deg for TWS %s kts, keeping last value', angleDeg, msToKts(polar[polarIndex].tws).toFixed(1) ) twaArray[existingIndex] = twaObj } else { twaArray.push(twaObj) } } // Create array of arrays from csv csv.split('\n').forEach(row => { const trimmedRow = row.trim() if (!trimmedRow) { return } const cells = trimmedRow.split(';').map(cell => cell.trim()) if (!cells[0]) { return } csvArray.push(cells) }) // app.debug('csvArray: %s', JSON.stringify(csvArray)) // Populate the polar object from CSV rows csvArray.forEach(row => { const rowKey = row[0].trim().toLowerCase() if (rowKey == 'twa/tws') { // The row with TWS columns // Create empty TWS objects in polar object app.debug('First row with TWS columns') polar = [] for (let index = 1; index < row.length; index++) { let twsObj = { tws: ktsToMs(row[index]) } // CSV kts to internal m/s polar.push(twsObj) } app.debug('polar: %s', JSON.stringify(polar)) } else if (row.filter(i => i === '0').length > 1) { app.debug('beat and run angles are included') // Beat / Run angle let angleDeg = Number(row[0]) let angle = degToRad(angleDeg) let angleName let VMGName app.debug('angle < halfPi %d < %d', angle, halfPi) if (angle < halfPi) { angleName = 'Beat angle' VMGName = 'Beat VMG' app.debug('cvsToPolar: row includes Beat angle: %s', row.join(';')) } else { angleName = 'Run angle' VMGName = 'Run VMG' app.debug('cvsToPolar: row includes Run angle: %s', row.join(';')) } for (let index = 0; index < row.length - 1; index++) { if (!row[index + 1]) { continue } const tbsCell = Number(row[index + 1]) if (Number.isNaN(tbsCell) || tbsCell === 0) { continue } if (tbsCell != 0) { polar[index][angleName] = angle let tbs = ktsToMs(tbsCell) * (options.perfAdjust || 1) let vmg = tbs * Math.abs(Math.cos(angle)) polar[index][VMGName] = roundDec(vmg) if (typeof polar[index]['twa'] == 'undefined') { polar[index]['twa'] = [] } let Obj = { twa: angle, tbs: tbs, vmg: vmg } upsertTwa(index, Obj, angleDeg) app.debug('Finding max speed') if ( typeof polar[index]['Max speed'] == 'undefined' || tbs > polar[index]['Max speed'] ) { polar[index]['Max speed'] = tbs polar[index]['Max speed angle'] = angle app.debug('Found max speed: %s', JSON.stringify(polar[index])) } } } } else { // Normal line let angleDeg = Number(row[0]) let angle = degToRad(angleDeg) // CSV deg to internal rad for (let index = 0; index < row.length - 1; index++) { if (typeof polar[index].twa == 'undefined') { polar[index].twa = [] } if (!row[index + 1]) { continue } const tbsCell = Number(row[index + 1]) if (Number.isNaN(tbsCell)) { continue } let tbs = ktsToMs(tbsCell) * (options.perfAdjust || 1) let vmg = tbs * Math.abs(Math.cos(angle)) let Obj = { twa: angle, tbs: tbs, vmg: vmg } // app.debug('Adding Obj: %s', JSON.stringify(Obj)) upsertTwa(index, Obj, angleDeg) // See if we need to set/overwrite beat/run angle // See if this a new Max speed if ( typeof polar[index]['Max speed'] == 'undefined' || tbs > polar[index]['Max speed'] ) { polar[index]['Max speed'] = tbs polar[index]['Max speed angle'] = angle app.debug('Found max speed: %s', JSON.stringify(polar[index])) } } } }) // Sort the twa arrays by angle polar.forEach(tws => { let twaArray = tws.twa twaArray = twaArray.sort((a, b) => a.twa - b.twa) // app.debug('twaArray sorted: %s', JSON.stringify(twaArray)) tws.twa = twaArray }) // Find the beat/run angle if not set yet. for (let index = 0; index < polar.length; index++) { let tws = polar[index].tws app.debug( 'Beat angle for TWS %s is %s', msToKts(tws).toFixed(0), polar[index]['Beat angle'] ) // app.debug(polar[index]['Beat angle']) if (typeof polar[index]['Beat angle'] == 'undefined') { app.debug('Finding beat angle for TWS %s') let beatVMG = 0 let beatElement = 0 let runVMG = 0 let runElement = 0 let twaArray = polar[index].twa for (let element = 0; element < twaArray.length; element++) { let Obj = twaArray[element] if (Obj.twa < halfPi) { // Beat if (Obj.vmg > beatVMG) { beatElement = element } } else { // Run if (Obj.vmg > runVMG) { runElement = element } } } app.debug( 'beatVMG for %s is %s (angle %s)', msToKts(tws).toFixed(0), msToKts(twaArray[beatElement].vmg).toFixed(2), radToDeg(twaArray[beatElement].twa).toFixed(1) ) app.debug( 'runVMG for %s is %s (angle %s)', msToKts(tws).toFixed(0), msToKts(twaArray[runElement].vmg).toFixed(2), radToDeg(twaArray[runElement].twa).toFixed(1) ) polar[index]['Beat angle'] = twaArray[beatElement].twa polar[index]['Beat VMG'] = twaArray[beatElement].vmg polar[index]['Run angle'] = twaArray[runElement].twa polar[index]['Run VMG'] = twaArray[runElement].vmg } } // And now fill in some missing ends to avoid doing expensive calculations in the main loop if (polar[0].tws > 0) { // Add a 0 line to allow interpolation at very low wind speeds app.debug('Add a 0 line to allow interpolation at very low wind speeds') let Obj = { tws: 0.0001, 'Beat angle': polar[0]['Beat angle'], 'Beat VMG': polar[0]['Beat VMG'], 'Run angle': polar[0]['Run angle'], 'Run VMG': polar[0]['Run VMG'], 'Max speed': 0, 'Max speed angle': polar[0]['Max speed angle'], twa: [] } Object.values(polar[0].twa).forEach(twaObj => { Obj.twa.push({ twa: twaObj.twa, tbs: 0, vmg: 0 }) }) polar.unshift(Obj) } for (let index = 0; index < polar.length; index++) { // Sorted on angle, so first is lowest let tws = polar[index].tws let twaArray = polar[index].twa let lowTBS = twaArray[0].tbs let lowTWA = twaArray[0].twa // app.debug('Padding polar for %s from 0 to first given angle (%d deg, %d kts)', msToKts(tws).toFixed(0), radToDeg(lowTWA), msToKts(lowTBS)) // Now put some extra values at the beginning var topArray = [] for (let angle = 0; angle < lowTWA; angle = angle + degToRad(5)) { app.debug( 'Padding for angle %d (< lowTWA)', radToDeg(angle).toFixed(1), radToDeg(lowTWA).toFixed(1) ) let tbs = (angle / lowTWA) * Math.pow(Math.cos((-1 * lowTWA + angle) * 2), 2) * lowTBS if (tbs < 0) { tbs = 0 } let Obj = { twa: angle, tbs: tbs } topArray.push(Obj) } // app.debug('Adding values: %s', JSON.stringify(topArray)) polar[index].twa = topArray.concat(twaArray) } for (let index = 0; index < polar.length; index++) { let tws = polar[index].tws let twaArray = polar[index].twa let highTBS = twaArray[twaArray.length - 1].tbs let highTWA = twaArray[twaArray.length - 1].twa app.debug( 'Padding polar for %s from highTWA to last given angle (%d, %d kts)', msToKts(tws).toFixed(0), highTWA, msToKts(highTBS) ) // Now put some extra values at the beginning var tailArray = [] for ( let angle = Math.PI; angle > highTWA; angle = angle - degToRad(5) ) { let tbs = Math.pow(highTWA / angle, 2) * highTBS let Obj = { twa: angle, tbs: tbs } tailArray.unshift(Obj) } // app.debug('Adding values: %s', JSON.stringify(tailArray)) polar[index].twa = twaArray.concat(tailArray) } // app.debug(JSON.stringify(polar)) return polar } function getChartData () { var backgroundColor = [] var borderColor = [] for (let c = 0; c < 20; c++) { let r = 0 + c * 10 let g = 130 + ((c * 20) % 100) let b = 80 + ((c * 30) % 120) let color = r + ', ' + g + ', ' + b backgroundColor.push('rgba(' + color + ', 1)') borderColor.push('rgba(' + color + ', 0.8)') } app.debug(backgroundColor) app.debug(borderColor) var data = { labels: [], datasets: [] } for (let angle = 0; angle <= 180; angle += 5) { data.labels.push(angle) } for (let index = 0; index < polar.length; index++) { // Add x axis label TWS let tws = msToKts(polar[index].tws).toFixed(0) let twaArray = polar[index].twa data.datasets[index] = { data: [], pointRadius: [], backgroundColor: backgroundColor[index], borderColor: borderColor[index], fill: false, label: tws + ' kts' } for (let twaIndex = 0; twaIndex <= twaArray.length - 1; twaIndex++) { let twaObj = twaArray[twaIndex] let radius = 2 if ( twaObj.twa == polar[index]['Beat angle'] || twaObj.twa == polar[index]['Run angle'] ) { radius = 5 } data.datasets[index].data.push({ x: Number(radToDeg(twaObj.twa).toFixed(1)), y: Number(msToKts(twaObj.tbs).toFixed(2)) }) data.datasets[index].pointRadius.push(radius) } } // Remove 0 kts line data.datasets.shift() return data } function applyDamping (Xn, unit, RC) { if (RC == 0) { return Xn } else { if (typeof damping[unit] == 'undefined') { // Doesn't exist yet, make obj for unit damping[unit] = { Yn: Xn, dt: Date.now() } } // Edge case when hovering around -pi and +pi for TWA if (unit == 'TWA') { if (Xn > halfPi && damping[unit].Yn < halfPi * -1) { // app.debug('applyDamping: unit: %s Xn: %d > %d Yn: %d < %d -2Pi', unit, Xn, halfPi, damping[unit].Yn, halfPi * -1) Xn = Xn - 2 * Math.PI } else if (Xn < halfPi * -1 && damping[unit].Yn > halfPi) { // app.debug('applyDamping: unit: %s Xn: %d < %d Yn: %d > %d +2Pi', unit, Xn, halfPi * -1, damping[unit].Yn, halfPi) Xn = Xn + 2 * Math.PI } } // app.debug('applyDamping: unit: %s Xn: %d Yn: %d ', unit, Xn, damping[unit].Yn) // Calculate a let dt = (Date.now() - damping[unit].dt) / 1000 damping[unit].dt = Date.now() let a = dt / (RC + dt) // Yn = (1-a) * Yn-1 + a * Xn let Yn = (1 - a) * (damping[unit].Yn || Xn) + a * Xn // Fix if we go outside -pi to pi if (unit == 'TWA') { if (Yn > Math.PI) { Yn = Yn - 2 * Math.PI } else if (Yn < Math.PI * -1) { Yn = Yn + 2 * Math.PI } } // Remember Yn damping[unit].Yn = Yn // app.debug('Unit: %s dt: %d a: %d obj: %s', unit, dt, a, JSON.stringify(damping[unit])) // app.debug('applyDamping: unit: %s new Yn: %d ', unit, damping[unit].Yn) return Yn } } } plugin.stop = function () { // Here we put logic we need when the plugin stops app.debug('Plugin stopped') unsubscribes.forEach(f => f()) unsubscribes = [] timers.forEach(timer => { clearInterval(timer) }) } return plugin } function radToDeg (radians) { return (radians * 180) / Math.PI } function degToRad (degrees) { return degrees * (Math.PI / 180.0) } function ktsToMs (knots) { return knots / 1.94384 } function msToKts (ms) { return ms * 1.94384 } function roundDec (value) { if (typeof value == 'undefined') { return undefined } else { value = Number(value.toFixed(3)) return value } }