polar-recorder
Version:
A SignalK plugin to record boat polars based on sailing performance
164 lines (129 loc) • 5.85 kB
JavaScript
const { radToDeg, msToKnots, angleDifferenceDeg } = require('./utils');
function mean(values) {
return values.reduce((a, b) => a + b, 0) / values.length;
}
function standardDeviation(values) {
const avg = mean(values);
return Math.sqrt(mean(values.map(v => (v - avg) ** 2)));
}
function isStableCourse(app, courseHistory, options) {
if (!options?.cogFilter?.useCogThreshold) return true;
if (courseHistory.length === 0) return false;
const thresholdDeg = options.cogFilter.sameCourseAngleOffset;
const anglesDeg = courseHistory.map(e => radToDeg(e.value));
const reference = anglesDeg[0];
const maxDelta = Math.max(...anglesDeg.map(a => angleDifferenceDeg(a, reference)));
const stable = maxDelta <= thresholdDeg;
app.debug(`[FILTER] Stable course ${stable} || maxDelta=${maxDelta} thresholdDeg=${thresholdDeg}`);
return stable;
}
function isStableHdg(app, headingHistory, options) {
if (!options?.hdgFilter?.useHdgThreshold) return true;
if (headingHistory.length === 0) return false;
const thresholdDeg = options.hdgFilter.sameCourseAngleOffset;
const anglesDeg = headingHistory.map(e => radToDeg(e.value));
const reference = anglesDeg[0];
const maxDelta = Math.max(...anglesDeg.map(a => angleDifferenceDeg(a, reference)));
const stable = maxDelta <= thresholdDeg;
app.debug(`[FILTER] Stable heading ${stable} || maxDelta=${maxDelta} thresholdDeg=${thresholdDeg}`);
return stable;
}
function isStableTWD(app, twdHistory, options) {
if (!options?.twdFilter?.useTwdThreshold) return true;
if (twdHistory.length === 0) return false;
const thresholdDeg = options.twdFilter.sameTwdAngleOffset;
const anglesDeg = twdHistory.map(e => radToDeg(e.value));
const reference = anglesDeg[0];
const maxDelta = Math.max(...anglesDeg.map(a => angleDifferenceDeg(a, reference)));
const stable = maxDelta <= thresholdDeg;
app.debug(`[FILTER] Stable TWD ${stable} || maxDelta=${maxDelta} thresholdDeg=${thresholdDeg}`);
return stable;
}
function passesVmgRatioFilter(app, stwMs, twaRad, twsMs, polarData, options) {
if (!options.vmgFilter.useVmgThreshold) return true;
const twaDeg = radToDeg(twaRad);
const twsKnots = msToKnots(twsMs);
const stwKnots = msToKnots(stwMs);
const { expectedBoatSpeed } = findClosestPolarPoint(Math.abs(twaDeg), twsKnots, polarData);
if (expectedBoatSpeed == null || expectedBoatSpeed < 0.01) {
app.debug("[FILTER] No expected boat speed found for this TWA/TWS.");
return true;
}
const ratio = stwKnots / expectedBoatSpeed;
app.debug(`[FILTER] STW=${stwKnots.toFixed(2)}kt - Polar=${expectedBoatSpeed.toFixed(2)}kt || Ratio=${ratio.toFixed(2)}`);
return (
ratio < options.vmgFilter.vmgRatioThresholdUp &&
ratio > options.vmgFilter.vmgRatioThresholdDown
);
}
function passesAvgSpeedFilter(app, stw, stwHistory, options) {
const f = options.speedFilter;
if (!f.useAvgSpeedThreshold || stw === undefined || stwHistory.length === 0) return true;
const values = stwHistory.map(e => e.value);
const baseline = options.useStdDev ? standardDeviation(values) : mean(values);
const ratio = baseline > 0.01 ? stw / baseline : null;
app.debug(`[FILTER] AVG STW Filter STW=${stw} | BASELINE=${baseline.toFixed(2)} | Ratio=${ratio}`);
return (
ratio !== null &&
ratio < f.avgSpeedThresholdUp &&
ratio > f.avgSpeedThresholdDown
);
}
function passesAvgTwaFilter(app, twa, twaHistory, options) {
const f = options.twaFilter;
if (!f.useAvgTwaThreshold || twa === undefined || twaHistory.length === 0) return true;
const values = twaHistory.map(e => e.value);
const baseline = options.useStdDev ? standardDeviation(values) : mean(values);
const ratio = Math.abs(baseline) > 0.01 ? Math.abs(twa) / Math.abs(baseline) : null;
app.debug(`[FILTER] AVG TWA Filter TWA=${twa} | BASELINE=${baseline.toFixed(2)} | Ratio=${ratio}`);
return (
ratio !== null &&
ratio < f.avgTwaThresholdUp &&
ratio > f.avgTwaThresholdDown
);
}
function passesAvgTwsFilter(app, tws, twsHistory, options) {
const f = options.twsFilter;
if (!f.useAvgTwsThreshold || tws === undefined || twsHistory.length === 0) return true;
const values = twsHistory.map(e => e.value);
const baseline = options.useStdDev ? standardDeviation(values) : mean(values);
const ratio = baseline > 0.01 ? tws / baseline : null;
app.debug(`[FILTER] AVG TWS Filter TWS=${tws} | BASELINE=${baseline.toFixed(2)} | Ratio=${ratio}`);
return (
ratio !== null &&
ratio < f.avgTwsThresholdUp &&
ratio > f.avgTwsThresholdDown
);
}
function findClosestPolarPoint(twa, tws, polarData) {
let closestTWA = null;
let closestTWS = null;
let expectedBoatSpeed = 0;
let minDistance = Infinity;
const windAngles = Object.keys(polarData).map(Number);
const windSpeeds = [...new Set(Object.values(polarData).flatMap(obj => Object.keys(obj).map(Number)))];
windAngles.forEach(angle => {
windSpeeds.forEach(speed => {
if (polarData[angle]?.[speed] != null) {
const dist = Math.sqrt((angle - twa) ** 2 + (speed - tws) ** 2);
if (dist < minDistance) {
minDistance = dist;
closestTWA = angle;
closestTWS = speed;
expectedBoatSpeed = polarData[angle][speed].boatSpeed;
}
}
});
});
return { closestTWA, closestTWS, expectedBoatSpeed };
}
module.exports = {
isStableCourse,
isStableHdg,
isStableTWD,
passesVmgRatioFilter,
passesAvgSpeedFilter,
passesAvgTwaFilter,
passesAvgTwsFilter,
findClosestPolarPoint
};