diff --git a/.gitignore b/.gitignore index 0d2122e..58c69df 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,10 @@ xcuserdata/ # MS Office Temporary Files ~$* + +# Local assistant artifacts — never commit +CLAUDE.md +.claude/ +*claude* +*Claude* +.aider* diff --git a/CMakeLists.txt b/CMakeLists.txt index a9ad5ed..56cf48e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,7 +23,7 @@ endif() set(CACHE{CMAKE_BUILD_TYPE} TYPE STRING VALUE "RelWithDebInfo") project(LiveTraffic - VERSION 4.4.1 + VERSION 4.5.0 DESCRIPTION "LiveTraffic X-Plane plugin") set(VERSION_BETA 0) @@ -57,25 +57,38 @@ endif() # For local builds, set environment variables before running cmake # Production environment -if(DEFINED ENV{FSC_PROD_CLIENT_ID}) +# +# The checks below reject an env var that is DEFINED but EMPTY (in +# addition to the obvious not-defined case). GitHub Actions exposes +# repository secrets as env-var assignments unconditionally — when a +# fork PR doesn't have the secret configured, GA passes the env var +# as the empty string, NOT as undefined. Without the `STREQUAL ""` +# guard, the cmake fall-through to the default `"3"` / `"INOP"` stub +# values never fires, the cmake substitution at line ~320 then emits +# bare `-DFSC_PROD_CLIENT_ID=` (no value), the preprocessor expands +# `FSC_PROD_CLIENT_ID` to nothing in `Src/LTFSCharter.cpp`, and the +# Linux CI build fails with "expected primary-expression before ','". +# This affects every fork PR; the empty-string guard makes the +# default-fallback path correct in both unset and empty-string cases. +if(DEFINED ENV{FSC_PROD_CLIENT_ID} AND NOT "$ENV{FSC_PROD_CLIENT_ID}" STREQUAL "") set(FSC_PROD_CLIENT_ID "$ENV{FSC_PROD_CLIENT_ID}" CACHE STRING "FSCharter production client ID") else() set(FSC_PROD_CLIENT_ID "3" CACHE STRING "FSCharter production client ID") endif() -if(DEFINED ENV{FSC_PROD_CLIENT_SECRET}) +if(DEFINED ENV{FSC_PROD_CLIENT_SECRET} AND NOT "$ENV{FSC_PROD_CLIENT_SECRET}" STREQUAL "") set(FSC_PROD_CLIENT_SECRET "$ENV{FSC_PROD_CLIENT_SECRET}" CACHE STRING "FSCharter production client secret (base64)") else() # Default fallback value, is handled specifically by LiveTraffic code set(FSC_PROD_CLIENT_SECRET "INOP" CACHE STRING "FSCharter production client secret (base64)") endif() -# Staging environment -if(DEFINED ENV{FSC_STAGING_CLIENT_ID}) +# Staging environment — same empty-string-guard rationale as above. +if(DEFINED ENV{FSC_STAGING_CLIENT_ID} AND NOT "$ENV{FSC_STAGING_CLIENT_ID}" STREQUAL "") set(FSC_STAGING_CLIENT_ID "$ENV{FSC_STAGING_CLIENT_ID}" CACHE STRING "FSCharter staging client ID") else() set(FSC_STAGING_CLIENT_ID "3" CACHE STRING "FSCharter staging client ID") endif() -if(DEFINED ENV{FSC_STAGING_CLIENT_SECRET}) +if(DEFINED ENV{FSC_STAGING_CLIENT_SECRET} AND NOT "$ENV{FSC_STAGING_CLIENT_SECRET}" STREQUAL "") set(FSC_STAGING_CLIENT_SECRET "$ENV{FSC_STAGING_CLIENT_SECRET}" CACHE STRING "FSCharter staging client secret (base64)") else() # Default fallback value, is handled specifically by LiveTraffic code diff --git a/Include/Constants.h b/Include/Constants.h index 00fff45..052537a 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -76,6 +76,120 @@ constexpr double TIME_REQU_POS = 0.5; // seconds before reaching curre constexpr double SIMILAR_TS_INTVL = 3; // seconds: Less than that difference and position-timestamps are considered "similar" -> positions are merged rather than added additionally constexpr double SIMILAR_POS_DIST = 7; // [m] if distance between positions less than this then favor heading from flight data over vector between positions constexpr double GND_COLLISION_DIST = 10; // [m] If another aircraft comes this close to a parked aircraft then the parked aircraft is removed + +/// [m] Maximum distance the rendered (live-tracked) position of an aircraft +/// may be from a parked-feed gate position before the periodic parked +/// re-fetch is allowed to re-seed that aircraft with the gate position. +/// +/// RT's parked DB updates on a daily cadence and lags real-world activity +/// by hours; once an aircraft has pushed back, taxied, or taken off, RT +/// can still be reporting it as "parked at gate X" for some time. Without +/// this skip, the periodic re-fetch (RT_PARKED_REFRESH_INTVL_S) silently +/// injects seed positions at the original gate into the *back* of the +/// aircraft's deque (at the current simTime + lookahead window). When the +/// render clock subsequently advances into those slots the aircraft +/// visually teleports back to the gate, then forward again as later live +/// data arrives. 50 m comfortably keeps us inside a stand footprint while +/// excluding anything past the nearest taxiway centerline. +constexpr double GATE_REFEED_MAX_DIST_M = 50; + +/// Maximum distance, in metres, between an aircraft's held position and the +/// nearest apt.dat startup-location (gate / stand / ramp slot) for the +/// position to be treated as "at a gate". Used as the third gate-detection +/// path in `LTFlightData::AddNewPos`: when `bGroundHolding` flips true on a +/// live-tracked aircraft (one that never received a RealTraffic parked-feed +/// seed), we query `LTAptFindStartupLoc()` and set `bGateParked = true` only +/// when the apt.dat lookup returns a startup-loc within this radius. The +/// value is small on purpose — typical stand widths are 20–60 m, and a tight +/// threshold prevents false positives from runway hold-shorts, taxiway +/// crossings, or maintenance pads being misclassified as gates (which would +/// later mis-trigger the pushback state machine). +constexpr double GATE_DETECT_MAX_DIST_M = 30.0; + +/// Maximum angular delta, in degrees, between the prior parked heading and +/// the first-motion feed heading for the feed to be considered a true-nose +/// source during a pushback. Used at the PB_NONE→PB_ACTIVE entry in +/// `LTFlightData::CalcHeading`: if `|feedHdg − prePosPb.heading()| <=` this +/// value, the feed value is locked in as the nose source for the rest of +/// the push (so the rendered nose tracks the aircraft's real rotation, +/// reported by the feed). Otherwise the feed is treated as course-over- +/// ground and the nose is derived from the motion track instead. +/// +/// 30° is chosen because a true-nose feed reads exactly the parked heading +/// when the aircraft is stationary; once motion starts the feed catches up +/// within a fraction of a second, so the very first motion slot's feedHdg +/// is still within a handful of degrees of the parked value. A course feed, +/// on the other hand, reports motion direction once motion starts — and +/// during a pushback that is ~180° away from the parked heading, well +/// outside the 30° window. The threshold is therefore comfortably wider +/// than measurement noise yet far tighter than the course/nose gap. +constexpr double PB_FEED_NOSE_AGREE_DEG = 30.0; + +/// Minimum groundspeed, in knots, for a slot to be classified as +/// "moving" by the pushback state machine in `LTFlightData::CalcHeading`. +/// Distinct from (and lower than) `GND_STATIONARY_GS_KT` because real +/// pushbacks roll at 0.4-1.4 kt — entirely below the global stationary +/// threshold (1.5 kt). Using the global threshold for the PB bMotion +/// check would mean the state machine never enters PB_ACTIVE for an +/// actual slow push, and the entry-gate logic in AddNewPos would keep +/// suppressing subsequent in-push slots (which depend on +/// `pbState != PB_NONE` to bypass the gate-hold). +/// +/// 0.3 kt is a hair above the noise floor of derived gs at sub-second +/// dt: a 3 m position-fix jitter over a 5 s slot yields ~0.6 m/s ≈ +/// 1.1 kt of false gs. We need a threshold under the actual slow-push +/// speed but above zero; 0.3 kt is comfortably both. Combined with the +/// upstream distance gate (GATE_HOLD_MIN_ACCEPT_M ≥ 30 m before the +/// first slot is accepted), this leaves no realistic path for noise to +/// trip the state machine. +constexpr double PB_MOTION_GS_KT = 0.3; + +/// Safety-valve maximum groundspeed, in knots, for the pushback state +/// machine. If a slot under PB_ACTIVE/PB_PAUSED arrives with `gs` above +/// this threshold, the state machine FORCES an exit to PB_NONE regardless +/// of the directional-resumed-motion test. +/// +/// Why this safety valve is needed: the normal exit logic compares +/// resumed-motion track against `pbHeldNose` (within 90° → forward +/// taxi → exit). When the held nose was chosen incorrectly at entry +/// (e.g. TRACK+180 picked because the feed disagreed with the parked +/// heading, but the feed was actually right because the aircraft had +/// already rotated during the GATE_HOLD suppression window), the +/// directional exit test reads against the wrong reference. The +/// aircraft then taxis out at 20+ knots while the state machine still +/// thinks "tug is pushing me backward" and renders the nose stuck at +/// the wrong angle — aircraft appears tail-first / ass-forward. +/// +/// 10 kt is a hard upper bound on physical pushback speed (real +/// pushbacks roll at 1-5 kt; tugs cannot move a 60+ ton airframe +/// faster than that). Any sustained gs above 10 kt is definitively +/// taxi, not pushback, and the state machine is wrong to still be +/// active. Force-exit and let the normal heading logic take over. +constexpr double PB_MAX_GS_KT = 10.0; + +/// Minimum distance, in metres, between an incoming feed slot and the +/// latest accepted deque position for the slot to be admitted while the +/// aircraft is `bGateParked` and not yet in the pushback state machine. +/// Slots closer than this are treated as feed noise and silently dropped. +/// +/// Why distance, not groundspeed or a slot counter: real pushbacks roll +/// at 0.4-1.4 kt, which is well below `GND_STATIONARY_GS_KT` (1.5 kt). +/// A counter that increments on "non-stationary" slots therefore never +/// advances during a real slow push, and any threshold based on that +/// counter would suppress legitimate pushback motion forever. RT-direct +/// noise at the gate, by contrast, manifests as one or two slots ~15-25 m +/// off the true position that then return to the gate; their distance +/// from the held position never sustains beyond ~25 m. +/// +/// 30 m is a comfortable separator: it sits clearly above the 15-25 m +/// noise envelope (so single- or paired-slot anomalies stay dropped), +/// while a real push reaches 30 m within 2-3 slots at typical pushback +/// speeds. When a slot finally exceeds this distance, the suppression +/// accepts it AND clears `bGroundHolding` so subsequent in-push slots +/// (which are typically only 3-6 m from the previous accepted slot, and +/// would otherwise be trivial-dropped) flow through and the rendered +/// push continues smoothly. +constexpr double GATE_HOLD_MIN_ACCEPT_M = 30.0; constexpr double FD_GND_AGL = 10; // [m] consider pos 'ON GRND' if this close to YProbe constexpr double FD_GND_AGL_EXT = 20; // [m] consider pos 'ON GRND' if this close to YProbe - extended, e.g. for RealTraffic constexpr double PROBE_HEIGHT_LIM[] = {5000,1000,500,-999999}; // if height AGL is more than ... feet @@ -88,6 +202,341 @@ constexpr double BEZIER_MIN_HEAD_DIFF = 2.5; ///< [°] turns of less than thi constexpr float EXPORT_USER_AC_PERIOD = 15.0f; ///< [s] how often to write user's aircraft data into the export file constexpr const char* EXPORT_USER_CALL = "USER";///< call sign used for user's plabe +//MARK: Ground Behavior Stability +// ----------------------------------------------------------------------------- +// Tunables that govern how aircraft are rendered while on the ground. The +// underlying problem is that public flight feeds (ADS-B, MLAT, multilat-fused +// channels) supply position samples roughly once per second with a few metres +// of positional noise. When an aircraft is stationary or slow-taxiing, that +// noise — if fed straight into the heading-from-position-delta math — produces +// wildly varying headings, which renders visually as the aircraft pivoting and +// "dancing" at the gate. The constants below enable a layered set of +// countermeasures: stationary detection, heading hysteresis, per-frame +// heading rate-limiting, a holding mode that ignores trivial position jitter +// after the aircraft has been parked for a while, hard-set ground attitude, +// and pushback detection. Every constant here is chosen for *why* documented +// inline; tweak with that rationale in mind. +// ----------------------------------------------------------------------------- + +/// [°] dead-band around the target heading inside which the rendered nose is +/// not moved. Empirical: RealTraffic feeds without a heading field (e.g. for +/// some channels) produce track-from-pos-delta wobbles of ~5–7° per slot at +/// slow taxi (1–6 kt, 10 m chunks). A 4° band absorbs most of that noise +/// while still letting genuine 5°+ taxi turns propagate. Original 0.5° was +/// far too tight to catch the real-world jitter envelope. +constexpr double GND_HEADING_HYSTERESIS_DEG = 4.0; + +/// [°/s] maximum rate at which the rendered heading is allowed to walk while +/// on the ground. Tuned to roughly match `TAXI_TURN_TIME` in the flight +/// model (30 s for a 360° turn = 12°/s natural rate). At this clamp a 7° +/// per-slot wobble takes ~0.6 s to walk through, which the eye reads as +/// smooth rotation; meanwhile real taxi turns of ~90° finish in ~7.5 s. +/// Previous value of 60°/s never actually engaged because per-frame heading +/// changes were always far below it. +constexpr double GND_HEADING_MAX_RATE_DPS = 12.0; + +/// [kn] groundspeed at-or-below which an aircraft is considered stationary +/// for the purposes of heading freezing and holding detection. Empirical: +/// parked aircraft at gates routinely have derived gs of 0.7–1.1 kt purely +/// from positional jitter in the feed (e.g. ±5 m over 10 s = 1 kt). Setting +/// the threshold above this band (1.5 kt) ensures parked aircraft stay in +/// the stationary regime while real slow taxi (≥2 kt observed) is still +/// classified as moving. +constexpr double GND_STATIONARY_GS_KT = 1.5; + +/// [s] continuous stationary streak after which the aircraft enters "holding" +/// mode. While holding, trivial position jitter is rejected (see +/// `GND_HOLDING_TRIVIAL_DIST_M`). 30 s was chosen as long enough that brief +/// taxi-pauses (e.g., at hold-short lines) do not trip the suppressor, while +/// short enough that genuinely parked aircraft become rock-steady within +/// half a minute of arriving at the stand. +constexpr double GND_HOLDING_TIMEOUT_S = 30.0; + +/// [m] inside holding mode, any new position update whose distance from the +/// current rendered position is below this threshold AND whose reported +/// groundspeed is below `GND_STATIONARY_GS_KT` is treated as feed noise and +/// silently dropped — the rendered aircraft does not move. Empirical: +/// parked-aircraft jitter envelope on the data we observed is up to ~7 m, +/// occasionally 12 m. 15 m gives comfortable margin so that all jitter is +/// caught while a single 15 m+ jump (typical of real taxi-leg starts) still +/// signals genuine motion and breaks the suppression. +constexpr double GND_HOLDING_TRIVIAL_DIST_M = 15.0; + +/// [m] minimum chord length (from→to in the local meters frame) below which +/// the ground-rendering Catmull-Rom spline is skipped and a linear position +/// interpolation is used instead. +/// +/// Why this exists: when the aircraft is parked or barely creeping, all four +/// spline control points sit within the ADS-B/MLAT feed-noise envelope +/// (~3 m typical). A Catmull-Rom tangent through 4 near-coincident-but-noisy +/// points is dominated by noise — the spline-derived heading swings around +/// even though `CalcHeading` has correctly frozen the slot-level heading to +/// the last good value. The render path then overwrites that frozen heading +/// with the noisy tangent, producing visible z-axis wobble on stopped +/// aircraft. +/// +/// 5 m is comfortably above the ~3 m noise floor and well below a meaningful +/// taxi step (a 1 kt creep over a 5 s slot is only ~2.6 m; real slow taxi +/// at 3 kt produces ~7.7 m per 5 s slot, well above the threshold). Below +/// 5 m we treat the leg as "no useful motion" and let `from.heading()` — +/// which is already the frozen lastGood value — pass through unchanged. +constexpr double GND_SPLINE_MIN_CHORD_M = 5.0; + +/// [kn] leg-average ground speed above which the ground-rendering spline is +/// skipped in favour of plain linear interpolation. +/// +/// Why this exists: the centripetal Catmull-Rom spline earns its keep at +/// *taxi* speed, where the aircraft turns and linear-chord interpolation +/// would render it sliding sideways through the corner. At runway speed — +/// takeoff roll and landing rollout — the aircraft tracks a dead-straight +/// line down the centreline; it does not (and physically cannot) turn. In +/// that regime the spline adds only fragility: +/// * a single glitchy feed sample (observed: a 777 takeoff roll with +/// feed speeds jumping 187→87→159 kt within 3 s) becomes a control +/// point the curve bulges around — linear interpolation would just +/// draw a straight line slightly off, far less visible; +/// * feed positions during the roll are sparse and irregularly spaced +/// (observed: a 31 s gap between samples while accelerating), so the +/// per-segment curve shape and the arc-length LUT change drastically +/// leg to leg; +/// * the spline's arc-length reparameterisation interacts awkwardly +/// with the acceleration-profile parameter `speed.getRatio()`. +/// Linear interpolation renders a straight line exactly, is immune to all +/// of the above, and returns `from` precisely at f=0 so the segment-switch +/// continuity mechanism stays seamless. +/// +/// 40 kn is chosen because normal taxi tops out around 20-25 kn and even +/// an aggressive high-speed runway turnoff is taken below ~40 kn — so at +/// 40 kn and above we are unambiguously in straight-line runway motion, +/// while every speed at which the aircraft actually turns still gets the +/// spline. +constexpr double GND_SPLINE_MAX_KT = 40.0; + +/// Neighbour weight for the ground-spline control-point smoothing kernel. +/// +/// Why this exists: a centripetal Catmull-Rom spline *interpolates* — the +/// rendered curve passes exactly through control points P1 and P2. So a +/// single noisy feed sample at P1 or P2 produces a visibly noisy rendered +/// position, and no amount of look-ahead buffering changes that, because +/// the curve is still pinned to the raw (noisy) point. To actually reduce +/// jitter the spline has to *approximate* the data instead of interpolating +/// it. +/// +/// We achieve that cheaply by pre-smoothing the *look-ahead* control point +/// P2 with its immediate neighbours using a 3-tap binomial kernel before +/// the curve is fit: +/// P2' = w·P1 + (1−2w)·P2 + w·P3 +/// With w = 0.25 this is the classic [1,2,1]/4 kernel — a mild low-pass +/// that pulls a noisy point a quarter of the way toward the average of its +/// neighbours. The points P1..P3 are already cached for the spline, so no +/// deeper buffer is needed. +/// +/// Only P2 is smoothed — never P1. The segment-switch logic in +/// `LTAircraft::CalcPPos` overwrites the new leg's start slot with the +/// current rendered position (`posList.front() = ppos`) so the leg +/// continues seamlessly from wherever the renderer is. That only works if +/// the spline returns P1 (== `from`) EXACTLY at u=0, which the unmodified +/// centripetal evaluator does. Smoothing P1 would make the evaluator +/// return P1' ≠ from at u=0, producing a visible position snap at every +/// segment switch (~6 s cadence). Smoothing P2 alone still removes the +/// jitter completely: each feed sample is reached as the smoothed P2' +/// endpoint of its leg and then carried into the next leg as `from`, so +/// the rendered path threads the smoothed points {P2'_k} without ever +/// visiting a raw noisy sample. +/// +/// Trade-off: on a genuine sharp taxi turn the kernel pulls the apex inward +/// by w of its deviation (corner-cutting). At w = 0.25 this is visually +/// indistinguishable from a real aircraft arcing through a turn — aircraft +/// do not pivot on a point — and the centripetal parameterisation already +/// rounds corners gracefully. Set to 0.0 to disable smoothing entirely and +/// fall back to pure interpolation. +constexpr double GND_SPLINE_SMOOTH_WEIGHT = 0.25; + +/// [s] backward sim-time jump tolerated by `LTAircraft::NextCycle` before it +/// triggers a full plugin re-init. +/// +/// X-Plane's sim time is supposed to be monotonic, but in practice it can step +/// backward by small amounts after a frame stutter, a brief pause/unpause, an +/// autosave hiccup, or any operation that retroactively adjusts the timestamp +/// of the current frame. A strict `diffTime < 0` test (the original behaviour) +/// trips on any of these, tearing the entire aircraft fleet down and rebuilding +/// from buffered data — a very visible "everything disappears, then traffic +/// fades back in over ~10 s as the deques refill" interruption that is well +/// out of proportion to a sub-second clock blip. +/// +/// We tolerate backward jumps shallower than this threshold: the per-frame +/// interpolators read `simTime` directly so a small reversal just means the +/// next frame renders at a slightly earlier interpolation point (visually a +/// brief stutter at most, no state corruption). Genuine time-warps — the user +/// changing time-of-day in the X-Plane menu, or skipping ahead via the date +/// dialog — produce jumps far larger than 1 s and still trigger the safety. +/// +/// Asymmetric on purpose: the *forward* limit stays at GetFdBufPeriod() +/// because forward-jumping past the buffer window means the data we have is +/// genuinely stale and re-init is the right response. Only the backward case +/// got the grace. +constexpr double TIME_NONLINEAR_BACKWARD_S = -1.0; + +/// Consecutive non-stationary feed updates required to actually exit holding. +/// A single isolated above-threshold slot (which is common — feed jitter can +/// transiently produce gs of 2 kt for one sample) should not break a stable +/// holding lock. Requiring two in a row means we're in real-taxi territory +/// before we trust the motion. +constexpr int GND_HOLDING_EXIT_CONSEC = 2; + +/// [kn] groundspeed ceiling under which the feed-provided heading is +/// considered as a possible source for the rendered nose direction. This +/// is the OUTER bound — within this band a secondary cross-check against +/// the position-derived track decides which source actually wins. See +/// `GND_FEED_TRACK_AGREE_DEG` below. Above this speed the position track +/// is always preferred (real taxi / rollout / takeoff). +constexpr double GND_USE_FEED_HEADING_MAX_KT = 10.0; + +/// [°] agreement window between the feed-provided heading and the +/// position-derived track angle. Used inside the on-ground feed-heading +/// branch in `LTFlightData::CalcHeading` to decide whether the feed +/// value is fresh enough to trust or has gone stale during a taxi turn. +/// +/// Why this matters: the heading reported in ADS-B/Mode S Enhanced +/// Surveillance (EHS) updates at a low rate — typically every 10 s, +/// sometimes slower, and not at all in regions without enhanced +/// interrogation coverage. Between EHS updates the feed value is held +/// constant by the ground station / receiver, so when an aircraft turns +/// during taxi the feed heading can lag the actual nose direction by +/// 10+ seconds (60° or more at a typical 6 °/s taxi turn rate). If the +/// renderer trusts that stale value, the aircraft visibly slides +/// sideways through the turn — its nose stays at the pre-turn direction +/// while its body progresses along the new direction. +/// +/// We compare the feed heading against the track angle (the bearing +/// from the previous slot to this one — always "now"). Three regimes: +/// * `Δ < GND_FEED_TRACK_AGREE_DEG` (default 30°) +/// — feed and track agree: either the aircraft is going straight, or +/// the most recent EHS reading is fresh. Trust the feed value +/// (smooth, matches the transponder-reported nose). +/// * `GND_FEED_TRACK_AGREE_DEG ≤ Δ ≤ 180° − GND_FEED_TRACK_AGREE_DEG` +/// — feed has gone stale during a turn. Fall through to the +/// position-derived heading branch, which uses the current track. +/// * `Δ > 180° − GND_FEED_TRACK_AGREE_DEG` — track is roughly opposite +/// of the feed value: this is pushback. Trust the feed (nose stays +/// pointing at the gate while the body moves backwards). +/// +/// 30° is wide enough to absorb a few seconds of EHS lag during a slow +/// taxi turn without flapping between feed and track on every degree of +/// gentle curvature, and narrow enough to catch the lag before the +/// sideways look becomes objectionable. Tunable in either direction +/// if real-world reports suggest a different balance. +constexpr double GND_FEED_TRACK_AGREE_DEG = 30.0; + +/// [kn] groundspeed at-or-above which the rendered nose direction is locked +/// to the direction of motion (the vector from `from` to `to` in the slot +/// interpolation), and any Bezier path between slots is suppressed in favour +/// of a straight-line interpolation. +/// +/// Why this exists: at higher ground speeds (landing rollout, takeoff roll, +/// fast taxi) the rendered aircraft must visually track ALONG its line of +/// motion. The slot-side heading filters (stationary freeze, hysteresis, +/// pushback detect) work correctly here — but the per-leg renderer in +/// `LTAircraft::CalcAcPos` walks heading toward the NEXT slot's reported +/// heading via a Bezier whose end-tangent is `to.heading()`. When the next +/// slot is on a turn-off taxiway (heading 326°) and the current slot is at +/// end-of-runway (heading 020°), the Bezier arcs across the corner — +/// aircraft visually "slides off the runway" with its nose pointing 53° +/// off the direction of motion. +/// +/// At gs ≥ 10 kn we therefore: +/// 1. Skip Bezier and force linear interpolation between slots, which +/// walks heading toward `vec.angle` (the direct bearing from `from` +/// to `to` — i.e., the actual direction of motion). +/// 2. Skip the half-way-through retarget to `to.heading()` so the +/// rendered heading stays locked to the motion vector for the +/// entire leg, only converging on the slot's reported heading once +/// the aircraft has decelerated below this threshold. +/// +/// 10 kn matches `GND_USE_FEED_HEADING_MAX_KT` so the two thresholds are +/// the boundary between "trust the feed heading" (slow) and "trust the +/// motion vector" (fast). No middle ground. +constexpr double GND_TRACK_HEADING_MIN_KT = 10.0; + +/// [°] pitch hard-set on every frame while the aircraft is on the ground +/// (except during the take-off / flare phases, which manage pitch dynamically). +/// 0° (level) matches LiveTraffic's pre-existing convention (the touch-down +/// transition previously walked pitch to 0) and avoids the visible "tail- +/// dragger" look the previous 2° value produced on narrow-body airliners. +/// Hard-setting it (rather than inheriting from the data feed, which usually +/// has no useful pitch on the ground) still serves its other purpose: +/// preventing pitch drift caused by inter-position interpolation in the slot +/// pipeline. +constexpr double GND_PITCH_DEG = 0.0; + +/// [°] roll hard-set on every frame while on the ground. Real aircraft never +/// bank while taxiing — they pivot flat — and the existing roll-from-turn-rate +/// computation can produce micro-banks from heading jitter that look wrong on +/// a parked aircraft. We zero it explicitly; the in-air banking logic stays +/// gated behind `!IsOnGnd()` so this only applies on the ground. +constexpr double GND_ROLL_DEG = 0.0; + +/// [°] alignment threshold deciding whether motion resuming after a pushback +/// pause is "forward" (push complete, exit) or "still being pushed" (tug +/// resumed, stay in pushback). Compared against `|track − pbHeldNose|` where +/// `pbHeldNose` is the last computed nose direction during the push. +/// +/// Below this angle: the resumed motion is aligned with the held nose → +/// aircraft is taxiing forward under its own power → EXIT to PB_NONE. +/// Above this angle: the resumed motion still points backwards relative +/// to the nose → the tug is continuing the push → re-enter PB_ACTIVE. +/// +/// 90° splits the half-planes cleanly: anything moving forward of the +/// aircraft's beam is taxi, anything moving aft is push. +constexpr double PB_EXIT_FORWARD_DIFF_DEG = 90.0; + +/// [°] minimum heading change at which a Bezier curve is constructed for a +/// ground leg. The general airborne threshold (`BEZIER_MIN_HEAD_DIFF`, 2.5°) +/// is too coarse for taxi where slow-but-real turns of 1–2° per leg still +/// benefit visually from being rendered as a curve with tangent-derived +/// heading rather than a polyline with the heading walking via the linear +/// MovingParam fallback. We pick 1° so that genuinely tiny noise-driven +/// "turns" are still ignored (they'll be absorbed by the hysteresis filter +/// in `LTFlightData::CalcHeading` or by the per-frame rate limit) but any +/// turn of clear visual significance gets the Bezier treatment. +constexpr double GND_BEZIER_MIN_HEAD_DIFF = 1.0; + +/// [s] duration over which the rendered altitude is blended from terrain +/// level up to the interpolated value at lift-off. +/// +/// Why this exists: while an aircraft is on the ground LiveTraffic clamps +/// `ppos.alt_m` to the terrain (so a parked or taxiing aircraft is exactly +/// at runway/taxiway height, regardless of what the feed says). The +/// moment the flight-model decides the aircraft has lifted off +/// (`bOnGrnd` flips from true to false, `phase` becomes `FPH_LIFT_OFF`), +/// that clamp stops applying. The next-rendered altitude becomes the raw +/// linear interpolation between the last on-ground slot and the next +/// airborne slot — which can be hundreds of feet above the runway +/// depending on how far apart those slots are in time. Without smoothing +/// the aircraft visibly teleports upwards in a single frame ("jumps into +/// the air on rotation"). +/// +/// We instead lerp from terrain altitude to the interpolated altitude +/// over `LIFTOFF_BLEND_TIME_S` using a smoothstep easing curve +/// f(t) = t² (3−2t). Smoothstep is the right choice because it is C¹- +/// continuous at both endpoints: +/// - at t=0, f'(0)=0, so the rendered altitude leaves the ground with +/// a vertical speed of zero — no perceived velocity jump; +/// - at t=1, f'(1)=0, so the *derivative* of the rendered altitude +/// matches the derivative of the raw interpolation exactly there +/// (`result'(1) = smoothstep'(1)·(interp−terrain) + smoothstep(1)· +/// interp'(1) = interp'(1)`), meaning the climb-rate seam at the +/// end of the blend is invisible. +/// +/// 10 s gives a visibly gradual lift-off that tracks the natural shape +/// of a real climb-out (rotate → wheels-up → gear-up → flap-retraction +/// span comparable seconds). A shorter blend (the original 1.5 s) made +/// the aircraft appear to leap from runway level to several hundred +/// feet within one airframe-length of forward travel; 10 s reads as +/// "climbing away from the runway" instead of "popping into the sky". +constexpr double LIFTOFF_BLEND_TIME_S = 10.0; + + //MARK: Flight Model constexpr double MDL_ALT_MIN = -1500; // [ft] minimum allowed altitude constexpr double MDL_ALT_MAX = 60000; // [ft] maximum allowed altitude @@ -151,7 +600,7 @@ constexpr int LT_NEW_VER_CHECK_TIME = 24; ///< [h] between two checks for a ne //MARK: Text Constants #define LIVE_TRAFFIC "LiveTraffic" #define LIVE_TRAFFIC_XPMP2 " LT" ///< short form for logging by XPMP2, so that log entries are aligned -#define LT_FM_VERSION "2.2" // expected version of flight model file format +#define LT_FM_VERSION "4.5.0" ///< expected version of flight model file format #define PLUGIN_SIGNATURE "TwinFan.plugin.LiveTraffic" #define PLUGIN_DESCRIPTION "Create Multiplayer Aircraft based on live traffic." constexpr const char* REMOTE_SIGNATURE = "TwinFan.plugin.XPMP2.Remote"; diff --git a/Include/CoordCalc.h b/Include/CoordCalc.h index 9c80de8..7de16cc 100644 --- a/Include/CoordCalc.h +++ b/Include/CoordCalc.h @@ -24,6 +24,7 @@ #define CoordCalc_h #include "XPLMScenery.h" +#include #include // positions and angles are in degrees @@ -107,6 +108,124 @@ positionTy CoordPlusVector (const positionTy& pos, const vectorTy& vec); // returns NaN in case of failure double YProbe_at_m (const positionTy& posAt, XPLMProbeRef& probeRef); +/// @brief Result of evaluating a Catmull-Rom spline at one parameter +/// @details All values are expressed in the local meters frame centred at +/// the spline's `P1` control point (see `CatmullRomEvalCentripetal`): +/// `x` increases eastward, `y` increases northward. `headingDeg` is +/// the curve's tangent direction at this parameter, converted to +/// the same compass convention LiveTraffic uses elsewhere +/// (0° = north, 90° = east, range [0, 360)). +struct CatmullRomResult { + double xMtr; ///< east offset from P1 in metres + double yMtr; ///< north offset from P1 in metres + double headingDeg; ///< curve tangent direction [0, 360) +}; + +/// @brief Evaluate a centripetal Catmull-Rom spline through four ground +/// positions at one parameter, returning both the curve point and +/// the tangent direction at that point. +/// +/// @details The spline interpolates **exactly through** `P1` and `P2` and +/// uses `P0` and `P3` as context to determine the tangent at the +/// endpoints. The centripetal parameterisation (α = 0.5 in +/// Lee 2009) makes the curve well-behaved at sharp corners — it +/// never produces the cusps or self-intersecting loops that +/// uniform Catmull-Rom can generate at a 90° taxi turn. +/// +/// The curve is fit in a local meters frame centred at `P1`, +/// using `Lat2Dist` / `Lon2Dist` for the conversion. This keeps +/// the math Euclidean (avoids cos(lat) accumulating across +/// control points) and the result is returned in the same local +/// frame; callers convert back to lat/lon via `Dist2Lat` / +/// `Dist2Lon` if a geographic position is needed. +/// +/// The heading is derived from the curve's tangent +/// (`atan2(dx, dy)`) so the returned heading is by construction +/// aligned with the rendered direction of motion at this point — +/// this is the property that eliminates the "sideways during +/// turn" symptom that linear-chord interpolation produces. +/// +/// @param P0 Control point before `P1`. May be a degenerate copy of `P1` +/// (same lat/lon) when no real predecessor is available — the +/// curve degenerates to a quadratic segment with zero tangent +/// at `P1`. Caller is responsible for choosing whether to do +/// this duplication; the function does NOT check for NaN. +/// @param P1 First interpolated control point — the curve passes through +/// this exactly at `u = 0`. Origin of the returned local frame. +/// @param P2 Second interpolated control point — the curve passes through +/// this exactly at `u = 1`. +/// @param P3 Control point after `P2`. May be a degenerate copy of `P2`. +/// @param u Curve parameter in `[0, 1]`; `u=0` returns `P1` (with the +/// local frame's origin), `u=1` returns `P2`. +/// @return Curve point in local meters frame relative to `P1`, and +/// tangent-derived heading at that point in degrees. +/// +/// @note Only the lat/lon of the control points are used. Altitude, +/// heading, and timestamps are ignored — the spline is purely a +/// horizontal-plane construction. +CatmullRomResult CatmullRomEvalCentripetal(const positionTy& P0, + const positionTy& P1, + const positionTy& P2, + const positionTy& P3, + double u); + +/// @brief Cached arc-length lookup table for a Catmull-Rom spline segment, +/// used to make the rendered animation advance at constant arc-length +/// speed (rather than constant knot-parameter speed). +/// +/// @details The native parameter `u ∈ [0, 1]` of `CatmullRomEvalCentripetal` +/// is NOT proportional to arc length on the curve — the spline's +/// arc length per unit `u` varies with local curvature. If the +/// caller advances `u` linearly with time, the rendered position +/// accelerates and decelerates within the segment (visible as a +/// "speed up / slow down" pulsation), and the velocity at the start +/// of leg N+1 does not match the velocity at the end of leg N +/// (visible as a small velocity pop at every segment boundary). +/// +/// This LUT subdivides the segment at `N` uniformly-spaced values +/// of `u`, evaluates the curve at each, and accumulates chord-based +/// arc length. The mapping s→u is then queried per frame to turn +/// a time-linear progression (0..1 across the leg duration) into a +/// curve parameter that advances at constant arc-length-per-time. +/// Visually the rendered aircraft now moves at the segment's mean +/// speed (`total_arc / duration`) throughout the segment. +/// +/// `N = 16` is a deliberate trade-off: the chord error against the +/// true integral is below 0.1 % on the curvatures we see at airport +/// ground speeds, the build cost is 16 spline evaluations per +/// segment switch (~once per 1-5 s of feed), and the per-frame +/// lookup is a 4-step binary search plus one lerp. +struct CatmullRomArcLut { + static constexpr int N = 16; ///< number of sample sub-intervals; N+1 entries + + /// Cumulative arc length at each sample. `sAtU[i]` is the arc length + /// from `u=0` to `u = i / N`. Always `sAtU[0] == 0`. + std::array sAtU{}; + /// Total arc length of the segment (i.e., `sAtU[N]`). Cached for + /// quick access in the per-frame lookup. + double totalArc = 0.0; + /// True once `Build()` has populated the table for the current segment. + /// Reset to false when the parent segment switches so the next render + /// frame rebuilds with the new control points. + bool valid = false; + + /// Sample the spline at `N+1` uniformly-spaced `u` values, accumulate + /// chord lengths between successive samples, and store the running + /// totals in `sAtU`. Must be called whenever the control-point set + /// changes (i.e., at every segment switch in `LTAircraft::CalcPPos`). + void Build(const positionTy& P0, const positionTy& P1, + const positionTy& P2, const positionTy& P3); + + /// Given a time-linear progression `f ∈ [0, 1]` across the segment, + /// return the curve parameter `u ∈ [0, 1]` at which the spline has + /// covered `f * totalArc` of arc length. The mapping is inverted by + /// a short binary search across the LUT plus one linear interpolation. + /// If the LUT has zero total arc (e.g., all control points coincided) + /// the function returns `f` unchanged — the spline will collapse to + /// a point anyway, so the choice of parameter is irrelevant. + double UFromArcFraction(double f) const; +}; + // // MARK: Estimated Functions on coordinates // diff --git a/Include/DataRefs.h b/Include/DataRefs.h index d734a59..e65898f 100644 --- a/Include/DataRefs.h +++ b/Include/DataRefs.h @@ -415,6 +415,7 @@ enum dataRefsLT { // debug options DR_DBG_AC_FILTER, DR_DBG_AC_POS, + DR_DBG_DIAGNOSTIC, ///< Detailed diagnostic log output DR_DBG_LOG_RAW_FD, DR_DBG_LOG_WEATHER, DR_DBG_MODEL_MATCHING, @@ -688,6 +689,7 @@ class DataRefs int bShowingAircraft = false; unsigned uDebugAcFilter = 0; // icao24 for a/c filter int bDebugAcPos = false;// output debug info on position calc into log file? + int bDebugDiagnostic = false;///< output detailed diagnostic debug messages that really fill up your Log very fast int bDebugLogRawFd = false;// log raw flight data to LTRawFD.log int bDebugWeather = false;///< log weather data for debugging exportFDFormat eDebugExportFdFormat = EXP_FD_AITFC; ///< Which format to use when exporting flight data? @@ -1087,6 +1089,7 @@ class DataRefs // livetraffic/dbg/ac_pos: Debug Positions for given a/c? inline bool GetDebugAcPos(const std::string& key) const { return bDebugAcPos && key == GetSelectedAcKey(); } + bool ShallLogDiagnostics() const { return bDebugDiagnostic; } inline bool GetDebugLogRawFD() const { return bDebugLogRawFd; } void SetDebugLogRawFD (bool bLog) { bDebugLogRawFd = bLog; } diff --git a/Include/LTAircraft.h b/Include/LTAircraft.h index 38c32e7..815a0c1 100644 --- a/Include/LTAircraft.h +++ b/Include/LTAircraft.h @@ -234,8 +234,10 @@ class LTAircraft : public XPMP2::Aircraft double PITCH_MAX = 15; // [°] maximum pitch angle (aoa) double PITCH_MAX_VSI = 2000; // [ft/min] maximum vsi above which pitch is MDL_PITCH_MAX double PITCH_FLAP_ADD = 4; // [°] to add if flaps extended - double PITCH_FLARE = 10; // [°] pitch during flare + double PITCH_ROTATE = 8; ///< [°] pitch during rotate + double PITCH_FLARE = 6; ///< [°] pitch during flare double PITCH_RATE = 3; // [°/s] pitch rate of change + double PITCH_HOLD_TOUCHDOWN = 3; ///< [s] How long to keep PITCH_FLARE after touch-down before lowering the nose? double PROP_RPM_MAX = 1200; // [rpm] maximum propeller revolutions per minute double LIGHT_LL_ALT = 100000; // [ft] Landing Lights on below this altitude; set zero for climb/approach only (GA) float LABEL_COLOR[4] = {1.0f, 1.0f, 0.0f, 1.0f}; // base color of a/c label @@ -284,6 +286,40 @@ class LTAircraft : public XPMP2::Aircraft // absolute positions (max 3: last, current destination, next) // as basis for calculating ppos per frame dequePositionTy posList; + /// Most-recently-retired `from` position. When `posList.pop_front()` is + /// called during the position switch in CalcPPos, the slot being removed + /// is copied here first so it remains available as the P0 control point + /// for the centripetal Catmull-Rom spline that renders ground position + /// and heading. lat() is NaN until the first switch has happened — + /// callers must check before use and fall back to duplicating P1. + positionTy posPrev; + /// Snapshot of the slot AFTER the current `to`, captured at segment + /// switch and held fixed for the duration of the current leg. Serves + /// as the P3 control point for the Catmull-Rom spline. + /// + /// Why snapshotted rather than read live from `posList[2]` each frame: + /// `posList[2]` can transition from "does not exist" (deque length < 3, + /// in which case we fall back to duplicating P2) to "exists" (a new + /// feed update lands) mid-segment. That transition silently changes + /// the spline geometry between frames, so the position rendered at + /// the current parameter `f` jumps — visible as a brief backward + /// snap synchronised with the feed cadence. By capturing P3 once at + /// segment start we guarantee the spline coefficients are constant + /// for the full leg; the new slot only takes effect on the NEXT + /// switch, where the boundary is C¹ continuous by construction. + /// lat() is NaN until the first switch has captured a real P3 — + /// callers must check and fall back to duplicating P2. + positionTy posNext; + /// Arc-length lookup table for the current ground-rendering Catmull-Rom + /// segment. Built once per segment switch (in the same `posPrev` / + /// `posNext` capture block) and consulted on every render frame to + /// re-parameterise the time-linear `f` into a curve parameter `u` that + /// advances arc-length-proportionally. Without this the rendered + /// position would visibly speed up and slow down within each segment + /// because the spline's native parameter does not track arc length. + /// `valid` is false until first build; the spline branch builds the + /// LUT on demand if it sees an invalid one. + CatmullRomArcLut splineLut; std::string labelInternal; // internal label, e.g. for error messages protected: @@ -300,6 +336,56 @@ class LTAircraft : public XPMP2::Aircraft flightPhaseE phase; // current flight phase double rotateTs; // when to rotate? double vsi; // vertical speed (ft/m) + /// Sim timestamp at which the aircraft transitioned from on-ground + /// to airborne. Used to smooth the altitude render during the first + /// `LIFTOFF_BLEND_TIME_S` seconds after lift-off — without this the + /// rendered altitude jumps from terrain level to the interpolated + /// climb-out altitude on a single frame. NAN when no blend is active. + double liftoffBlendStartTs = NAN; + /// Sim timestamp at which `FPH_TOUCH_DOWN` was entered. The frame + /// loop in `CalcFlightModel` defers the nose-down `pitch.moveTo( + /// GND_PITCH_DEG)` until `TOUCHDOWN_HOLD_PITCH_S` seconds have + /// elapsed since this timestamp — modelling the aerobrake during + /// which a real airliner holds its nose up after the mains touch. + /// Cleared back to NAN once the deferred move has fired. + double touchdownTs = NAN; + /// Terrain altitude (m, MSL) captured on the frame that the + /// aircraft transitioned to airborne. Used as the START of the + /// liftoff blend curve. Frozen so the curve does not jitter if + /// `terrainAlt_m` from `YProbe` changes slightly as the aircraft + /// moves horizontally during the blend. NAN when no blend active. + double liftoffStartAlt_m = NAN; + /// @brief Per-aircraft archive of altitude samples used by + /// `LookupAltAtTs` for its Gaussian-weighted local linear + /// regression smoothing. + /// @details `fd.posDeque` only retains ONE past slot at any given + /// render time (the loop in `LTFlightData::CalcNextPos` + /// pops slots aggressively to keep the deque short). + /// Running a Gaussian-weighted regression directly on + /// `posDeque` therefore has the regression's weight + /// concentrated on a single past sample whose Gaussian + /// weight near `targetTs` is close to 1.0 — and the + /// moment that sample is popped, the weighted mean + /// recomputes WITHOUT it, in a single frame. For a + /// climbing aircraft the just-popped slot was the low- + /// altitude one, so removing it makes the mean jump up + /// by tens to hundreds of feet. That is the discrete + /// jump symptom the user reported despite "smoothing". + /// + /// We mirror every slot we observe in `posDeque` into + /// this archive, and the archive does NOT pop when + /// `posDeque` does. The regression then runs on + /// `pastAltSamples_` + `posDeque` (deduped), so popped + /// slots remain in the regression with their Gaussian + /// weight smoothly decaying toward zero as `targetTs` + /// moves past them. No more discrete jumps at the + /// deque-pop boundary. + /// + /// Pruned per frame to keep only samples within + /// ~`±10·σ` of `targetTs` — well beyond the Gaussian + /// tail so the regression result is indistinguishable + /// from one over the unpruned history. + mutable std::deque pastAltSamples_; bool bArtificalPos; // running on artifical positions for roll-out? bool bNeedSpeed = false; ///< need speed calculation? bool bNeedCCBezier = false; ///< need Bezier calculation due to cut-corner case? @@ -415,6 +501,23 @@ class LTAircraft : public XPMP2::Aircraft void CalcCorrAngle (); /// determines terrain altitude via XPLM's Y Probe bool YProbe (); + /// @brief Interpolate altitude (m, MSL) at an arbitrary timestamp + /// across `posList`, with linear extrapolation past the end. + /// @details Used by the liftoff-blend code to look up the natural + /// climb-out altitude at the moment the blend will end + /// (`liftoffBlendStartTs + LIFTOFF_BLEND_TIME_S`). Driving + /// the blend toward this future target — instead of toward + /// the live `from`-to-`to` linear interp — decouples the + /// rendered altitude from the slope discontinuities at each + /// deque-slot boundary, eliminating the visible "kinks" + /// that otherwise show up mid-blend whenever the aircraft + /// crosses from one leg to the next. When the requested + /// timestamp is beyond the deque's last slot, projects + /// forward using the slope of the final two slots. + /// @param targetTs Absolute sim timestamp at which alt is wanted. + /// @return Interpolated/extrapolated altitude in metres MSL; falls + /// back to `terrainAlt_m` if `posList` is empty. + double LookupAltAtTs (double targetTs) const; // determines if now visible bool CalcVisible (); /// Determines AI priority based on bearing to user's plane and ground status diff --git a/Include/LTFlightData.h b/Include/LTFlightData.h index 99146b3..2d6e269 100644 --- a/Include/LTFlightData.h +++ b/Include/LTFlightData.h @@ -146,9 +146,49 @@ class LTFlightData std::string route() const; // flight + route std::string flightRoute() const; - // best guess for an airline livery: opIcao if exists, otherwise first 3 digits of call sign + /// @brief Best-guess ICAO airline code for XPMP2 livery matching. + /// + /// Resolution order: + /// 1. `opIcao` — set explicitly by channels that fetch master data + /// (OpenSky master, FSCharter, AutoATC). Always trustworthy. + /// 2. First three characters of `call` — works for ICAO ATC + /// callsigns of the form `<3-letter-airline>` such as + /// `AAL1146`, `RPA5665`, `QFA1926`. RealTraffic's field 13 + /// ("ATC Callsign") delivers this form for commercial flights. + /// 3. Empty string — returned when neither source produces a usable + /// code (see below). + /// + /// Why the alpha test: many callsigns the feed delivers are not + /// ICAO airline callsigns at all. Private/GA aircraft commonly + /// transmit their registration as the callsign (e.g. `N552FX`, + /// `N99ABC`, `9K1876`, `1I637`), and parking-position data uses + /// short flight-number-style strings (`QF2`). Blindly substringing + /// those gives `"N55"`, `"N99"`, `"9K1"`, `"1I6"` — none of which + /// are valid airline codes — and XPMP2 then matches at the + /// "ignore-airline" fallback tier, producing visibly random + /// liveries. Requiring the first three characters to all be + /// letters filters out these false-airline cases; XPMP2 then + /// does type-only matching, which is more faithful to reality. + /// + /// @return ICAO airline code (3 letters, all alphabetic) or empty + /// string when the callsign is not airline-shaped. inline std::string airlineCode() const - { return opIcao.empty() ? call.substr(0,3) : opIcao; } + { + if (!opIcao.empty()) + return opIcao; + if (call.length() < 3) + return ""; + // Each of the first three characters must be an alphabetic + // letter for the substring to plausibly be an ICAO airline + // code. A digit or punctuation in slot 0–2 indicates a + // registration (e.g. `N552FX`) or some other non-airline + // identifier — return empty so XPMP2 falls back to type-only. + if (!std::isalpha(static_cast(call[0])) || + !std::isalpha(static_cast(call[1])) || + !std::isalpha(static_cast(call[2]))) + return ""; + return call.substr(0, 3); + } /// is this a ground vehicle? bool isGrndVehicle() const; /// is this a static object? (marked by a/c type being TWR) @@ -238,6 +278,71 @@ class LTFlightData positionTy posRwy; ///< determined rwy (likely) to land on (position) std::string rwyId; ///< determined rwy (likely) to land non (human-readable text) + // ---- Ground holding state (see Constants.h `GND_HOLDING_TIMEOUT_S`) ----- + // Once an aircraft has been continuously stationary on the ground for + // longer than the holding timeout, `bGroundHolding` flips to true and + // subsequent feed updates that fall within the "trivial jitter" envelope + // (small distance + low groundspeed) are dropped by `AddNewPos` rather + // than being appended to `posToAdd`. The streak start timestamp is the + // wall-clock `pos.ts()` of the first stationary slot we observed; it is + // reset to 0 whenever the aircraft moves meaningfully or leaves ground. + /// First timestamp of the current stationary-on-ground streak (sec). + /// 0 means "not currently in a stationary streak". + double groundHoldingSinceTs = 0.0; + /// True once the streak has lasted longer than `GND_HOLDING_TIMEOUT_S`. + /// While true, trivial feed updates are suppressed in `AddNewPos`. + bool bGroundHolding = false; + /// Count of consecutive non-stationary updates seen while in holding. + /// We do not exit holding on the first one — see `GND_HOLDING_EXIT_CONSEC`. + /// Feed jitter can briefly produce a single 2 kt sample for a truly + /// parked aircraft; requiring multiple consecutive non-stationary slots + /// before exiting avoids those false-exits. + int groundNonStationaryCnt = 0; + + // ---- Pushback state (simplified state machine) ----------------------- + // PB_NONE : not in pushback (taxiing, parked, airborne). + // PB_ACTIVE : currently being pushed; aircraft is moving and the + // heading is overridden to `track + 180°` so the tail + // leads the direction of motion. Naturally tracks + // rotating pushes because the nose is recomputed every + // motion slot. + // PB_PAUSED : was in pushback, now stationary. The next motion slot + // decides: aligned with held nose ⇒ taxi (EXIT); against + // held nose ⇒ tug continuing (back to PB_ACTIVE). + // + // Entry signal: `bGateParked` is true (we have observed a + // SPOS_STARTUP slot — the aircraft is parked at a gate) AND a slot + // with meaningful motion arrives. Exit clears `bGateParked` so the + // aircraft must return to a gate before another pushback can fire. + enum PushbackStateE { PB_NONE = 0, PB_ACTIVE, PB_PAUSED }; + PushbackStateE pbState = PB_NONE; + /// [°] last nose direction computed during pushback. May be sourced + /// either from the feed (`feedHdg` — true-nose case) or derived from + /// motion (`HeadingNormalize(track + 180°)` — course-feed case). The + /// choice is locked once at PB_NONE→PB_ACTIVE entry (see + /// `pbUseFeedNose`) and is NOT re-evaluated mid-push, to avoid the + /// per-slot flip-flop that produced visible spinning in earlier + /// revisions. Held through PB_PAUSED so the exit-direction test + /// (resumed motion vs this nose) has a stable reference even if the + /// pause lasted many slots. + double pbHeldNose = NAN; + /// True when the nose source for this pushback is the FEED heading + /// (`it->heading()` captured before override). False when the source + /// is the position-derived motion track (`track + 180°`). Decided + /// once at PB_NONE→PB_ACTIVE entry by comparing the first-motion + /// feedHdg against the prior parked heading: if they are within + /// ~30° the feed is reporting true nose (rotates accurately during + /// the push) — trust it. Otherwise the feed is reporting course- + /// over-ground (≈ motion direction during push, useless as a nose + /// reference) — derive nose from track. Locked for the duration of + /// the push; cleared on exit (PB_NONE) or airborne transition. + bool pbUseFeedNose = false; + /// True once a SPOS_STARTUP slot has been added to the deque (i.e. + /// the aircraft is/was parked at an apt.dat startup location). + /// Required for `PB_NONE → PB_ACTIVE` entry. Cleared on the same + /// frame the state machine exits to `PB_NONE`. + bool bGateParked = false; + // STATIC DATA (protected, access will be mutex-controlled for thread-safety) FDStaticData statData; @@ -436,6 +541,26 @@ class LTFlightData // actions on all flight data / treating mapFd as lists static void UpdateAllModels (); static const LTFlightData* FindFocusAc (const double bearing); + + /// Prune `FF****` placeholder-hex duplicates. + /// + /// Some upstream ingest paths emit aircraft with synthetic hex + /// IDs of the form `FF****` when the source does not carry a real + /// ICAO transponder code. When a real-ICAO source (X_adsb_icao, + /// V_adsb_icao, V_mlat) later picks up the same aircraft and + /// reports its actual hex, the LiveTraffic side ends up with TWO + /// LTFlightData entries for the same physical aircraft — one keyed + /// by the FF placeholder, one by the real hex — both rendered as + /// separate aircraft in the sim. The duplicate at the FF key + /// cannot reconcile by itself because FDKeyTy is the primary key + /// in `mapFd` and changing it isn't supported. + /// + /// This periodic prune scans `mapFd`: any entry whose hex starts + /// with "FF" and whose callsign matches a non-FF entry's callsign + /// is invalidated (`SetInvalid`), letting the standard cleanup + /// pipeline remove it. Callable from the main thread only — takes + /// the mapFd mutex. + static void PrunePlaceholderHexDuplicates (); #ifdef DEBUG static void RemoveAllAcButSelected (); #endif diff --git a/Include/LTRealTraffic.h b/Include/LTRealTraffic.h index e7a4f01..fd7745a 100644 --- a/Include/LTRealTraffic.h +++ b/Include/LTRealTraffic.h @@ -67,6 +67,13 @@ constexpr size_t RT_NET_BUF_SIZE = 8192; // constexpr double RT_SMOOTH_GROUND = 35.0; // smooth 35s of ground data constexpr double RT_VSI_AIRBORNE = 80.0; ///< if VSI is more than this then we assume "airborne" +/// [s] interval at which the parked-traffic request is re-issued so RT's +/// parked snapshot stays fresh while the user sits at an airport. Without +/// this the parked picture is fetched exactly once (on airport-data load) +/// and then frozen for the whole visit: new arrivals never appear and +/// aircraft that have since departed are never reconciled. 300 s = 5 min. +constexpr double RT_PARKED_REFRESH_INTVL_S = 300.0; + #define MSG_RT_STATUS "RealTraffic network status changed to: %s" #define MSG_RT_LAST_RCVD " | last msg %.0fs ago" #define MSG_RT_ADJUST " | historic traffic from %s" @@ -85,8 +92,26 @@ constexpr double RT_VSI_AIRBORNE = 80.0; ///< if VSI is more than this then w #define RT_TRAFFIC_XATTPSX "XATTPSX" #define RT_TRAFFIC_XGPSPSX "XGPSPSX" -// Constant for direct connection -constexpr long RT_DRCT_DEFAULT_WAIT = 8000L; ///< [ms] Default wait time between traffic requests +// Constant for direct connection. +// +// Floor on the wait between traffic requests in milliseconds. The +// per-response `rrl` value supplied by the RealTraffic server is the +// authoritative rate limit (see `ProcessFetchedData`) and is honoured +// directly when it is at or above this floor. The floor exists only +// to defend against the server returning rrl=0 (or no rrl at all), +// in which case we fall back to this conservative interval. +// +// RT supports 2 s polling in regular operation; the floor matches that +// minimum so a 2 s `rrl` from the server is taken at face value rather +// than overridden to a larger value. The previous 8 s floor predates +// RT's documented 2 s capability and was capping the per-aircraft +// position granularity at ~10-20 s, which the ground renderer can +// observably struggle with (sideways-during-turn, backward routing +// through SnapToTaxiways, jumpy liftoff). Tighter polling makes the +// per-aircraft sample gap closer to the EHS heading update interval, +// so the filtering layers added in earlier commits engage less often +// and the visual quality improves overall. +constexpr long RT_DRCT_DEFAULT_WAIT = 2000L; ///< [ms] Floor between traffic requests (RT's `rrl` controls the actual cadence) constexpr std::chrono::seconds RT_DRCT_ERR_WAIT = std::chrono::seconds(5); ///< standard wait between errors constexpr std::chrono::seconds RT_DRCT_ERR_RATE = std::chrono::seconds(10); ///< wait in case of rate violations, too many sessions constexpr std::chrono::minutes RT_DRCT_WX_WAIT = std::chrono::minutes(1); ///< How often to update weather? @@ -143,8 +168,9 @@ enum RT_DIRECT_FIELDS_TY { RT_DRCT_WindSpeed, ///< Wind speed (19) RT_DRCT_SAT_OAT, ///< SAT/OAT in C (none) RT_DRCT_TAT, ///< TAT (none) - RT_DRCT_ICAO_ID, ///< Is this an ICAO valid hex ID (1) - RT_DRCT_NUM_FIELDS ///< Number of known fields + RT_DRCT_ICAO_ID, ///< (47) Is this an ICAO valid hex ID (1) + RT_DRCT_Operator, ///< (48) ICAO operator/airline flag code (e.g. "QFA", "FDX"); empty when the hex is not in the BaseStation DB (~30% of records). Hex-keyed, so unaffected by wet-lease / codeshare callsign confusion. Used to populate `FDStaticData::opIcao` for livery matching. (v6) + RT_DRCT_NUM_FIELDS ///< Number of known fields (= 49 in v6) }; /// Fields in a response to a parked aircraft request @@ -227,9 +253,14 @@ enum RT_RTTFC_FIELDS_TY { RT_RTTFC_WINDSPD, ///< wind speed in kts RT_RTTFC_OAT, ///< outside air temperature / static air temperature RT_RTTFC_TAT, ///< total air temperature - RT_RTTFC_ISICAOHEX, ///< is this hexid an ICAO assigned ID. - RT_RTTFC_AUGMENTATION_STATUS, ///< has this record been augmented from multiple sources - RT_RTTFC_MIN_TFC_FIELDS ///< always last, minimum number of fields + RT_RTTFC_ISICAOHEX, ///< (40) is this hexid an ICAO-assigned ID + RT_RTTFC_BARO_ALT_UNCORRECTED, ///< (41) raw ADS-B baro altitude (1013.25 hPa reference) — v6 occupies this slot; v5 had `augmentation_status` here + RT_RTTFC_MIN_TFC_FIELDS, ///< (= 42) strict minimum-fields parser gate, preserved at the pre-v11.1.452 baseline for backward compat with older RT App builds that don't send the new fields below + // ----- v6 OPTIONAL fields (RealTraffic v11.1.452+) ----- + // These are NOT enforced by the min-fields gate above; readers must + // bounds-check `tfc.size() > RT_RTTFC_` before accessing. + RT_RTTFC_AUTHENTICATION = RT_RTTFC_MIN_TFC_FIELDS, ///< (42) authentication checksum (safe to ignore) + RT_RTTFC_OPERATOR, ///< (43) ICAO operator/airline flag code (e.g. "QFA", "FDX"); empty when the hex is not in the BaseStation DB (~30% of records, mostly private/military). Hex-keyed, so unaffected by wet-lease / codeshare callsign confusion. Used to populate `FDStaticData::opIcao` for livery matching. }; // map of id to last received datagram (for duplicate datagram detection) @@ -330,6 +361,12 @@ class RealTrafficConnection : public LTFlightDataChannel long lTotalFlights = -1; /// Shall we check for parked traffic next time around? (Set from main thread after airport data updates) bool bDoParkedTraffic = false; + /// Wall-clock time (`std::time`) of the last parked-traffic fetch. + /// Drives the periodic re-fetch in `SetRequType` — see + /// `RT_PARKED_REFRESH_INTVL_S`. 0 = never fetched yet (so the first + /// `SetRequType` call re-arms immediately, which is harmless because + /// the connection-init path already arms `bDoParkedTraffic` too). + double tLastParkedRefresh = 0.0; // TCP connection to send current position std::thread thrTcpServer; ///< thread of the TCP listening thread (short-lived) @@ -346,6 +383,12 @@ class RealTrafficConnection : public LTFlightDataChannel #endif /// last simtime that we received UDP traffic double lastReceivedTime = 0.0; + /// TEMPORARY (FEED_DIAG): per-aircraft last feed-timestamp accepted + /// by the channel. Used to verify that successive RT positions for + /// the same hex id arrive with monotonically increasing timestamps, + /// and to flag backwards / duplicate positions that would explain + /// rendered aircraft moving backwards. Cleared on connection start. + std::map lastFeedTs; /// last known position to detect fast movement (to request buffered traffic and the like) positionTy lastKnownViewPos; /// Expecting buffered traffic first? diff --git a/Include/LTSynthetic.h b/Include/LTSynthetic.h index 48aafa1..8bc070e 100644 --- a/Include/LTSynthetic.h +++ b/Include/LTSynthetic.h @@ -27,6 +27,7 @@ #define LTSynthetic_h #include "LTChannel.h" +#include // // MARK: SyntheticConnection @@ -46,6 +47,30 @@ class SyntheticConnection : public LTFlightDataChannel /// @brief Position information per tracked plane /// @note Defined `static` to preserve information across restarts static mapSynDataTy mapSynData; + + /// @brief Hex IDs of aircraft that have been evicted from a stand by + /// the gate-handoff logic (see `FetchAllData`). + /// @details Permanent for the plugin lifetime. Once a real, live-tracked + /// aircraft reaches FPH_PARKED on top of a stale parked-feed + /// ghost and the ghost is evicted, the ghost's hex id is + /// remembered here so neither: + /// (a) a subsequent FetchAllData pass falling inside the + /// async-SetInvalid teardown window can re-adopt it + /// into `mapSynData` (the "ghost comes back ~40 s + /// later via Synthetic" symptom — task #43), nor + /// (b) a future RealTraffic parked-feed re-fetch (which + /// runs every RT_PARKED_REFRESH_INTVL_S) can re-seed + /// that hex id with the stale gate position RT's DB + /// still carries. + /// Long-lived because RT's parked DB updates on a daily + /// cadence; a short grace period would just let the ghost + /// come back on the next 5-min re-fetch. + /// @note `unsigned long` because that is the form + /// `LTFlightData::FDKeyTy::num` exposes for an ICAO transponder + /// hex (which is what we evict against; non-ICAO key types do + /// not participate in gate-handoff). + static std::set evictedHexIds; + public: /// Constructor SyntheticConnection (); @@ -56,6 +81,15 @@ class SyntheticConnection : public LTFlightDataChannel /// Processes the available stored data bool ProcessFetchedData () override; + /// Record that a hex id was evicted from a stand by gate-handoff. + /// Call from the eviction path so subsequent re-adoption attempts + /// (Synthetic and RealTraffic parked-feed) can skip the ghost. + static void MarkEvicted (unsigned long hex); + /// Has the given hex id been evicted from a stand in this session? + /// Read from both Synthetic re-adoption and RealTraffic + /// ProcessParkedAcBuffer to short-circuit re-seeding. + static bool WasEvicted (unsigned long hex); + protected: void Main () override; ///< virtual thread main function }; diff --git a/Lib/XPMP2 b/Lib/XPMP2 index 2bddcba..d572752 160000 --- a/Lib/XPMP2 +++ b/Lib/XPMP2 @@ -1 +1 @@ -Subproject commit 2bddcba2a0d7780cc11e340ee9b6c517b8f67cdd +Subproject commit d5727520d8e8014009b66ff238741a9324bc1ad9 diff --git a/LiveTraffic.xcodeproj/project.pbxproj b/LiveTraffic.xcodeproj/project.pbxproj index 0ff63fb..301fae4 100755 --- a/LiveTraffic.xcodeproj/project.pbxproj +++ b/LiveTraffic.xcodeproj/project.pbxproj @@ -834,8 +834,8 @@ LIBRARY_SEARCH_PATHS = Lib/fmod; LIVETRAFFIC_VERSION_BETA = 1; LIVETRAFFIC_VER_MAJOR = 4; - LIVETRAFFIC_VER_MINOR = 4; - LIVETRAFFIC_VER_PATCH = 1; + LIVETRAFFIC_VER_MINOR = 5; + LIVETRAFFIC_VER_PATCH = 0; LLVM_LTO = NO; MACH_O_TYPE = mh_dylib; MACOSX_DEPLOYMENT_TARGET = 10.15; @@ -955,8 +955,8 @@ LIBRARY_SEARCH_PATHS = Lib/fmod; LIVETRAFFIC_VERSION_BETA = 1; LIVETRAFFIC_VER_MAJOR = 4; - LIVETRAFFIC_VER_MINOR = 4; - LIVETRAFFIC_VER_PATCH = 1; + LIVETRAFFIC_VER_MINOR = 5; + LIVETRAFFIC_VER_PATCH = 0; LLVM_LTO = YES; MACH_O_TYPE = mh_dylib; MACOSX_DEPLOYMENT_TARGET = 10.15; diff --git a/Resources/FlightModels.prf b/Resources/FlightModels.prf index e43528b..7ebe435 100644 --- a/Resources/FlightModels.prf +++ b/Resources/FlightModels.prf @@ -1,4 +1,4 @@ -LiveTraffic 2.2 +LiveTraffic 4.5.0 # LiveTraffic FlightModels configuration file @@ -42,8 +42,10 @@ PITCH_MIN_VSI -1000 # [ft/min] minimal vsi below which pitch is MDL_PITC PITCH_MAX 15 # [°] maximum pitch angle (aoa) PITCH_MAX_VSI 2000 # [ft/min] maximum vsi above which pitch is MDL_PITCH_MAX PITCH_FLAP_ADD 4 # [°] to add if flaps extended -PITCH_FLARE 10 # [°] pitch during flare +PITCH_ROTATE 8 # [°] pitch during rotate +PITCH_FLARE 6 # [°] pitch during flare PITCH_RATE 3 # [°/s] pitch rate of change +PITCH_HOLD_TOUCHDOWN 3 # [s] How long to keep PITCH_FLARE after touch-down before lowering the nose? LIGHT_LL_ALT 10000 # [ft] Landing Lights on below this altitude; set zero for take-off/final only (GA) EXT_CAMERA_LON_OFS -45 # longitudinal external camera offset EXT_CAMERA_LAT_OFS 0 # lateral... @@ -59,9 +61,12 @@ AGL_FLARE 30 # [ft] height AGL to start flare ROLL_RATE 3 # [°/s] roll rate in normal turns MIN_FLIGHT_SPEED 120 # [kn] minimum flight speed, below that not considered valid data MAX_FLIGHT_SPEED 600 # [kn] maximum flight speed, above that not considered valid data -PITCH_MAX 10 # [°] maximum pitch angle (aoa) +PITCH_MAX 15 # [°] maximum pitch angle (aoa) PITCH_MAX_VSI 2500 # [ft/min] maximum vsi above which pitch is MDL_PITCH_MAX +PITCH_ROTATE 11 # [°] pitch during rotate +PITCH_FLARE 7 # [°] pitch during flare PITCH_RATE 2 # [°/s] pitch rate of change +PITCH_HOLD_TOUCHDOWN 5 # [s] How long to keep PITCH_FLARE after touch-down before lowering the nose? LABEL_COLOR FF0000 # red label for huge jets EXT_CAMERA_LON_OFS -70 # longitudinal external camera offset EXT_CAMERA_LAT_OFS 0 # lateral... @@ -76,6 +81,7 @@ ROLL_RATE 6 # [°/s] roll rate in normal turns FLAPS_UP_SPEED 130 # [kt] below that: initial climb, above that: climb FLAPS_DOWN_SPEED 180 # [kt] above that: descend, below that: approach PROP_RPM_MAX 1200 # [rpm] maximum propeller revolutions per minute +PITCH_HOLD_TOUCHDOWN 2 # [s] How long to keep PITCH_FLARE after touch-down before lowering the nose? EXT_CAMERA_LON_OFS -30 # longitudinal external camera offset EXT_CAMERA_LAT_OFS 0 # lateral... EXT_CAMERA_VERT_OFS 10 # vertical... @@ -88,6 +94,7 @@ MIN_FLIGHT_TURN_TIME 45 # [s] minimum allowable time for a 360° turn in fli ROLL_RATE 8 # [°/s] roll rate in normal turns FLAPS_UP_SPEED 130 # [kt] below that: initial climb, above that: climb FLAPS_DOWN_SPEED 180 # [kt] above that: descend, below that: approach +PITCH_HOLD_TOUCHDOWN 2 # [s] How long to keep PITCH_FLARE after touch-down before lowering the nose? LABEL_COLOR 00F0F0 # light blue labels for business jets EXT_CAMERA_LON_OFS -20 # longitudinal external camera offset EXT_CAMERA_LAT_OFS 0 # lateral... @@ -118,8 +125,10 @@ PITCH_MIN_VSI -500 # [ft/min] minimal vsi below which pitch is MDL_PITC PITCH_MAX 10 # [°] maximum pitch angle (aoa) PITCH_MAX_VSI 600 # [ft/min] maximum vsi above which pitch is MDL_PITCH_MAX PITCH_FLAP_ADD 2 # [°] to add if flaps extended -PITCH_FLARE 5 # [°] pitch during flare +PITCH_ROTATE 5 # [°] pitch during rotate +PITCH_FLARE 4 # [°] pitch during flare PITCH_RATE 3 # [°/s] pitch rate of change +PITCH_HOLD_TOUCHDOWN 1 # [s] How long to keep PITCH_FLARE after touch-down before lowering the nose? LIGHT_LL_ALT 0 # [ft] Landing Lights on below this altitude; set zero for climb/approach only (GA) LABEL_COLOR 00FF00 # green labels for GA and smaller PROP_RPM_MAX 1200 # [rpm] maximum propeller revolutions per minute @@ -149,8 +158,10 @@ PITCH_MIN_VSI -1000 # [ft/min] minimal vsi below which pitch is MDL_PITC PITCH_MAX 15 # [°] maximum pitch angle (aoa) PITCH_MAX_VSI 2000 # [ft/min] maximum vsi above which pitch is MDL_PITCH_MAX PITCH_FLAP_ADD 3 # [°] to add if flaps extended -PITCH_FLARE 10 # [°] pitch during flare +PITCH_ROTATE 7.5 # [°] pitch during rotate +PITCH_FLARE 4 # [°] pitch during flare PITCH_RATE 3 # [°/s] pitch rate of change +PITCH_HOLD_TOUCHDOWN 2 # [s] How long to keep PITCH_FLARE after touch-down before lowering the nose? LIGHT_LL_ALT 0 # [ft] Landing Lights on below this altitude; set zero for take-off/final only (GA) EXT_CAMERA_LON_OFS -30 # longitudinal external camera offset EXT_CAMERA_LAT_OFS 0 # lateral... @@ -188,8 +199,10 @@ PITCH_MIN_VSI -1000 # [ft/min] minimal vsi below which pitch is MDL_PITC PITCH_MAX 5 # [°] maximum pitch angle (aoa) PITCH_MAX_VSI 1000 # [ft/min] maximum vsi above which pitch is MDL_PITCH_MAX PITCH_FLAP_ADD 1 # [°] to add if flaps extended +PITCH_ROTATE 5 # [°] pitch during rotate PITCH_FLARE 3 # [°] pitch during flare PITCH_RATE 1 # [°/s] pitch rate of change +PITCH_HOLD_TOUCHDOWN 0 # [s] How long to keep PITCH_FLARE after touch-down before lowering the nose? LIGHT_LL_ALT 0 # [ft] Landing Lights on below this altitude; set zero for take-off/final only (GA) LABEL_COLOR 00FF00 # green labels for GA and smaller PROP_RPM_MAX 1200 # [rpm] maximum propeller revolutions per minute @@ -213,6 +226,8 @@ FLAPS_UP_SPEED 5 # [kt] below that: initial climb, above that: climb FLAPS_DOWN_SPEED 15 # [kt] above that: descend, below that: approach MAX_FLIGHT_SPEED 300 # [kn] maximum flight speed, above that not considered valid data PROP_RPM_MAX 400 # [rpm] maximum propeller revolutions per minute +PITCH_ROTATE 0 # [°] pitch during rotate +PITCH_FLARE 0.5 # [°] pitch during flare EXT_CAMERA_LON_OFS -20 # longitudinal external camera offset EXT_CAMERA_LAT_OFS 0 # lateral... EXT_CAMERA_VERT_OFS 5 # vertical... diff --git a/Src/CoordCalc.cpp b/Src/CoordCalc.cpp index c884add..77206a1 100644 --- a/Src/CoordCalc.cpp +++ b/Src/CoordCalc.cpp @@ -249,6 +249,268 @@ ptTy Bezier (double t, const ptTy& p0, const ptTy& p1, const ptTy& p2, const ptT } +// --------------------------------------------------------------------------- +// Centripetal Catmull-Rom spline evaluation +// --------------------------------------------------------------------------- +// +// Background +// ---------- +// LiveTraffic previously interpolated the rendered ground position linearly +// between two consecutive feed positions (chord interpolation), and walked +// the rendered heading toward the chord direction via a MovingParam. That +// works well when the aircraft is going straight, but during a curved taxi +// turn the chord between two feed samples does NOT match the arc the +// aircraft actually flew along the taxiway — the chord cuts across the +// corner. The rendered aircraft visibly slides through the turn at an +// angle that matches neither the entry nor the exit taxiway centreline. +// +// A smooth curve fit through several consecutive ground positions captures +// the actual arc — and its tangent at the current rendered point is the +// correct nose direction at that point. Position and heading then come +// from the same curve, which guarantees they are aligned. +// +// Centripetal Catmull-Rom (vs uniform / chordal) +// ----------------------------------------------- +// All Catmull-Rom variants interpolate exactly through their control points +// and are C¹-continuous. They differ in how the knot intervals between +// control points are spaced: +// +// * Uniform: dt_i = 1 (each segment gets equal parametric weight) +// * Chordal: dt_i = |P_{i+1} - P_i| +// * Centripetal: dt_i = sqrt(|P_{i+1} - P_i|) +// +// At a sharp turn the uniform variant overshoots and can even produce a +// self-intersecting loop; the chordal variant pulls the curve so close +// to the control polygon that it loses its smoothness. The centripetal +// variant — Lee 2009 — is the unique choice that avoids cusps and loops +// at any control-point configuration, including 90° corners. That is +// precisely what we want for taxi turns. +// +// Algorithm +// --------- +// We use the barycentric form (Lee's algorithm), which evaluates the +// non-uniform Catmull-Rom curve at a single parameter `t` ∈ [t1, t2] +// without needing to construct an explicit Bezier or Hermite spline. +// +// A1 = lerp_t(P0, P1, t, [t0, t1]) +// A2 = lerp_t(P1, P2, t, [t1, t2]) +// A3 = lerp_t(P2, P3, t, [t2, t3]) +// B1 = lerp_t(A1, A2, t, [t0, t2]) +// B2 = lerp_t(A2, A3, t, [t1, t3]) +// C = lerp_t(B1, B2, t, [t1, t2]) +// +// where `lerp_t(p, q, t, [ta, tb]) = ((tb-t)*p + (t-ta)*q) / (tb - ta)`. +// +// The knot intervals are `dt_i = sqrt(|P_{i+1} - P_i|)` (centripetal); the +// knots are accumulated as `t_{i+1} = t_i + dt_i` starting from `t_0 = 0`. +// A floor of 1e-6 is applied to each chord distance before the sqrt to +// avoid division by zero when two consecutive positions coincide (which +// can happen when the feed delivers a duplicate or a "trivial" position +// that wasn't dropped upstream). +// +// The tangent direction at `t` is obtained by a small finite difference +// in the curve parameter (0.1 % of the central segment length in `t`- +// space). For our purposes the magnitude of the tangent does not matter; +// only its direction, converted to a compass bearing in [0, 360). +// Analytical derivatives of the barycentric form are tractable but messy +// enough that the finite difference approach is preferred for clarity +// and adds only ~30 multiplies per evaluation. +// +// Coordinate frame +// ---------------- +// The math is performed in a local meters frame centred at `P1`. We +// compute east/north offsets for each control point using `Lat2Dist` and +// `Lon2Dist` (the latter takes a reference latitude for the cos(lat) +// factor, for which we use `P1`'s latitude). Over the typical span of +// four taxi positions (a few hundred metres at most) this approximation +// is accurate to centimetres — far below any rendering precision we +// care about, and avoids the multi-degree accumulation that would +// happen if we treated raw lat/lon as Euclidean. +// --------------------------------------------------------------------------- + +/// Linear interpolation by parameter on a non-uniform knot interval. +/// Returns ((tb - t) * a + (t - ta) * b) / (tb - ta). +static inline double NonUniformLerp(double a, double b, + double ta, double tb, double t) +{ + return ((tb - t) * a + (t - ta) * b) / (tb - ta); +} + +CatmullRomResult CatmullRomEvalCentripetal(const positionTy& P0, + const positionTy& P1, + const positionTy& P2, + const positionTy& P3, + double u) +{ + // Convert all four control points to a local meters frame centred on + // P1. Use P1's latitude for the cos(lat) factor in Lon2Dist — over + // the small span of four taxi positions this is more than accurate + // enough and keeps the calculation Euclidean. + const double refLat = P1.lat(); + const double x1 = 0.0, y1 = 0.0; // P1 at origin + const double x0 = Lon2Dist(P0.lon() - P1.lon(), refLat); + const double y0 = Lat2Dist(P0.lat() - P1.lat()); + const double x2 = Lon2Dist(P2.lon() - P1.lon(), refLat); + const double y2 = Lat2Dist(P2.lat() - P1.lat()); + const double x3 = Lon2Dist(P3.lon() - P1.lon(), refLat); + const double y3 = Lat2Dist(P3.lat() - P1.lat()); + + // Centripetal knot intervals: dt_i = sqrt(chord distance). + // Apply a floor (1e-6) on each chord before the sqrt so that + // coincident control points (duplicate feed samples) don't divide + // by zero — the spline will just degenerate to a near-quadratic + // segment with vanishing derivative through that knot. + const double t0 = 0.0; + const double t1 = t0 + std::sqrt(std::max(std::hypot(x1 - x0, y1 - y0), 1e-6)); + const double t2 = t1 + std::sqrt(std::max(std::hypot(x2 - x1, y2 - y1), 1e-6)); + const double t3 = t2 + std::sqrt(std::max(std::hypot(x3 - x2, y3 - y2), 1e-6)); + + // Map the user's u ∈ [0, 1] (fraction of the segment from P1 to P2) + // to the spline parameter t ∈ [t1, t2]. + const double t = t1 + std::clamp(u, 0.0, 1.0) * (t2 - t1); + + // Barycentric evaluation at parameter t (Lee 2009). + // The same six lerps are needed for x and y, so we evaluate both + // coordinates in parallel inside a lambda — keeps the math readable. + auto eval = [&](double tEval) -> std::pair + { + // First-tier lerps along the three input segments. + const double Ax1 = NonUniformLerp(x0, x1, t0, t1, tEval); + const double Ay1 = NonUniformLerp(y0, y1, t0, t1, tEval); + const double Ax2 = NonUniformLerp(x1, x2, t1, t2, tEval); + const double Ay2 = NonUniformLerp(y1, y2, t1, t2, tEval); + const double Ax3 = NonUniformLerp(x2, x3, t2, t3, tEval); + const double Ay3 = NonUniformLerp(y2, y3, t2, t3, tEval); + // Second-tier lerps across the wider intervals. + const double Bx1 = NonUniformLerp(Ax1, Ax2, t0, t2, tEval); + const double By1 = NonUniformLerp(Ay1, Ay2, t0, t2, tEval); + const double Bx2 = NonUniformLerp(Ax2, Ax3, t1, t3, tEval); + const double By2 = NonUniformLerp(Ay2, Ay3, t1, t3, tEval); + // Final lerp — the actual curve point. + const double Cx = NonUniformLerp(Bx1, Bx2, t1, t2, tEval); + const double Cy = NonUniformLerp(By1, By2, t1, t2, tEval); + return { Cx, Cy }; + }; + + // Curve point at the requested parameter. + const auto [Cx, Cy] = eval(t); + + // Tangent by small finite difference in curve-parameter space. + // Step size is 0.1 % of the central segment in t-space, which is + // plenty for numerical stability and well below any wavelength of + // detail in the spline. Direction matters; magnitude does not. + const double eps = (t2 - t1) * 1e-3; + const auto [Cx2, Cy2] = eval(t + eps); + const double dx = Cx2 - Cx; + const double dy = Cy2 - Cy; + + // Convert tangent (x = east, y = north) to a compass bearing + // (0 = north, 90 = east). atan2 with the arguments swapped gives + // the compass-convention angle; then normalise into [0, 360). + double headingDeg = std::atan2(dx, dy) * 180.0 / PI; + if (headingDeg < 0.0) + headingDeg += 360.0; + + return { Cx, Cy, headingDeg }; +} + +// --------------------------------------------------------------------------- +// MARK: Catmull-Rom arc-length LUT +// --------------------------------------------------------------------------- +// +// Why this exists +// --------------- +// `CatmullRomEvalCentripetal`'s parameter `u ∈ [0, 1]` is centripetal-knot +// space, NOT arc length. Arc length per unit `u` along the curve varies with +// local curvature, so advancing `u` linearly with time makes the rendered +// aircraft "breathe" — speeding up through low-curvature sections, slowing +// down through tighter ones — and the rendered velocity at the end of one +// segment rarely matches the velocity at the start of the next. +// +// This LUT subdivides the segment at 16 uniformly-spaced `u` values, chords +// between successive samples, and accumulates the running total. Given a +// time-linear progression `f`, the inverse lookup returns the `u` for which +// the curve has covered `f * totalArc` of arc length — which makes the +// rendered velocity constant at `totalArc / duration` throughout the segment. +// +// Cost analysis: 16 spline evaluations per Build() (called once per segment +// switch, ~once per 1-5 s of feed cadence), plus a 4-step binary search and +// one linear interp per render frame. Trivial. +// --------------------------------------------------------------------------- + +void CatmullRomArcLut::Build(const positionTy& P0, const positionTy& P1, + const positionTy& P2, const positionTy& P3) +{ + // First sample is the segment's P1, expressed in P1's own local frame — + // so by definition (xPrev, yPrev) = (0, 0). We seed the loop from there + // and accumulate chord lengths between successive curve samples. + sAtU[0] = 0.0; + double xPrev = 0.0; + double yPrev = 0.0; + + for (int i = 1; i <= N; ++i) { + // Sample the spline at u = i / N. We re-use the existing evaluator + // rather than inlining the math here — keeps the LUT and the per-frame + // evaluation guaranteed to use exactly the same curve. + const double u = double(i) / double(N); + const CatmullRomResult s = CatmullRomEvalCentripetal(P0, P1, P2, P3, u); + + // Chord length from previous sample to this one. For N=16 samples + // across a typical taxi-leg this is well below 0.1% off the true + // integral — visually indistinguishable from the continuous curve. + const double dx = s.xMtr - xPrev; + const double dy = s.yMtr - yPrev; + sAtU[i] = sAtU[i - 1] + std::hypot(dx, dy); + + xPrev = s.xMtr; + yPrev = s.yMtr; + } + + totalArc = sAtU[N]; + valid = true; +} + +double CatmullRomArcLut::UFromArcFraction(double f) const +{ + // Defensive clamp: callers should pass f in [0, 1] but if a per-frame + // computation produced a tiny over/undershoot from float rounding we + // do not want to walk off either end of the LUT. + f = std::clamp(f, 0.0, 1.0); + + // Degenerate segment (all control points coincide → zero arc length). + // Returning f directly is correct: the spline evaluator at any u in + // this case yields P1, so the choice of u does not matter. + if (totalArc <= 0.0) + return f; + + // Target arc length to reach. + const double sTarget = f * totalArc; + + // Find the LUT bucket j such that sAtU[j] <= sTarget <= sAtU[j+1]. + // The LUT is monotonically increasing by construction so a simple + // upper_bound binary search suffices. + auto it = std::upper_bound(sAtU.begin(), sAtU.end(), sTarget); + if (it == sAtU.begin()) { + // sTarget == 0 within float precision; return u = 0. + return 0.0; + } + if (it == sAtU.end()) { + // sTarget == totalArc within float precision; return u = 1. + return 1.0; + } + const int jUpper = int(it - sAtU.begin()); + const int jLower = jUpper - 1; + const double sLow = sAtU[jLower]; + const double sHigh = sAtU[jUpper]; + + // Linear interpolation in u-space across this LUT bucket. The bucket + // covers u in [jLower/N, jUpper/N], which is 1/N wide; we move + // proportionally to where sTarget falls between sLow and sHigh. + const double bucketWidth = sHigh - sLow; + const double frac = (bucketWidth > 0.0) ? (sTarget - sLow) / bucketWidth : 0.0; + return (double(jLower) + frac) / double(N); +} + // returns terrain altitude at given position // returns NaN in case of failure double YProbe_at_m (const positionTy& posAt, XPLMProbeRef& probeRef) diff --git a/Src/DataRefs.cpp b/Src/DataRefs.cpp index ab5e28c..6f9b367 100644 --- a/Src/DataRefs.cpp +++ b/Src/DataRefs.cpp @@ -569,6 +569,7 @@ DataRefs::dataRefDefinitionT DATA_REFS_LT[CNT_DATAREFS_LT] = { // debug options {"livetraffic/dbg/ac_filter", DataRefs::LTGetInt, DataRefs::LTSetDebugAcFilter, GET_VAR, false }, {"livetraffic/dbg/ac_pos", DataRefs::LTGetInt, DataRefs::LTSetBool, GET_VAR, true }, + {"livetraffic/dbg/diagnostic", DataRefs::LTGetInt, DataRefs::LTSetBool, GET_VAR, true }, {"livetraffic/dbg/log_raw_fd", DataRefs::LTGetInt, DataRefs::LTSetBool, GET_VAR, false }, {"livetraffic/dbg/log_weather", DataRefs::LTGetInt, DataRefs::LTSetBool, GET_VAR, false }, {"livetraffic/dbg/model_matching", DataRefs::LTGetInt, DataRefs::LTSetBool, GET_VAR, true }, @@ -670,6 +671,7 @@ void* DataRefs::getVarAddr (dataRefsLT dr) // debug options case DR_DBG_AC_FILTER: return &uDebugAcFilter; case DR_DBG_AC_POS: return &bDebugAcPos; + case DR_DBG_DIAGNOSTIC: return &bDebugDiagnostic; case DR_DBG_LOG_RAW_FD: return &bDebugLogRawFd; case DR_DBG_LOG_WEATHER: return &bDebugWeather; case DR_DBG_MODEL_MATCHING: return &bDebugModelMatching; @@ -2564,12 +2566,39 @@ bool DataRefs::SetDefaultCarIcaoType(const std::string type) void DataRefs::SetChannelEnabled (dataRefsLT ch, bool bEnable) { bChannel[ch - DR_CHANNEL_FIRST] = bEnable; - + // If OpenSky Tracking is enabled then make sure OpenSky Master is also if (IsChannelEnabled(DR_CHANNEL_OPEN_SKY_ONLINE)) { bChannel[DR_CHANNEL_OPEN_SKY_AC_MASTERDATA - DR_CHANNEL_FIRST] = true; } - + + // When the user enables a channel we also reset its validity flag. + // + // Background: an LTChannel goes invalid (`bValid=false`) after too many + // consecutive network errors. While invalid the channel's `shallRun()` + // returns false and `LTFlightDataAcMaintenance` never restarts its + // thread — even when `bChannel[ch]` is true. Without this reset the + // intuitive recovery path of toggling the channel checkbox off and back + // on does nothing visible, because the toggle only flips `bChannel[ch]` + // and leaves `bValid` alone. The user is left to find the separate + // "Restart Stopped Channels" button in the settings UI (which calls + // `LTFlightDataRestartInvalidChs`). Calling `SetValid(true)` here makes + // the toggle do what the user expects: a re-enable revives an + // invalidated channel for the next maintenance tick to restart its + // network thread. + // + // Trade-off: this also means a channel invalidated for a *persistent* + // reason (e.g. bad credentials) will be retried each time the user + // touches the toggle. That is preferable to silent inaction — the + // user can see the failure repeat and react accordingly. The error + // counter is reset by SetValid(true), so the channel gets a fresh + // CH_MAC_ERR_CNT budget before going invalid again. + if (bEnable) { + LTChannel* pCh = LTFlightDataGetCh(ch); + if (pCh && !pCh->IsValid()) + pCh->SetValid(true); + } + // if a channel got disabled check if any tracking data channel is left if (!bEnable && AreAircraftDisplayed() && // something just got disabled? And A/C are currently displayed? !LTFlightDataAnyTrackingChEnabled()) // but no tracking data channel left active? diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index c2a64b4..a65309d 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -82,7 +82,13 @@ bool NextCycle (int newCycle) // frames we assume some debugging delay and instead increase buffering time if (dataRefs.GetNumAc() > 0) { - if (currCycle.diffTime < 0) { + // Only re-init on a *substantial* backward jump (> 1 s). Sub-second + // backward steps caused by frame stutters or brief pause/unpause + // cycles are absorbed silently — the per-frame interpolators read + // simTime directly, so a small reversal is just a tiny stutter on + // the next render, not a state-corrupting event. See + // TIME_NONLINEAR_BACKWARD_S in Constants.h for the rationale. + if (currCycle.diffTime < TIME_NONLINEAR_BACKWARD_S) { // jumped backward...that has nothing to do with debugging dataRefs.SetReInitAll(true); SHOW_MSG(logWARN, ERR_TIME_NONLINEAR, currCycle.diffTime); @@ -110,8 +116,15 @@ bool NextCycle (int newCycle) // time should move forward (positive difference) and not too much either // If time moved to far between two calls then we better start over // (but no problem if no a/c yet displayed anyway) + // Backward step: ignore sub-second blips (frame stutter, brief + // pause/unpause, autosave). Only a substantial reversal triggers + // re-init. Forward step: any jump beyond the buffer period means + // our buffered data is stale and re-init is the correct response. + // See TIME_NONLINEAR_BACKWARD_S in Constants.h for the rationale + // behind the asymmetric thresholds. if (dataRefs.GetNumAc() > 0 && - (currCycle.diffTime < 0 || currCycle.diffTime > dataRefs.GetFdBufPeriod()) ) { + (currCycle.diffTime < TIME_NONLINEAR_BACKWARD_S || + currCycle.diffTime > dataRefs.GetFdBufPeriod()) ) { // too much time passed...we start over and reinit all aircraft dataRefs.SetReInitAll(true); SHOW_MSG(logWARN, ERR_TIME_NONLINEAR, currCycle.diffTime); @@ -866,8 +879,10 @@ bool fm_processModelLine (const char* fileName, int ln, else FM_ASSIGN(PITCH_MAX); else FM_ASSIGN(PITCH_MAX_VSI); else FM_ASSIGN(PITCH_FLAP_ADD); + else FM_ASSIGN(PITCH_ROTATE); else FM_ASSIGN(PITCH_FLARE); else FM_ASSIGN_MIN(PITCH_RATE, 1.0); // avoid zero - this becomes a divisor + else FM_ASSIGN(PITCH_HOLD_TOUCHDOWN); else FM_ASSIGN(PROP_RPM_MAX); else FM_ASSIGN(LIGHT_LL_ALT); else FM_ASSIGN(EXT_CAMERA_LON_OFS); @@ -956,7 +971,7 @@ bool LTAircraft::FlightModel::ReadFlightModelFile () return false; } - // first line is supposed to be the version - and we know of exactly one: + // first line is supposed to be the version - and we expect exactly one: std::vector lnVer; std::string text; if (!safeGetline(fIn, text) || // read a line @@ -1291,8 +1306,15 @@ probeNextTs(0), terrainAlt_m(0.0) // standard internal label (e.g. for logging) is transpIcao + ac type + another id if available CalcLabelInternal(statCopy); - // init moving params where necessary - pitch.SetVal(0); + // init moving params where necessary. + // The pitch is initialised to the static ground attitude + // `GND_PITCH_DEG` whenever the aircraft starts its life on the + // ground (parked / taxiing) so the first rendered frame already + // looks correct — otherwise the MovingParam would briefly target + // 0° and produce a visible nose-bob before the ground-attitude + // override (in `CalcAcPos`) kicks in. Aircraft created in flight + // continue to start at neutral 0°. + pitch.SetVal(IsOnGrnd() ? GND_PITCH_DEG : 0); corrAngle.SetVal(0); // calculate our first position, must also succeed @@ -1559,15 +1581,46 @@ bool LTAircraft::CalcPPos() } // Finally: Time to switch to next position? - // (Must have reach/passed posList[1] and there must be a third position, + // (Must have reach/passed posDeque[1] and there must be a third position, // which can now serve as 'to') while ( posList[1].ts() <= currCycle.simTime && posList.size() >= 3 ) { + // Preserve the slot we are about to discard. The Catmull-Rom + // spline that renders ground position + heading needs the + // *previous* `from` as its P0 control point — the slot that + // gave the curve its incoming tangent at P1. Without this + // save the spline at the start of each new leg would treat + // the leg as the head of the deque and lose its smoothness + // at the join. + posPrev = posList.front(); + // By just removing the first element (current 'from') from the deqeue - // we make posList[2] the next 'to' + // we make posDeque[2] the next 'to' posList.pop_front(); + + // Snapshot the spline's exit-tangent control point (P3) for the + // new leg. After the pop, the new leg is from = posDeque[0], + // to = posDeque[1], and the slot one beyond `to` is posDeque[2] + // (if it exists). We freeze that value into `posNext` and use + // it for the entire leg — see the LTAircraft::posNext docstring + // for why we must NOT read posDeque[2] live each frame. If the + // deque does not yet extend that far we leave posNext invalid + // (lat()=NaN) and the spline evaluator falls back to + // duplicating P2 for a stable, slightly-tighter exit. + if (posList.size() >= 3) + posNext = posList[2]; + else + posNext = positionTy(); // lat()/lon() default to NaN + + // Invalidate the arc-length LUT. The control points for the new + // segment are not yet bound to a numeric value here (we want to + // build the LUT only if the spline branch is actually entered — + // chord-skip and air legs would not use it). The spline branch + // checks `splineLut.valid` and rebuilds on the first render + // frame of any segment that needs it. + splineLut.valid = false; // Now: If running point-to-point, ie. _not_ cutting corners with // Bezier curves, then to absolutely ensure we continue seamlessly from current - // ppos we set posList[0] ('from') to ppos. Should be close anyway in normal + // ppos we set posDeque[0] ('from') to ppos. Should be close anyway in normal // situations. (It's not if the simulation was halted while feeding live // data, then posList got completely outdated and ppos might jump beyond the entire list.) if ( ppos < posList[1]) { @@ -1575,7 +1628,7 @@ bool LTAircraft::CalcPPos() ppos.f.specialPos = posList.front().f.specialPos; ppos.f.bCutCorner = posList.front().f.bCutCorner; ppos.edgeIdx = posList.front().edgeIdx; - // Then overwrite posList[0] if not currently turning using a Bezier + // Then overwrite posDeque[0] if not currently turning using a Bezier if (!turn.isTsInbetween(currCycle.simTime)) posList.front() = ppos; } @@ -1678,9 +1731,46 @@ bool LTAircraft::CalcPPos() // *** Heading *** // Try a Bezier curve first, if that doesn't work... + // + // On the ground we use the lower `GND_BEZIER_MIN_HEAD_DIFF` + // threshold so that 1–2° taxi turns also get curve-tangent + // heading and are visually rendered as a smooth arc rather than + // as a heading walked via the linear MovingParam fallback. In + // the air we keep the original 2.5° threshold so en-route course + // corrections don't constantly enter/exit Bezier mode. + const double minHeadDiff = IsOnGrnd() ? GND_BEZIER_MIN_HEAD_DIFF + : BEZIER_MIN_HEAD_DIFF; + // At high ground speed (landing rollout, takeoff roll, fast taxi) + // we deliberately skip Bezier and use straight-line interpolation + // instead. The Bezier's end-tangent comes from `to.heading()` which + // is the next slot's reported heading — when that next slot is on + // a turn-off taxiway and the current slot is on the runway, the + // Bezier arcs the path across the runway corner and the rendered + // aircraft visually slides off the runway with its nose pointing + // away from its direction of motion. Linear interpolation makes + // the renderer walk heading toward `vec.angle` (the motion vector + // — see the moveQuickestToBy call below) which is what an aircraft + // physically does on the ground at speed: nose along the track. + // See `GND_TRACK_HEADING_MIN_KT` in Constants.h for the rationale. + // + // We use the leg's AVERAGE speed (`vec.speed_kn()` = dist/dt) and + // NOT the current rendered speed. The rendered speed at leg-setup + // is the speed the aircraft is *coming into* the leg — so for a + // taxi-to-runway-entry leg where the aircraft taxis in slowly and + // exits at runway-roll speed (e.g., gs 5 kn → 12 kn over 47 m in + // 7.84 s, leg-average ~12 kn), the rendered start speed is 5 kn + // and would fail this threshold even though the leg is the very + // transition we want to handle straight-line. Using leg-average + // catches all legs whose endpoint speed crosses the threshold, + // which is what aligns the rendered nose with the runway from + // the moment the aircraft starts accelerating onto it. + const bool bGndFast = IsOnGrnd() && + !std::isnan(vec.speed) && + vec.speed_kn() >= GND_TRACK_HEADING_MIN_KT; if (to.f.bCutCorner || // next position is to use a cut-corner curve? vec.dist <= SIMILAR_POS_DIST || // no reasonable leg distance and turn amount? - std::abs(HeadingDiff(ppos.heading(), to.heading())) < BEZIER_MIN_HEAD_DIFF || + std::abs(HeadingDiff(ppos.heading(), to.heading())) < minHeadDiff || + bGndFast || // high-speed ground motion: never Bezier !turn.Define(ppos, to)) // or defining the Bezier failed for some other reason? { // ...start the turn from the initial heading to the vector heading @@ -1840,21 +1930,313 @@ bool LTAircraft::CalcPPos() heading.SetVal(ppos.heading()); } // No Bezier curve currently active: + else if (from.IsOnGnd() && to.IsOnGnd()) { + // ------------------------------------------------------------------ + // Ground rendering — centripetal Catmull-Rom spline. + // + // While both endpoints of the current leg are on the ground we + // interpolate position and heading along a smooth curve fit through + // four control points: P0 = the previous `from` (preserved in + // `posPrev` when the position switch popped it from the deque), + // P1 = current from, P2 = current to, and P3 = the slot AFTER `to` + // if one is available in `posList`. P0 / P3 set the entry / exit + // tangents so adjacent legs join with C¹ continuity. + // + // P2 (the look-ahead endpoint) is lightly pre-smoothed against its + // neighbours before the curve is fit (see GND_SPLINE_SMOOTH_WEIGHT + // and the control-point smoothing block below), which turns the + // otherwise strictly-interpolating spline into an approximating + // one — the rendered path no longer threads exactly through every + // noisy feed sample. P1 is left raw so the curve still returns + // `from` exactly at u=0, which the segment-switch continuity + // mechanism depends on. + // + // Heading is the tangent direction at the spline parameter — by + // construction the rendered nose points the way the rendered + // position is moving, which is the property that eliminates the + // "sideways through a turn" symptom that linear-chord + // interpolation produces. + // + // Math is performed in a local meters frame centred on P1 (see + // `CatmullRomEvalCentripetal`). We convert the returned local + // (x, y) back to lat/lon using `Dist2Lat` / `Dist2Lon` on the + // same origin. Altitude and pitch stay on the linear path: + // altitude is irrelevant on the ground (clamped to terrainAlt + // later by the bOnGrnd branch), and pitch is hard-set to + // GND_PITCH_DEG by the same later block. + // + // The Bezier curve `turn` is *not* used while the spline is in + // effect — turn.GetPos returned false above, otherwise we + // wouldn't be in this branch. The spline supersedes Bezier for + // ground rendering because Bezier's end-tangents come from + // slot.heading() (subject to EHS lag and channel-side filter + // ambiguity) while the spline's tangents come from positions + // only, so the spline is robust to a stale or missing feed + // heading at the segment endpoints. + // ------------------------------------------------------------------ + + // Compute the chord length from→to in the local meters frame + // first. If it is below GND_SPLINE_MIN_CHORD_M we are looking + // at a "no useful motion" leg — parked aircraft jitter or a + // near-stationary creep dominated by feed noise — and the + // spline tangent through near-coincident control points would + // produce a noise-driven heading that overrides the (already + // correctly frozen) slot heading. Skip the spline in that + // regime: linear position interp + preserve from.heading(). + const double dyMtr = Lat2Dist(to.lat() - from.lat()); + const double dxMtr = Lon2Dist(to.lon() - from.lon(), from.lat()); + const double chordMtr = std::sqrt(dxMtr * dxMtr + dyMtr * dyMtr); + + // Leg-average ground speed (chord / duration). Used purely to + // pick the interpolation method below — see GND_SPLINE_MAX_KT. + // `duration` is guaranteed positive by the LOG_ASSERT_FD above. + const double legSpeedKt = (chordMtr / duration) * KT_per_M_per_S; + + if (chordMtr < GND_SPLINE_MIN_CHORD_M) { + // Stationary / sub-noise motion — pure linear interp and + // pass the slot heading through. CalcHeading has frozen + // from.heading() to lastGoodHeading_ in this regime, so + // both `from.heading()` and `to.heading()` should agree + // and equal the parked heading. We blend them defensively + // via shortest-path so a stray 1° slot mismatch does not + // unwrap into a 359° backward swing. + ppos.lat() = from.lat() + Dist2Lat(dyMtr * f); + ppos.lon() = from.lon() + Dist2Lon(dxMtr * f, from.lat()); + ppos.alt_m() = from.alt_m() * (1 - f) + to.alt_m() * f; + ppos.pitch() = from.pitch() * (1 - f) + to.pitch() * f; + const double h0 = from.heading(); + const double hd = HeadingDiff(h0, to.heading()); + ppos.heading() = HeadingNormalize(h0 + hd * f); + heading.SetVal(ppos.heading()); + } + else if (legSpeedKt > GND_SPLINE_MAX_KT) { + // High-speed straight-line runway motion — takeoff roll or + // landing rollout. The spline exists to handle TURNS, which + // do not happen at this speed; here it would only add + // fragility (outlier amplification, arc-length-LUT vs + // acceleration-profile interaction, leg-to-leg curve-shape + // changes on sparse irregular feed data). Plain linear + // interpolation renders the straight centreline track + // exactly and returns `from` precisely at f=0, keeping the + // segment-switch continuity seamless. See GND_SPLINE_MAX_KT + // in Constants.h for the full rationale. + ppos.lat() = from.lat() + Dist2Lat(dyMtr * f); + ppos.lon() = from.lon() + Dist2Lon(dxMtr * f, from.lat()); + ppos.alt_m() = from.alt_m() * (1 - f) + to.alt_m() * f; + ppos.pitch() = from.pitch() * (1 - f) + to.pitch() * f; + + // Heading from the chord bearing — the direct line of + // travel between the two feed positions, which on a runway + // IS the aircraft heading. Far-apart high-speed samples + // make this rock-solid: ~3 m of feed noise on a 200 m+ + // chord is well under 1° of heading error. atan2(east, + // north) gives the compass-convention bearing, matching + // the convention used everywhere else in this file. + double hdg = std::atan2(dxMtr, dyMtr) * 180.0 / PI; + if (hdg < 0.0) + hdg += 360.0; + ppos.heading() = hdg; + heading.SetVal(hdg); + } + else { + // Choose control points. P0 comes from posPrev (cached at + // the previous segment-switch). P3 comes from posNext + // (cached at THIS segment's switch — see posNext docstring + // in LTAircraft.h for why we MUST NOT read posDeque[2] live + // here). When either snapshot is unavailable (insufficient + // deque depth at switch time) we duplicate the adjacent + // endpoint, which produces a zero entry/exit tangent and + // degenerates the spline to a quadratic-like segment at + // the boundary — safe, no overshoot. + const bool haveP0 = !std::isnan(posPrev.lat()); + const bool haveP3 = !std::isnan(posNext.lat()); + const positionTy& P0 = (haveP0 ? posPrev : from); + const positionTy& P3 = (haveP3 ? posNext : to); + + // Control-point smoothing — LOOK-AHEAD ENDPOINT (P2) ONLY. + // + // A centripetal Catmull-Rom spline interpolates: the curve + // passes exactly through P1 and P2, so a noisy feed sample at + // either endpoint becomes a noisy rendered position. We turn + // it into an approximating spline by pre-smoothing P2 with a + // 3-tap binomial kernel against its neighbours: + // P2' = w·from + (1−2w)·to + w·P3 (w = GND_SPLINE_SMOOTH_WEIGHT) + // + // P1 is deliberately NOT smoothed. The position-switch loop + // above does `posList.front() = ppos` (line ~1631) to make a + // new leg continue seamlessly from wherever the renderer + // currently is — a mechanism that only works if the renderer + // returns posDeque[0] (== `from`) EXACTLY at the start of the + // leg. The unmodified Catmull-Rom evaluator does exactly that + // (it interpolates P1 at u=0). If we smoothed P1 the evaluator + // would instead return P1' ≠ from at u=0, so every segment + // switch the rendered position would snap from `ppos` to P1' — + // the visible ~6 s forward/backward jump. + // + // Smoothing only P2 still removes the jitter completely: + // every feed sample is reached as the *smoothed* P2' endpoint + // of its leg, and is then carried into the next leg as `from` + // via the ppos overwrite. So the rendered path threads the + // smoothed points {P2'_k} — the raw noisy samples are never + // visited — while u=0 still yields `from` exactly, keeping the + // segment joins seamless. + // + // P2 is only smoothed when posNext is a *real* slot; when it + // fell back to a duplicated `to` the kernel would just bias + // the endpoint toward the segment interior, so we keep `to` + // raw in that case. See GND_SPLINE_SMOOTH_WEIGHT in + // Constants.h for the corner-cutting trade-off. + const double w = GND_SPLINE_SMOOTH_WEIGHT; + positionTy P2s = to; // copy ts/flags/alt/heading + if (haveP3 && w > 0.0) { + P2s.lat() = w * from.lat() + (1.0 - 2.0 * w) * to.lat() + w * P3.lat(); + P2s.lon() = w * from.lon() + (1.0 - 2.0 * w) * to.lon() + w * P3.lon(); + } + + // Arc-length reparameterisation. The spline's native u is + // centripetal-knot space, NOT arc length, so feeding `f` + // directly to the evaluator would make the rendered position + // accelerate and decelerate within the segment (the user- + // observed "slow down / speed back up" pulsation). Instead + // we (re)build the arc-length LUT on the first frame of the + // segment and look up the u that corresponds to having + // traversed `f * totalArc` along the curve. Result: the + // rendered position advances at constant arc-length-per- + // time across the leg, with the visible speed equal to + // totalArc / duration. The LUT is built from the SAME + // control points (raw `from`, smoothed P2s) used for eval. + if (!splineLut.valid) + splineLut.Build(P0, from, P2s, P3); + const double uArc = splineLut.UFromArcFraction(f); + + const CatmullRomResult cr = + CatmullRomEvalCentripetal(P0, from, P2s, P3, uArc); + + // Convert spline result (local meters from P1) back to + // geographic coordinates. P1 is the raw `from` (NOT smoothed, + // see above), so the local-frame origin is from.lat/lon. + ppos.lat() = from.lat() + Dist2Lat(cr.yMtr); + ppos.lon() = from.lon() + Dist2Lon(cr.xMtr, from.lat()); + + // Altitude and pitch on the linear path — these are not + // part of the horizontal-plane spline. On the ground + // altitude will be clamped to terrainAlt_m below anyway; + // pitch is forced to GND_PITCH_DEG by the same block. + ppos.alt_m() = from.alt_m() * (1 - f) + to.alt_m() * f; + ppos.pitch() = from.pitch() * (1 - f) + to.pitch() * f; + + // Heading. + // + // Normally the spline tangent is the heading: the rendered + // nose points along the rendered direction of motion, which + // is what eliminates the "sideways through a turn" symptom. + // + // EXCEPTION: if EITHER end of the leg carries `bHeadFixed`, + // an upstream stage has deliberately set a heading that must + // NOT be overridden — currently that means a pushback leg, + // where CalcHeading set the slot heading to the held nose + // direction so the nose stays pointed away from the + // (backward) direction of travel. The spline tangent here + // points along that backward motion, so using it would + // render the aircraft tail-first the wrong way. Instead we + // interpolate the slot headings across the leg (shortest- + // path), preserving the intended nose direction while still + // drawing the smooth spline *position*. + // + // Why both ends, not just `from`: at the PB_NONE→PB_ACTIVE + // transition the previous leg's `to` (now `from` here) came + // from the parked era and has bHeadFixed=false. Pinning + // bHeadFixed retroactively onto the predecessor slot is not + // always possible — when posDeque has been drained during a + // long stationary period, CalcHeading uses pAc->GetToPos() + // as a virtual predecessor and that slot is not writable + // from CalcHeading. Honouring `to.f.bHeadFixed` here covers + // that case from the destination side: as long as the slot + // we are transitioning *into* has its heading fixed (PB + // override), interpolate instead of tangent. + if (from.f.bHeadFixed || to.f.bHeadFixed) { + const double h0 = from.heading(); + const double hd = HeadingDiff(h0, to.heading()); + ppos.heading() = HeadingNormalize(h0 + hd * f); + heading.SetVal(ppos.heading()); + } else { + // Sync the MovingParam so any downstream code that reads + // `heading.get()` sees the spline-derived value as the + // current state. + ppos.heading() = cr.headingDeg; + heading.SetVal(cr.headingDeg); + } + } + } else { - // Now we apply the factor so that with time we move from 'from' to 'to'. - // Note that this calculation also works if we passed 'to' already - // (due to no newer 'to' available): we just keep going the same way. - // This is effectively a scaled vector sum, broken down into its components: + // Air or air↔ground transition. + // + // lat/lon/pitch use the same linear interpolation as before — + // the user-visible feature for those is positional, not slope + // smoothness, and the existing ground-rendering Catmull-Rom + // already handles the curve quality where it matters (taxi, + // rollout, slow turns). + // + // Altitude, however, goes through `LookupAltAtTs(simTime)` so + // the rendered climb/descend profile is a C¹-continuous + // Hermite/Catmull-Rom spline across all of `posList`. The + // previous `from.alt_m() * (1-f) + to.alt_m() * f` was only + // C⁰ across leg boundaries (slope was a step at every slot + // transition), and the visible symptom was a sequence of + // "kinks" in the rendered VSI whenever the active leg changed + // — most clearly during the 10 s liftoff blend, where the + // aircraft might cross several leg boundaries. + // + // Using the spline always (not only inside the blend) keeps + // the blend's seam at t = T seamless: the blend formula + // ends with ppos.alt_m() = LookupAltAtTs(simTime), and the + // post-blend rendering uses the very same value. ppos.lat() = from.lat() * (1 - f) + to.lat() * f; ppos.lon() = from.lon() * (1 - f) + to.lon() * f; - ppos.alt_m() = from.alt_m() * (1 - f) + to.alt_m() * f; + ppos.alt_m() = LookupAltAtTs(currCycle.simTime); ppos.pitch() = from.pitch() * (1 - f) + to.pitch() * f; // we handle roll later separately - + // Get heading from moving param ppos.heading() = heading.get(); } - + + // ---------------------------------------------------------------------- + // Per-frame heading rate limit (ground only). + // + // Even after `LTFlightData::CalcHeading` filtered out stationary jitter + // and applied a hysteresis dead-band on the deque side, the *target* + // heading that arrives here can still jump abruptly when, e.g., a new + // position slot becomes the active `to` and changes the heading + // MovingParam's destination. Without rate-limiting, that jump would be + // rendered as a single-frame snap-rotation — visually wrong for an + // aircraft on the ground. We therefore clamp the per-frame change to + // `GND_HEADING_MAX_RATE_DPS * dt`. Anything larger walks toward the + // target at the maximum allowed rate; the rendered nose then never + // moves faster than `GND_HEADING_MAX_RATE_DPS` (see `Constants.h`). + // + // We use `HeadingDiff` so that the clamp picks the signed shortest + // path across the 360°/0° wrap. The MovingParam is re-synced to the + // clamped value so it does not race ahead in subsequent frames. + // + // Airborne aircraft skip this clamp: in the air, the heading + // MovingParam is already smoothed via `defDuration` (TAXI_TURN_TIME + // vs FLIGHT_TURN_TIME, see the half-way preparations below) and an + // additional clamp here would make en-route course changes lag. + // ---------------------------------------------------------------------- + if (IsOnGrnd() && + !std::isnan(prevHead) && + !std::isnan(ppos.heading())) + { + const double maxStep_deg = GND_HEADING_MAX_RATE_DPS * currCycle.diffTime; + const double delta_deg = HeadingDiff(prevHead, ppos.heading()); + if (std::abs(delta_deg) > maxStep_deg) { + ppos.heading() = HeadingNormalize( + prevHead + std::copysign(maxStep_deg, delta_deg)); + heading.SetVal(ppos.heading()); + } + } + // calculate timestamp can be a bit off, especially when acceleration is in progress, // overwrite with current value as of now ppos.ts() = currCycle.simTime; @@ -1884,8 +2266,33 @@ bool LTAircraft::CalcPPos() // set the flag to fetch the next leg. All the rest is done above bNeedCCBezier = true; } - // otherwise prepare turning heading to final heading (if not done already) - else if (!dequal(heading.toVal(), to.heading())) { + // otherwise prepare turning heading to final heading (if not done already). + // + // On the ground at high leg-average speed (rollout, takeoff, + // fast taxi) we deliberately do NOT retarget heading to + // `to.heading()` here. The linear path set up at the start of + // the leg already aimed heading at `vec.angle` (the motion + // direction), which is the visually correct nose direction + // during high-speed ground travel. Retargeting to the next + // slot's reported heading would restart the same problem the + // Bezier-skip above is trying to avoid: rendered nose pointing + // away from the direction of motion. Once leg-average speed + // drops below `GND_TRACK_HEADING_MIN_KT`, this branch is + // allowed to fire and the aircraft can begin converging on + // the slot's reported orientation for the upcoming turn-off, + // gate manoeuvre, or other slow-speed activity. + // + // The condition uses `vec.speed_kn()` (leg-average) to match + // the bGndFast check at leg-setup above. Using the current + // rendered `GetSpeed_kt()` here would let the retarget fire + // during the acceleration phase of a takeoff leg before the + // rendered speed has caught up to the leg average, undoing + // the Bezier-skip choice for the very legs that need it most. + else if (!dequal(heading.toVal(), to.heading()) && + !(IsOnGrnd() && + !std::isnan(vec.speed) && + vec.speed_kn() >= GND_TRACK_HEADING_MIN_KT)) + { heading.defDuration = IsOnGrnd() ? pMdl->TAXI_TURN_TIME : pMdl->FLIGHT_TURN_TIME; heading.moveQuickestToBy(ppos.heading(), to.heading(), // target heading NAN, to.ts(), // by target timestamp @@ -1922,12 +2329,144 @@ bool LTAircraft::CalcPPos() // but tires are rotating tireRpm.SetVal(std::min(TireRpm(GetSpeed_kt()), tireRpm.defMax)); + + // ------------------------------------------------------------------ + // Hard-set ground attitude every frame to defeat feed-driven jitter. + // + // Why this exists: data feeds and the position-interpolation code + // path can produce small drifts in pitch and roll while an aircraft + // is sitting on (or rolling along) the ground. Real aircraft are + // mechanically held in a fixed attitude by their landing gear — + // they do not bank while taxiing and their pitch is determined by + // gear geometry rather than dynamic flight forces. So we forcibly + // clamp pitch and roll to the constants `GND_PITCH_DEG` / + // `GND_ROLL_DEG` defined in `Constants.h`, overriding whatever the + // interpolation/flight-model code produced earlier in this frame. + // + // Exceptions: phases where the nose is genuinely moving relative + // to the ground — rotation for take-off (`FPH_ROTATE`), lift-off + // itself (`FPH_LIFT_OFF`), the flare before touchdown + // (`FPH_FLARE`), the single-cycle touchdown event + // (`FPH_TOUCH_DOWN`), and the roll-out that immediately follows + // touchdown (`FPH_ROLL_OUT`). In all of these the flight-model + // code is actively driving the `pitch` MovingParam through a + // planned transition — `pitch.moveTo(ROTATE_PITCH_MAX_DEG)` on + // rotate, VSI-derived target on lift-off, + // `pitch.moveTo(PITCH_FLARE)` on flare, + // `pitch.moveTo(GND_PITCH_DEG)` on touchdown to walk the nose + // down smoothly during roll-out. Overriding pitch during any of + // these phases would visibly snap the nose. In particular: + // - Without the `FPH_ROLL_OUT` exception, the de-rotation + // animation gets clobbered one frame after touchdown + // (touchdown is documented as a single-frame event) and + // the aircraft appears to slam its nose-wheel down. + // - Without the `FPH_LIFT_OFF` exception, an aircraft whose + // phase advances ROTATE → LIFT_OFF *while still bOnGrnd* + // (VSI crossed `VSI_STABLE` before the aircraft physically + // left the runway — common on takeoff rolls where the + // altitude is barometric and the smoothed value crosses + // `MDL_CLOSE_TO_GND` a frame or two before the deque + // bracket itself leaves the ground) gets its rotation pitch + // forcibly reset to `GND_PITCH_DEG = 2°` for as many frames + // as it takes for bOnGrnd to flip false. Visible as: nose + // pitches up, briefly flips level on the runway, then + // pitches up again once airborne. Reported on AAL2449. + // Roll is forced flat in all phases — ground aircraft never + // bank, so no exception is needed there. + if (phase != FPH_ROTATE && + phase != FPH_LIFT_OFF && + phase != FPH_FLARE && + phase != FPH_TOUCH_DOWN && + phase != FPH_ROLL_OUT) + { + ppos.pitch() = GND_PITCH_DEG; + ppos.roll() = GND_ROLL_DEG; + } else { + // Even in the dynamic-pitch phases, roll should still be + // forced flat — there is no scenario where a wheeled + // aircraft banks during rotation/flare/touchdown/roll-out. + ppos.roll() = GND_ROLL_DEG; + } } else { // not on the ground // just lifted off? then recalc vsi if (phase == FPH_LIFT_OFF && dequal(vsi, 0)) { vsi = ppos.vsi_ft(to); } + + // ------------------------------------------------------------------ + // Smooth altitude blend during the first LIFTOFF_BLEND_TIME_S + // seconds after the on-ground → airborne transition. + // + // CalcFlightModel records `liftoffBlendStartTs` on the frame that + // bOnGrnd flips from true to false. Up until that moment the + // altitude was clamped to `terrainAlt_m` by the `if (bOnGrnd)` + // branch above; on the very next frame the clamp goes away and + // ppos.alt_m takes on its raw linearly-interpolated value + // between the last on-ground slot (at terrain level) and the + // next airborne slot (which may be 100-500 ft above the runway, + // depending on how far apart the feed samples are in time). + // Without intervention the aircraft visibly teleports up to + // that interpolated altitude in a single frame — the "jumps + // into the air on rotation" symptom. + // + // We lerp from the frozen terrain altitude at liftoff + // (`liftoffStartAlt_m`) to the live spline-smoothed raw + // altitude (already computed into `ppos.alt_m()` via + // `LookupAltAtTs(simTime)` in the air-branch above) using + // Ken Perlin's quintic smootherstep: + // + // blend(t) = 6t⁵ − 15t⁴ + 10t³ + // + // Smootherstep is C²-continuous at both endpoints (value AND + // slope AND curvature are zero at t=0 and at t=1 with respect + // to the blend's shape), so neither the runway departure nor + // the post-blend handover introduces a kink from the blend + // function itself. + // + // Because the live `ppos.alt_m()` going in is now also a + // smooth Hermite/Catmull-Rom spline across `posList` (rather + // than the per-leg-linear interp it used to be), the only + // remaining source of "kinks" — slope discontinuities at + // deque slot boundaries — is gone. The combined visual is + // a single smooth runway-to-climbout arc. + // + // At t = T (sinceLiftoff = LIFTOFF_BLEND_TIME_S), blend = 1 + // and ppos.alt_m() = LookupAltAtTs(simTime). The very next + // frame (sinceLiftoff > T) bails out of the blend and the + // air-branch above continues to write the same Hermite + // value, so the seam is mathematically exact. + // ------------------------------------------------------------------ + if (!std::isnan(liftoffBlendStartTs) && + !std::isnan(liftoffStartAlt_m)) + { + const double sinceLiftoff = + currCycle.simTime - liftoffBlendStartTs; + if (sinceLiftoff < LIFTOFF_BLEND_TIME_S) + { + const double t = sinceLiftoff / LIFTOFF_BLEND_TIME_S; + const double blend = + t * t * t * (t * (t * 6.0 - 15.0) + 10.0); + // Clamp the (LookupAltAtTs − liftoffStartAlt) delta to + // be non-negative. If the regression momentarily dips + // below the frozen liftoff terrain (which can happen + // when past-ground samples briefly out-weight the + // future airborne samples during the early blend + // window), the blend output must not render the + // aircraft below the runway. Combined with the + // `bOnGrnd` lock above this guarantees the aircraft + // stays at or above terrain throughout the blend. + const double diff = std::max(0.0, + ppos.alt_m() - liftoffStartAlt_m); + ppos.alt_m() = liftoffStartAlt_m + diff * blend; + } else { + // Blend complete — release the frozen start altitude + // and continue rendering with the spline-smoothed + // raw alt that the air-branch above already produces. + liftoffBlendStartTs = NAN; + liftoffStartAlt_m = NAN; + } + } } // save this position for (next) camera view position @@ -1960,6 +2499,33 @@ void LTAircraft::CalcFlightModel (const positionTy& /*from*/, const positionTy& // else: we could also be airborne, // so assume 'on the ground' if 'very' close to it, otherwise airborne bOnGrnd = PHeight <= MDL_CLOSE_TO_GND; + // Liftoff-blend ground lock. + // + // Once the blend has started, the flight model has *committed* + // to the aircraft being airborne; the visible aircraft is being + // ramped up from terrain along the blend curve. If a transient + // dip in the smoothed altitude (returned by `LookupAltAtTs`) + // briefly takes the value back below `MDL_CLOSE_TO_GND` above + // terrain, the `else` branch below would clamp `ppos.alt` to + // terrain ("slam to ground"), the phase would regress to + // `FPH_TO_ROLL`/`FPH_TAXI`, and the next time the regression + // value rises again the lift-off transition fires anew — + // `liftoffBlendStartTs` resets and the blend restarts from + // scratch. Visibly: aircraft climbs to ~Nft, slams to the + // runway, continues T/O roll, rotates again, climbs again. + // That sequence was exactly what users reported on KLM99L / + // IBE07TV and similar departures with sparse early-climb data. + // + // The dip is mostly an artefact of the smoothing window + // rebalancing as past slots' Gaussian weights decay or the + // next future slot's slope contribution shifts — physically + // the aircraft has not landed. We therefore lock `bOnGrnd` + // to false for the duration of the active blend. + if (bOnGrnd && !std::isnan(liftoffBlendStartTs) && + currCycle.simTime - liftoffBlendStartTs < LIFTOFF_BLEND_TIME_S) + { + bOnGrnd = false; + } ppos.f.onGrnd = bOnGrnd ? GND_ON : GND_OFF; } @@ -2016,9 +2582,46 @@ void LTAircraft::CalcFlightModel (const positionTy& /*from*/, const positionTy& phase = FPH_ROTATE; } + // Diagnostic: log every bOnGrnd transition during the climb-out. + // Single line per transition, per aircraft — low volume in the log + // but enough to reconstruct any takeoff sequence and rule out (or + // catch) repeat lift-off/slam-to-ground events. + if (dataRefs.ShallLogDiagnostics() && + bOnGrndPrev != bOnGrnd && bFPhPrev != FPH_UNKNOWN) { + const bool blendActive = + !std::isnan(liftoffBlendStartTs) && + (currCycle.simTime - liftoffBlendStartTs) < LIFTOFF_BLEND_TIME_S; + LOG_MSG(logDEBUG, + "ALT_DIAG %s simT=%.1f bOnGrnd %d->%d ppos.alt=%.1fft " + "PHeight=%.1fft terrain=%.1fft blendActive=%d sinceLO=%.2fs phase=%s", + key().c_str(), currCycle.simTime, + int(bOnGrndPrev), int(bOnGrnd), + ppos.alt_ft(), PHeight, GetTerrainAlt_ft(), + int(blendActive), + std::isnan(liftoffBlendStartTs) + ? -1.0 + : currCycle.simTime - liftoffBlendStartTs, + FlightPhase2String(phase).c_str()); + } + // last frame: on ground, this frame: not on ground -> we just lifted off if ( bOnGrndPrev && !bOnGrnd && bFPhPrev != FPH_UNKNOWN ) { phase = FPH_LIFT_OFF; + // Record the wall-clock sim time so CalcPPos can blend the + // altitude smoothly upward from terrain over the next + // LIFTOFF_BLEND_TIME_S seconds. Without this, the rendered + // altitude would jump from terrain level (clamped while on + // ground) to the raw interpolated airborne value on this very + // frame — the aircraft visually teleports upward. See the + // blend application near `if (bOnGrnd) ... else { ... }` in + // CalcPPos and the rationale in Constants.h. + liftoffBlendStartTs = currCycle.simTime; + // Freeze the start altitude (terrain at this moment). Using a + // frozen value rather than reading `terrainAlt_m` every frame + // means the blend curve does not bob as the YProbe samples + // slightly different terrain elevations while the aircraft + // moves horizontally during the 10 s blend. + liftoffStartAlt_m = terrainAlt_m; } // climbing but not even reached gear-up altitude @@ -2145,7 +2748,13 @@ void LTAircraft::CalcFlightModel (const positionTy& /*from*/, const positionTy& // (as we don't do any counter-measure in the next ENTERED-statements // we can lift the nose only if we are exatly AT rotate phase) if (phase == FPH_ROTATE) { - pitch.max(); + // Cap the rotate-phase pitch target at `pMdl->PITCH_ROTATE`. + // Once the aircraft transitions to FPH_LIFT_OFF the in-air pitch logic in + // `LTFlightData::CalcNextPos` (line ~1700) takes over and + // walks pitch toward the VSI-derived target, clamped to + // `pMdl->PITCH_MAX` — so steep climbs can still reach the + // full 15°, just not while the gear is still on the runway. + pitch.moveTo(pMdl->PITCH_ROTATE); gearDeflection.min(); // and start easing up on the wheels } } @@ -2212,7 +2821,32 @@ void LTAircraft::CalcFlightModel (const positionTy& /*from*/, const positionTy& gearDeflection.max(); // start main gear deflection spoilers.max(); // start deploying spoilers ppos.f.onGrnd = GND_ON; - pitch.moveTo(0); + // DEFERRED nose-down: do NOT call `pitch.moveTo(GND_PITCH_DEG)` + // here. Record the touchdown timestamp instead; the frame-level + // check further down in this function will fire the moveTo only + // after `TOUCHDOWN_HOLD_PITCH_S` seconds have elapsed, modelling + // the aerobrake during which real airliners keep the nose + // pitched up at `PITCH_FLARE` after the main gear is on the + // runway. Until that delayed moveTo fires, the MovingParam's + // last commanded target remains `PITCH_FLARE` (set on + // `FPH_FLARE` entry) and the ground-attitude override in + // `CalcAcPos` is bypassed for both `FPH_TOUCH_DOWN` and + // `FPH_ROLL_OUT`, so the pitch stays at flare value during the + // hold. + touchdownTs = currCycle.simTime; + } + + // Deferred nose-down after touchdown (TOUCHDOWN_HOLD_PITCH_S hold). + // Fires once, then clears `touchdownTs` so subsequent frames do + // nothing. If for any reason the aircraft is destroyed mid-hold, + // the timestamp dies with it. If the aircraft re-touchdowns + // (e.g. porpoising) before we fire, the ENTERED(FPH_TOUCH_DOWN) + // block above simply re-stamps the timestamp, restarting the hold. + if (!std::isnan(touchdownTs) && + currCycle.simTime >= touchdownTs + pMdl->PITCH_HOLD_TOUCHDOWN) + { + pitch.moveTo(GND_PITCH_DEG); + touchdownTs = NAN; } // roll-out @@ -2288,13 +2922,18 @@ void LTAircraft::CalcRoll (double _prevHeading) const double partOfCircle = HeadingDiff(_prevHeading, ppos.heading()) / 360.0; const double timeFullCircle = currCycle.diffTime / partOfCircle; // at current turn rate (if small then we turn _very_ fast!) - // On the ground we should actually better be levelled, but we turn the nose wheel + // On the ground we should actually better be levelled, but we turn the nose wheel. + // Note: this assignment is "early" — the final ground-attitude clamp in + // `CalcAcPos` (the `bOnGrnd` block) will re-assert `GND_ROLL_DEG` after + // `CalcFlightModel` has run, so anything we write here is just a sane + // intermediate. We still set it explicitly so log output / debug dumps + // in between these two points show the correct value. if (IsOnGrnd()) { // except...if we are a stopped glider ;-) if (GetSpeed_m_s() < 0.2 && pMdl->isGlider()) ppos.roll() = MDL_GLIDER_STOP_ROLL; else - ppos.roll() = 0.0; + ppos.roll() = GND_ROLL_DEG; // Nose wheel steering: Hm...we would need to know a lot about the plane's // geometry to do that exactly right...so we just guess: 30° for a standard turn: @@ -2347,6 +2986,183 @@ void LTAircraft::CalcCorrAngle () } } +// Smooth altitude (m, MSL) at an arbitrary timestamp, fitted as a +// Gaussian-weighted local linear regression. The samples come from a +// per-aircraft archive (`pastAltSamples_`) that mirrors every slot +// we have ever seen in `fd.posDeque`, augmented with the future +// slots currently in `fd.posDeque`. The archive does NOT pop when +// `fd.posDeque` does, so popped slots remain in the regression +// (with their Gaussian weight smoothly decaying toward zero as +// `targetTs` advances past them). This is the *only* shape of input +// that produces visually smooth output — see the rationale on +// `pastAltSamples_` in the header for the full diagnosis. +double LTAircraft::LookupAltAtTs (double targetTs) const +{ + // Take the flight-data lock for the duration of the regression. + // dataAccessMutex is recursive, so if a caller already holds it + // the inner lock is a no-op. The fit is a single linear pass + // over the archive, so the critical section is short. + std::lock_guard lock(fd.dataAccessMutex); + const dequePositionTy& fullDeque = fd.GetPosDeque(); + + // Append any slots we have not yet archived. Both `pastAltSamples_` + // and `fullDeque` are individually sorted by ts; the boundary + // condition is that everything in `pastAltSamples_` came from + // earlier observations of `fullDeque` and is therefore older than + // or equal to the deque's current contents. We append every slot + // whose ts is strictly greater than the last archived ts. + for (const auto& p : fullDeque) { + if (pastAltSamples_.empty() || + p.ts() > pastAltSamples_.back().ts()) + { + pastAltSamples_.push_back(p); + } + } + // Prune samples well outside the Gaussian tail. We keep ±30 s + // around `targetTs`: with σ = 5 s the weight at ±30 s = ±6 σ is + // exp(-18) ≈ 1.5e-8, indistinguishable from zero for double- + // precision arithmetic, so pruned samples could not measurably + // affect the regression output. + constexpr double PRUNE_HORIZON_S = 30.0; + while (!pastAltSamples_.empty() && + pastAltSamples_.front().ts() < targetTs - PRUNE_HORIZON_S) + { + pastAltSamples_.pop_front(); + } + // Also prune samples in the *future* beyond the same horizon — + // they are buffered for later rendering and should not affect + // the regression at the current `targetTs`. (In normal operation + // `fullDeque`'s future extent is much smaller than 30 s, so this + // is mostly a safety net.) + while (!pastAltSamples_.empty() && + pastAltSamples_.back().ts() > targetTs + PRUNE_HORIZON_S) + { + pastAltSamples_.pop_back(); + } + + if (pastAltSamples_.empty()) { + // Nothing to regress against. Fall back to the freshest + // available value: deque front if any, else terrain. + if (!fullDeque.empty()) + return fullDeque.front().alt_m(); + return terrainAlt_m; + } + if (pastAltSamples_.size() == 1) + return pastAltSamples_.front().alt_m(); + + // ----------------------------------------------------------------- + // Smoothing strategy: Gaussian-weighted local linear regression. + // + // Why an *approximating* fit rather than an *interpolating* spline: + // ADS-B reports altitude in 25 ft quantization steps, and feed + // slots arrive at irregular cadence (1–5 s between samples). + // The per-segment slope is therefore *jagged by construction* — + // a single steady climb at 2500 fpm shows up as alternating + // 2000-fpm and 3400-fpm legs whenever the quantization boundary + // straddles a sample interval. An interpolating spline (PCHIP or + // otherwise) must pass through every data point, so the + // quantization-induced slope variation is faithfully reproduced + // as visible "kinks" in the rendered climb every 4–6 s. That was + // the user's "100 ft up every few seconds" symptom. + // + // Local linear regression instead fits a *trend line* through the + // window of nearby samples and renders the aircraft along that + // trend. The 25 ft quantization noise is averaged out; the + // rendered altitude moves at the *mean* climb rate of the window + // rather than the instantaneous (quantized) per-leg slope. + // + // Weights are Gaussian in (targetTs − ts), σ = 3 s: + // - As targetTs advances and slots fall outside ±2σ, their + // weight drops smoothly toward zero — no discontinuity when + // a slot leaves the effective window. This is the key + // reason for the Gaussian weighting: a hard-edged window + // would introduce a step every time a sample left it. + // - σ = 3 s sets the smoothing scale: a few seconds wide enough + // to span typical sample intervals and average out + // quantization noise, but narrow enough that the rendered + // altitude tracks genuine climb-rate changes with under-1 s + // lag (e.g. when transitioning from initial climb to cruise + // climb at the gear-up altitude). + // + // Gaussian-weighted local linear regression in (ts, alt) over the + // archived sample list. The fitted line evaluated at `targetTs` is + // the rendered altitude. + // + // Why the archive (`pastAltSamples_`) and not `fullDeque` directly: + // `fullDeque` keeps at most ONE past slot at a time (the loop in + // LTFlightData::CalcNextPos pops slots as soon as `posDeque[1]` + // slides into the past). That single past sample has near-unit + // Gaussian weight at `targetTs`. The frame it pops, the weight- + // ed mean recomputes WITHOUT it in a single step, and for a + // climbing aircraft (where the just-popped slot was the lowest- + // altitude one) the mean visibly jumps UP — that is exactly the + // "instant 100 ft up" symptom the user reported despite the + // smoothing. The archive does not pop, so the same sample + // remains in the regression with its Gaussian weight smoothly + // decaying to zero over many frames as `targetTs` advances past + // it. No more discrete jumps at the deque-pop boundary. + // + // Why σ = 5 s: ADS-B altitude quantization (25 ft) plus typical + // 1–5 s slot cadence means individual per-leg slopes can fluctuate + // wildly even on a steady climb. σ = 5 s spans ~10–15 effective + // samples in the ±2σ band — enough to average out the per-leg + // jitter — while keeping the lag relative to real altitude + // changes under ~1 s (visible climb-rate transitions still come + // through promptly). + // + // Why we work in *relative* time (t − targetTs) rather than raw + // epoch seconds: timestamps are ~1.78e9, so t·t is ~3.16e19. The + // textbook variance formula Σw·t² − sumW·tMean² then subtracts + // two numbers of order 3e19 to get a result of order 10², and a + // 64-bit double has only ~16 significant digits — most of the + // result is lost to catastrophic cancellation. Working in + // (t − targetTs), all values stay in the ±30 s range, the + // cancellation goes away, and the slope/intercept are computed + // accurately. After the shift the query point lives at t_rel=0, + // so the regression's prediction at `targetTs` is simply + // aMean − slope · tMean_rel. + constexpr double SMOOTH_SIGMA_S = 5.0; + const double invTwoSigmaSq = 1.0 / (2.0 * SMOOTH_SIGMA_S * SMOOTH_SIGMA_S); + + double sumW = 0.0; + double sumWT = 0.0; // Σ w · (t − targetTs) + double sumWA = 0.0; + double sumWTT = 0.0; // Σ w · (t − targetTs)² + double sumWTA = 0.0; // Σ w · (t − targetTs) · a + for (const auto& p : pastAltSamples_) { + const double t_rel = p.ts() - targetTs; + const double a = p.alt_m(); + const double w = std::exp(-t_rel * t_rel * invTwoSigmaSq); + sumW += w; + sumWT += w * t_rel; + sumWA += w * a; + sumWTT += w * t_rel * t_rel; + sumWTA += w * t_rel * a; + } + // Degenerate case: zero combined weight (all samples extremely + // far from targetTs even after pruning). Fall back to the nearest + // archived sample's altitude. + if (sumW <= 0.0) { + const positionTy* pBest = &pastAltSamples_.front(); + double dtBest = std::abs(pBest->ts() - targetTs); + for (const auto& p : pastAltSamples_) { + const double dt = std::abs(p.ts() - targetTs); + if (dt < dtBest) { dtBest = dt; pBest = &p; } + } + return pBest->alt_m(); + } + + const double tMean_rel = sumWT / sumW; + const double aMean = sumWA / sumW; + const double varT = sumWTT - sumW * tMean_rel * tMean_rel; + const double slope = (varT > 0.0) + ? ((sumWTA - sumW * tMean_rel * aMean) / varT) + : 0.0; + // Predict at t_rel = 0 (= targetTs). + return aMean - slope * tMean_rel; +} + + // determines terrain altitude via XPLM's Y Probe bool LTAircraft::YProbe () { diff --git a/Src/LTApt.cpp b/Src/LTApt.cpp index 226300c..b182c0f 100644 --- a/Src/LTApt.cpp +++ b/Src/LTApt.cpp @@ -2723,6 +2723,17 @@ positionTy LTAptFindStartupLoc (const positionTy& pos, double maxDist, double* outDist) { + // Airport layout not (yet / currently) loaded? While `AsyncReadApt` + // is running, gmapApt is purged-then-half-rebuilt, so a lookup here + // would match the wrong startup location or none. Refuse until the + // layout is reliably available. (ProcessParkedAcBuffer is already + // gated upstream; this also guards the Synthetic channel's call site + // and any lookup that races a mid-flight reload.) + if (!LTAptAvailable()) { + if (outDist) *outDist = NAN; + return positionTy(); + } + // Access to the list of airports is guarded by a lock std::unique_lock lock(mtxGMapApt, dataRefs.IsXPThread() ? @@ -2763,7 +2774,17 @@ bool LTAptSnap (LTFlightData& fd, dequePositionTy::iterator& posIter, // Configured off? if (dataRefs.GetFdSnapTaxiDist_m() <= 0) return false; - + + // Airport layout not (yet / currently) loaded? + // `AsyncReadApt` first PURGES airports from gmapApt and then re-adds + // them one at a time, releasing the lock between each — so while a + // load is in progress the map is half-built. Snapping against that + // matches positions to the wrong taxiway/startup location (or none), + // and the wrong heading then sticks. `bAptAvailable` is false for the + // whole load window; refuse to snap until it is reliably back. + if (!LTAptAvailable()) + return false; + // Access to the list of airports is guarded by a lock std::unique_lock lock(mtxGMapApt, dataRefs.IsXPThread() ? diff --git a/Src/LTChannel.cpp b/Src/LTChannel.cpp index 4577c7a..2554cb4 100644 --- a/Src/LTChannel.cpp +++ b/Src/LTChannel.cpp @@ -603,7 +603,41 @@ bool LTOnlineChannel::InitCurl () curl_easy_setopt(pCurl, CURLOPT_WRITEFUNCTION, LTOnlineChannel::ReceiveData); curl_easy_setopt(pCurl, CURLOPT_WRITEDATA, this); curl_easy_setopt(pCurl, CURLOPT_USERAGENT, HTTP_USER_AGENT); - + + // Connection-handling for short-interval polling. + // + // The RealTraffic channel (and any other channel polled more often + // than ~30 s) was producing curl error 55 / "Connection died, tried + // N times before giving up" at roughly 30 s intervals — symptomatic + // of the kept-alive HTTP connection being recycled mid-flight by + // *something* in the network path (an upstream load balancer at the + // server, a NAT/firewall idle-drop timer, or curl's own connection + // cache hitting an internal age limit). TCP keepalive at the OS + // level (CURLOPT_TCP_KEEPALIVE + KEEPIDLE + KEEPINTVL) was tried + // first but did NOT prevent the symptom, so whatever is recycling + // the connection is doing so actively rather than as a passive + // idle-timer expiry. Keepalive probes don't help when the other + // side is sending RST or FIN on a schedule of its own. + // + // Definitive fix: forbid connection reuse entirely (FORBID_REUSE). + // Every request opens a fresh TCP+TLS connection and closes it + // after the response is received. No kept-alive socket ever sits + // around long enough to go stale. The cost is a TLS handshake on + // every request — about 100-300 ms with modern TLS 1.3 session + // resumption — which is fine at the 2 s polling cadence we use. + // + // The keepalive options remain set as a defensive measure for any + // edge case where reuse still happens (curl can in principle hold + // a connection across the boundary between two transfers even with + // FORBID_REUSE set on the *previous* one if FORBID_REUSE is only + // on the *current* request setup; the option is per-handle but + // applies to the transfer-just-completed). Belt-and-braces — no + // harm in setting both. + curl_easy_setopt(pCurl, CURLOPT_TCP_KEEPALIVE, 1L); + curl_easy_setopt(pCurl, CURLOPT_TCP_KEEPIDLE, 20L); + curl_easy_setopt(pCurl, CURLOPT_TCP_KEEPINTVL, 10L); + curl_easy_setopt(pCurl, CURLOPT_FORBID_REUSE, 1L); + // success return true; } diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index 0e346ab..16378c4 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -848,6 +848,31 @@ void LTFlightData::SnapToTaxiways (bool& bChanged) statData.isGrndVehicle() || // ground vehicle (pAc && pAc->IsGroundVehicle())) return; + + // Skip snap-to-taxiway entirely while the aircraft is in ground-holding. + // + // bGroundHolding is set by AddNewPos after a sustained stationary streak + // (see GND_HOLDING_TIMEOUT_S). It is our positive assertion that this + // aircraft is parked. Real-feed data for parked aircraft can occasionally + // produce isolated large position jumps (observed: ACA34 at YSSY, 105 m + // jump while the RT app showed the aircraft stationary). Such jumps + // exceed our 15 m trivial-drop threshold and end up in posDeque, but + // they are almost always feed glitches rather than real motion. + // + // If we let SnapToTaxiways run on a glitched 100m+ jump, it computes a + // shortest path through the airport's taxi graph and inserts a sequence + // of intermediate waypoints with NaN heading. CalcHeading then derives + // heading from the vector between those synthesized waypoints — which + // reflects the taxiway geometry, not the aircraft's nose direction — + // and the rendered aircraft visually dances through the phantom path. + // + // By suppressing snap during holding, we let the glitched jump pass + // through the deque as a single linear interpolation (a one-time visual + // wobble at worst, no waypoint procession). When the aircraft genuinely + // begins to taxi, AddNewPos's GND_HOLDING_EXIT_CONSEC counter clears + // bGroundHolding and snap-to-taxiway resumes for subsequent slots. + if (bGroundHolding) + return; // Loop over position in the deque dequePositionTy::iterator iter = posDeque.begin(); @@ -858,11 +883,39 @@ void LTFlightData::SnapToTaxiways (bool& bChanged) positionTy& pos = *iter; if (pos.IsOnGnd() && !pos.IsPostProcessed()) { + // Run the EHS-staleness cross-check (and the rest of the + // on-ground heading filter chain) on this slot BEFORE + // handing it to LTAptSnap. + // + // Why: `LTAptSnap` uses `pos.heading()` to decide which + // direction along a taxi edge to route the aircraft (see + // TaxiEdge::startByHeading / endByHeading in LTApt.cpp). + // The feed-supplied heading comes from Mode S Enhanced + // Surveillance, which updates roughly every 10 s and lags + // during turns. If snap reads a stale value the synthesised + // taxi path can be routed BACKWARD along the edge — the + // rendered aircraft visibly moves the wrong way along its + // taxiway between feed samples. Observed for DAL973 and + // RPA5716 at YSSY. + // + // `CalcHeading` (since commit d861869) applies the feed-vs- + // track cross-check that catches exactly this case: if the + // feed heading disagrees with the actual motion track by + // 30-150° it falls through to the track-derived value + // instead. Running it here means snap sees the corrected + // heading and routes the right way. + // + // The existing post-snap CalcHeading loop in CalcNextPos + // remains responsible for filling in the heading of the + // intermediate waypoints that snap itself synthesises + // (those are inserted with heading=NaN). + CalcHeading(iter); + // Try snapping to a rwy or taxiway if (LTAptSnap(*this, iter, true)) bChanged = true; } // non-artificial ground position - + // move on to next ++iter; } // while all posDeque positions @@ -1472,13 +1525,716 @@ void LTFlightData::TriggerCalcNewPos ( double simTime ) // flight data. void LTFlightData::CalcHeading (dequePositionTy::iterator it) { - // skip any fiddling with the heading in case it is fixed - if (it->f.bHeadFixed) - return; - // access guarded by a mutex + // + // NOTE: the `bHeadFixed` early-return is intentionally NOT here. + // The pushback state machine below mutates per-flight persistent + // state (`pbState`, `pbHeldNose`, `bGateParked`) that must be + // updated consistently across deque re-evaluations performed by + // `CalcNextPos` (the `bChanged` recompute loop in CalcNextPos + // re-runs CalcHeading on every slot in posDeque). If we early- + // return on `bHeadFixed`, the state machine misses the state + // transitions encoded in already-overridden slots and later slots + // see the wrong `pbState`. The `bHeadFixed` guard is therefore + // moved AFTER the pushback section: the state machine always + // runs and updates state, but the override block only WRITES the + // heading on slots whose heading has not already been fixed. std::lock_guard lock (dataAccessMutex); + // ---------------------------------------------------------------------- + // Pushback state machine (simplified). + // + // Premise: we know an aircraft is parked at a gate when it has had a + // SPOS_STARTUP slot in its deque (the `bGateParked` flag is set in + // AddNewPos when such a slot is added). When a gate-parked aircraft + // starts to move, we assume it is being pushed back. The push remains + // active until the aircraft becomes stationary AND then resumes + // motion in the opposite direction (i.e. forward, taxi). While in + // pushback the rendered heading is forced to `track + 180°` so the + // tail leads the direction of motion — naturally handling rotating + // pushes because the nose is recomputed every motion slot. + // + // States: + // PB_NONE : not in pushback. The only way to enter is from + // `bGateParked && bMotion`. Falls through to normal + // heading logic. + // PB_ACTIVE : being pushed. Heading override active. + // PB_PAUSED : was being pushed, currently stationary. Heading + // held at `pbHeldNose`. On next motion slot: + // - motion forward of held nose → exit to PB_NONE + // - motion still against held nose → back to PB_ACTIVE + // ---------------------------------------------------------------------- + if (!it->IsOnGnd()) { + // Airborne — any pushback is long over; clear the state defensively. + pbState = PB_NONE; + pbHeldNose = NAN; + pbUseFeedNose = false; + bGateParked = false; + } + else { + // Resolve a predecessor position for the PB state machine. + // + // Normal case: the slot before `it` in posDeque is the + // predecessor. But after a long stationary period at a gate, + // CalcNextPos has drained the deque (each rendered position is + // popped from the front), and `it` arrives as the ONLY element + // in posDeque (or at posDeque.begin()) when GATE_RELEASE finally + // lets a slot through. In that state there is no in-deque + // predecessor — the state machine would skip its entry test + // entirely and fall through to the FEEDHDG cross-check, which + // for a post-rotation aircraft mistakes "feedHdg ≈ track" for + // forward taxi and assigns the motion-direction heading, + // rendering the aircraft facing forward into the push. + // + // Fix: when there is no in-deque predecessor, fall back to the + // aircraft's last-rendered position via `pAc->GetToPos()`. That + // is the parked position with the parked heading — exactly the + // reference we need to detect "motion is rearward of the held + // nose" and enter PB_ACTIVE on the first accepted slot. + const positionTy* pPrePos = nullptr; + if (it != posDeque.cbegin()) { + pPrePos = &*std::prev(it); + } else if (pAc) { + const positionTy& toPos = pAc->GetToPos(); + if (toPos.isNormal(true) && toPos.IsOnGnd() && + !std::isnan(toPos.ts()) && it->ts() > toPos.ts()) + { + pPrePos = &toPos; + } + } + + if (pPrePos) { + const positionTy& prePosPb = *pPrePos; + if (prePosPb.IsOnGnd() && it->ts() > prePosPb.ts()) { + // ------------------------------------------------------------- + // Robust 4-slot track filter. + // + // At slow ground speeds (a few knots), the 1Hz GPS deltas are + // dominated by per-fix noise (~3 m typical). A single 2-point + // delta can swing the derived track angle by tens of degrees, + // which then drives the rendered nose (track + 180° during a + // pushback) into visible spinning even when the aircraft is + // moving in a steady direction. + // + // Procedure (per user direction): + // 1. Collect the latest 4 ground positions ending at *it. + // 2. Convert to a local east/north metres frame around the + // newest sample (Lat2Dist / Lon2Dist). + // 3. Compute the equal-weight centroid of the 4 points. + // 4. Reject the 2 points with the largest residual from + // the centroid as outliers. + // 5. Derive the motion vector from the remaining 2 inliers, + // oldest-to-newest in time. The angle of that vector is + // the filtered track; its length is the filtered chord. + // + // The aircraft's facing direction is derived elsewhere from + // this track (track + 180° during a push, see computeNose() + // below). So both motion and rendered nose come from the + // same robust estimate. + // + // If fewer than 4 ground positions are available (early in + // a flight, after a non-ground gap), fall back to the simple + // 2-point between() vector. + // ------------------------------------------------------------- + vectorTy pbTrack = prePosPb.between(*it); + { + std::array samples; + size_t n = 0; + auto walk = it; + while (n < samples.size()) { + if (!walk->IsOnGnd()) break; + samples[n++] = walk; + if (walk == posDeque.cbegin()) break; + if (n < samples.size()) --walk; + } + if (n == samples.size()) { + // samples[0] is newest, samples[3] is oldest — reverse + // to oldest-to-newest temporal order. + std::reverse(samples.begin(), samples.end()); + + // Local east/north metres around the newest sample. + const positionTy& refPos = *samples[3]; + std::array, 4> xy; + for (size_t i = 0; i < 4; ++i) { + xy[i].first = Lon2Dist(samples[i]->lon() - refPos.lon(), + refPos.lat()); + xy[i].second = Lat2Dist(samples[i]->lat() - refPos.lat()); + } + + // Equal-weight centroid. Equal weights are deliberate: + // a recency-weighted mean would bias the centroid + // toward recent positions and then preferentially flag + // older positions as outliers even when they aren't + // noisy — the wrong thing for outlier detection. + double cx = 0.0, cy = 0.0; + for (const auto& p : xy) { cx += p.first; cy += p.second; } + cx *= 0.25; cy *= 0.25; + + // Residual magnitude per sample. + std::array, 4> resid; + for (size_t i = 0; i < 4; ++i) { + const double dx = xy[i].first - cx; + const double dy = xy[i].second - cy; + resid[i] = { std::sqrt(dx*dx + dy*dy), i }; + } + // Sort residuals descending; first two are the outliers. + std::sort(resid.begin(), resid.end(), + [](const std::pair& a, + const std::pair& b) + { return a.first > b.first; }); + const size_t out1 = resid[0].second; + const size_t out2 = resid[1].second; + + // Inliers in original (oldest→newest) order. + size_t i0 = SIZE_MAX, i1 = SIZE_MAX; + for (size_t i = 0; i < 4; ++i) { + if (i == out1 || i == out2) continue; + if (i0 == SIZE_MAX) i0 = i; + else i1 = i; + } + + if (i0 != SIZE_MAX && i1 != SIZE_MAX) { + const double dx = xy[i1].first - xy[i0].first; + const double dy = xy[i1].second - xy[i0].second; + const double dist = std::sqrt(dx*dx + dy*dy); + if (dist >= SIMILAR_POS_DIST) { + // Replace the 2-point estimate with the + // filtered chord. CoordAngle returns a bearing + // in degrees (0..360, north=0, clockwise) — the + // same convention as positionTy::between(). + pbTrack.angle = CoordAngle(samples[i0]->lat(), + samples[i0]->lon(), + samples[i1]->lat(), + samples[i1]->lon()); + pbTrack.dist = dist; + } + // If the filtered chord is below noise floor, + // leave the 2-point pbTrack alone; bMotion below + // will then treat it as stationary. + } + } + } + + const double pbGs_kt = prePosPb.speed_kt(*it); + // Capture the feed-reported heading on THIS slot BEFORE we + // override it. The state machine's nose-source decision (see + // `pbUseFeedNose`) reads this value at PB_NONE→PB_ACTIVE + // entry, and PB_ACTIVE refreshes pbHeldNose from this same + // value on every motion slot when the feed is the chosen + // source. Reading from `it->heading()` AFTER the override + // block would observe our own previously-written value, not + // the feed's actual report. + const double pbFeedHdg = it->heading(); + + // bMotion: is this slot's motion meaningful for state + // machine purposes? Gate on groundspeed and a defined + // track angle ONLY — do NOT require per-slot chord length + // ≥ SIMILAR_POS_DIST. A real pushback at 1–3 kt produces + // 3–6 m of motion per 3–5 s slot, which is below 7 m and + // would falsely flip the machine to PAUSED on every slot + // even though motion is continuous. The looser test keeps + // the machine in ACTIVE for the whole push so the nose + // is refreshed each slot. + // + // Use `PB_MOTION_GS_KT` (0.3 kt), NOT the global + // `GND_STATIONARY_GS_KT` (1.5 kt). Real pushbacks roll at + // 0.4–1.4 kt — entirely below the global stationary + // threshold — so gating on `gs > GND_STATIONARY_GS_KT` + // here would prevent the state machine from EVER entering + // PB_ACTIVE for a real slow push. The aircraft would never + // get a heading override, the renderer's spline branch + // would fall back to the motion tangent (≈ direction of + // travel), and the rendered nose would face FORWARD into + // the push instead of tail-first. Combined with the + // upstream distance-based GATE_HOLD in AddNewPos + // (`GATE_HOLD_MIN_ACCEPT_M`), there is no realistic noise + // path that can trip the state machine at 0.3 kt — any + // noise that produces ≥0.3 kt for one slot is filtered out + // upstream by the 30 m gate. + const bool bMotion = !std::isnan(pbGs_kt) && + pbGs_kt > PB_MOTION_GS_KT && + !std::isnan(pbTrack.angle); + + // Nose source for the entire push: either the FEED heading + // (when the feed is reporting true compass nose — rotates + // accurately during a rotating push) or `track + 180°` (when + // the feed is reporting course-over-ground — useless as a + // nose reference during a push because COG ≈ motion direction + // ≈ 180° away from where the nose is actually pointing). + // + // The choice is made ONCE at PB_NONE→PB_ACTIVE entry by + // comparing the first-motion feedHdg against the prior parked + // heading — see the entry block below — and persisted in + // `pbUseFeedNose` for the rest of the push. NOT re-evaluated + // mid-push; per-slot re-evaluation produced the visible + // spinning bug in earlier revisions when the source flipped + // every slot. + auto computeNose = [&]() -> double { + if (pbUseFeedNose && !std::isnan(pbFeedHdg)) + return pbFeedHdg; + return HeadingNormalize(pbTrack.angle + 180.0); + }; + + // --------------------------------------------------------- + // Safety-valve: emergency exit on excessive groundspeed. + // + // Real pushbacks roll at 1-5 kt — a tug cannot move a 60+ + // ton airframe faster than that. A slot with gs above + // PB_MAX_GS_KT (10 kt) under PB_ACTIVE/PB_PAUSED is + // definitively taxi, not pushback, and the state machine + // is wrong to still be active. + // + // This valve catches the case where the held nose was + // chosen incorrectly at PB_NONE→PB_ACTIVE entry (e.g. + // TRACK+180 was picked because feedHdg disagreed with + // parkedHdg, but the feed was actually right because the + // aircraft had already rotated during the GATE_HOLD + // suppression window). With a wrong held nose, the + // directional exit test in PB_PAUSED reads against the + // wrong reference and the state machine stays "ACTIVE / + // PAUSED" indefinitely while the real aircraft taxis out. + // Observed for UAL1240 (28 kt taxi still in PB_ACTIVE) and + // JIA5575 (8 kt taxi-out, never exited). + // + // Force-exit lets the normal heading logic (FEEDHDG cross- + // check, position-derived heading) take over. Visually a + // small heading jump may occur at the moment of exit but + // that is preferable to several minutes of tail-first + // rendering. + if ((pbState == PB_ACTIVE || pbState == PB_PAUSED) && + !std::isnan(pbGs_kt) && + pbGs_kt > PB_MAX_GS_KT) + { + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "PUSHBACK_DIAG %s FORCE_EXIT gs=%.2fkt > %.1fkt" + " — exiting %s to PB_NONE", + key().c_str(), + pbGs_kt, PB_MAX_GS_KT, + pbState == PB_ACTIVE ? "ACTIVE" : "PAUSED"); + } + pbState = PB_NONE; + pbHeldNose = NAN; + pbUseFeedNose = false; + bGateParked = false; + // Fall through to the switch below; PB_NONE branch + // will simply do nothing on this slot (no entry test + // because bGateParked is now false), and the rest of + // CalcHeading runs normally. + } + + switch (pbState) { + case PB_NONE: + // Enter pushback only when: + // (a) the aircraft was parked at a gate + // (bGateParked, set in AddNewPos for slots + // carrying SPOS_STARTUP or for aircraft + // whose apt.dat lookup found a startup-loc + // within GATE_DETECT_MAX_DIST_M when + // bGroundHolding flipped true), AND + // (b) this slot carries real motion (gs above + // stationary threshold; no chord requirement), + // AND + // (c) the motion is REARWARD of the prior held + // heading — i.e. moving in the opposite + // half-plane to where the nose was last facing. + // + // Condition (c) is a one-shot entry test. Without it, + // a forward taxi resuming from a brief stop near an + // apt.dat startup-loc would false-positive as a push. + // 90° splits the half-planes. + // + // On entry we ALSO decide the nose source for the + // entire push. If the first-motion feed heading is + // within PB_FEED_NOSE_AGREE_DEG of the prior parked + // heading, the feed is reporting true compass nose + // (rotates accurately during the push) — lock onto + // feed for the duration. Otherwise the feed is + // course-over-ground; derive the nose from track + // instead. + if (bGateParked && bMotion && + !std::isnan(prePosPb.heading()) && + std::abs(HeadingDiff(prePosPb.heading(), pbTrack.angle)) + > PB_EXIT_FORWARD_DIFF_DEG) + { + pbState = PB_ACTIVE; + pbUseFeedNose = + !std::isnan(pbFeedHdg) && + std::abs(HeadingDiff(prePosPb.heading(), + pbFeedHdg)) + <= PB_FEED_NOSE_AGREE_DEG; + pbHeldNose = computeNose(); + + // Retroactively pin the predecessor slot's heading + // so the renderer interpolates across the entry + // leg instead of using the motion tangent. + // + // Why this matters: the ground-rendering spline in + // LTAircraft::CalcAcPos has two heading branches + // (see `LTAircraft.cpp:2143`): + // * `from.f.bHeadFixed == true` → interpolate + // `from.heading()` and `to.heading()` across + // the leg (preserve slot headings). + // * `from.f.bHeadFixed == false` → use the + // spline tangent, i.e. the direction of motion. + // + // Slots written by the pushback override block + // below already have `bHeadFixed=true`. But the + // PREDECESSOR slot — the last parked slot — was + // produced by the live-feed path that DOES NOT + // set `bHeadFixed`. So on the leg from "last + // parked slot" to "first pushback slot", the + // renderer falls into the spline-tangent branch + // and points the rendered nose along the motion + // direction (≈ 180° opposite to where we want + // it). The aircraft visually pivots to face the + // push direction at the gate — the exact symptom + // we are trying to avoid. + // + // The fix: stamp `bHeadFixed=true` onto prePosPb. + // Its heading value is already the parked heading + // (unchanged), so this only flips the flag; the + // renderer then takes the interpolation branch + // and the rendered nose swings smoothly from the + // parked heading to `pbHeldNose` over the leg. + // + // Guard against the `pPrePos` fallback case + // (when prePos was sourced from pAc->GetToPos() + // because posDeque had no in-deque predecessor). + // In that branch `std::prev(it)` would be UB + // (walks past `posDeque.cbegin()`). The renderer- + // side change in LTAircraft::CalcAcPos that also + // checks `to.f.bHeadFixed` covers this case from + // the other direction. + if (it != posDeque.cbegin()) + std::prev(it)->f.bHeadFixed = true; + + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "PUSHBACK_DIAG %s ENTRY src=%s parkedHdg=%.1f" + " firstFeedHdg=%.1f firstTrack=%.1f" + " heldNose=%.1f (predBHF pinned)", + key().c_str(), + pbUseFeedNose ? "FEED" : "TRACK+180", + prePosPb.heading(), + std::isnan(pbFeedHdg) ? -1.0 : pbFeedHdg, + pbTrack.angle, + pbHeldNose); + } + } else if (bGateParked && bMotion) { + // Forward motion from a gate-parked aircraft — + // this is not a push. Clear bGateParked so the + // permissive flag doesn't keep triggering the + // entry test on every subsequent motion slot. + bGateParked = false; + } + break; + + case PB_ACTIVE: + if (bMotion) { + // Refresh nose on every motion slot. The source + // (feed or track+180°) was locked at entry and + // does not change here — only the underlying + // value evolves with new feed/motion data. + pbHeldNose = computeNose(); + } else { + // Truly stationary slot (gs below threshold). + // Don't clear pbHeldNose — it is the reference + // for the next motion-direction test. + pbState = PB_PAUSED; + } + break; + + case PB_PAUSED: + if (bMotion) { + // Direction-of-resumed-motion test. During the + // prior push the motion was `pbHeldNose ± 180°` + // (tail-leading). "Opposite direction now" means + // new motion track is aligned with `pbHeldNose` + // (nose-leading taxi). Within 90° of pbHeldNose + // → forward taxi, EXIT. Otherwise the tug is + // still pushing → back to ACTIVE. + if (!std::isnan(pbHeldNose) && + std::abs(HeadingDiff(pbHeldNose, pbTrack.angle)) + < PB_EXIT_FORWARD_DIFF_DEG) + { + pbState = PB_NONE; + pbHeldNose = NAN; + pbUseFeedNose = false; + bGateParked = false; + // Fall through to normal heading logic below. + } else { + pbState = PB_ACTIVE; + pbHeldNose = computeNose(); + } + } + break; + } + + // Heading override while in PB_ACTIVE or PB_PAUSED. + if (pbState == PB_ACTIVE || pbState == PB_PAUSED) { + // Only WRITE the heading on slots that haven't already + // had their heading fixed. On a deque re-evaluation + // (CalcNextPos recompute loop) the state machine above + // has already run and updated `pbState`; rewriting an + // already-fixed heading would either be a no-op (same + // value) or worse, an inconsistency if the state + // machine now disagrees with the prior decision. + if (!it->f.bHeadFixed) { + // Always write pbHeldNose — it has just been refreshed + // by computeNose() (feed nose if it disagrees with + // track, otherwise derived track+180°). This keeps + // rotating pushbacks visually correct: the body + // tracks the actual nose direction reported by the + // feed, not the position-delta chord which is wrong + // when the aircraft is rotating through the push. + if (!std::isnan(pbHeldNose)) { + it->heading() = pbHeldNose; + } else if (!std::isnan(prePosPb.heading())) { + it->heading() = prePosPb.heading(); + } + it->f.bHeadFixed = true; + + // Per-slot diagnostic line — emitted only on the + // initial override pass (when bHeadFixed flips + // false→true), not on every re-eval. + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "PUSHBACK_DIAG %s state=%s gs=%.2fkt track=%.1f" + " heldNose=%.1f assigned=%.1f bGateParked=%d", + key().c_str(), + pbState == PB_ACTIVE ? "ACTIVE" : "PAUSED", + std::isnan(pbGs_kt) ? -1.0 : pbGs_kt, + std::isnan(pbTrack.angle) ? -1.0 : pbTrack.angle, + std::isnan(pbHeldNose) ? -1.0 : pbHeldNose, + it->heading(), + int(bGateParked)); + } + } + return; + } + } + } // close if (pPrePos) + } + + // Honour the per-slot `bHeadFixed` flag for everything below this + // point (feed-heading, position-derived, etc.). It is intentional + // that the pushback section above runs BEFORE this gate so that + // its persistent state machine stays consistent across deque + // re-evaluations — see the note at the top of this function. + if (it->f.bHeadFixed) + return; + + // ---------------------------------------------------------------------- + // Trust feed-provided heading at slow ground speed (with staleness check). + // + // The position-from-track logic below derives heading by taking + // atan2 over consecutive lat/lon pairs. At parked, slow-taxi, and + // pushback speeds that math produces wildly wrong answers: + // * parked aircraft: positional jitter IS the apparent motion vector + // * pushback: the track points OPPOSITE to the nose (tail-first), + // so atan2 gives a heading 180° off + // + // RealTraffic and most ADS-B feeds provide an explicit heading field + // sourced from Mode S Enhanced Surveillance (EHS) — the aircraft's + // own reported nose direction. We prefer it for the slow-ground cases. + // + // *Staleness*: EHS heading typically updates only every 10 s, and is + // unavailable entirely in regions without enhanced interrogation + // coverage. Between EHS updates the feed value is held constant by + // the receiver. During a taxi turn at 5–10 kn that 10 s freshness + // window is long enough for the aircraft to change direction by 60° + // or more — if we blindly trust the held value the rendered nose + // visibly lags the actual motion and the aircraft appears to slide + // sideways through the turn. + // + // Defence: cross-check the feed heading against the *current* track + // (bearing from the predecessor slot to this one). Three regimes, + // gated by `GND_FEED_TRACK_AGREE_DEG` (see Constants.h): + // * |Δ| < 30°: feed agrees with track — fresh, or aircraft is + // going straight. Trust feed. + // * 30° ≤ |Δ| ≤ 150°: feed has lagged during a turn. Fall through + // to the position-derived branch — track wins. + // * |Δ| > 150°: track is roughly opposite the feed — pushback. + // Trust feed (nose stays at gate). + // + // Limitations: + // * Feed heading exactly 0.0 may be a channel "no data" sentinel + // rather than a real reading. We accept that risk — the agree- + // window check filters out the worst cases (a stale 0.0 paired + // with non-zero motion will fall outside the agree window). + // * Above `GND_USE_FEED_HEADING_MAX_KT` (10 kn) the track-derived + // heading is always preferred (real taxi / rollout / takeoff). + // ---------------------------------------------------------------------- + if (it->IsOnGnd() && !std::isnan(it->heading())) { + // Derive groundspeed + track angle from the predecessor pair when + // possible. A missing predecessor (head of deque) means we have + // no track to cross-check against — feed is the best we have. + double gsDerived_kt = NAN; + double trackAngle = NAN; + if (it != posDeque.cbegin()) { + const positionTy& prePos = *std::prev(it); + if (prePos.IsOnGnd() && it->ts() > prePos.ts()) { + gsDerived_kt = prePos.speed_kt(*it); + // Only compute a track angle when motion is non-trivial; + // for jitter-only displacement the bearing is meaningless + // and would force us into the disagreement band by noise + // alone. + if (gsDerived_kt > GND_STATIONARY_GS_KT) { + const vectorTy vec = prePos.between(*it); + trackAngle = vec.angle; + } + } + } + + // Decide whether the feed heading is the right source for this + // slot. The reason string is purely for the diagnostic log line + // that follows — it lets us see WHY a feed value won (or lost) + // when investigating regressions from a Log.txt capture. + bool trustFeed = false; + const char* reason = ""; + if (std::isnan(gsDerived_kt)) { + // No predecessor — no track to compare. Feed is the only + // reliable source we have. + trustFeed = true; + reason = "no predecessor"; + } else if (gsDerived_kt < GND_STATIONARY_GS_KT) { + // Stationary: positional jitter dominates any track we could + // compute, so it would be garbage. Feed value wins. + trustFeed = true; + reason = "stationary"; + } else if (gsDerived_kt < GND_USE_FEED_HEADING_MAX_KT) { + // Slow motion in the band where feed could be used — apply + // the cross-check against the track angle. + if (!std::isnan(trackAngle)) { + const double delta = + std::abs(HeadingDiff(it->heading(), trackAngle)); + if (delta < GND_FEED_TRACK_AGREE_DEG) { + trustFeed = true; + reason = "agrees with track"; + } else if (delta > (180.0 - GND_FEED_TRACK_AGREE_DEG)) { + trustFeed = true; + reason = "track reversed (pushback)"; + } + // else: feed has gone stale during a turn — fall through + // to the position-derived heading branch below. + } else { + // No track to compare (shouldn't happen if gsDerived_kt + // is non-NaN and above stationary, but be defensive). + trustFeed = true; + reason = "no track to compare"; + } + } + // Above GND_USE_FEED_HEADING_MAX_KT we never trust feed — the + // outer-loop track-heading regime in LTAircraft::CalcAcPos owns + // that range. Leave `trustFeed=false` so we fall through. + + if (trustFeed && dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_FEEDHDG %s ts=%.1f feedHdg=%.1f gs=%.2fkt" + " track=%.1f (%s)", + key().c_str(), it->ts(), it->heading(), + std::isnan(gsDerived_kt) ? 0.0 : gsDerived_kt, + std::isnan(trackAngle) ? -1.0 : trackAngle, + reason); + return; + } + } + + // ---------------------------------------------------------------------- + // Ground-stationary freeze. + // + // Purpose: when an aircraft is on the ground and not really moving, the + // raw position samples from a 1 Hz feed carry a few metres of jitter. + // If we let the normal vector-between-positions math compute a heading + // from that jitter, the rendered nose will swing wildly — the "dance" + // symptom users see at gates and slow taxi. This branch detects the + // stationary case (low derived groundspeed between this slot and at + // least one neighbour) and reuses the previously trusted heading from + // the predecessor in the deque, which has already been filtered by the + // earlier `CalcHeading` calls that produced it. Threshold: + // `GND_STATIONARY_GS_KT` (see `Constants.h` for the rationale). + // ---------------------------------------------------------------------- + if (it->IsOnGnd()) { + // Derived groundspeed FROM the predecessor (if any) to this slot, + // in knots. We use the position-pair speed helper rather than the + // dynamic-data feed value because the feed value is what we are + // trying to filter — the derived value tells us whether this slot + // is "moving" relative to its neighbour regardless of what the feed + // claims. + double gsFromPrev_kt = NAN; + if (it != posDeque.cbegin()) { + const positionTy& prePos = *std::prev(it); + if (prePos.IsOnGnd() && it->ts() > prePos.ts()) + gsFromPrev_kt = prePos.speed_kt(*it); + } + // Derived groundspeed FROM this slot to the successor (if any) + double gsToNext_kt = NAN; + if (std::next(it) != posDeque.cend()) { + const positionTy& nextPos = *std::next(it); + if (nextPos.IsOnGnd() && nextPos.ts() > it->ts()) + gsToNext_kt = it->speed_kt(nextPos); + } + + // Classify each adjacent segment. We require BOTH segments (or the + // only available one at the ends of the deque) to be stationary + // before we lock the heading. Requiring two consecutive zero-ish + // slots avoids reacting to a single isolated tight cluster that + // can occur briefly during normal taxi. + const bool prevStationary = !std::isnan(gsFromPrev_kt) && + gsFromPrev_kt <= GND_STATIONARY_GS_KT; + const bool nextStationary = !std::isnan(gsToNext_kt) && + gsToNext_kt <= GND_STATIONARY_GS_KT; + const bool isolated = std::isnan(gsFromPrev_kt) || + std::isnan(gsToNext_kt); + + // ----- GROUND DIAGNOSTIC LOGGING (tag: GND_DIAG_CHD) ----- + // Captures the per-slot inputs that drive the stationary-freeze + // decision in CalcHeading. Fires unconditionally for every ground + // slot. Search the log for "GND_DIAG_CHD" to filter. + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_CHD %s ts=%.1f gsPrev=%.2fkt gsNext=%.2fkt" + " hdg_in=%.1f prevHdg=%.1f nextHdg=%.1f stationary={p=%d,n=%d,iso=%d}", + key().c_str(), it->ts(), + gsFromPrev_kt, gsToNext_kt, + it->heading(), + it != posDeque.cbegin() ? std::prev(it)->heading() : NAN, + std::next(it) != posDeque.cend() ? std::next(it)->heading() : NAN, + prevStationary ? 1 : 0, + nextStationary ? 1 : 0, + isolated ? 1 : 0); + } + + if ((prevStationary && nextStationary) || + (isolated && (prevStationary || nextStationary))) + { + // Prefer the predecessor's heading — it's the most recent + // value that already passed through this filter chain. + if (it != posDeque.cbegin()) { + const double prevHead = std::prev(it)->heading(); + if (!std::isnan(prevHead)) { + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_FREEZE %s ts=%.1f kept prevHdg=%.1f", + key().c_str(), it->ts(), prevHead); + } + it->heading() = prevHead; + return; + } + } + // No usable predecessor: if the slot already carries a + // heading (e.g., a feed channel like RealTraffic provides + // one directly), keep it — anything is better than the + // jitter-derived value we would otherwise produce. + if (!std::isnan(it->heading())) + return; + // Otherwise fall through to the normal computation below; + // the existing `SIMILAR_POS_DIST` short-circuit will likely + // still kick in and stabilise this slot from the predecessor. + } + } + // vectors to / from the position at "it" vectorTy vecTo, vecFrom; @@ -1546,6 +2302,44 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) it->heading() = 0; } + // ---------------------------------------------------------------------- + // Ground heading hysteresis. + // + // After the heading for this slot has been (re)computed by the logic + // above, snap it back to the predecessor's heading if the difference + // is below `GND_HEADING_HYSTERESIS_DEG`. The reasoning: real-feed + // ADS-B/MLAT data routinely produces sub-degree variations in the + // track-from-pos-delta even when the aircraft is genuinely moving + // in a straight line. Those sub-degree changes do not represent + // physical reality and, if propagated, accumulate frame-by-frame + // into visible nose-wobble at slow ground speeds. The dead-band + // matches the convention used for the rendered heading rate-limit + // in `LTAircraft::CalcAcPos`, so the two layers reinforce each + // other rather than fighting. + // + // We only do this on the ground — in the air, small heading changes + // are usually meaningful (drift, gentle course corrections) and + // suppressing them would make en-route tracks look "stairstepped". + // ---------------------------------------------------------------------- + if (it->IsOnGnd() && it != posDeque.cbegin()) { + const double prevHead = std::prev(it)->heading(); + if (!std::isnan(prevHead) && !std::isnan(it->heading())) { + const double dHead = std::abs(HeadingDiff(prevHead, it->heading())); + // ----- GROUND DIAGNOSTIC LOGGING (tag: GND_DIAG_HYST) - + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_HYST %s ts=%.1f prevHdg=%.1f newHdg=%.1f" + " |delta|=%.2f%s", + key().c_str(), it->ts(), prevHead, it->heading(), dHead, + dHead < GND_HEADING_HYSTERESIS_DEG ? " -> SNAP" : ""); + } + if (dHead < GND_HEADING_HYSTERESIS_DEG) + { + it->heading() = prevHead; + } + } + } + // just as a safeguard...they can't be many situations this triggers, // but we don't want nan values any longer after this if (std::isnan(it->heading())) @@ -1807,6 +2601,321 @@ void LTFlightData::AddNewPos ( positionTy& pos ) LOG_MSG(logDEBUG,DBG_SKIP_NEW_POS_TS,pos.dbgTxt().c_str()); return; } + + // -------------------------------------------------------------- + // Ground holding state machine + trivial-update suppression. + // + // Purpose: an aircraft that has been parked for longer than + // `GND_HOLDING_TIMEOUT_S` should ignore the small-amplitude + // position jitter that real feeds keep producing for a + // stationary target. Once we enter "holding", we silently + // drop incoming slots whose distance from the latest known + // position is below `GND_HOLDING_TRIVIAL_DIST_M` AND whose + // derived groundspeed (from the time/distance pair) is at or + // below `GND_STATIONARY_GS_KT`. That is the data-layer half + // of the dance fix: the rendered aircraft never even sees + // these updates so it cannot react to them. + // + // The state machine is driven entirely by `pos.ts()` (the + // feed-reported wall-clock timestamp), not by sim time — + // that way pause/resume and rate-changes in X-Plane don't + // influence the streak length. + // -------------------------------------------------------------- + const bool bothOnGround = pos.IsOnGnd() && pLatestPos->IsOnGnd(); + const double dtTs = pos.ts() - pLatestPos->ts(); + const double dist_m = pLatestPos->dist(pos); + const double gs_kt = (dtTs > 0) + ? pLatestPos->speed_kt(pos) + : NAN; + const bool isStationary = bothOnGround && + !std::isnan(gs_kt) && + gs_kt <= GND_STATIONARY_GS_KT; + + // ----- GROUND DIAGNOSTIC LOGGING (tag: GND_DIAG_ADD) - + // Unconditional, fires once per on-ground feed update. + // Captures the parameters used by the stationary / holding + // decision so thresholds can be tuned from real data. Search + // the log for "GND_DIAG_ADD" to see only these lines. + // To remove later: delete this block. + if (bothOnGround && dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_ADD %s ts=%.1f dt=%.2fs dist=%.2fm gs=%.2fkt" + " hdg_prev=%.1f hdg_in=%.1f holdingSince=%.1f holding=%d", + key().c_str(), pos.ts(), dtTs, dist_m, gs_kt, + pLatestPos->heading(), pos.heading(), + groundHoldingSinceTs, bGroundHolding ? 1 : 0); + } + + if (isStationary) { + // Stationary slot: reset the "consecutive non-stationary" + // counter — we just saw a moving slot streak interrupted. + groundNonStationaryCnt = 0; + + // Either continue an existing streak or start a fresh one. + // The streak start is the timestamp of the LATEST already- + // known position so the elapsed time below is "how long has + // the aircraft been frozen at this point in space". + if (groundHoldingSinceTs <= 0.0) + groundHoldingSinceTs = pLatestPos->ts(); + + // Promote to holding once the streak exceeds the timeout. + // Threshold lives in `Constants.h` (`GND_HOLDING_TIMEOUT_S`). + if (!bGroundHolding && + (pos.ts() - groundHoldingSinceTs) >= GND_HOLDING_TIMEOUT_S) + { + bGroundHolding = true; + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_HOLDIN %s entering ground-holding" + " (stationary for %.1fs)", + key().c_str(), + pos.ts() - groundHoldingSinceTs); + } + + // ------------------------------------------------------ + // Third gate-detection path (apt.dat startup-locations). + // + // Background: `bGateParked` previously had two sources — + // SPOS_STARTUP slots (from RT's parked-traffic snapshot + // or the apt.dat snap path), and the indiscriminate + // bGroundHolding flag itself (set at line ~2697 below). + // The second source is too permissive: ANY extended + // stationary period flags the aircraft as gate-parked, + // including runway hold-shorts, taxi pauses, and + // maintenance pads. The pushback state machine then + // false-positives forward taxi as a push and renders + // the aircraft tail-first (visible spinning). + // + // Fix: at the moment we promote to holding, run a one- + // shot apt.dat lookup. If a startup-location (gate / + // stand / ramp) is within GATE_DETECT_MAX_DIST_M of the + // held position, the aircraft is really at a gate. + // Set bGateParked = true here; do NOT rely on the + // permissive bGroundHolding-only path below for live- + // tracked aircraft. SPOS_STARTUP slots still set the + // flag through the existing path independently. + // + // Why only at the flip moment: the lookup is O(stands- + // per-airport) on a mutex-guarded structure. Running it + // on every subsequent stationary slot is wasteful, and + // the answer cannot change for a non-moving aircraft. + // If LTApt is not yet available at this exact moment + // (asynchronous reload, far-from-camera airport), + // LTAptFindStartupLoc returns an empty positionTy and + // bGateParked stays false — acceptable, because the + // aircraft was likely not visible to the user either. + // The lookup is performed twice: once with the tight + // GATE_DETECT_MAX_DIST_M threshold (the actual decision + // gate), and once with a much larger probe radius + // (10×) so the diagnostic log can report the distance + // to the *nearest* startup-loc even when the tight + // gate rejects it. That way a log review immediately + // shows "we missed AAL1408 because the closest + // startup-loc was 47 m away" versus "no apt.dat + // startup-locs at this airport at all" — two very + // different failure modes. + // Lookups are performed twice: once with the tight + // GATE_DETECT_MAX_DIST_M threshold (the actual gate), + // once with a 10× probe radius so the diagnostic can + // report the distance to the nearest startup-loc even + // when the tight check rejects it. + // + // IMPORTANT: success/failure is determined by the + // `outDist` parameter, NOT by `positionTy::isNormal()` + // on the returned position. The returned positionTy + // has `ts=NaN, alt=NaN` (gates have no inherent time + // or altitude — the caller supplies those), and + // `isNormal()` requires both to be set. Using + // `isNormal()` as the success signal silently rejects + // EVERY valid startup-loc match. `outDist` is set to + // a finite distance only when a match was found + // within the search radius (see `FindStartupLoc` at + // `Src/LTApt.cpp:1814`), so it is the right gate. + const bool aptAvail = LTAptAvailable(); + double gateDist = NAN; + const positionTy gatePos = + LTAptFindStartupLoc(pos, GATE_DETECT_MAX_DIST_M, + &gateDist); + if (!std::isnan(gateDist) && + gateDist <= GATE_DETECT_MAX_DIST_M) + { + bGateParked = true; + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_GATE %s HIT lat=%.6f lon=%.6f" + " startup-loc at %.1fm (hdg=%.1f)" + " — bGateParked=true", + key().c_str(), + pos.lat(), pos.lon(), + gateDist, gatePos.heading()); + } + } else { + // Tight lookup failed. Probe a wide radius and + // log the position so the failure mode can be + // distinguished (apt unavailable / no airport / + // closest startup-loc just outside threshold). + double probeDist = NAN; + (void)LTAptFindStartupLoc(pos, + GATE_DETECT_MAX_DIST_M * 10.0, + &probeDist); + const char* mode; + if (!aptAvail) mode = "APT_UNAVAIL"; + else if (!std::isnan(probeDist)) mode = "NEAR"; + else mode = "NOAPT"; + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_GATE %s %s lat=%.6f lon=%.6f" + " (tight %.0fm, probe %.0fm: nearest=%.1fm)" + " — bGateParked stays false", + key().c_str(), mode, + pos.lat(), pos.lon(), + GATE_DETECT_MAX_DIST_M, + GATE_DETECT_MAX_DIST_M * 10.0, + std::isnan(probeDist) ? -1.0 : probeDist); + } + } + } + + // While in holding, drop trivial jitter outright. We still + // allow through anything that moves more than the trivial + // distance, because that may signal a genuine push-back or + // taxi start that we must not miss. + // + // EXCEPTION: never drop a position flagged SPOS_STARTUP. + // Those are not feed jitter — they are *intentional* + // placements: the RealTraffic parked-feed bootstrap seeds + // (4 identical positions used to bring a parked aircraft + // into existence) and the Synthetic channel's keep-alive + // re-feeds (which hold an adopted parked aircraft alive). + // Both arrive at dist≈0 from the held position, so the + // plain trivial-drop would eat them — starving the parked + // aircraft of the very positions it needs to exist and to + // persist, which is exactly the "no parked traffic at all" + // symptom. Raw jittery LIVE-feed positions are NOT + // SPOS_STARTUP at this point (taxiway snapping runs later + // in the pipeline), so genuine jitter is still suppressed. + if (bGroundHolding && dist_m < GND_HOLDING_TRIVIAL_DIST_M && + pos.f.specialPos != SPOS_STARTUP) + { + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_DROP %s dropping trivial update" + " (dist=%.2fm, gs=%.2fkt)", + key().c_str(), dist_m, gs_kt); + } + // Update `youngestTS` to the dropped slot's feed ts. + // Without this, an aircraft that sits at the gate + // receiving valid feed updates (all trivial-dropped) + // looks "stale" to the outdate check at the bottom + // of CalcNextPos — `youngestTS + GetAcOutdatedIntvl() + // < simTime` fires after ~180 s and the aircraft is + // removed even though the feed is alive. We keep the + // deque content unchanged (the drop is the whole + // point) but advance the freshness timestamp so the + // outdate check sees the aircraft as live. + if (pos.ts() > youngestTS) + youngestTS = pos.ts(); + return; + } + } else { + // Non-stationary slot: increment the consecutive counter. + // We do not exit holding on the first one — feed jitter can + // briefly produce a single 2 kt sample for a truly parked + // aircraft. Only after `GND_HOLDING_EXIT_CONSEC` consecutive + // non-stationary slots do we trust that the aircraft is + // really moving and break the suppression. + groundNonStationaryCnt++; + if (bGroundHolding && + groundNonStationaryCnt >= GND_HOLDING_EXIT_CONSEC) + { + bGroundHolding = false; + // Reset the streak start to "now" so that if motion + // ceases again immediately, the next holding promotion + // is timed from the resumption of stationarity (not + // from the moment the original streak began long ago). + groundHoldingSinceTs = pos.ts(); + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_HOLDOUT %s exiting ground-holding" + " (consec=%d, dist=%.2fm, gs=%.2fkt)", + key().c_str(), groundNonStationaryCnt, + dist_m, gs_kt); + } + } + } + + // ---------------------------------------------------------- + // Gate-parked motion suppression — DISTANCE-based. + // + // For an aircraft we have positively identified as parked at + // a gate (`bGateParked == true`), suppress any slot whose + // displacement from the latest accepted position is less than + // `GATE_HOLD_MIN_ACCEPT_M` (30 m). Slots that exceed the + // threshold are taken as evidence of real motion, are + // accepted into the deque, and trigger an immediate release + // of `bGroundHolding` so subsequent (now much smaller) + // per-slot deltas during the push are not trivial-dropped. + // + // Why distance and not a counter of non-stationary slots: + // real pushbacks roll at 0.4-1.4 kt — below + // `GND_STATIONARY_GS_KT` (1.5 kt). The non-stationary + // counter therefore never advances during a slow push, and + // a counter-based gate would suppress the entire push + // (observed with AAL1408: every slot dropped, aircraft never + // rendered any motion and was eventually outdated and + // removed). Distance-based gating succeeds the moment the + // aircraft has moved far enough from the gate to rule out + // noise — typically 2-3 slots into a real push. + // + // For the noise case (AAL2501, AAL2761 — single or paired + // ~18 m anomalies that return to the gate), every individual + // slot sits inside the 30 m envelope and is correctly + // dropped; the held position never advances. + // + // `pbState == PB_NONE` is essential: once the state machine + // has entered PB_ACTIVE/PB_PAUSED we are committed to the + // push and must let every slot through (including pause + // slots that would otherwise be filtered). + // + // `SPOS_STARTUP` exempt — same rationale as the trivial-drop + // above (intentional placements). + if (bGateParked && pbState == PB_NONE && + pos.f.specialPos != SPOS_STARTUP) + { + if (dist_m < GATE_HOLD_MIN_ACCEPT_M) { + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_GATE_HOLD %s dropping motion at gate" + " (dist=%.2fm/%.0fm, gs=%.2fkt, isStat=%d)", + key().c_str(), dist_m, + GATE_HOLD_MIN_ACCEPT_M, gs_kt, + isStationary ? 1 : 0); + } + // Advance freshness timestamp even on drop — see the + // matching update in the trivial-drop branch above. + if (pos.ts() > youngestTS) + youngestTS = pos.ts(); + return; + } + // Slot is far enough from the held position to be real + // motion. Accept it AND release bGroundHolding so the + // subsequent in-push slots (which are typically only + // 3-6 m from the previous accepted slot, and would + // therefore be trivial-dropped if holding stayed true) + // can flow through and continue the rendered push. + if (bGroundHolding) { + bGroundHolding = false; + groundHoldingSinceTs = pos.ts(); + if (dataRefs.ShallLogDiagnostics()) { + LOG_MSG(logDEBUG, + "GND_DIAG_GATE_RELEASE %s accepting motion at" + " gate (dist=%.2fm >= %.0fm, gs=%.2fkt) —" + " bGroundHolding cleared", + key().c_str(), dist_m, + GATE_HOLD_MIN_ACCEPT_M, gs_kt); + } + } + } } // add pos to the queue of data to be added @@ -1943,6 +3052,43 @@ void LTFlightData::AppendNewPos() posDeque.emplace_back(pos); dequePositionTy::iterator i = std::prev(posDeque.end()); + // Pushback gate signal. `bGateParked` is the persistent + // assertion that THIS aircraft is currently parked at a real + // gate (as opposed to merely "stationary on the airport + // surface"). Three sources set it true: + // + // (a) The slot was placed at an apt.dat startup location + // (SPOS_STARTUP) — from RT's parked-traffic snapshot + // or from the Synthetic channel's keep-alive seeds. + // Handled here, per-slot, because each incoming RT + // parked-feed re-fetch lands a fresh SPOS_STARTUP + // position and must (re-)assert the flag. + // + // (b) The aircraft entered ground-holding AND apt.dat + // confirms a startup-location within + // GATE_DETECT_MAX_DIST_M of the held position. + // Handled above at the bGroundHolding-flip site — + // one-shot lookup, not per-slot. This is the third + // path described in `GATE_DETECT_MAX_DIST_M`'s + // comment in Constants.h. It catches live-tracked + // aircraft that were never in RT's parked snapshot + // (UAL466, AAL1771 etc.) without false-positiving + // runway hold-shorts as gates. + // + // (c) Implicit: a previously-set bGateParked persists + // until the pushback state machine in CalcHeading + // exits to PB_NONE (push complete) or the aircraft + // goes airborne. + // + // The OLD code also set bGateParked from `bGroundHolding` + // alone — that was too permissive (any stationary period + // anywhere on the airport flagged a "gate") and produced + // the visible spin when the pushback state machine then + // mistook a forward taxi resumption for a push. The + // apt.dat-confirmed path above replaces it. + if (pos.f.specialPos == SPOS_STARTUP) + bGateParked = true; + // *** heading *** // Recalc heading of adjacent positions: before p, p itself, and after p @@ -2836,6 +3982,72 @@ void LTFlightData::UpdateAllModels () } } +// Prune `FF****` placeholder-hex duplicates. +// +// See header doc for full rationale. Two-pass design: +// pass 1 — collect callsigns held by non-FF (real-hex) entries +// pass 2 — invalidate any FF entry whose callsign is in the set +// +// Two passes are necessary because we cannot decide-and-invalidate in +// a single sweep: a real-hex entry may be discovered AFTER its FF +// counterpart in map iteration order, and we would miss the prune. +// +// Performance: O(N) for N entries in mapFd, run from the main thread. +// The scan walks raw `mapFd` keys and uses `GetUnsafeStat()` to read +// callsigns without per-entry mutex acquisition — racy but acceptable: +// the worst case is a stale callsign read that we re-evaluate on the +// next call (this method is intended to be invoked periodically, not +// once-per-frame). +void LTFlightData::PrunePlaceholderHexDuplicates () +{ + try { + // Hold the map mutex for the whole pass: we both read and + // potentially SetInvalid entries within it, and any concurrent + // erase from the cleanup pipeline must not race with our + // iteration. + std::lock_guard lock (mapFdMutex); + + // Lambda: does the hex key start with "FF" (case-insensitive)? + // FDKeyTy::key is the canonical uppercase hex string per + // SetKey()'s normalization, but we tolerate either case here + // for robustness. + auto isPlaceholderHex = [](const std::string& hex) -> bool { + return hex.length() >= 2 && + (hex[0] == 'F' || hex[0] == 'f') && + (hex[1] == 'F' || hex[1] == 'f'); + }; + + // Pass 1: collect callsigns from real-hex (non-FF) entries. + // Skip entries with empty callsigns — they cannot be matched. + std::set realHexCallsigns; + for (const auto& fdPair : mapFd) { + if (isPlaceholderHex(fdPair.first.key)) + continue; + const std::string& call = fdPair.second.GetUnsafeStat().call; + if (!call.empty()) + realHexCallsigns.insert(call); + } + + // Pass 2: invalidate FF entries whose callsign is held by a + // real-hex entry. SetInvalid(true) also drops the rendered + // aircraft so the visual duplicate disappears immediately. + for (auto& fdPair : mapFd) { + if (!isPlaceholderHex(fdPair.first.key)) + continue; + const std::string& call = fdPair.second.GetUnsafeStat().call; + if (!call.empty() && realHexCallsigns.count(call) > 0) { + LOG_MSG(logINFO, + "PRUNE_FF: removing placeholder-hex %s (cs=%s)" + " — real-hex entry exists for same callsign", + fdPair.first.key.c_str(), call.c_str()); + fdPair.second.SetInvalid(); + } + } + } catch(const std::system_error& e) { + LOG_MSG(logERR, ERR_LOCK_ERROR, "mapFd", e.what()); + } +} + // finds the closest a/c roughly in the given direction ('focus a/c') const LTFlightData* LTFlightData::FindFocusAc (const double bearing) { diff --git a/Src/LTMain.cpp b/Src/LTMain.cpp index ccf7d56..6462dfd 100644 --- a/Src/LTMain.cpp +++ b/Src/LTMain.cpp @@ -1088,17 +1088,38 @@ void LTRegularUpdates() if (lstCycleNum == currCycleNum) return; lstCycleNum = currCycleNum; - + // all calls needed (up to) every flight loop: - + // Update cached values dataRefs.UpdateCachedValues(); - + // Check if some msg window needs to show CheckThenShowMsgWindow(); // handle new network data (that func has a short-cut exit if nothing to do) LTFlightData::AppendAllNewPos(); + + // Periodic prune of `FF****` placeholder-hex duplicates. + // + // Some upstream ingest paths emit aircraft with synthetic + // `FF****` hex IDs when the source does not carry a real ICAO + // code. When a real-ICAO source later picks up the same callsign, + // we end up with two LTFlightData entries (one per hex) and two + // rendered aircraft. The prune walks mapFd and invalidates any + // FF-hex entry whose callsign matches a non-FF entry. Throttled + // to once every 10 s because the scan locks mapFd, and the + // duplicate condition develops over many seconds (placeholder + // appears, real-hex picks up 5-60 s later) — running per-flight- + // loop would be wasteful. + { + static std::chrono::steady_clock::time_point lastPrune; + const auto now = std::chrono::steady_clock::now(); + if (now - lastPrune >= std::chrono::seconds(10)) { + lastPrune = now; + LTFlightData::PrunePlaceholderHexDuplicates(); + } + } // Count flight loop callbacks without camera control dataRefs.CntCyclesWithoutCamera(); diff --git a/Src/LTRealTraffic.cpp b/Src/LTRealTraffic.cpp index d526785..77e47a0 100644 --- a/Src/LTRealTraffic.cpp +++ b/Src/LTRealTraffic.cpp @@ -323,6 +323,16 @@ std::chrono::time_point RealTrafficConnection::SetReq curr.eRequType = CurrTy::RT_REQU_WEATHER; return tNextWeather; } + // Periodically re-arm the parked-traffic request so RT's parked + // snapshot stays fresh — new arrivals show up, departed aircraft are + // reconciled — without the user having to change airports. The + // one-shot trigger (DoReadParkedTraffic, fired on airport-data + // refresh) alone would freeze the parked picture for the whole visit. + // tLastParkedRefresh starts at 0, so the first call re-arms + // immediately; that is harmless as connection-init already arms it. + if (dataRefs.ShallKeepParkedAircraft() && + std::time(nullptr) - tLastParkedRefresh >= RT_PARKED_REFRESH_INTVL_S) + bDoParkedTraffic = true; if (bDoParkedTraffic && LTAptAvailable()) { // Do the parked traffic now, and only when airport details are available so we can place the aircraft correctly curr.eRequType = CurrTy::RT_REQU_PARKED; return tNextTraffic; @@ -583,7 +593,23 @@ bool RealTrafficConnection::ProcessFetchedData () // --- Parked Aircraft --- if (curr.eRequType == CurrTy::RT_REQU_PARKED) { + // Re-check airport-layout availability at PROCESSING time, not just + // at request time. SetRequType already gated the request on + // LTAptAvailable(), but the ~1-2 s network round-trip between + // issuing the request and the response arriving is long enough for + // the camera to have moved — which kicks off an async apt.dat + // reload and flips bAptAvailable false. Processing the response + // then would run the startup-location lookups in + // ProcessParkedAcBuffer against a purged / half-rebuilt airport + // map and mis-place every parked aircraft. If the layout is not + // ready right now, drop this response and leave bDoParkedTraffic + // armed so the next cycle retries once the layout is back. + // tLastParkedRefresh is intentionally NOT updated, so a dropped + // attempt does not consume the periodic-refresh interval. + if (!LTAptAvailable()) + return true; // not an error — just retry next cycle bDoParkedTraffic = false; // Repeat only when instructed + tLastParkedRefresh = std::time(nullptr); // remember when, for the periodic re-fetch (RT_PARKED_REFRESH_INTVL_S) return ProcessParkedAcBuffer(json_object_get_object(pObj, "data")); } @@ -755,7 +781,16 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) stat.setOrigDest( jag_s(pJAc, RT_DRCT_Origin), jag_s(pJAc, RT_DRCT_Dest) ); stat.flight = jag_s(pJAc, RT_DRCT_FlightNum); - + // v6: ICAO operator/airline flag code, hex-keyed in RT's BaseStation + // DB. Authoritative for livery matching — immune to wet-lease / + // codeshare callsign confusion that the callsign-substring fallback + // in FDStaticData::airlineCode() gets wrong. Empty (~30% of records) + // for hexes RT doesn't have in the DB; those fall through to the + // existing callsign/type-only path with no behaviour change. + std::string opCode = jag_s(pJAc, RT_DRCT_Operator); + if (!opCode.empty()) + stat.opIcao = std::move(opCode); + std::string s = jag_s(pJAc, RT_DRCT_Category); stat.catDescr = GetADSBEmitterCat(s); stat.slug = GetSlug(fdKey.num); @@ -823,6 +858,37 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) // add the static data fd.UpdateData(std::move(stat), pos.dist(posView)); + // --- FEED_DIAG (HTTP-Direct path) --- + // Per-aircraft monotonicity + source check. We want to see + // every position the channel accepts: hex, callsign, feed + // timestamp, msg_type/source (e.g. V_adsb_icao), age of + // position (`seen` / PosAge), elapsed dt since the previous + // accepted feed timestamp for the same hex, and a flag for + // OK / BACKWARDS / REPEAT / NEW. Helps identify backwards + // feeds sneaking in that produce backwards rendered motion. + if (dataRefs.ShallLogDiagnostics()) + { + const std::string srcMsg = jag_s(pJAc, RT_DRCT_MsgSrcType); + const std::string callDg = jag_s(pJAc, RT_DRCT_CallSign); + const double srcAge = jag_n(pJAc, RT_DRCT_PosAge); + const auto itLast = lastFeedTs.find(fdKey.num); + const double prevTs = (itLast == lastFeedTs.end()) ? NAN : itLast->second; + const double dtFeed = std::isnan(prevTs) ? NAN : (posTime - prevTs); + const char* flag = std::isnan(prevTs) ? "NEW" + : (dtFeed > 0.0) ? "OK" + : (dtFeed < 0.0) ? "BACKWARDS" + : "REPEAT"; + LOG_MSG(logDEBUG, + "FEED_DIAG %s cs=%s ts=%.1f src=%s seen=%.1f dt=%+.2f alt=%.0fft gnd=%d vsi=%+.0ffpm %s [HTTP]", + fdKey.c_str(), callDg.c_str(), + posTime, srcMsg.c_str(), srcAge, dtFeed, + pos.alt_ft(), + pos.f.onGrnd == GND_ON ? 1 : 0, + dyn.vsi, + flag); + lastFeedTs[fdKey.num] = posTime; + } + // add the dynamic data fd.AddDynData(dyn, 0, 0, &pos); @@ -831,7 +897,7 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) IncErrCnt(); } } - + return true; } @@ -888,12 +954,28 @@ bool RealTrafficConnection::ProcessParkedAcBuffer (const JSON_Object* pData) continue; } - // Get the parking position and timestamp first to check for duplicates + // Get the parking position and timestamp first. std::string parkPos = jag_s(pJAc, RT_PARK_ParkPosName); double ts = jag_n(pJAc, RT_PARK_LastTimeStamp); - if (mapPData.count(parkPos) > 0) { - // we know that parking position already! - parkedAcData& dat = mapPData.at(parkPos); + + // Decide the dedup key. RT's parked feed retains stale history — + // a stand can be listed with several aircraft, the older ones + // being departures RT has not yet cleared — so for a *real* stand + // we keep only the newest-timestamp entry. BUT RT_PARK_ParkPosName + // is frequently EMPTY (GA, cargo, remote stands with no Jeppesen + // name). An empty name is not a stand identity: if we used it as + // the key, every empty-name aircraft across the whole airport + // would collapse into a single map slot and all but one would be + // silently dropped — which is exactly the "only a small fraction + // of parked traffic shows" symptom. So for empty parkPos we key + // on the unique hex id instead, guaranteeing each such aircraft + // is kept. The '#' prefix can never collide with a real Jeppesen + // parking-position string. + const std::string dedupKey = parkPos.empty() ? ('#' + key) : parkPos; + + if (mapPData.count(dedupKey) > 0) { + // we already have an aircraft for this stand identity + parkedAcData& dat = mapPData.at(dedupKey); if (ts > dat.ts) { // but new data is newer -> replace dat = { jag_n_nan(pJAc, RT_PARK_Lat), @@ -906,8 +988,8 @@ bool RealTrafficConnection::ProcessParkedAcBuffer (const JSON_Object* pData) }; } } else { - // We don't yet know that parking position, store data in new map record - mapPData.emplace(std::move(parkPos), + // first aircraft for this stand identity, store in new record + mapPData.emplace(dedupKey, parkedAcData { jag_n_nan(pJAc, RT_PARK_Lat), jag_n_nan(pJAc, RT_PARK_Lon), @@ -931,7 +1013,18 @@ bool RealTrafficConnection::ProcessParkedAcBuffer (const JSON_Object* pData) // not matching a/c filter? -> skip it if ((!acFilter.empty() && (fdKey != acFilter)) ) continue; - + + // Refuse to re-seed a hex id that gate-handoff has already + // evicted earlier in this session (task #43). RT's parked DB + // can lag for hours: a stand that the live feed has shown + // emptying (the parked ghost was evicted when a new aircraft + // pulled in) will still appear in the parked re-fetch for a + // long time afterwards. Without this guard, every 5 minutes + // (RT_PARKED_REFRESH_INTVL_S) the ghost would be created + // anew. See SyntheticConnection::MarkEvicted. + if (SyntheticConnection::WasEvicted(fdKey.num)) + continue; + // position positionTy pos (dat.lat, dat.lon); pos.heading() = 0.0; @@ -939,6 +1032,45 @@ bool RealTrafficConnection::ProcessParkedAcBuffer (const JSON_Object* pData) // see later how TS is used: we send 3 instances to make the a/c appear immediately pos.ts() = dataRefs.GetSimTime() - 0.5 * double(dataRefs.GetFdBufPeriod()); + // Defence-in-depth: if this hex id is *already* being live + // tracked by another channel, and that live aircraft has + // either left the ground or moved meaningfully away from the + // gate, do NOT inject the stale parked seed (TFL3NA-class + // bug). Symptom we are blocking: TFL3NA was taxiing out and + // then airborne when the 5-minute parked re-fetch fired, + // silently appending an SPOS_STARTUP GND_ON seed at the + // original gate to the deque with a timestamp *later* than + // the live airborne positions. The render clock eventually + // walked into the seed and the aircraft visually teleported + // back to the gate before snapping forward again. The + // GATE_REFEED_MAX_DIST_M (= 50 m) test means "still inside + // the stand footprint"; anything beyond that is no longer at + // the gate, regardless of what RT's parked DB still believes. + { + std::unique_lock mapFdLock (mapFdMutex); + auto it = mapFd.find(fdKey); + if (it != mapFd.end()) { + std::lock_guard fdLock (it->second.dataAccessMutex); + if (it->second.IsValid() && it->second.hasAc()) { + const LTAircraft* pAc = it->second.GetAircraft(); + if (pAc) { + // Released both locks via scope exit before + // the `continue` below — they are inside this + // inner block. + if (!pAc->IsOnGrnd()) { + mapFdLock.unlock(); // be explicit + continue; // already airborne — never re-seed + } + const positionTy gatePos (dat.lat, dat.lon); + if (pAc->GetPPos().dist(gatePos) > GATE_REFEED_MAX_DIST_M) { + mapFdLock.unlock(); + continue; // taxied away from the gate + } + } + } + } + } + // position is rather important, we check for validity // (we do allow alt=NAN if on ground) if ( !pos.isNormal(true) ) { @@ -965,14 +1097,59 @@ bool RealTrafficConnection::ProcessParkedAcBuffer (const JSON_Object* pData) dyn.vsi = 0.0; dyn.pChannel = this; - // Try to find a matching "startup position" to perfectly put the aircraft in place - positionTy startupPos = LTAptFindStartupLoc(pos, - (double)dataRefs.GetFdSnapTaxiDist_m()); - if (startupPos.isNormal(true)) { + // Try to find a matching "startup position" to perfectly put the + // aircraft in place — a real apt.dat gate/stand with a known + // heading. Pass maxDist = NAN so the search uses the generous + // internal default (3 × the taxi-snap distance): RT's parked + // coordinates are not always precise to the metre, and the old + // 1 × radius missed many stands. + double startupDist = NAN; + positionTy startupPos = LTAptFindStartupLoc(pos, NAN, &startupDist); + // A startup location was matched iff startupDist is a real number + // (LTAptFindStartupLoc / FindStartupLoc set it to NAN when nothing + // is found, to the metre distance when found). + // + // Do NOT test startupPos.isNormal() here: LTAptFindStartupLoc + // returns the matched location with a NaN timestamp — it is a + // static apt.dat coordinate, not a tracked position — and + // positionTy::isNormal() rejects a NaN ts. So isNormal() would + // report "not found" for EVERY successful match, which is exactly + // why parked aircraft were all left at the placeholder 0° heading, + // facing north. startupDist is the reliable signal. + const bool bFoundStartup = !std::isnan(startupDist); + if (bFoundStartup) { + // Snap exactly onto the apt.dat startup location and adopt + // its known heading. pos.lat() = startupPos.lat(); pos.lon() = startupPos.lon(); pos.heading() = startupPos.heading(); } + + // Flag the position as a startup/parked placement UNCONDITIONALLY + // — whether or not an apt.dat stand was matched. This is + // essential, not cosmetic: + // * the FPH_PARKED test in LTAircraft requires SPOS_STARTUP; + // only then does the Synthetic channel adopt the aircraft to + // keep it alive, otherwise it ages out a few minutes after + // creation (its only positions span simTime-45..+90); + // * the ground-holding trivial-drop in LTFlightData::AddNewPos + // exempts SPOS_STARTUP positions — without the flag the four + // identical bootstrap seed positions are dropped as "jitter" + // and the aircraft is left with too few positions to render + // at all (the "no parked traffic showing up" symptom). + // RT's parked feed is authoritative that the aircraft is parked; + // if we simply could not match an apt.dat stand it still belongs + // at its reported lat/lon — just without a precise gate heading + // (RT's parked feed carries no heading field, so heading stays + // at the 0° set above). + pos.f.specialPos = SPOS_STARTUP; + pos.f.bHeadFixed = true; + + // Sync the dynamic-data heading with the (possibly startup-loc + // corrected) position heading. dyn.heading was captured above + // before the startup-location lookup, so without this it would + // still hold the placeholder 0°. + dyn.heading = pos.heading(); try { // from here on access to fdMap guarded by a mutex @@ -1009,7 +1186,11 @@ bool RealTrafficConnection::ProcessParkedAcBuffer (const JSON_Object* pData) } } - LOG_MSG(logINFO, "Received %d parked aircraft", int(numVals)); + // Report both the raw count and the post-dedup count actually + // processed — a large gap between them points at parkPos collisions + // (stale RT history, or empty Jeppesen names) eating the traffic. + LOG_MSG(logINFO, "Received %d parked aircraft, %d after dedup", + int(numVals), int(mapPData.size())); return true; } @@ -1164,7 +1345,41 @@ bool RealTrafficConnection::PreProcessWeather(const JSON_Object* pData) s.clear(); metar.clear(); } - + + // Reject placeholder weather responses. + // + // RealTraffic sometimes returns a stripped-down weather payload for a + // query location whose real weather it previously delivered correctly + // — observed: at YSSY, a valid {"ICAO":"YSSY","QNH":1034,"METAR":...} + // response was followed ~60 s later by a {"QNH":1013, no ICAO, no METAR} + // response for the *same* query coordinates. The 1013 is RT's standard- + // pressure placeholder, not a real reading. + // + // If we accept it, rtWx.QNH gets overwritten to 1013.25 and + // BaroAltToGeoAlt_ft stops applying the local-pressure correction — + // landing aircraft at high-QNH airports then render ~500 ft below true + // and touch down a mile short of the runway. + // + // A response is treated as a placeholder when ALL of: + // * No ICAO/airport identifier + // * No METAR text + // * QNH equals (within 0.5 hPa) the ISA standard 1013.25 hPa + // * We already hold a *non-standard* QNH that we trust + // The last condition lets a real 1013-hPa reading still come in as a + // first weather update; we only reject placeholders when they would + // overwrite a previously confirmed non-standard value. + if (s.empty() && metar.empty() && + std::abs(wxQNH - HPA_STANDARD) < 0.5 && + !std::isnan(rtWx.QNH) && + std::abs(rtWx.QNH - HPA_STANDARD) >= 0.5) + { + LOG_MSG(logDEBUG, + "Ignoring placeholder RealTraffic weather" + " (QNH=%.1f, no ICAO, no METAR); keeping previous QNH=%.1f", + wxQNH, rtWx.QNH); + return true; + } + // If this is live data, not historic, then we can use it instead of separately querying METAR if (!isHistoric()) { rtWx.w.qnh_pas = dataRefs.SetWeather((float)wxQNH, s, metar); @@ -2025,6 +2240,14 @@ bool RealTrafficConnection::ProcessRTTFC (LTFlightData::FDKeyTy& fdKey, stat.reg = tfc[RT_RTTFC_AC_TAILNO]; stat.setOrigDest(tfc[RT_RTTFC_FROM_IATA], tfc[RT_RTTFC_TO_IATA]); stat.slug = GetSlug(fdKey.num); + // v6 (RealTraffic v11.1.452+): ICAO operator/airline flag code, + // hex-keyed. Optional — older RT App builds don't send the + // field (so it sits past RT_RTTFC_MIN_TFC_FIELDS) and per the + // doc ~30% of records carry an empty string. Bounds-check both. + // When present this is authoritative for livery matching and + // wins over the callsign-substring guess in airlineCode(). + if (tfc.size() > RT_RTTFC_OPERATOR && !tfc[RT_RTTFC_OPERATOR].empty()) + stat.opIcao = tfc[RT_RTTFC_OPERATOR]; const std::string& sCat = tfc[RT_RTTFC_CATEGORY]; stat.catDescr = GetADSBEmitterCat(sCat); @@ -2074,6 +2297,39 @@ bool RealTrafficConnection::ProcessRTTFC (LTFlightData::FDKeyTy& fdKey, // add the static data fd.UpdateData(std::move(stat), dist); + // --- FEED_DIAG (UDP RTTFC path) --- + // Per-aircraft monotonicity + source check; see the HTTP variant + // for details. `seen` (RT_RTTFC_SEEN) and msg_type are bounds- + // checked because the compact 18-field RT App variant strips + // them — for short messages we log empty/NAN placeholders so the + // line still shows the timestamp and monotonicity flag. + if (dataRefs.ShallLogDiagnostics()) + { + std::string srcMsg; + double srcAge = NAN; + if (tfc.size() > RT_RTTFC_MSG_TYPE) + srcMsg = tfc[RT_RTTFC_MSG_TYPE]; + if (tfc.size() > RT_RTTFC_SEEN && !tfc[RT_RTTFC_SEEN].empty()) { + try { srcAge = std::stod(tfc[RT_RTTFC_SEEN]); } catch (...) {} + } + const auto itLast = lastFeedTs.find(fdKey.num); + const double prevTs = (itLast == lastFeedTs.end()) ? NAN : itLast->second; + const double dtFeed = std::isnan(prevTs) ? NAN : (posTime - prevTs); + const char* flag = std::isnan(prevTs) ? "NEW" + : (dtFeed > 0.0) ? "OK" + : (dtFeed < 0.0) ? "BACKWARDS" + : "REPEAT"; + LOG_MSG(logDEBUG, + "FEED_DIAG %s cs=%s ts=%.1f src=%s seen=%.1f dt=%+.2f alt=%.0fft gnd=%d vsi=%+.0ffpm %s [UDP]", + fdKey.c_str(), tfc[RT_RTTFC_CS_ICAO].c_str(), + posTime, srcMsg.c_str(), srcAge, dtFeed, + pos.alt_ft(), + pos.f.onGrnd == GND_ON ? 1 : 0, + dyn.vsi, + flag); + lastFeedTs[fdKey.num] = posTime; + } + // add the dynamic data fd.AddDynData(dyn, 0, 0, &pos); diff --git a/Src/LTSynthetic.cpp b/Src/LTSynthetic.cpp index cf6b688..492033f 100644 --- a/Src/LTSynthetic.cpp +++ b/Src/LTSynthetic.cpp @@ -35,6 +35,40 @@ // Position information per tracked plane SyntheticConnection::mapSynDataTy SyntheticConnection::mapSynData; +// Hex ids that have been evicted from a stand by gate-handoff +// (see SyntheticConnection::FetchAllData). Persistent for the plugin +// lifetime: see the rationale in LTSynthetic.h. A `static` member with +// no destructor is fine here — the set is tiny (one entry per real +// gate-handoff event observed since plugin load) and is freed when +// the plugin unloads with the process. +std::set SyntheticConnection::evictedHexIds; + +// Record that a hex id was evicted from a stand. Called from the +// gate-handoff eviction site below so that: +// - a follow-up Synthetic FetchAllData pass cannot re-adopt the +// ghost while the original async SetInvalid() is still tearing +// down the parked LTFlightData entry, and +// - the periodic RealTraffic parked-feed re-fetch +// (RT_PARKED_REFRESH_INTVL_S, see ProcessParkedAcBuffer) cannot +// re-seed the same hex id with the stale gate position RT's +// parked DB still carries hours after the real aircraft has +// departed. +// Idempotent — repeated calls are harmless. +void SyntheticConnection::MarkEvicted (unsigned long hex) +{ + evictedHexIds.insert(hex); +} + +// Has this hex id been evicted from a stand at some point in this +// session? Read from both the Synthetic re-adoption path +// (FetchAllData below) and the RealTraffic parked re-feed path +// (LTRealTraffic::ProcessParkedAcBuffer) to short-circuit +// re-introducing a ghost we already decided to remove. +bool SyntheticConnection::WasEvicted (unsigned long hex) +{ + return evictedHexIds.find(hex) != evictedHexIds.end(); +} + // Constructor SyntheticConnection::SyntheticConnection () : LTFlightDataChannel(DR_CHANNEL_SYNTHETIC, SYNTHETIC_NAME, CHT_SYNTHETIC_DATA) @@ -109,6 +143,19 @@ bool SyntheticConnection::FetchAllData(const positionTy&) if (fd.IsValid() && fd.hasAc()) { const LTAircraft& ac = *fd.GetAircraft(); if (ac.GetFlightPhase() == FPH_PARKED) { + // Refuse to re-adopt a hex id we have already evicted + // from a stand earlier in this session (task #43). The + // race we are blocking: a Synthetic FetchAllData pass + // running while the live aircraft is still FPH_PARKED + // could otherwise re-create the ghost's mapSynData + // entry (and a follow-up RT parked re-fetch could + // re-create its LTFlightData entry), making the ghost + // visibly resurrect ~40 s after we evicted it. + if (WasEvicted(key.num)) { + // Also make sure no stale entry lingers. + mapSynData.erase(p.first); + continue; + } // This a/c is parked, find/create the entry in our storage SynDataTy& parkDat = mapSynData[key]; // we keep the previous heading because we looked that up from Startup location master data @@ -124,8 +171,28 @@ bool SyntheticConnection::FetchAllData(const positionTy&) mapSynData.erase(p.first); } - // Test if the aircraft came too close to any other parked aircraft on the ground - if (ac.IsOnGrnd() && !ac.IsGroundVehicle()) { + // Gate handoff: evict a stale "ghost" parked aircraft when a + // real one takes its stand. + // + // A new aircraft pulling into a gate is only ever in the LIVE + // feed (it was tracked taxiing in) — never in the parked + // feed, which is a periodic snapshot. So if THIS aircraft has + // itself come to rest as a parked aircraft, any *other* stored + // parked aircraft sitting on the same stand must be a stale + // ghost: RT's parked snapshot had the previous occupant, who + // has since left. Remove the ghost. + // + // The trigger is `ac.GetFlightPhase() == FPH_PARKED` rather + // than the old "any on-ground non-vehicle aircraft" test, and + // that distinction is the whole fix. FPH_PARKED means `ac` is + // on the ground, stopped, AND snapped to an apt.dat startup + // location — i.e. it has genuinely parked. An aircraft merely + // taxiing PAST the gate, or holding short nearby, is not + // FPH_PARKED and therefore no longer wrongly evicts parked + // traffic. GND_COLLISION_DIST then only has to discriminate + // "same stand", which it does easily — distinct stands' + // reference points are far further apart than 10 m. + if (ac.GetFlightPhase() == FPH_PARKED) { for (auto i = mapSynData.begin(); i != mapSynData.end(); ) { // Only compare to other aircraft (not myself) if (i->first == key) { @@ -136,6 +203,15 @@ bool SyntheticConnection::FetchAllData(const positionTy&) { LOG_MSG(logDEBUG, "%s came too close to parked %s, removing the parked aircraft", fd.keyDbg().c_str(), i->first.c_str()); + // Remember this hex id so neither a subsequent + // Synthetic FetchAllData pass (race against the + // async SetInvalid teardown below) nor the + // 5-minute RealTraffic parked-feed re-fetch + // can resurrect the ghost. Task #43: the + // observed symptom was SLM994 reappearing + // ~40 s after eviction via Synthetic, and + // again later via the parked re-fetch. + MarkEvicted(i->first.num); // find the parked aircraft in the map of active aircraft and have it removed there try { LTFlightData& fdParked = mapFd.at(i->first); diff --git a/Src/LTWeather.cpp b/Src/LTWeather.cpp index e34c95c..4051558 100644 --- a/Src/LTWeather.cpp +++ b/Src/LTWeather.cpp @@ -1403,6 +1403,9 @@ static bool bSetWeather = false; ///< is there a next weather to static bool bResetWeather = false; ///< Shall weather be reset, ie. handed back to XP? static LTWeather setWeather; ///< the weather we set last time +/// Is currently an async operation running to fetch METAR? +static std::future futWeather; + // Initialize Weather module, dataRefs bool WeatherInit_xp () { @@ -1421,6 +1424,10 @@ bool WeatherInit_xp () void WeatherStop () { WeatherReset(); + + // thread cleanup: if a request still underway wait for the thread to end + if (futWeather.valid()) + futWeather.wait_for(std::chrono::seconds(5)); } // Can we set weather? (X-Plane 12 forward only) @@ -1816,9 +1823,6 @@ float WeatherQNHfromMETAR (const std::string& metar) } -/// Is currently an async operation running to fetch METAR? -static std::future futWeather; - // Asynchronously, fetch fresh weather information bool WeatherFetchUpdate (const positionTy& pos, float radius_nm) { diff --git a/Src/SettingsUI.cpp b/Src/SettingsUI.cpp index 05fbac4..655826e 100644 --- a/Src/SettingsUI.cpp +++ b/Src/SettingsUI.cpp @@ -1514,6 +1514,8 @@ void LTSettingsUI::buildInterface() "Logs how available tracking data was matched with the chosen CSL model (into Log.txt)"); ImGui::FilteredCfgCheckbox("Log a/c positions", sFilter, DR_DBG_AC_POS, "Logs detailed position information of currently selected aircraft (into Log.txt)"); + ImGui::FilteredCfgCheckbox("Log detailed diagnostics", sFilter, DR_DBG_DIAGNOSTIC, + "Logs very detailed diagnostics about data feed, ground and altitude calcs, that fill up your Log.txt very fast"); ImGui::FilteredCfgCheckbox("Log Weather", sFilter, DR_DBG_LOG_WEATHER, "Logs detailed information about how X-Plane's weather is set (into Log.txt)"); if (ImGui::FilteredLabel("Log Weather now", sFilter)) { diff --git a/docs/readme.html b/docs/readme.html index 3578b3b..96ea5d6 100755 --- a/docs/readme.html +++ b/docs/readme.html @@ -133,7 +133,7 @@

Release Notes

v4

-

v4.4.1

+

v4.5.0

Update: In case of doubt you can always just copy all files from the archive @@ -143,11 +143,36 @@

v4.4.1

At least copy the following files, which have changed compared to v4.4.0:

  • lin|mac|win_x64/LiveTraffic.xpl
  • +
  • Resources +
      +
    • FlightModels.prf
    • +
    • related.txt
    • +
    • relOp.txt
    • +
    • SendTraffic.py
    • +
    +

Change log:

    +
  • + Big thanks to Balthasar Indermuehle (www.flyrealtraffic.com) + for contributing the following long overdue improvements to modeling aircraft movements: +
      +
    • Improved taxiing by implementing a new algorithm how taxi paths are chosen;
    • +
    • Smoothened rotate/lift-off and flare/roll-out phases;
    • +
    • Implemented push-back: Planes shall move backward during push back from a parking position;
    • +
    • Eliminated "dancing" aircraft at parking positions (mostly);
    • +
    • Improved livery matching by adding more operator data to RealTraffic and + code sharing contributions to relOp.txt;
      + Still, be reminded that you'll + only every see AA A320 if you install liveries for it!
    • +
    • Fixed an issue with processing RealTraffic's weather that could lead to wrong aircraft altitude corrections.
    • +
    + (That short summary may not do justice to 3668 lines of code change... + see here for more details if interested) +
  • Fixed CTD with XP12.4.3-b1 due to "violation by LiveTraffic" calling XPLMIsFeatureEnabled from a worker thread.