( ac: Aircraft, site: GeoPoint, now: number, history: TrackHistory, params: PredictParams, mount: MountModel, limits: CameraLimits, currentPanTilt: PanTilt | null, )
| 129 | * recompute slew from the resulting move, predict again. |
| 130 | */ |
| 131 | export function predictAim( |
| 132 | ac: Aircraft, |
| 133 | site: GeoPoint, |
| 134 | now: number, |
| 135 | history: TrackHistory, |
| 136 | params: PredictParams, |
| 137 | mount: MountModel, |
| 138 | limits: CameraLimits, |
| 139 | currentPanTilt: PanTilt | null, |
| 140 | ): Prediction | null { |
| 141 | // Denoised position when smoothing is on (kills ~1 Hz aim jitter from |
| 142 | // ADS-B position noise); raw fix otherwise. |
| 143 | const geo = |
| 144 | params.posSmoothing && params.posSmoothing > 0 |
| 145 | ? history.smoothGeo(ac, now, 1 - params.posSmoothing * 0.85) ?? aircraftGeoPoint(ac) |
| 146 | : aircraftGeoPoint(ac); |
| 147 | if (!geo) return null; |
| 148 | |
| 149 | const fixAgeSec = Math.max(0, (now - (ac.ts ?? now)) / 1000) + (ac.seen ?? 0); |
| 150 | const turnRateDps = history.turnRateDps(ac.hex); |
| 151 | const kin = { |
| 152 | lat: geo.lat, |
| 153 | lon: geo.lon, |
| 154 | altM: geo.altM, |
| 155 | gsKt: ac.gs, |
| 156 | trackDeg: ac.track, |
| 157 | vRateFpm: ac.baroRate, |
| 158 | turnRateDps, |
| 159 | }; |
| 160 | |
| 161 | const predictAt = (leadSec: number): AzEl => |
| 162 | azElFromSite(site, predictGeo(kin, leadSec)); |
| 163 | |
| 164 | // The camera doesn't move the instant we command it — aim where the plane |
| 165 | // will be when the command BITES, not when it's sent. While continuously |
| 166 | // tracking, this is the term that converts a steady trailing error into a |
| 167 | // centered lock (the rate feedforward picks up the matching value because |
| 168 | // the analytic rate is evaluated at the same shifted epoch). |
| 169 | const motorLag = params.motorLatencySec ?? 0; |
| 170 | |
| 171 | // Pass 1: assume a small slew. |
| 172 | let lead = fixAgeSec + params.adsbLatencySec + motorLag + 0.3; |
| 173 | let clamped = lead > params.maxLeadSec + fixAgeSec; |
| 174 | lead = Math.min(lead, params.maxLeadSec + fixAgeSec); |
| 175 | let azEl = predictAt(lead); |
| 176 | |
| 177 | // Pass 2: refine with the actual slew time to the pass-1 aim point. |
| 178 | if (currentPanTilt) { |
| 179 | const goal = mountFromWorld(azEl.azDeg, azEl.elDeg, mount); |
| 180 | const slew = slewSeconds(currentPanTilt, goal, limits); |
| 181 | let refined = fixAgeSec + params.adsbLatencySec + motorLag + slew; |
| 182 | if (refined > params.maxLeadSec + fixAgeSec) { |
| 183 | refined = params.maxLeadSec + fixAgeSec; |
| 184 | clamped = true; |
| 185 | } |
| 186 | azEl = predictAt(refined); |
| 187 | lead = refined; |
| 188 | } |
no test coverage detected