signalk-ais-target-prioritizer
Version:
A SignalK plugin that priorizes AIS targets according to guard and CPA criteria.
430 lines (373 loc) • 15.2 kB
JavaScript
;
import { mmsiMidToCountry } from './mmsi-mid-decoder.mjs';
const METERS_PER_NM = 1852;
const KNOTS_PER_M_PER_S = 1.94384;
const LOST_TARGET_WARNING_AGE = 10 * 60; // in seconds - 10 minutes
export { updateDerivedData, toRadians, toDegrees };
function updateDerivedData(targets, selfTarget, collisionProfiles, TARGET_MAX_AGE) {
// update self first
if (!selfTarget) {
console.warn('selfTarget is null', selfTarget);
return;
}
updateSingleTargetDerivedData(selfTarget, selfTarget, collisionProfiles, TARGET_MAX_AGE);
if (!selfTarget.isValid) {
console.warn('selfTarget is invalid', selfTarget);
return;
}
// then update all other targets
targets.forEach((target, mmsi) => {
if (mmsi != selfTarget.mmsi) {
updateSingleTargetDerivedData(target, selfTarget, collisionProfiles, TARGET_MAX_AGE);
}
});
}
function toRadians(v) {
return v * Math.PI / 180;
};
function toDegrees(v) {
return v * 180 / Math.PI;
};
function updateSingleTargetDerivedData(target, selfTarget, collisionProfiles, TARGET_MAX_AGE) {
target.y = target.latitude * 111120;
// FIXME this might work better using an average of the latitudes of the target and selfTarget
target.x = target.longitude * 111120 * Math.cos(toRadians(selfTarget.latitude));
target.vy = target.sog * Math.cos(target.cog); // cog is in radians
target.vx = target.sog * Math.sin(target.cog); // cog is in radians
if (target.mmsi != selfTarget.mmsi) {
calculateRangeAndBearing(selfTarget, target);
updateCpa(selfTarget, target);
evaluateAlarms(target, collisionProfiles);
}
var lastSeen = Math.round((new Date() - target.lastSeenDate) / 1000);
if (lastSeen < 0) {
lastSeen = 0;
}
var mmsiMid = getMid(target.mmsi);
target.lastSeen = lastSeen;
target.isLost = lastSeen > LOST_TARGET_WARNING_AGE ? true : false;
target.mmsiCountryCode = mmsiMidToCountry.get(mmsiMid)?.code;
target.mmsiCountryName = mmsiMidToCountry.get(mmsiMid)?.name;
target.cpaFormatted = formatCpa(target.cpa);
target.tcpaFormatted = formatTcpa(target.tcpa);
target.rangeFormatted = target.range != null ? (target.range / METERS_PER_NM).toFixed(2) + ' NM' : '---';
target.bearingFormatted = target.bearing != null ? target.bearing + ' T' : '---';
target.sogFormatted = target.sog != null ? (target.sog * KNOTS_PER_M_PER_S).toFixed(1) + ' kn' : '---';
target.cogFormatted = target.cog != null ? (Math.round(toDegrees(target.cog)) + ' T') : '---';
target.hdgFormatted = target.hdg != null ? (Math.round(toDegrees(target.hdg)) + ' T') : '---';
target.rotFormatted = Math.round(toDegrees(target.rot)) || '---';
target.aisClassFormatted = target.aisClass + (target.isVirtual ? ' (virtual)' : '');
target.sizeFormatted = `${target.length?.toFixed(1) ?? '---'} m x ${target.beam?.toFixed(1) ?? '---'} m`;
target.imoFormatted = target.imo?.replace(/imo/i, '') || '---';
target.latitudeFormatted = formatLat(target.latitude);
target.longitudeFormatted = formatLon(target.longitude);
if (!target.latitude || !target.longitude || target.lastSeen > TARGET_MAX_AGE) {
//console.log("invalid target", target.mmsi, target.latitude, target.longitude, target.lastSeen);
target.isValid = false;
} else {
target.isValid = true;
}
}
function calculateRangeAndBearing(selfTarget, target) {
if (!selfTarget.isValid
|| !target.latitude
|| !target.longitude) {
target.range = null;
target.bearing = null;
// console.log('cant calc range bearing', selfTarget, target);
return;
}
target.range = Math.round(getDistanceFromLatLonInMeters(selfTarget.latitude, selfTarget.longitude, target.latitude, target.longitude));
target.bearing = Math.round(getRhumbLineBearing(selfTarget.latitude, selfTarget.longitude, target.latitude, target.longitude));
if (target.bearing >= 360) {
target.bearing = 0;
}
}
// from: http://geomalgorithms.com/a07-_distance.html
function updateCpa(selfTarget, target) {
if (selfTarget.x == null
|| selfTarget.y == null
|| selfTarget.vx == null
|| selfTarget.vy == null
|| target.x == null
|| target.y == null
|| target.vx == null
|| target.vy == null
) {
//console.log('cant calc cpa: missing data', target.mmsi);
target.cpa = null;
target.tcpa = null;
return;
}
// dv = Tr1.v - Tr2.v
// this is relative speed
// m/s
var dv = {
x: target.vx - selfTarget.vx,
y: target.vy - selfTarget.vy,
}
// (m/s)^2
var dv2 = dot(dv, dv);
// guard against division by zero
// the tracks are almost parallel
// or there is almost no relative movement
if (dv2 < 0.00000001) {
// console.log('cant calc tcpa: ',target.mmsi);
target.cpa = null;
target.tcpa = null;
return;
}
// w0 = Tr1.P0 - Tr2.P0
// this is relative position
// 111120 m / deg lat
// m
// FIXME isnt this the same as target.y - selfTarget.y
var w0 = {
x: target.x - selfTarget.x,
y: target.y - selfTarget.y
// x: (target.longitude - selfTarget.longitude) * 111120 * Math.cos(toRadians(selfTarget.latitude)),
// y: (target.latitude - selfTarget.latitude) * 111120,
}
// in secs
// m * m/s / (m/s)^2 = m / (m/s) = s
var tcpa = -dot(w0, dv) / dv2;
// if tcpa is in the past,
// or if tcpa is more than 3 hours in the future
// then dont calc cpa & tcpa
if (!tcpa || tcpa < 0 || tcpa > 3 * 3600) {
//console.log('discarding tcpa: ', target.mmsi, tcpa);
target.cpa = null;
target.tcpa = null;
return;
}
// Point P1 = Tr1.P0 + (ctime * Tr1.v);
// m
var p1 = {
x: selfTarget.x + tcpa * selfTarget.vx,
y: selfTarget.y + tcpa * selfTarget.vy,
}
// Point P2 = Tr2.P0 + (ctime * Tr2.v);
// m
var p2 = {
x: target.x + tcpa * target.vx,
y: target.y + tcpa * target.vy,
}
// in meters
var cpa = dist(p1, p2);
// in meters
target.cpa = Math.round(cpa);
// in seconds
target.tcpa = Math.round(tcpa);
}
// #define dot(u,v) ((u).x * (v).x + (u).y * (v).y + (u).z * (v).z)
function dot(u, v) {
return u.x * v.x + u.y * v.y;
}
// #define norm(v) sqrt(dot(v,v))
// norm = length of vector
function norm(v) {
return Math.sqrt(dot(v, v));
}
// #define d(u,v) norm(u-v)
// distance = norm of difference
function dist(u, v) {
return norm({
x: u.x - v.x,
y: u.y - v.y,
});
}
function evaluateAlarms(target, collisionProfiles) {
try {
// guard alarm
target.guardAlarm =
(target.range != null && target.range < collisionProfiles[collisionProfiles.current].guard.range * METERS_PER_NM)
&& (collisionProfiles[collisionProfiles.current].guard.speed == 0
|| (target.sog != null && target.sog > collisionProfiles[collisionProfiles.current].guard.speed / KNOTS_PER_M_PER_S));
// collision alarm
target.collisionAlarm =
target.cpa != null && target.cpa < collisionProfiles[collisionProfiles.current].danger.cpa * METERS_PER_NM
&& target.tcpa != null && target.tcpa > 0 && target.tcpa < collisionProfiles[collisionProfiles.current].danger.tcpa
&& (collisionProfiles[collisionProfiles.current].danger.speed == 0
|| (target.sog != null && target.sog > collisionProfiles[collisionProfiles.current].danger.speed / KNOTS_PER_M_PER_S));
// collision warning
target.collisionWarning =
target.cpa != null && target.cpa < collisionProfiles[collisionProfiles.current].warning.cpa * METERS_PER_NM
&& target.tcpa != null && target.tcpa > 0 && target.tcpa < collisionProfiles[collisionProfiles.current].warning.tcpa
&& (collisionProfiles[collisionProfiles.current].warning.speed == 0
|| (target.sog != null && target.sog > collisionProfiles[collisionProfiles.current].warning.speed / KNOTS_PER_M_PER_S));
target.sartAlarm = (target.mmsi.startsWith('970'));
target.mobAlarm = (target.mmsi.startsWith('972'));
target.epirbAlarm = (target.mmsi.startsWith('974'));
//FIXME - need to clean up this order logic.
// targets with alarm status must be at the top
// targets with negative tcpa are very low priority
// alarm
if (target.guardAlarm
|| target.collisionAlarm
|| target.sartAlarm
|| target.mobAlarm
|| target.epirbAlarm) {
target.alarmState = 'danger';
target.order = 10000;
}
// warning
else if (target.collisionWarning) {
target.alarmState = 'warning';
target.order = 20000;
}
// no alarm/warning - but has positive tcpa (closing)
else if (target.tcpa != null && target.tcpa > 0) {
target.alarmState = null;
target.order = 30000;
}
// no alarm/warning and moving away)
else {
target.alarmState = null;
target.order = 40000;
}
var alarms = [];
if (target.guardAlarm) alarms.push('guard');
if (target.collisionAlarm || target.collisionWarning) alarms.push('cpa');
if (target.sartAlarm) alarms.push('sart');
if (target.mobAlarm) alarms.push('mob');
if (target.epirbAlarm) alarms.push('epirb');
if (alarms.length > 0) {
target.alarmType = alarms.join(',');
} else {
target.alarmType = null;
}
// sort sooner tcpa targets to top
if (target.tcpa != null && target.tcpa > 0) {
// sort vessels with any tcpa above vessels that dont have a tcpa
target.order -= 1000;
// tcpa of 0 seconds reduces order by 1000 (this is an arbitrary weighting)
// tcpa of 60 minutes reduces order by 0
var weight = 1000;
target.order -= Math.max(0, Math.round(weight - weight * target.tcpa / 3600));
}
// sort closer cpa targets to top
if (target.cpa != null && target.cpa > 0) {
// cpa of 0 nm reduces order by 2000 (this is an arbitrary weighting)
// cpa of 5 nm reduces order by 0
var weight = 2000;
target.order -= Math.max(0, Math.round(weight - weight * target.cpa / 5 / METERS_PER_NM));
}
// sort closer targets to top
if (target.range != null && target.range > 0) {
// range of 0 nm increases order by 0
// range of 5 nm increases order by 500
target.order += Math.round(100 * target.range / METERS_PER_NM);
}
// FIXME might be interesting to calculate rate of closure
// high positive rate of close decreases order
// sort targets with no range to bottom
if (target.range == null) {
target.order += 99999;
}
}
catch (err) {
console.error('error in evaluateAlarms', err.message, err);
}
}
function getDistanceFromLatLonInMeters(lat1, lon1, lat2, lon2) {
var R = 6371000; // Radius of the earth in meters
var dLat = toRadians(lat2 - lat1);
var dLon = toRadians(lon2 - lon1);
var a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
;
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
var d = R * c; // Distance in meters
return d;
};
function getRhumbLineBearing(lat1, lon1, lat2, lon2) {
// difference of longitude coords
var diffLon = toRadians(lon2 - lon1);
// difference latitude coords phi
var diffPhi = Math.log(
Math.tan(
toRadians(lat2) / 2 + Math.PI / 4
) /
Math.tan(
toRadians(lat1) / 2 + Math.PI / 4
)
);
// recalculate diffLon if it is greater than pi
if (Math.abs(diffLon) > Math.PI) {
if (diffLon > 0) {
diffLon = (Math.PI * 2 - diffLon) * -1;
}
else {
diffLon = Math.PI * 2 + diffLon;
}
}
//return the angle, normalized
return (toDegrees(Math.atan2(diffLon, diffPhi)) + 360) % 360;
};
// 012345678
// 8MIDXXXXX Diver’s radio (not used in the U.S. in 2013)
// MIDXXXXXX Ship
// 0MIDXXXXX Group of ships; the U.S. Coast Guard, for example, is 03699999
// 00MIDXXXX Coastal stations
// 111MIDXXX SAR (Search and Rescue) aircraft
// 99MIDXXXX Aids to Navigation
// 98MIDXXXX Auxiliary craft associated with a parent ship
// 970MIDXXX AIS SART (Search and Rescue Transmitter) (might be bad info - might be no MID)
// 972XXXXXX MOB (Man Overboard) device (no MID)
// 974XXXXXX EPIRB (Emergency Position Indicating Radio Beacon) AIS (no MID)
function getMid(mmsi) {
if (
mmsi.startsWith('111')
|| mmsi.startsWith('970')
) {
return mmsi.substring(3, 6);
} else if (
mmsi.startsWith('00')
|| mmsi.startsWith('98')
|| mmsi.startsWith('99')
) {
return mmsi.substring(2, 5);
} else if (
mmsi.startsWith('0')
|| mmsi.startsWith('8')
) {
return mmsi.substring(1, 4);
} else {
return mmsi.substring(0, 3);
}
}
// N 39° 57.0689
function formatLat(dec) {
var decAbs = Math.abs(dec);
var deg = ('0' + Math.floor(decAbs)).slice(-2);
var min = ('0' + ((decAbs - deg) * 60).toFixed(4)).slice(-7);
return (dec > 0 ? "N" : "S") + " " + deg + "° " + min;
}
// W 075° 08.3692
function formatLon(dec) {
var decAbs = Math.abs(dec);
var deg = ('00' + Math.floor(decAbs)).slice(-3);
var min = ('0' + ((decAbs - deg) * 60).toFixed(4)).slice(-7);
return (dec > 0 ? "E" : "W") + " " + deg + "° " + min;
}
// 1.53 NM
function formatCpa(cpa) {
// if cpa is null it should be returned as blank. toFixed makes it '0.00'
return cpa != null ? (cpa / METERS_PER_NM).toFixed(2) + ' NM' : '---';
}
// hh:mm:ss or mm:ss e.g. 01:15:23 or 51:37
function formatTcpa(tcpa) {
if (tcpa == null || tcpa < 0) {
return '---';
}
// when more than 60 mins, then format hh:mm:ss
else if (Math.abs(tcpa) >= 3600) {
return new Date(1000 * Math.abs(tcpa)).toISOString().substring(11, 19) // + ' hours'
}
// when less than 60 mins, then format mm:ss
else {
return new Date(1000 * Math.abs(tcpa)).toISOString().substring(14, 19) // + ' mins'
}
}