From 5af794d15eef63984ac6e1a2af42d1695bc64deb Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:07:40 +1000 Subject: [PATCH 01/43] chore: ignore local helper and editor artifacts Adds patterns for local-only scratch files and helper scripts that should not be tracked. --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 0d2122e9..58c69df3 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* From 98f7f710f21508e34427b7b62b81ef863c412538 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:09:03 +1000 Subject: [PATCH 02/43] feat/Ground: Add tunables for ground heading stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new "Ground Behavior Stability" block of compile-time constants that will drive a layered fix for the visual "dance" seen when stationary or slow-taxiing aircraft are rendered from jittery 1 Hz feed positions: heading hysteresis, per-frame heading rate limiting, stationary detection, holding suppression, hard-set ground attitude, and pushback detection thresholds. This change only declares the constants — no behaviour is wired up yet. Each value carries an inline rationale comment so the chosen threshold is justified at the point of use. --- Include/Constants.h | 91 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/Include/Constants.h b/Include/Constants.h index 00fff45d..bd0ff435 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -88,6 +88,97 @@ 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. Real-feed ADS-B jitter routinely produces sub-degree variation +/// in track-from-pos-delta — we want those to be ignored so that a parked or +/// slow-taxiing aircraft is visually stable. Set just wide enough to absorb +/// sensor noise without making genuine slow turns look stepped. +constexpr double GND_HEADING_HYSTERESIS_DEG = 0.5; + +/// [°/s] maximum rate at which the rendered heading is allowed to walk while +/// on the ground. Even if the *target* heading jumps by a large amount, the +/// displayed heading walks smoothly at no more than this rate so the eye +/// never sees a snap-rotation. 60°/s ≈ 1° per frame at a 60 fps draw rate; +/// real ground turns from taxi-out to runway-line-up rarely exceed this. +constexpr double GND_HEADING_MAX_RATE_DPS = 60.0; + +/// [kn] groundspeed at-or-below which an aircraft is considered stationary +/// for the purposes of heading freezing and holding detection. 0.5 kn is +/// roughly 0.26 m/s — well below the slowest real taxi speed, but above the +/// numerical noise that can creep in when a parked aircraft's reported +/// groundspeed is "almost zero" rather than exactly zero in the feed. +constexpr double GND_STATIONARY_GS_KT = 0.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. The value matches +/// `SIMILAR_POS_DIST` (already used elsewhere in the heading code) so the +/// two thresholds remain consistent; a future change here should propagate. +constexpr double GND_HOLDING_TRIVIAL_DIST_M = 7.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). +/// A real aircraft sits with a slightly nose-up attitude due to gear geometry; +/// 2° is a good neutral average across narrow-bodies, wide-bodies, and most +/// GA singles. Hard-setting it (rather than inheriting from the data feed, +/// which usually has no useful pitch on the ground) prevents pitch drift +/// caused by inter-position interpolation in the slot pipeline. +constexpr double GND_PITCH_DEG = 2.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; + +/// [kn] upper bound on groundspeed for an event to even be considered as +/// pushback. Typical pushback tugs move aircraft at 1–3 kn; anything faster +/// is taxi rather than pushback and the normal heading-from-track logic +/// should apply. +constexpr double PUSHBACK_DETECT_GS_MAX_KT = 3.0; + +/// [°] absolute heading difference between the track-over-ground (derived +/// from the current position delta) and the aircraft's last-known good +/// heading required to classify a slow motion as pushback. 135° is well +/// inside the "going backwards" half-plane (which begins at 90°) but leaves +/// margin so that a sharply curving forward taxi never accidentally trips the +/// pushback heuristic. +constexpr double PUSHBACK_DETECT_HEAD_DIFF_DEG = 135.0; + +/// [m] distance behind the aircraft's nose along the reversed heading at +/// which the pushback Bezier curve's control point is placed. ~10 m mid-point +/// gives a smooth, gentle rearward arc instead of an instant straight-line +/// reverse; matches the approximate distance a tug pushes an airliner during +/// the first second of the maneuver. +constexpr double PUSHBACK_MIDPOINT_DIST_M = 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 From 511bc679ac2dc0e9fefa3a41d3f0bbb054af9533 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:11:27 +1000 Subject: [PATCH 03/43] feat/Ground: Hard-set pitch and roll on the ground Forces rendered pitch/roll to GND_PITCH_DEG / GND_ROLL_DEG each frame whenever bOnGrnd is true, with phase exceptions for FPH_ROTATE, FPH_FLARE and FPH_TOUCH_DOWN where the nose is dynamically moving relative to the ground. Also aligns the pitch MovingParam: aircraft spawned on the ground start at GND_PITCH_DEG, and the touch-down phase walks pitch to GND_PITCH_DEG (was 0). Eliminates a class of subtle pitch/roll drift that produced visual "nose-bobbing" and micro-bank in slow-taxi and parked aircraft. --- Src/LTAircraft.cpp | 57 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index c2a64b4e..690843f5 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -1291,8 +1291,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 @@ -1922,6 +1929,35 @@ 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`), the flare + // before touchdown (`FPH_FLARE`), and the single-cycle touchdown + // event (`FPH_TOUCH_DOWN`). In those phases the flight-model code + // is actively driving the `pitch` MovingParam through a planned + // transition (e.g., `pitch.max()` on rotate, `pitch.moveTo( + // PITCH_FLARE)` on flare), and overriding it here would visibly + // freeze the maneuver. + if (phase != FPH_ROTATE && + phase != FPH_FLARE && + phase != FPH_TOUCH_DOWN) + { + ppos.pitch() = GND_PITCH_DEG; + ppos.roll() = GND_ROLL_DEG; + } } else { // not on the ground // just lifted off? then recalc vsi @@ -2212,7 +2248,13 @@ 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); + // After the wheels are firmly down we want the nose to settle at + // the static ground attitude `GND_PITCH_DEG` rather than at level + // 0°. The MovingParam smoothly walks pitch from its FLARE value + // down to this new target over the next few frames; the + // ground-attitude override in `CalcAcPos` then keeps it pinned + // there during the rest of the taxi/parked life of the aircraft. + pitch.moveTo(GND_PITCH_DEG); } // roll-out @@ -2288,13 +2330,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: From cbd27faa0b6cd94b1f9cd1455da3801478dcc250 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:13:38 +1000 Subject: [PATCH 04/43] feat/Ground: Stationary heading freeze and hysteresis in CalcHeading Adds two ground-only filters to LTFlightData::CalcHeading: 1. Stationary freeze: when the slot is on the ground and the derived groundspeed to its neighbour(s) is at or below GND_STATIONARY_GS_KT, reuse the predecessor's already-filtered heading instead of computing one from jittery position deltas. Requires both adjacent segments (or the only available one at deque ends) to be stationary, so a single tight cluster during taxi does not lock the heading. 2. Hysteresis: after the heading has been computed, snap it to the predecessor's value when the difference is below GND_HEADING_HYSTERESIS_DEG. Absorbs the sub-degree noise that ADS-B/MLAT feeds routinely contain even on a straight ground roll. Both filters apply only to on-ground slots; airborne logic is unchanged. Together with the SIMILAR_POS_DIST short-circuit that already existed, these stop the heading sequence at gates from swinging wildly under feed jitter. --- Src/LTFlightData.cpp | 101 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index 0e346ab4..f9dc1747 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1479,6 +1479,77 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) // access guarded by a mutex std::lock_guard lock (dataAccessMutex); + // ---------------------------------------------------------------------- + // 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); + + 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)) { + 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 +1617,36 @@ 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())) { + if (std::abs(HeadingDiff(prevHead, it->heading())) < + 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())) From 51d319bf989d7360c7024a45e531eaec29abd05d Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:14:45 +1000 Subject: [PATCH 05/43] feat/Ground: Rate-limit per-frame heading change on the ground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a clamp in LTAircraft::CalcAcPos that limits the per-frame change in ppos.heading() to GND_HEADING_MAX_RATE_DPS * dt when the aircraft is on the ground. The check uses HeadingDiff for signed shortest-path semantics across the 360°/0° wrap and re-syncs the heading MovingParam so subsequent frames continue from the clamped value rather than catching up in a snap. Airborne heading propagation is unchanged: in the air, the heading MovingParam is already smoothed via FLIGHT_TURN_TIME, and an extra clamp would make en-route course changes visibly lag. Together with the deque-side stationary freeze and hysteresis, this ensures that even a deliberate large heading jump at slow taxi (e.g., new "to" position with a new heading) walks smoothly to the target instead of snapping. --- Src/LTAircraft.cpp | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index 690843f5..1a738660 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -1857,11 +1857,47 @@ bool LTAircraft::CalcPPos() ppos.alt_m() = from.alt_m() * (1 - f) + to.alt_m() * f; 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; From c517eb59a16661b8948186f11c6a4adcbebfcc89 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:16:26 +1000 Subject: [PATCH 06/43] feat/Ground: Holding timeout suppresses trivial feed updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-aircraft holding state machine in LTFlightData: - groundHoldingSinceTs tracks the start of the current stationary- on-ground streak (feed timestamp, not sim time). - bGroundHolding flips to true once the streak exceeds GND_HOLDING_TIMEOUT_S. In AddNewPos, once bGroundHolding is set, incoming positions that fall within GND_HOLDING_TRIVIAL_DIST_M of the latest known position and whose derived groundspeed is below GND_STATIONARY_GS_KT are dropped silently — never reaching posToAdd. Movements that exceed the trivial-distance envelope (potential pushback or taxi start) always go through and reset the holding state. This is the data-layer half of the ground "dance" fix: long-parked aircraft become rock-steady because the noisy feed updates never reach the renderer. Verbose debug logs are emitted on state transitions when GetDebugAcPos is enabled for the aircraft. --- Include/LTFlightData.h | 15 ++++++++ Src/LTFlightData.cpp | 79 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/Include/LTFlightData.h b/Include/LTFlightData.h index 99146b35..e72b7a99 100644 --- a/Include/LTFlightData.h +++ b/Include/LTFlightData.h @@ -238,6 +238,21 @@ 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; + // STATIC DATA (protected, access will be mutex-controlled for thread-safety) FDStaticData statData; diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index f9dc1747..98f57489 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1908,6 +1908,85 @@ 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; + + if (isStationary) { + // 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.GetDebugAcPos(key())) + LOG_MSG(logDEBUG, + "%s: entering ground-holding suppression" + " (stationary for %.1fs)", + key().c_str(), + pos.ts() - groundHoldingSinceTs); + } + + // 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. + if (bGroundHolding && dist_m < GND_HOLDING_TRIVIAL_DIST_M) + { + if (dataRefs.GetDebugAcPos(key())) + LOG_MSG(logDEBUG, + "%s: dropping trivial ground update" + " (dist=%.2fm, gs=%.2fkt)", + key().c_str(), dist_m, gs_kt); + return; + } + } else { + // Any non-stationary slot ends the streak. We also clear + // the holding flag so a fresh stationary period after + // genuine taxi motion has to re-earn the suppression. + groundHoldingSinceTs = 0.0; + if (bGroundHolding) { + bGroundHolding = false; + if (dataRefs.GetDebugAcPos(key())) + LOG_MSG(logDEBUG, + "%s: exiting ground-holding suppression" + " (dist=%.2fm, gs=%.2fkt)", + key().c_str(), dist_m, gs_kt); + } + } } // add pos to the queue of data to be added From df0f1e3a64524839b96f239cf4a37587a702da91 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:18:07 +1000 Subject: [PATCH 07/43] feat/Ground: Lower Bezier activation threshold for taxi turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GND_BEZIER_MIN_HEAD_DIFF (1°) and uses it in place of the airborne 2.5° BEZIER_MIN_HEAD_DIFF when the aircraft is on the ground. Effect: slow taxi turns of 1–2° per leg are now rendered as a Bezier curve with tangent-derived heading instead of as a polyline whose heading walks via the MovingParam fallback. Combined with the rate-limit clamp and the deque-side stationary freeze, this gives visually fluid ground turns at all sizes without making airborne course corrections enter/exit Bezier mode constantly. --- Include/Constants.h | 11 +++++++++++ Src/LTAircraft.cpp | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Include/Constants.h b/Include/Constants.h index bd0ff435..08854c7d 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -178,6 +178,17 @@ constexpr double PUSHBACK_DETECT_HEAD_DIFF_DEG = 135.0; /// the first second of the maneuver. constexpr double PUSHBACK_MIDPOINT_DIST_M = 10.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; + //MARK: Flight Model constexpr double MDL_ALT_MIN = -1500; // [ft] minimum allowed altitude diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index 1a738660..c8945f75 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -1685,9 +1685,18 @@ 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; 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 || !turn.Define(ppos, to)) // or defining the Bezier failed for some other reason? { // ...start the turn from the initial heading to the vector heading From 22e4d4b20ae540baa1c70bb71d85613090f1b188 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:19:19 +1000 Subject: [PATCH 08/43] feat/Ground: Detect pushback and keep nose pointed at gate Adds a state-free pushback heuristic to LTFlightData::CalcHeading. When a ground slot derived groundspeed falls between GND_STATIONARY_GS_KT and PUSHBACK_DETECT_GS_MAX_KT and the track direction is at least PUSHBACK_DETECT_HEAD_DIFF_DEG away from the previous heading, the slot heading is locked to the predecessor heading rather than rotated to match the (backward) track. The rendered aircraft then visually moves backwards while keeping its nose pointed at the gate, which is the correct pushback appearance. Detection is purely sample-based: every slot is classified on its own geometry, so no extra per-aircraft state is needed and the behaviour naturally turns off when the tug stops (next slot below stationary threshold) or when the aircraft starts forward taxi (track realigns with heading). --- Src/LTFlightData.cpp | 54 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index 98f57489..c4c0a209 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1550,6 +1550,60 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) } } + // ---------------------------------------------------------------------- + // Pushback detection (state-free). + // + // A genuine pushback at a gate has a very recognisable geometric + // signature: the aircraft is on the ground, moving slowly (a few + // knots — the tug's pace), and the direction of motion (the track + // derived from pos[n-1]→pos[n]) is nearly opposite to the + // aircraft's previous heading. In that situation the physical + // aircraft is moving *backwards* while its nose still points + // forward at the gate. + // + // The default heading-from-track logic would rotate the rendered + // aircraft by 180° to face the direction it is moving, producing + // a bizarre visual where the airliner appears to taxi tail-first. + // We catch this case here and freeze the heading to the + // predecessor's value — the rendered aircraft then keeps its nose + // pointed at the gate while its world position is interpolated + // backwards, which is exactly the correct visual. + // + // Thresholds: + // - speed in (GND_STATIONARY_GS_KT, PUSHBACK_DETECT_GS_MAX_KT] + // (faster than holding-stationary, slower than taxi) + // - |track − previous heading| ≥ PUSHBACK_DETECT_HEAD_DIFF_DEG + // (well inside the "going backwards" half-plane) + // + // This is intentionally state-free: every slot is classified on + // its own geometry. When the tug stops, the next slot fails the + // "moving slowly" check and normal heading logic takes over. When + // the aircraft begins forward taxi after pushback, the track + // realigns with heading and pushback no longer triggers. + // ---------------------------------------------------------------------- + if (it->IsOnGnd() && it != posDeque.cbegin()) { + const positionTy& prePos = *std::prev(it); + if (prePos.IsOnGnd() && it->ts() > prePos.ts()) { + const double gsDerived_kt = prePos.speed_kt(*it); + if (!std::isnan(gsDerived_kt) && + gsDerived_kt > GND_STATIONARY_GS_KT && + gsDerived_kt <= PUSHBACK_DETECT_GS_MAX_KT) + { + const vectorTy track = prePos.between(*it); + const double pHead = prePos.heading(); + if (!std::isnan(track.angle) && !std::isnan(pHead)) { + const double trackVsHead = + std::abs(HeadingDiff(pHead, track.angle)); + if (trackVsHead >= PUSHBACK_DETECT_HEAD_DIFF_DEG) { + // Pushback: keep nose pointing at gate. + it->heading() = pHead; + return; + } + } + } + } + } + // vectors to / from the position at "it" vectorTy vecTo, vecFrom; From a2decf9c2d03349bcbb606fd31a94a1dea08fc83 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:33:22 +1000 Subject: [PATCH 09/43] fix/Ground: Set ground pitch to 0 and add diagnostic logging - GND_PITCH_DEG: 2.0 -> 0.0. The 2 deg value looked wrong on common narrow-bodies (tail-dragger appearance). 0 deg matches LiveTraffic pre-existing convention (touch-down already walked pitch to 0). - Adds temporary unconditional diagnostic logging tagged GND_DIAG_ADD, GND_DIAG_CHD, GND_DIAG_HYST, GND_DIAG_FREEZE, GND_DIAG_HOLDIN, GND_DIAG_HOLDOUT, GND_DIAG_DROP in LTFlightData::AddNewPos and CalcHeading. These fire for every on-ground feed update and heading computation regardless of GetDebugAcPos, so per-aircraft pos debug does NOT need to be enabled. Search the log for "GND_DIAG_" to isolate. To be removed once thresholds are tuned. --- Include/Constants.h | 14 +++++---- Src/LTFlightData.cpp | 73 +++++++++++++++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 08854c7d..104f0302 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -143,12 +143,14 @@ constexpr double GND_HOLDING_TRIVIAL_DIST_M = 7.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). -/// A real aircraft sits with a slightly nose-up attitude due to gear geometry; -/// 2° is a good neutral average across narrow-bodies, wide-bodies, and most -/// GA singles. Hard-setting it (rather than inheriting from the data feed, -/// which usually has no useful pitch on the ground) prevents pitch drift -/// caused by inter-position interpolation in the slot pipeline. -constexpr double GND_PITCH_DEG = 2.0; +/// 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 diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index c4c0a209..2d4a4252 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1526,6 +1526,22 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) const bool isolated = std::isnan(gsFromPrev_kt) || std::isnan(gsToNext_kt); + // ----- TEMPORARY 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. + 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))) { @@ -1534,6 +1550,9 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) if (it != posDeque.cbegin()) { const double prevHead = std::prev(it)->heading(); if (!std::isnan(prevHead)) { + LOG_MSG(logDEBUG, + "GND_DIAG_FREEZE %s ts=%.1f kept prevHdg=%.1f", + key().c_str(), it->ts(), prevHead); it->heading() = prevHead; return; } @@ -1693,8 +1712,14 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) if (it->IsOnGnd() && it != posDeque.cbegin()) { const double prevHead = std::prev(it)->heading(); if (!std::isnan(prevHead) && !std::isnan(it->heading())) { - if (std::abs(HeadingDiff(prevHead, it->heading())) < - GND_HEADING_HYSTERESIS_DEG) + const double dHead = std::abs(HeadingDiff(prevHead, it->heading())); + // ----- TEMPORARY GROUND DIAGNOSTIC LOGGING (tag: GND_DIAG_HYST) - + 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; } @@ -1992,6 +2017,21 @@ void LTFlightData::AddNewPos ( positionTy& pos ) !std::isnan(gs_kt) && gs_kt <= GND_STATIONARY_GS_KT; + // ----- TEMPORARY 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) { + 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) { // Either continue an existing streak or start a fresh one. // The streak start is the timestamp of the LATEST already- @@ -2006,12 +2046,11 @@ void LTFlightData::AddNewPos ( positionTy& pos ) (pos.ts() - groundHoldingSinceTs) >= GND_HOLDING_TIMEOUT_S) { bGroundHolding = true; - if (dataRefs.GetDebugAcPos(key())) - LOG_MSG(logDEBUG, - "%s: entering ground-holding suppression" - " (stationary for %.1fs)", - key().c_str(), - pos.ts() - groundHoldingSinceTs); + LOG_MSG(logDEBUG, + "GND_DIAG_HOLDIN %s entering ground-holding" + " (stationary for %.1fs)", + key().c_str(), + pos.ts() - groundHoldingSinceTs); } // While in holding, drop trivial jitter outright. We still @@ -2020,11 +2059,10 @@ void LTFlightData::AddNewPos ( positionTy& pos ) // taxi start that we must not miss. if (bGroundHolding && dist_m < GND_HOLDING_TRIVIAL_DIST_M) { - if (dataRefs.GetDebugAcPos(key())) - LOG_MSG(logDEBUG, - "%s: dropping trivial ground update" - " (dist=%.2fm, gs=%.2fkt)", - key().c_str(), dist_m, gs_kt); + LOG_MSG(logDEBUG, + "GND_DIAG_DROP %s dropping trivial update" + " (dist=%.2fm, gs=%.2fkt)", + key().c_str(), dist_m, gs_kt); return; } } else { @@ -2034,11 +2072,10 @@ void LTFlightData::AddNewPos ( positionTy& pos ) groundHoldingSinceTs = 0.0; if (bGroundHolding) { bGroundHolding = false; - if (dataRefs.GetDebugAcPos(key())) - LOG_MSG(logDEBUG, - "%s: exiting ground-holding suppression" - " (dist=%.2fm, gs=%.2fkt)", - key().c_str(), dist_m, gs_kt); + LOG_MSG(logDEBUG, + "GND_DIAG_HOLDOUT %s exiting ground-holding" + " (dist=%.2fm, gs=%.2fkt)", + key().c_str(), dist_m, gs_kt); } } } From a1e0423013ad81ee27af1055dc60d824c1530f8b Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 19:46:03 +1000 Subject: [PATCH 10/43] fix/Ground: Retune dance-suppression thresholds from real feed data Diagnostic logs from AA3107 (parked, RealTraffic feed providing a stable heading) and AA2675 (slow-taxiing, RealTraffic feed providing no heading) showed three concrete misfits in the previous thresholds: - Parked aircraft routinely produced derived gs of 0.75-1.05 kt at the gate purely from feed jitter, just above the 0.5 kt stationary threshold. Holding therefore exited and small position movements propagated to the renderer. - Slow-taxi heading wobbled 5-7 deg per slot due to track-from-pos- delta on RealTraffic positions without a heading field. The 0.5 deg hysteresis never engaged. - A single isolated above-threshold slot ended the holding streak immediately, requiring another full 30 s of stationary samples before suppression resumed. Changes: - GND_STATIONARY_GS_KT 0.5 -> 1.5 (above gate-jitter band) - GND_HEADING_HYSTERESIS_DEG 0.5 -> 4.0 (catches slow-taxi wobble) - GND_HEADING_MAX_RATE_DPS 60.0 -> 12.0 (matches TAXI_TURN_TIME) - GND_HOLDING_TRIVIAL_DIST_M 7.0 -> 15.0 (covers observed envelope) - new GND_HOLDING_EXIT_CONSEC = 2 (consecutive non-stationary slots needed to exit) Holding state machine in AddNewPos updated: - Stationary slot resets groundNonStationaryCnt to 0. - Non-stationary slot increments the counter; only exits holding once the counter reaches GND_HOLDING_EXIT_CONSEC. - On exit, groundHoldingSinceTs is reset to the current ts (not 0) so that if motion ceases again immediately, the next holding promotion is timed from the resumption of stationarity. --- Include/Constants.h | 52 +++++++++++++++++++++++++++--------------- Include/LTFlightData.h | 6 +++++ Src/LTFlightData.cpp | 29 +++++++++++++++++------ 3 files changed, 61 insertions(+), 26 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 104f0302..65fda4f2 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -105,25 +105,30 @@ constexpr const char* EXPORT_USER_CALL = "USER";///< call sign used for user's p // ----------------------------------------------------------------------------- /// [°] dead-band around the target heading inside which the rendered nose is -/// not moved. Real-feed ADS-B jitter routinely produces sub-degree variation -/// in track-from-pos-delta — we want those to be ignored so that a parked or -/// slow-taxiing aircraft is visually stable. Set just wide enough to absorb -/// sensor noise without making genuine slow turns look stepped. -constexpr double GND_HEADING_HYSTERESIS_DEG = 0.5; +/// 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. Even if the *target* heading jumps by a large amount, the -/// displayed heading walks smoothly at no more than this rate so the eye -/// never sees a snap-rotation. 60°/s ≈ 1° per frame at a 60 fps draw rate; -/// real ground turns from taxi-out to runway-line-up rarely exceed this. -constexpr double GND_HEADING_MAX_RATE_DPS = 60.0; +/// 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. 0.5 kn is -/// roughly 0.26 m/s — well below the slowest real taxi speed, but above the -/// numerical noise that can creep in when a parked aircraft's reported -/// groundspeed is "almost zero" rather than exactly zero in the feed. -constexpr double GND_STATIONARY_GS_KT = 0.5; +/// 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 @@ -136,10 +141,19 @@ 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. The value matches -/// `SIMILAR_POS_DIST` (already used elsewhere in the heading code) so the -/// two thresholds remain consistent; a future change here should propagate. -constexpr double GND_HOLDING_TRIVIAL_DIST_M = 7.0; +/// 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; + +/// 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; /// [°] pitch hard-set on every frame while the aircraft is on the ground /// (except during the take-off / flare phases, which manage pitch dynamically). diff --git a/Include/LTFlightData.h b/Include/LTFlightData.h index e72b7a99..030959a2 100644 --- a/Include/LTFlightData.h +++ b/Include/LTFlightData.h @@ -252,6 +252,12 @@ class LTFlightData /// 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; // STATIC DATA (protected, access will be mutex-controlled for thread-safety) FDStaticData statData; diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index 2d4a4252..71e74f8b 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -2033,6 +2033,10 @@ void LTFlightData::AddNewPos ( positionTy& pos ) } 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 @@ -2066,16 +2070,27 @@ void LTFlightData::AddNewPos ( positionTy& pos ) return; } } else { - // Any non-stationary slot ends the streak. We also clear - // the holding flag so a fresh stationary period after - // genuine taxi motion has to re-earn the suppression. - groundHoldingSinceTs = 0.0; - if (bGroundHolding) { + // 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(); LOG_MSG(logDEBUG, "GND_DIAG_HOLDOUT %s exiting ground-holding" - " (dist=%.2fm, gs=%.2fkt)", - key().c_str(), dist_m, gs_kt); + " (consec=%d, dist=%.2fm, gs=%.2fkt)", + key().c_str(), groundNonStationaryCnt, + dist_m, gs_kt); } } } From 51a1ced4eb7bdd82798e435721307e33ec3313ab Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Tue, 12 May 2026 20:00:10 +1000 Subject: [PATCH 11/43] fix/Ground: Trust feed-provided heading at slow ground speed When the data feed (e.g. RealTraffic) supplies an explicit heading value AND the aircraft is on the ground moving below GND_USE_FEED_HEADING_MAX_KT (10 kn), use the feed value directly and skip the position-from-track derivation entirely. The previous behaviour computed heading from atan2 over consecutive lat/lon pairs, which is wrong in two important slow-ground cases: 1. Pushback. The aircraft is moving tail-first, so the track-over- ground points OPPOSITE to the nose. atan2 produced a heading ~180 deg off, which is exactly what was observed for AA1146 and AA2980 during real pushbacks at gates: their feed heading was correctly ~30 deg / ~20 deg but the rendered nose snapped to 270 deg matching the backwards motion. 2. Parked aircraft with feed-fabricated jitter. The "motion" vector between samples is dominated by noise, so the derived heading wanders. Meanwhile the feed-reported heading is usually stable and correct. The new fast-path check runs before all other heading logic in CalcHeading: if on ground and feed heading is non-NaN and derived gs < threshold, accept the feed value and return. Above the threshold the track-derived heading is the better source (it reflects the curve the aircraft is actually flying) so we fall through to the normal logic. A new GND_DIAG_FEEDHDG diagnostic logs each trust event so we can verify the rule fires for pushback/slow-taxi traffic. --- Include/Constants.h | 14 +++++++++++ Src/LTFlightData.cpp | 57 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/Include/Constants.h b/Include/Constants.h index 65fda4f2..4c013d28 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -155,6 +155,20 @@ constexpr double GND_HOLDING_TRIVIAL_DIST_M = 15.0; /// before we trust the motion. constexpr int GND_HOLDING_EXIT_CONSEC = 2; +/// [kn] groundspeed ceiling under which a feed-provided heading is trusted +/// in preference to a heading derived from the position track. Rationale: +/// at low ground speeds (parked, slow taxi, pushback) the track-over-ground +/// is unreliable because positional jitter dominates the small genuine +/// motion vector — and during pushback the track is the OPPOSITE direction +/// to the nose. The feed (ADS-B / RealTraffic / etc.) usually has the +/// aircraft's actual reported heading available; we should use it whenever +/// it is present and we are moving slowly enough on the ground that the +/// position-derived alternative cannot be trusted. 10 kn covers the +/// pushback band (1–3 kn) and slow taxi (up to ~8 kn) with margin; at +/// faster speeds the track-derived heading becomes reliable and the feed +/// value (which can lag during sharp turns) is no longer the better source. +constexpr double GND_USE_FEED_HEADING_MAX_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 diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index 71e74f8b..e471102a 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1479,6 +1479,63 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) // access guarded by a mutex std::lock_guard lock (dataAccessMutex); + // ---------------------------------------------------------------------- + // Trust feed-provided heading at slow ground speed. + // + // The position-from-track logic below derives heading by taking + // atan2 over consecutive lat/lon pairs. That works well at flying + // speeds where the genuine motion vector dwarfs sensor noise — but + // on the ground at parked, slow-taxi, or pushback speeds it produces + // wildly wrong answers: + // * parked aircraft: the noise vector IS the apparent motion vector + // * pushback: the track points OPPOSITE to the nose (the aircraft + // is moving tail-first), so atan2 gives a heading 180° off + // + // RealTraffic and most ADS-B feeds provide an explicit `heading` + // field per sample, derived from the transponder or magnetic-track + // data — which IS the correct nose direction in all of the above + // cases. The previous logic ignored that field whenever the + // position-derived computation succeeded. We now reverse that + // priority below `GND_USE_FEED_HEADING_MAX_KT`: if the feed has + // a value and we are slow on the ground, keep it. + // + // Limitations: + // * Feed heading set to exactly 0.0 may be a "no data" sentinel + // rather than a real measurement (some channels do this). We + // can't distinguish without channel-specific knowledge, so we + // accept the risk — anything is better than rotating a parked + // aircraft by 180° during a real pushback. + // * Above the threshold (≥10 kn) the track-derived heading + // becomes the better source (it reflects the actual curve the + // aircraft is flying) so we fall through to the normal logic. + // ---------------------------------------------------------------------- + if (it->IsOnGnd() && !std::isnan(it->heading())) { + // Derive groundspeed from the predecessor pair when possible. + // A missing predecessor means this is the head of the deque — + // we have no track to compare against anyway, so the feed value + // is definitively the best source. + double gsDerived_kt = NAN; + if (it != posDeque.cbegin()) { + const positionTy& prePos = *std::prev(it); + if (prePos.IsOnGnd() && it->ts() > prePos.ts()) + gsDerived_kt = prePos.speed_kt(*it); + } + if (std::isnan(gsDerived_kt) || + gsDerived_kt < GND_USE_FEED_HEADING_MAX_KT) + { + // Feed value wins. Leave `it->heading()` untouched and skip + // every downstream branch — stationary-freeze, pushback + // detection, and the position-delta computation would all + // potentially overwrite a perfectly good heading. + LOG_MSG(logDEBUG, + "GND_DIAG_FEEDHDG %s ts=%.1f feedHdg=%.1f gs=%.2fkt" + " (trusted)", + key().c_str(), it->ts(), it->heading(), + std::isnan(gsDerived_kt) ? 0.0 : gsDerived_kt); + return; + } + } + // ---------------------------------------------------------------------- // Ground-stationary freeze. // From 1102320cd790e5a54fa53a03fcbe926364cf743b Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Wed, 13 May 2026 08:36:34 +1000 Subject: [PATCH 12/43] fix/Livery: Tail-number detection in FDStaticData::airlineCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, FDStaticData::airlineCode() returned call.substr(0,3) when opIcao was empty. For aircraft whose callsign is actually a tail registration (N552FX, 9K1876, 1I637, etc.) this produced garbage 3-character strings like "N55", "9K1", "1I6" which XPMP2 then tried to match as an airline. No airline ICAO code begins with a digit, so the match always failed and XPMP2 fell back to "any livery of this aircraft class" — producing visibly random liveries. The fix is a simple alpha test: the first three characters of an ICAO airline ATC callsign are always letters (AAL, RPA, QFA). If any of the first three characters is not alphabetic, return an empty string so XPMP2 does type-only matching instead. From a captured session, this affects 7 of 36 wrong-livery outcomes (~20%): N-prefix registrations of small jets, GA piston aircraft, and entries where the feed delivers a registration in the callsign field. Companion change (not in this commit, ships separately): the installed Resources/relOp.txt has been extended with North American codeshare/merger groups (Republic with American Eagle, SkyWest with United Express, Endeavor with Delta Connection, US Airways with American, Continental with United, Northwest with Delta, etc.) so regional carriers without dedicated CSL liveries fall back to their parent mainline's livery rather than a random unrelated airline. That change lives at /Users/balt/X-Plane 12/Resources/plugins/ LiveTraffic/Resources/relOp.txt (the file XPMP2 actually loads at runtime) and has a mirror in the XPMP2 submodule source at Lib/XPMP2/Resources/relOp.txt which has not been committed because it requires a separate commit inside the submodule. --- Include/LTFlightData.h | 44 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/Include/LTFlightData.h b/Include/LTFlightData.h index 030959a2..b659f7b0 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) From 658dc6be9038515daf20b64d5961f25ca129cfba Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Wed, 13 May 2026 09:07:55 +1000 Subject: [PATCH 13/43] fix/Ground: De-rotate smoothly on landing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ground-pitch override in LTAircraft::CalcAcPos was snapping pitch to GND_PITCH_DEG one frame after touchdown. FPH_TOUCH_DOWN is documented as a single-cycle event; the next frame is FPH_ROLL_OUT, which the override did not previously exempt. So even though CalcFlightModel kicked off a smooth pitch.moveTo(GND_PITCH_DEG) on touchdown, the override clobbered the MovingParam's walk on the very next frame — producing a one-frame nose-slam visible to the user. Fix: add FPH_ROLL_OUT to the phase exception list. During roll-out the MovingParam now walks pitch smoothly from PITCH_FLARE down to GND_PITCH_DEG over ~1 second. Once roll-out ends and the aircraft enters FPH_TAXI, the override applies again and pitch is locked at GND_PITCH_DEG (which by then matches the MovingParam value). Roll is still forced flat in all phases including the dynamic-pitch ones — there is no scenario where a wheeled aircraft banks during rotation, flare, touchdown, or roll-out. --- Src/LTAircraft.cpp | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index c8945f75..4dc9637c 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -1990,18 +1990,32 @@ bool LTAircraft::CalcPPos() // // Exceptions: phases where the nose is genuinely moving relative // to the ground — rotation for take-off (`FPH_ROTATE`), the flare - // before touchdown (`FPH_FLARE`), and the single-cycle touchdown - // event (`FPH_TOUCH_DOWN`). In those phases the flight-model code - // is actively driving the `pitch` MovingParam through a planned - // transition (e.g., `pitch.max()` on rotate, `pitch.moveTo( - // PITCH_FLARE)` on flare), and overriding it here would visibly - // freeze the maneuver. + // 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.max()` on rotate, + // `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. Roll is forced flat in all phases — + // ground aircraft never bank, so no exception is needed there. if (phase != FPH_ROTATE && phase != FPH_FLARE && - phase != FPH_TOUCH_DOWN) + 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 From 6aad04c04ceb9d6c6849603a2e9dbb17d03963a3 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Wed, 13 May 2026 09:23:52 +1000 Subject: [PATCH 14/43] fix/Ground: Track motion direction at high ground speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing rollout and takeoff aircraft were rendered with their nose pointing toward the NEXT slot's reported heading instead of along their actual direction of motion. When the next slot was on a turn- off taxiway (heading 326 deg) and the current slot was at end-of- runway (heading 020 deg), BezierCurve::Define used to.heading() as the end-tangent, arcing the rendered path across the runway corner. Result: aircraft visually "slides off the runway" with its nose ~50 deg off the motion vector. Fix: introduce GND_TRACK_HEADING_MIN_KT (10 kn) as the boundary between "trust the feed heading" (slow taxi / pushback / parked) and "trust the motion vector" (rollout / takeoff / fast taxi). In LTAircraft::CalcAcPos two coordinated changes: 1. At leg setup, when on ground and gs >= GND_TRACK_HEADING_MIN_KT, skip BezierCurve::Define and fall through to the linear-path branch. The linear path already targets `vec.angle` (the bearing from `from` to `to` — the actual motion direction) as the heading goal, which is what an aircraft physically does on the ground at speed: nose along the track. 2. At the half-way-through-leg checkpoint, skip the retarget to `to.heading()` under the same condition. The leg-start setup has already pointed heading at the motion vector; retargeting would restart the same Bezier-clobber problem. Once gs drops below the threshold, the retarget is allowed again so the aircraft can converge on the slot's reported orientation for turn-offs, gate manoeuvres, etc. 10 kn is deliberately the same threshold as GND_USE_FEED_HEADING_MAX_KT so the two rules form one consistent boundary: below 10 kn, the feed heading wins; at or above 10 kn, the motion vector wins. No middle ground. --- Include/Constants.h | 30 ++++++++++++++++++++++++++++++ Src/LTAircraft.cpp | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 4c013d28..6cf5f48d 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -169,6 +169,36 @@ constexpr int GND_HOLDING_EXIT_CONSEC = 2; /// value (which can lag during sharp turns) is no longer the better source. constexpr double GND_USE_FEED_HEADING_MAX_KT = 10.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 diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index 4dc9637c..55b7348f 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -1694,9 +1694,24 @@ bool LTAircraft::CalcPPos() // 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. + const bool bGndFast = IsOnGrnd() && + GetSpeed_kt() >= 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())) < 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 @@ -1936,8 +1951,24 @@ 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 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 the aircraft slows 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. + else if (!dequal(heading.toVal(), to.heading()) && + !(IsOnGrnd() && + GetSpeed_kt() >= 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 From 947767190f7edb269e8bb933be1a5299eeeaf5eb Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Wed, 13 May 2026 09:46:20 +1000 Subject: [PATCH 15/43] fix/Ground: Use leg-average speed not current speed for track-heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A previous commit added a bGndFast guard so that at high ground speed the renderer skips the Bezier path between slots and walks heading along the motion vector. The guard used GetSpeed_kt() — the currently-rendered speed at leg-setup time — which is the speed the aircraft is COMING INTO the leg, not the speed during the leg. This produced a regression on takeoff (ADO15 observed): the leg that spans taxi-into-runway has gs entering at ~5 kt and gs leaving at ~12 kt (47 m in 7.84 s). At leg-setup the rendered speed is ~5 kt, so bGndFast was false, Bezier was set up across the 89 deg corner from taxi heading (59 deg) to runway heading (330 deg), and the rendered nose arced through ~14 deg mid-leg while the aircraft positioned onto the runway. The subsequent runway-roll legs at gs > 50 kt did engage track-heading and started walking nose toward the motion vector — but the MovingParam's walk rate (TAXI_TURN_TIME) is too slow to converge before liftoff, so the aircraft was still ~45 deg off motion direction through rotation and into climb-out. Fix: use the leg's average speed vec.speed_kn() = dist/dt instead. The taxi-to-runway leg averages 11.8 kt — above the 10 kt threshold — so track-heading engages from the moment the aircraft enters that transition leg, and the nose walks straight toward the motion vector (which is along the runway) rather than arcing through the corner. By the time the aircraft is at runway-roll speeds, the rendered heading is already aligned with the runway centerline, liftoff happens nose-aligned, and the climb-out continues nose-aligned. Same fix applied to the half-way retarget skip so the two conditions remain consistent. --- Src/LTAircraft.cpp | 50 +++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index 55b7348f..ace68920 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -1706,8 +1706,21 @@ bool LTAircraft::CalcPPos() // — 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() && - GetSpeed_kt() >= GND_TRACK_HEADING_MIN_KT; + !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())) < minHeadDiff || @@ -1953,21 +1966,30 @@ bool LTAircraft::CalcPPos() } // otherwise prepare turning heading to final heading (if not done already). // - // On the ground at high 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 the aircraft slows 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. + // 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() && - GetSpeed_kt() >= GND_TRACK_HEADING_MIN_KT)) + !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 From 4528131815a1b039d66cc5f33652949e3d7f98bf Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Wed, 13 May 2026 10:07:59 +1000 Subject: [PATCH 16/43] fix/Ground: Skip SnapToTaxiways while in ground-holding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LTFlightData::SnapToTaxiways inserts taxiway-node waypoints into posDeque with heading=NaN, expecting CalcHeading to fill it in later. For a parked aircraft in bGroundHolding an isolated large position jump from the feed (observed: ACA34 at YSSY, 105 m jump while the RT app showed the aircraft stationary) gets accepted — it exceeds the 15 m trivial-drop threshold — and SnapToTaxiways then synthesises a sequence of intermediate waypoints along the airport taxi graph between the original parking position and the jumped position. CalcHeading on those phantom slots sees hdg_in=NaN, fails the feed-heading-trust check, fails the stationary check (the synthetic inter-waypoint derived gs is ~7 kt), and falls through to vector- from-pos-delta. The resulting headings reflect the taxiway geometry (302° → 256° → 244° in the observed case), not the aircraft's nose direction. The renderer walks the aircraft through the phantom path and the parked aircraft visually dances along a non-existent taxi route. Fix: skip the snap loop entirely when bGroundHolding is true. The glitched position still enters posDeque, but it is now interpolated as a single linear segment (a one-time visual wobble at worst) and no waypoints with their rotating headings are inserted. When the aircraft genuinely begins to taxi, AddNewPos's GND_HOLDING_EXIT_CONSEC counter clears bGroundHolding and snap-to-taxiway resumes for subsequent slots. --- Src/LTFlightData.cpp | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index e471102a..7e23e372 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(); From 82042be8bacbececfb6b338d98be9d968793c1ab Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Wed, 13 May 2026 10:08:17 +1000 Subject: [PATCH 17/43] fix/Weather: Reject placeholder QNH=1013 responses from RealTraffic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When RealTraffic's weather endpoint sometimes returns a stripped-down response — no ICAO, no METAR, QNH=1013 — for a query location whose real weather it previously delivered correctly. Observed at YSSY: a valid {"ICAO":"YSSY","QNH":1034,"METAR":...} response followed ~60 s later by a {"QNH":1013, no ICAO, no METAR} response for the same query coordinates. The 1013 is RealTraffic's standard-pressure placeholder, not a real reading. The previous code in PreProcessWeather accepted these placeholders unconditionally. rtWx.QNH got overwritten from 1034 back to 1013.25, and BaroAltToGeoAlt_ft stopped applying the local-pressure correction. At YSSY (real QNH 1034) the correction is +560 ft per 1500 ft of baro altitude; without it, landing aircraft render ~500 ft below true MSL and touch down on the surrounding terrain a mile short of the runway threshold. Fix: in PreProcessWeather, treat a response as a placeholder and skip the SetWeather call when ALL of: * no ICAO/airport identifier * no METAR text * QNH ~ 1013.25 (within 0.5 hPa) * we already hold a confirmed non-standard QNH The last condition lets a genuine 1013-hPa reading still arrive as a first weather update; we only reject placeholders when they would overwrite a previously confirmed non-standard value. --- Src/LTRealTraffic.cpp | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/Src/LTRealTraffic.cpp b/Src/LTRealTraffic.cpp index 3448295e..6a815845 100644 --- a/Src/LTRealTraffic.cpp +++ b/Src/LTRealTraffic.cpp @@ -1164,7 +1164,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); From 21c0a364c45d3934e7fabca17ae3e972e40fe87b Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Wed, 13 May 2026 11:10:53 +1000 Subject: [PATCH 18/43] fix/Channels: Reset validity when channel checkbox is re-enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A channel that hit too many consecutive network errors gets marked bValid=false. While invalid, LTChannel::shallRun() returns false and LTFlightDataAcMaintenance() never restarts its thread — even if the user toggles the channel's enable checkbox off and back on, because the toggle only flips bChannel[ch] and leaves bValid alone. The intended recovery path was the hidden "Restart Stopped Channels" button in Settings → Basic (which calls LTFlightDataRestartInvalidChs). Most users won't notice that button and will instead reach for the channel's own checkbox, which silently does nothing — the thread never resumes even though the UI now claims the channel is enabled. Fix: in DataRefs::SetChannelEnabled, when bEnable=true, look up the LTChannel and call SetValid(true) on any channel that is currently invalid. This makes the off-then-on toggle behave the way the user expects: a re-enable revives an invalidated channel and the next maintenance tick (~2 s later) starts its network thread. SetValid(true) also resets errCnt, so the channel gets a fresh CH_MAC_ERR_CNT budget before it could go invalid again. If the underlying failure is persistent (bad credentials, server still down, …) the channel will simply re-invalidate on the next attempt and the user gets immediate visible feedback that something is wrong with the configuration — preferable to silent inaction. --- Src/DataRefs.cpp | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/Src/DataRefs.cpp b/Src/DataRefs.cpp index ab5e28ca..f3d88c92 100644 --- a/Src/DataRefs.cpp +++ b/Src/DataRefs.cpp @@ -2564,12 +2564,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? From d86186964ef4931a6a0a95152b7fe5e4579f44cf Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Thu, 14 May 2026 08:52:15 +1000 Subject: [PATCH 19/43] fix/Ground: Cross-check feed heading vs track to catch EHS staleness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tester reported aircraft "driving sideways" during parts of taxi routes — after leaving the gate, before starting the taxi to take off, and on parts of the taxi-to-gate after landing. Investigation showed the symptom is concentrated on TURNS, not straight segments. Root cause: the heading field that LiveTraffic trusts at slow ground speed (`GND_USE_FEED_HEADING_MAX_KT = 10 kn`) is sourced from Mode S Enhanced Surveillance (EHS), which typically only updates every ~10 s and is unavailable entirely in regions without enhanced interrogation coverage. Between EHS updates the value is held constant by the receiver. During a taxi turn at 5–10 kn the aircraft can change direction by 60° or more inside one 10 s freshness window — and the held value lags. The renderer was trusting the stale value, so the nose stayed at the pre-turn direction while the body progressed along the new direction. Fix: cross-check the feed heading against the position-derived track (bearing from the predecessor slot to this one — always "now") inside the on-ground feed-heading branch of LTFlightData::CalcHeading. Three bands gated by the new GND_FEED_TRACK_AGREE_DEG = 30° constant: |Δ| < 30° agree trust feed (straight taxi or fresh EHS) 30° ≤ |Δ| ≤ 150° disagree fall through (feed has lagged in turn) |Δ| > 150° opposite trust feed (pushback) Unchanged at fast ground speeds: above 10 kn the track-heading regime in LTAircraft::CalcAcPos already owns the rendering. Unchanged when stationary: the track is meaningless under positional jitter so feed still wins. Unchanged in pushback: the >150° band detects the reversed track explicitly. Diagnostic GND_DIAG_FEEDHDG log line is extended with the track angle and a short reason string so future regressions are easy to attribute from a Log.txt capture. --- Include/Constants.h | 54 ++++++++++++++---- Src/LTFlightData.cpp | 133 +++++++++++++++++++++++++++++++------------ 2 files changed, 138 insertions(+), 49 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 6cf5f48d..0c6f5c33 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -155,20 +155,50 @@ constexpr double GND_HOLDING_TRIVIAL_DIST_M = 15.0; /// before we trust the motion. constexpr int GND_HOLDING_EXIT_CONSEC = 2; -/// [kn] groundspeed ceiling under which a feed-provided heading is trusted -/// in preference to a heading derived from the position track. Rationale: -/// at low ground speeds (parked, slow taxi, pushback) the track-over-ground -/// is unreliable because positional jitter dominates the small genuine -/// motion vector — and during pushback the track is the OPPOSITE direction -/// to the nose. The feed (ADS-B / RealTraffic / etc.) usually has the -/// aircraft's actual reported heading available; we should use it whenever -/// it is present and we are moving slowly enough on the ground that the -/// position-derived alternative cannot be trusted. 10 kn covers the -/// pushback band (1–3 kn) and slow taxi (up to ~8 kn) with margin; at -/// faster speeds the track-derived heading becomes reliable and the feed -/// value (which can lag during sharp turns) is no longer the better source. +/// [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 diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index 7e23e372..063af5a6 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1505,58 +1505,117 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) std::lock_guard lock (dataAccessMutex); // ---------------------------------------------------------------------- - // Trust feed-provided heading at slow ground speed. + // 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. That works well at flying - // speeds where the genuine motion vector dwarfs sensor noise — but - // on the ground at parked, slow-taxi, or pushback speeds it produces - // wildly wrong answers: - // * parked aircraft: the noise vector IS the apparent motion vector - // * pushback: the track points OPPOSITE to the nose (the aircraft - // is moving tail-first), so atan2 gives a heading 180° off + // 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 per sample, derived from the transponder or magnetic-track - // data — which IS the correct nose direction in all of the above - // cases. The previous logic ignored that field whenever the - // position-derived computation succeeded. We now reverse that - // priority below `GND_USE_FEED_HEADING_MAX_KT`: if the feed has - // a value and we are slow on the ground, keep it. + // 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 set to exactly 0.0 may be a "no data" sentinel - // rather than a real measurement (some channels do this). We - // can't distinguish without channel-specific knowledge, so we - // accept the risk — anything is better than rotating a parked - // aircraft by 180° during a real pushback. - // * Above the threshold (≥10 kn) the track-derived heading - // becomes the better source (it reflects the actual curve the - // aircraft is flying) so we fall through to the normal logic. + // * 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 from the predecessor pair when possible. - // A missing predecessor means this is the head of the deque — - // we have no track to compare against anyway, so the feed value - // is definitively the best source. + // 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()) + 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; + } + } } - if (std::isnan(gsDerived_kt) || - gsDerived_kt < GND_USE_FEED_HEADING_MAX_KT) - { - // Feed value wins. Leave `it->heading()` untouched and skip - // every downstream branch — stationary-freeze, pushback - // detection, and the position-delta computation would all - // potentially overwrite a perfectly good heading. + + // 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) { LOG_MSG(logDEBUG, "GND_DIAG_FEEDHDG %s ts=%.1f feedHdg=%.1f gs=%.2fkt" - " (trusted)", + " track=%.1f (%s)", key().c_str(), it->ts(), it->heading(), - std::isnan(gsDerived_kt) ? 0.0 : gsDerived_kt); + std::isnan(gsDerived_kt) ? 0.0 : gsDerived_kt, + std::isnan(trackAngle) ? -1.0 : trackAngle, + reason); return; } } From f9941c39af157f78b638a253f2545cac613f60f6 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Thu, 14 May 2026 09:03:02 +1000 Subject: [PATCH 20/43] fix/Ground: Smooth altitude blend on liftoff (no more pop-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported symptom: aircraft taking off "jump into the air" at rotation — the rendered altitude teleports from runway level to a few hundred feet in a single frame rather than walking up gradually as the aircraft rotates and lifts off. Cause: while the aircraft is on the ground, CalcPPos's `if (bOnGrnd)` branch clamps ppos.alt_m to terrainAlt_m every frame. The very next frame after CalcFlightModel decides the aircraft is airborne (`bOnGrnd` flips from true to false, phase becomes FPH_LIFT_OFF), that clamp stops applying and ppos.alt_m takes on its raw linearly- interpolated value between the last on-ground slot and the next airborne slot. Feed samples during takeoff are often 5-15 s apart, so by the time the renderer reaches the first airborne slot the interpolation has ppos.alt_m already at 100-500 ft above the runway. The aircraft visibly teleports up to that altitude in one frame. Fix: introduce LIFTOFF_BLEND_TIME_S = 1.5 s (Constants.h) and a new liftoffBlendStartTs member on LTAircraft. CalcFlightModel records the sim time on the frame `bOnGrnd` transitions true→false. For the next 1.5 s, CalcPPos's airborne branch lerps ppos.alt_m from terrainAlt_m toward the raw interpolated value using a cubic smoothstep easing (f(t) = t² (3-2t), C¹-continuous at both ends — no kink at start or finish of the blend). After 1.5 s the blend is complete and the aircraft renders at its full interpolated altitude as normal. 1.5 s coincides roughly with the pitch.max() walk driven by FPH_ROTATE, so the visual rotation and the altitude lift play out together. The cubic smoothstep avoids the linear-ramp "track up the slope" look and gives a natural ease-in/ease-out climb-out. --- Include/Constants.h | 23 +++++++++++++++++++ Include/LTAircraft.h | 6 +++++ Src/LTAircraft.cpp | 54 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/Include/Constants.h b/Include/Constants.h index 0c6f5c33..9a99c684 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -279,6 +279,29 @@ constexpr double PUSHBACK_MIDPOINT_DIST_M = 10.0; /// 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 100–500 ft 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. 1.5 s is short +/// enough to match the visual expectation of "rotation → lift-off" (about +/// the same duration as the pitch-up walk driven by `pitch.max()` on +/// `FPH_ROTATE`) and long enough that the eye reads it as a gradual +/// transition rather than a teleport. +constexpr double LIFTOFF_BLEND_TIME_S = 1.5; + //MARK: Flight Model constexpr double MDL_ALT_MIN = -1500; // [ft] minimum allowed altitude diff --git a/Include/LTAircraft.h b/Include/LTAircraft.h index 38c32e71..9e13af6e 100644 --- a/Include/LTAircraft.h +++ b/Include/LTAircraft.h @@ -300,6 +300,12 @@ 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; 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? diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index ace68920..0d0d8617 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -2076,6 +2076,51 @@ bool LTAircraft::CalcPPos() 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 instead lerp from terrain altitude to the interpolated + // altitude using a cubic smoothstep easing (f(t) = t² (3-2t)), + // which is C¹-continuous at both endpoints — no visible kink + // when the blend starts or ends. After LIFTOFF_BLEND_TIME_S + // the blend is complete and we revert to the raw interpolated + // value (so we don't perpetually drag the aircraft back toward + // the runway). + // ------------------------------------------------------------------ + if (!std::isnan(liftoffBlendStartTs)) { + const double sinceLiftoff = + currCycle.simTime - liftoffBlendStartTs; + if (sinceLiftoff < LIFTOFF_BLEND_TIME_S && + ppos.alt_m() > terrainAlt_m) + { + const double t = sinceLiftoff / LIFTOFF_BLEND_TIME_S; + // Cubic smoothstep: 0 at t=0, 1 at t=1, zero slope at + // both ends — visually identical to "ease in/out". + const double blend = t * t * (3.0 - 2.0 * t); + ppos.alt_m() = + terrainAlt_m + + (ppos.alt_m() - terrainAlt_m) * blend; + } else { + // Blend complete (or terrain probe disagrees with our + // notion of liftoff — bail out rather than dragging the + // aircraft below its interpolated altitude). + liftoffBlendStartTs = NAN; + } + } } // save this position for (next) camera view position @@ -2167,6 +2212,15 @@ void LTAircraft::CalcFlightModel (const positionTy& /*from*/, const positionTy& // 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; } // climbing but not even reached gear-up altitude From 6fb0b1fdfa46f5b7681ba38cc5e1976810754658 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Thu, 14 May 2026 10:08:20 +1000 Subject: [PATCH 21/43] feat/RT: Lower default traffic-request wait floor to 2s RealTraffic supports 2 s polling in regular operation; the previous 8 s `RT_DRCT_DEFAULT_WAIT` floor was capping the per-aircraft sample gap at ~10-20 s even when the server's RRL response would otherwise allow faster polling. The per-response `rrl` value supplied by RealTraffic 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. Lowering it to 2 s matches RT's documented minimum. Downstream effect: per-aircraft position granularity drops from ~10-20 s gaps to ~2-4 s gaps. The Mode-S EHS heading update interval (~10 s) becomes the dominant lag source rather than the polling floor, so the EHS-staleness cross-check added in commit d861869 engages less often (feed and track will agree more often) and the SnapToTaxiways backward-routing symptom investigated in task #24 becomes much less severe (less inter-sample distance for the synthesised path to mis-route across). No new config knob is exposed: the server-supplied RRL still drives the actual cadence, this constant is purely the safety floor. --- Include/LTRealTraffic.h | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Include/LTRealTraffic.h b/Include/LTRealTraffic.h index c9a9c06e..a80e4521 100644 --- a/Include/LTRealTraffic.h +++ b/Include/LTRealTraffic.h @@ -85,8 +85,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? From a28ae93121a56a1967cd8b3ec9c63a53f4652e4f Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Thu, 14 May 2026 10:09:19 +1000 Subject: [PATCH 22/43] fix/Ground: Apply heading cross-check before SnapToTaxiways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tester reported aircraft "taxiing backwards" for parts of the route (observed: DAL973, RPA5716 at YSSY). Root cause: LTAptSnap uses pos.heading() to decide which direction along a taxi edge to route the aircraft (TaxiEdge::startByHeading / endByHeading in LTApt.cpp, called from several sites: 734, 763, 839, 1298, 1348, 1369, 1401). The feed-supplied heading is sourced from Mode S Enhanced Surveillance (EHS), which updates only every ~10 s and lags noticeably during turns. When SnapToTaxiways processes a slot whose feed heading is stale, the synthesised taxi path between this slot and the next gets routed in the wrong direction along the edge — the rendered aircraft visibly moves backward along its taxiway between feed samples. CalcHeading (since commit d861869) already cross-checks the feed heading against the position-derived track and falls through to the track-derived value when the two disagree by 30–150°. But CalcNextPos ran SnapToTaxiways BEFORE the CalcHeading loop, so snap was reading the raw feed heading, bypassing the cross-check. Fix: in SnapToTaxiways, call CalcHeading on each on-ground slot immediately before LTAptSnap. This applies the full on-ground heading filter chain — stationary freeze, feed/track cross-check, pushback detect, hysteresis — so snap sees a heading that reflects the actual direction of travel rather than the stale EHS value. The existing post-snap CalcHeading loop in CalcNextPos stays in place. Its role is to fill in the heading of the intermediate waypoints that SnapToTaxiways itself synthesises (inserted with heading=NaN, then derived from the bearing between consecutive waypoints once the path geometry is known). The recent RT 2 s polling change (6fb0b1f) reduces but does not eliminate this symptom — even at 2 s feed samples, if the EHS lag exceeds 2 s (which can still happen) snap could route wrong. This fix addresses the underlying cause regardless of polling rate. --- Src/LTFlightData.cpp | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index 063af5a6..bd1eedff 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -883,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 From 486b916ec94d55b7707ccf488bf7d575dacc8ce8 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Thu, 14 May 2026 10:40:00 +1000 Subject: [PATCH 23/43] feat/Ground: Centripetal Catmull-Rom spline for position + heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the linear chord interpolation between two consecutive ground position slots with a centripetal Catmull-Rom spline fit through four control points: P0 = the most-recently-retired `from`, P1 = current from, P2 = current to, P3 = the slot after `to` if available. The rendered position comes from the spline at the current leg parameter, and the rendered heading comes from the spline's tangent at the same parameter (atan2 of dC/dt). Why this is the right shape of fix The previous chord-based interpolation produced a visible "sideways" look during taxi turns: the rendered position cut straight across each turn corner while the rendered heading walked toward the chord direction via a rate-limited MovingParam. Because the chord is the average of the entry and exit headings, the nose always lagged the actual taxiway tangent by half the per-leg heading change. With larger feed gaps (or at higher taxi speeds) the lag became visually objectionable — tester reports for TAP227, TAP215 and others. A smooth curve through four positions captures the actual arc the aircraft flew, and its tangent at any parameter is by construction the direction of motion at that point. Position and heading then come from the same curve and are aligned by construction — no separate rate-limit, no chord-vs-arc mismatch. Centripetal (Lee 2009) parameterisation is specifically chosen over the uniform or chordal variants because it is the only Catmull-Rom form that avoids cusps and self-intersecting loops at sharp corners, which we routinely encounter on runway turn-offs (45-90° in 30-60 m). Math implementation New helper `CatmullRomEvalCentripetal` in CoordCalc.{h,cpp}. Operates on a local meters frame centred at P1 (using existing Lat2Dist / Lon2Dist helpers) so the math is Euclidean and avoids cos(lat) accumulation across control points. Knot intervals use sqrt(chord distance) per the centripetal variant; barycentric (Lee) evaluation form for both the position and a finite-difference tangent. Heavily commented inline. LTAircraft changes * New `posPrev` member (positionTy, default NaN) stores the slot being popped during the position switch in CalcPPos so it remains available as P0 for the next leg's spline. * In CalcPPos, the non-Bezier branch now picks between spline and linear: - Both endpoints on ground → spline (lat/lon + heading from spline; alt + pitch stay linear because they're not part of the horizontal-plane curve and get clamped/forced later by the bOnGrnd block anyway). - Any other case → linear, as before. This includes cruise legs, the touchdown / liftoff boundary, and air segments. * Existing Bezier path (turn.GetPos) still wins when active — that machinery is unchanged and continues to handle cut-corner setups elsewhere in the flight model. On the ground, Bezier rarely fires now because the per-frame heading rate limit and the new spline both produce smooth results without it. What this replaces / interacts with * Per-frame heading rate limit (a1e0423): still in place, still runs after the spline assigns ppos.heading(). It's a no-op against spline output because the spline tangent already varies continuously with the parameter; the clamp only ever fires for the linear-fallback case now. * Half-way retarget to to.heading(): unchanged — still skipped at high ground speed; allowed at low ground speed where the slot heading from the feed/track cross-check (d861869) is a useful secondary target. The spline-set heading dominates in practice. * SnapToTaxiways waypoint insertion: complementary. Snap inserts extra control points along the taxi graph; the spline then naturally curves through them, which is exactly the smooth taxiway-centreline shape we want. --- Include/CoordCalc.h | 61 ++++++++++++++++ Include/LTAircraft.h | 7 ++ Src/CoordCalc.cpp | 165 +++++++++++++++++++++++++++++++++++++++++++ Src/LTAircraft.cpp | 89 +++++++++++++++++++++-- 4 files changed, 318 insertions(+), 4 deletions(-) diff --git a/Include/CoordCalc.h b/Include/CoordCalc.h index 9c80de84..0d795da4 100644 --- a/Include/CoordCalc.h +++ b/Include/CoordCalc.h @@ -107,6 +107,67 @@ 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); + // // MARK: Estimated Functions on coordinates // diff --git a/Include/LTAircraft.h b/Include/LTAircraft.h index 9e13af6e..01bb21bd 100644 --- a/Include/LTAircraft.h +++ b/Include/LTAircraft.h @@ -284,6 +284,13 @@ 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; std::string labelInternal; // internal label, e.g. for error messages protected: diff --git a/Src/CoordCalc.cpp b/Src/CoordCalc.cpp index c884addc..7b4effbc 100644 --- a/Src/CoordCalc.cpp +++ b/Src/CoordCalc.cpp @@ -249,6 +249,171 @@ 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 }; +} + // 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/LTAircraft.cpp b/Src/LTAircraft.cpp index 0d0d8617..a2518cc6 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -1569,6 +1569,15 @@ bool LTAircraft::CalcPPos() // (Must have reach/passed posList[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' posList.pop_front(); @@ -1884,11 +1893,83 @@ 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`. The curve passes exactly + // through P1 and P2, and P0 / P3 set the entry / exit tangents so + // adjacent legs join with C¹ continuity. + // + // 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. + // ------------------------------------------------------------------ + + // Choose control points, duplicating endpoints when the deque + // does not extend far enough in either direction. A duplicated + // endpoint produces a zero entry/exit tangent and the spline + // degenerates to a quadratic-like segment at the boundary — + // safe, no overshoot. + const positionTy& P0 = (!std::isnan(posPrev.lat()) ? posPrev : from); + const positionTy& P3 = (posList.size() >= 3 ? posList[2] : to); + + const CatmullRomResult cr = + CatmullRomEvalCentripetal(P0, from, to, P3, f); + + // Convert spline result (local meters from P1) back to geographic + // coordinates. P1 == from, so the 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 is going + // to 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 from spline tangent. Sync the MovingParam so that any + // downstream code that reads `heading.get()` (e.g. the half-way + // retarget block below) sees the spline-derived value as the + // current state rather than racing ahead toward a separately- + // tracked target. + 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 — linear interpolation (existing + // behaviour). Used for cruise legs and for the rare leg where + // exactly one endpoint is on the ground (touchdown / lift-off + // boundaries; the liftoff blend in the airborne branch later in + // this function smooths the altitude visual on top of this). + // Note that this calculation also works if we passed `to` + // already (due to no newer `to` available): we just keep going + // the same way. 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; From cee1c1cb153993e61698927364cfc212dec60c8b Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Thu, 14 May 2026 10:45:46 +1000 Subject: [PATCH 24/43] fix/Channel: Enable TCP keepalive on curl handles After the RT polling floor dropped to 2 s (commit 6fb0b1f) the log started showing curl error 55 (CURLE_SEND_ERROR) with the message "Connection died, tried 5 times before giving up", at roughly 27 s intervals. Each occurrence cost five send attempts plus a channel error counter tick. Symptom is the classic stale-HTTP-keep-alive case: the previous request's TCP connection is silently dropped by an intermediate NAT/firewall (or by the server) while we are idle between polls, and libcurl finds out only when its next `send()` on the kept-alive socket fails. At an 8 s poll cadence the idle interval was short enough that the connection rarely went stale; at 2 s it should be even less of a problem in principle, but the symptom suggests that some hop in the path declares the socket idle on a sub-30 s timer that the previous cadence happened to dodge by aligning with the server's own keepalive cadence. Fix: enable TCP keepalive at the OS level via curl options in LTOnlineChannel::InitCurl: * CURLOPT_TCP_KEEPALIVE = 1 enable SO_KEEPALIVE * CURLOPT_TCP_KEEPIDLE = 20 first probe after 20 s of idle * CURLOPT_TCP_KEEPINTVL = 10 subsequent probes every 10 s With the OS sending a TCP heartbeat on each idle socket, NATs and servers see the connection as active and don't drop it. The kept- alive HTTP connection then stays usable for the next 2 s poll, eliminating the CURLE_SEND_ERROR cluster. Applies to all online channels (OpenSky / ADSBHub / RealTraffic / etc.) since the option is set in the base-class InitCurl. The non-RT channels poll less frequently so they were unlikely to hit the symptom, but keepalive is a strict improvement for any persistent HTTPS connection that may sit idle for tens of seconds. --- Src/LTChannel.cpp | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/Src/LTChannel.cpp b/Src/LTChannel.cpp index 4577c7a9..e74be766 100644 --- a/Src/LTChannel.cpp +++ b/Src/LTChannel.cpp @@ -603,7 +603,33 @@ 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); - + + // TCP keepalive — defends against the kept-alive HTTP connection + // being silently dropped by an intermediate NAT/firewall or by the + // server while we are idle between polls. + // + // Without this, libcurl reuses the socket from the previous request + // even though some hop in the path has already dropped its half of + // the connection. The `send()` then fails (curl reports CURLE_SEND_ + // ERROR / "Connection died, tried N times before giving up") and we + // log a noisy error. Empirically appeared at ~30 s intervals once + // the RT poll cadence dropped to 2 s — the connection became stale + // between polls just often enough for it to bite. + // + // With keepalive enabled, the OS sends a small TCP-layer heartbeat + // every `KEEPIDLE` seconds on each idle socket, so intermediaries + // see the connection as active and don't drop it. We set: + // * KEEPALIVE = 1: enable the feature. + // * KEEPIDLE = 20 s: send the first probe after 20 s of idle + // (well below the typical 30-60 s NAT idle-drop timer). + // * KEEPINTVL = 10 s: subsequent probes every 10 s if no reply. + // These options are no-ops at the curl level on platforms that + // don't support TCP_KEEPIDLE / TCP_KEEPINTVL (some legacy Windows), + // but the underlying SO_KEEPALIVE flag still helps. + curl_easy_setopt(pCurl, CURLOPT_TCP_KEEPALIVE, 1L); + curl_easy_setopt(pCurl, CURLOPT_TCP_KEEPIDLE, 20L); + curl_easy_setopt(pCurl, CURLOPT_TCP_KEEPINTVL, 10L); + // success return true; } From 600904ffc28d6bf7ab2ae4c15c9388195f3c5b24 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Thu, 14 May 2026 17:16:26 +1000 Subject: [PATCH 25/43] fix/Channel: Forbid curl connection reuse to stop persistent error 55 TCP keepalive (cee1c1c) did not stop the CURLE_SEND_ERROR / "Connection died, tried 5 times" pattern recurring at ~30s intervals. Whatever is recycling the kept-alive socket does so actively (upstream load balancer, NAT idle-drop, or curl's own connection-cache age limit), and keepalive probes cannot help against an active RST or FIN. Set CURLOPT_FORBID_REUSE so every request opens a fresh TCP+TLS connection and closes it after the response: no kept-alive socket ever survives long enough to go stale. Cost is one TLS handshake per request (~100-300ms with TLS 1.3 resumption), negligible at the 2s RT polling cadence. The keepalive options are kept as belt-and-braces. --- Src/LTChannel.cpp | 48 +++++++++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/Src/LTChannel.cpp b/Src/LTChannel.cpp index e74be766..2554cb47 100644 --- a/Src/LTChannel.cpp +++ b/Src/LTChannel.cpp @@ -604,31 +604,39 @@ bool LTOnlineChannel::InitCurl () curl_easy_setopt(pCurl, CURLOPT_WRITEDATA, this); curl_easy_setopt(pCurl, CURLOPT_USERAGENT, HTTP_USER_AGENT); - // TCP keepalive — defends against the kept-alive HTTP connection - // being silently dropped by an intermediate NAT/firewall or by the - // server while we are idle between polls. + // Connection-handling for short-interval polling. // - // Without this, libcurl reuses the socket from the previous request - // even though some hop in the path has already dropped its half of - // the connection. The `send()` then fails (curl reports CURLE_SEND_ - // ERROR / "Connection died, tried N times before giving up") and we - // log a noisy error. Empirically appeared at ~30 s intervals once - // the RT poll cadence dropped to 2 s — the connection became stale - // between polls just often enough for it to bite. + // 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. // - // With keepalive enabled, the OS sends a small TCP-layer heartbeat - // every `KEEPIDLE` seconds on each idle socket, so intermediaries - // see the connection as active and don't drop it. We set: - // * KEEPALIVE = 1: enable the feature. - // * KEEPIDLE = 20 s: send the first probe after 20 s of idle - // (well below the typical 30-60 s NAT idle-drop timer). - // * KEEPINTVL = 10 s: subsequent probes every 10 s if no reply. - // These options are no-ops at the curl level on platforms that - // don't support TCP_KEEPIDLE / TCP_KEEPINTVL (some legacy Windows), - // but the underlying SO_KEEPALIVE flag still helps. + // 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; From 78b58db86970ae762e75f5f31ff8ec9454497c93 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Thu, 14 May 2026 17:16:26 +1000 Subject: [PATCH 26/43] feat/Ground: Spline rendering quality - reparam, smoothing, fallbacks Builds on the centripetal Catmull-Rom ground spline (486b916) with a set of fixes for jitter, speed pulsation and segment-boundary jumps surfaced during testing: - Arc-length reparameterisation: a per-segment 16-sample LUT (CatmullRomArcLut) maps the time-linear parameter onto constant arc-length-per-time, so the rendered position no longer speeds up and slows down within a leg. - Cache P3 (posNext) at segment switch instead of reading posList[2] live each frame. A mid-segment feed update no longer shifts the spline geometry underfoot and snaps the rendered position. - Control-point smoothing: pre-smooth the look-ahead endpoint P2 with a [1,2,1]/4 binomial kernel so the spline approximates rather than interpolates noisy feed samples. P1 is left raw so the curve still returns 'from' exactly at u=0, keeping segment joins seamless with the posList.front()=ppos continuity mechanism. - High-speed fallback: above 40kt (takeoff roll, landing rollout) skip the spline for plain linear interpolation. The aircraft tracks a straight line there; the spline only amplifies glitchy feed data and interacts awkwardly with the acceleration-profile parameter. - Near-stationary fallback: below a 5m chord, skip the spline so a noise-dominated tangent cannot wobble the heading of a parked aircraft. - Tolerate sub-1s backward sim-time jumps in NextCycle instead of tearing down and rebuilding the whole aircraft fleet on every frame stutter or brief pause/unpause. --- Include/Constants.h | 115 +++++++++++++++++++++ Include/CoordCalc.h | 58 +++++++++++ Include/LTAircraft.h | 27 +++++ Src/CoordCalc.cpp | 97 ++++++++++++++++++ Src/LTAircraft.cpp | 237 ++++++++++++++++++++++++++++++++++++------- 5 files changed, 500 insertions(+), 34 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 9a99c684..e9ac6db4 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -148,6 +148,121 @@ constexpr double GND_HOLDING_TIMEOUT_S = 30.0; /// 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 diff --git a/Include/CoordCalc.h b/Include/CoordCalc.h index 0d795da4..7de16cc3 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 @@ -168,6 +169,63 @@ CatmullRomResult CatmullRomEvalCentripetal(const positionTy& P0, 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/LTAircraft.h b/Include/LTAircraft.h index 01bb21bd..8ddacf36 100644 --- a/Include/LTAircraft.h +++ b/Include/LTAircraft.h @@ -291,6 +291,33 @@ class LTAircraft : public XPMP2::Aircraft /// 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: diff --git a/Src/CoordCalc.cpp b/Src/CoordCalc.cpp index 7b4effbc..77206a12 100644 --- a/Src/CoordCalc.cpp +++ b/Src/CoordCalc.cpp @@ -414,6 +414,103 @@ CatmullRomResult CatmullRomEvalCentripetal(const positionTy& P0, 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/LTAircraft.cpp b/Src/LTAircraft.cpp index a2518cc6..30bab64e 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); @@ -1581,6 +1594,28 @@ bool LTAircraft::CalcPPos() // By just removing the first element (current 'from') from the deqeue // we make posList[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 = posList[0], + // to = posList[1], and the slot one beyond `to` is posList[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 posList[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 @@ -1902,9 +1937,17 @@ bool LTAircraft::CalcPPos() // 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`. The curve passes exactly - // through P1 and P2, and P0 / P3 set the entry / exit tangents so - // adjacent legs join with C¹ continuity. + // 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 @@ -1930,36 +1973,162 @@ bool LTAircraft::CalcPPos() // heading at the segment endpoints. // ------------------------------------------------------------------ - // Choose control points, duplicating endpoints when the deque - // does not extend far enough in either direction. A duplicated - // endpoint produces a zero entry/exit tangent and the spline - // degenerates to a quadratic-like segment at the boundary — - // safe, no overshoot. - const positionTy& P0 = (!std::isnan(posPrev.lat()) ? posPrev : from); - const positionTy& P3 = (posList.size() >= 3 ? posList[2] : to); - - const CatmullRomResult cr = - CatmullRomEvalCentripetal(P0, from, to, P3, f); - - // Convert spline result (local meters from P1) back to geographic - // coordinates. P1 == from, so the 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 is going - // to 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; + // 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 posList[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 posList[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(); + } - // Heading from spline tangent. Sync the MovingParam so that any - // downstream code that reads `heading.get()` (e.g. the half-way - // retarget block below) sees the spline-derived value as the - // current state rather than racing ahead toward a separately- - // tracked target. - ppos.heading() = cr.headingDeg; - heading.SetVal(cr.headingDeg); + // 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 from spline tangent. 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 { // Air or air↔ground transition — linear interpolation (existing From 5336ce301a90470d0186aaf3b6ca553c4dc78f33 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Thu, 14 May 2026 18:31:26 +1000 Subject: [PATCH 27/43] fix/Parked: Make RealTraffic parked traffic show, persist and face right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RealTraffic parked-aircraft feed was almost entirely missing in the sim. Several compounding bugs, fixed together: - Dedup collapsed empty parking-position names. ProcessParkedAcBuffer dedups by RT_PARK_ParkPosName (Jeppesen stand), keeping the newest-timestamp entry per stand. But that field is frequently empty (GA, cargo, remote stands), so every empty-name aircraft collided into one map slot and all but one were dropped. Empty names now key on the unique hex id instead, so each is kept. The log line also reports the post-dedup count. - SPOS_STARTUP was set only on a successful gate match. Without that flag an aircraft never enters FPH_PARKED, so the Synthetic channel never adopts it to keep it alive and it ages out minutes after creation; and the ground-holding trivial-drop is not exempted for it. The flag (and bHeadFixed) is now set unconditionally — RT's feed is authoritative that the aircraft is parked, gate match or not. - Ground-holding trivial-drop ate the bootstrap seeds. AddNewPos drops stationary near-duplicate positions as jitter once holding is active. The parked feed's four identical seed positions (and the Synthetic keep-alive re-feeds) are exactly that shape, so they were dropped and the aircraft was left with too few positions to render. SPOS_STARTUP positions are now exempt from the drop: they are intentional placements, not feed jitter, and raw live-feed jitter is not SPOS_STARTUP at AddNewPos time (snapping runs later). - Every parked aircraft faced north. The gate match was tested via startupPos.isNormal(), but LTAptFindStartupLoc returns the matched apt.dat location with a NaN timestamp and isNormal() rejects a NaN ts — so the match was discarded every time and the heading override never ran. The match is now tested on the returned distance (NaN only when nothing matched). The startup-location search radius is also widened to the 3x default since RT's coordinates are not metre-precise, and dyn.heading is synced after the gate-match correction. - Gate handoff was indiscriminate. SyntheticConnection removed any stored parked aircraft within 10 m of any on-ground aircraft, so traffic merely taxiing past a gate wrongly evicted parked aircraft. The trigger is now ac.GetFlightPhase() == FPH_PARKED: only an aircraft that has itself come to rest on a stand evicts a stale ghost there. - Parked traffic is now re-fetched periodically. The request was a one-shot fired only on airport-data load, freezing the parked picture for the whole visit. RT_PARKED_REFRESH_INTVL_S (300 s) re-arms it so new arrivals appear and departures are reconciled. --- Include/LTRealTraffic.h | 13 ++++++ Src/LTFlightData.cpp | 17 ++++++- Src/LTRealTraffic.cpp | 98 ++++++++++++++++++++++++++++++++++++----- Src/LTSynthetic.cpp | 24 +++++++++- 4 files changed, 138 insertions(+), 14 deletions(-) diff --git a/Include/LTRealTraffic.h b/Include/LTRealTraffic.h index a80e4521..3cf01c10 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" @@ -348,6 +355,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) diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index bd1eedff..d66214a2 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -2230,7 +2230,22 @@ void LTFlightData::AddNewPos ( positionTy& pos ) // 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. - if (bGroundHolding && dist_m < GND_HOLDING_TRIVIAL_DIST_M) + // + // 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) { LOG_MSG(logDEBUG, "GND_DIAG_DROP %s dropping trivial update" diff --git a/Src/LTRealTraffic.cpp b/Src/LTRealTraffic.cpp index 6a815845..37bdc6e9 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; @@ -584,6 +594,7 @@ bool RealTrafficConnection::ProcessFetchedData () // --- Parked Aircraft --- if (curr.eRequType == CurrTy::RT_REQU_PARKED) { 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")); } @@ -888,12 +899,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 +933,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), @@ -965,14 +992,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 +1081,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; } diff --git a/Src/LTSynthetic.cpp b/Src/LTSynthetic.cpp index cf6b6888..cdd251cc 100644 --- a/Src/LTSynthetic.cpp +++ b/Src/LTSynthetic.cpp @@ -124,8 +124,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) { From 908ca73739625cb3d07dbd29e5186d4f647412f5 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sat, 16 May 2026 11:50:31 +1000 Subject: [PATCH 28/43] fix/Apt: Gate startup-loc snapping on airport-layout availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parked-traffic request was gated on LTAptAvailable() at issue time (SetRequType), but everything downstream was not — ProcessParkedAcBuffer could run ~1-2s later against a half-built gmapApt after the camera moved and a new async apt.dat load started. LTAptSnap and LTAptFindStartupLoc were not gated at any point, so live arrivals could also snap against a reloading layout and pick up wrong startup locations / wrong headings that then stuck (KLM604 "facing ass to the terminal" was an example). Three gates added: - ProcessFetchedData re-checks LTAptAvailable() when the parked response arrives, before calling ProcessParkedAcBuffer. If the layout went not-ready in the request->response window, the response is dropped, bDoParkedTraffic stays armed, and tLastParkedRefresh is left unchanged so the next cycle retries promptly. - LTAptSnap bails immediately if !LTAptAvailable(). AsyncReadApt first PurgeApt's, then re-adds airports one by one, releasing the lock between each, so a snap against gmapApt mid-load lands in the wrong taxiway / startup location and the bad heading sticks. - LTAptFindStartupLoc bails similarly. ProcessParkedAcBuffer is now also gated upstream, but this also protects the Synthetic channel's call site and any future lookup that races a mid-flight reload. Direct answer to the dev's "hope it doesn't backfire" concern after the parked-traffic acceleration work. --- Src/LTApt.cpp | 23 ++++++++++++++++++++++- Src/LTRealTraffic.cpp | 15 +++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Src/LTApt.cpp b/Src/LTApt.cpp index 226300cc..b182c0f4 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/LTRealTraffic.cpp b/Src/LTRealTraffic.cpp index 37bdc6e9..136a5c55 100644 --- a/Src/LTRealTraffic.cpp +++ b/Src/LTRealTraffic.cpp @@ -593,6 +593,21 @@ 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")); From 95a418983753eccf48efc5974fa1071a22c93555 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sat, 16 May 2026 11:50:31 +1000 Subject: [PATCH 29/43] wip/Pushback: State machine + heading-source selection (still incomplete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the state-free per-slot pushback heuristic with a real state machine, restructured to run BEFORE the feed-heading block so it is authoritative once it engages. Adds the renderer bridge needed for the held heading to actually reach the screen, and chooses the heading source per slot based on what the feed is actually reporting. THIS IS NOT YET A WORKING FIX. Pushback detection still misses cases where the feed heading is course-over-ground and the parked->push geometry is borderline (DAL73), false-triggers / fights for cases where the snapping pipeline interleaves SPOS_STARTUP positions into the deque (AMX026), and does not fully reproduce the "facing ass to the terminal" path (KLM604 — separate snapping issue). Work continues; this commit captures the state to keep the Windows-build tester in sync. What changed: - LTFlightData::bPushback state (Include/LTFlightData.h). Plus headingStable / headingStartup members consumed by the PUSHBACK_DIAG diagnostic and likely by the next round of detection redesign. - PUSHBACK_DETECT_GS_MAX_KT raised 3->12 (entry-only ceiling now; the 135deg direction gate is the real discriminator). PUSHBACK_EXIT_FWD_DIFF_DEG added (45deg, exit-when-moving-forward-relative-to-maintained-nose). PUSHBACK_FEED_IS_NOSE_DEG added (90deg, switches the held-heading source between feedHdg (true-nose feeds) and track+180 (course-over- ground feeds)). PUSHBACK_MIDPOINT_DIST_M removed (was dead code). - CalcHeading: pushback state machine moved BEFORE the feed-heading cross-check so it is authoritative when engaged. ENTRY on the geometric signature, HOLD chooses feedHdg-vs-track+180 per slot based on |feedHdg-track|, EXIT on direction reversal (no timer). Sub-threshold-motion slots carry the predecessor heading. Airborne forces bPushback=false defensively. - LTAircraft spline branch: when from.f.bHeadFixed is set, the rendered heading is interpolated from the slot headings instead of overridden by the spline tangent. Without this, the held pushback heading was discarded at render time because the tangent points along the (backward) motion. - TEMPORARY: PUSHBACK_DIAG logging in CalcHeading dumps feedHdg, track, the captured stable / startup reference headings and the angular gaps to each. To be removed once the detection is sorted. --- Include/Constants.h | 70 ++++++++++---- Include/LTFlightData.h | 22 +++++ Src/LTAircraft.cpp | 34 ++++++- Src/LTFlightData.cpp | 202 ++++++++++++++++++++++++++++++----------- 4 files changed, 251 insertions(+), 77 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index e9ac6db4..bc42b4c7 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -362,26 +362,60 @@ constexpr double GND_PITCH_DEG = 0.0; /// gated behind `!IsOnGnd()` so this only applies on the ground. constexpr double GND_ROLL_DEG = 0.0; -/// [kn] upper bound on groundspeed for an event to even be considered as -/// pushback. Typical pushback tugs move aircraft at 1–3 kn; anything faster -/// is taxi rather than pushback and the normal heading-from-track logic -/// should apply. -constexpr double PUSHBACK_DETECT_GS_MAX_KT = 3.0; - -/// [°] absolute heading difference between the track-over-ground (derived -/// from the current position delta) and the aircraft's last-known good -/// heading required to classify a slow motion as pushback. 135° is well -/// inside the "going backwards" half-plane (which begins at 90°) but leaves -/// margin so that a sharply curving forward taxi never accidentally trips the -/// pushback heuristic. +/// [kn] upper bound on groundspeed for an event to be considered the START +/// of a pushback. This gates ENTRY into the pushback state only — once the +/// state is entered it is held regardless of speed, so a long or brisk push +/// never drops out. The real discriminator for a pushback is the DIRECTION +/// (the aircraft moving backwards relative to its nose — see +/// `PUSHBACK_DETECT_HEAD_DIFF_DEG`); nothing else moves backwards on the +/// ground, so the speed ceiling only needs to be generous enough that the +/// first feed slot of the push is caught even when the feed cadence is +/// sparse. Observed: with a sparse RT feed the first slot of a push can +/// already read 8–9 kn, so 12 kn is used — well above realistic tug speed +/// (which the direction gate would catch anyway) but a useful guard +/// against a glitchy high-speed position jump masquerading as a push. +constexpr double PUSHBACK_DETECT_GS_MAX_KT = 12.0; + +/// [°] minimum |track − nose-heading| to ENTER the pushback state. 135° is +/// well inside the "going backwards" half-plane (which begins at 90°) and +/// leaves margin so a sharply curving forward taxi never trips entry. The +/// track is derived from the current position delta; the nose heading is +/// the predecessor slot's (last-known-good) heading. constexpr double PUSHBACK_DETECT_HEAD_DIFF_DEG = 135.0; -/// [m] distance behind the aircraft's nose along the reversed heading at -/// which the pushback Bezier curve's control point is placed. ~10 m mid-point -/// gives a smooth, gentle rearward arc instead of an instant straight-line -/// reverse; matches the approximate distance a tug pushes an airliner during -/// the first second of the maneuver. -constexpr double PUSHBACK_MIDPOINT_DIST_M = 10.0; +/// [°] maximum |track − nose-heading| to EXIT the pushback state. While the +/// state is held, `CalcHeading` keeps the slot heading updated to +/// `track + 180°` — i.e. the predecessor slot's heading is always the +/// current nose direction. When a meaningful-motion slot's track points +/// within this angle of that nose heading, the aircraft is moving FORWARD +/// (taxiing away under its own power) — the push is over. This is a far +/// more reliable end-of-pushback signal than any elapsed-time proxy: it +/// cannot fire while the aircraft is stopped (tug still attached / just +/// disconnected produces only sub-threshold motion), and it copes with +/// pushes of any length or distance. +/// +/// Entry at ≥135° and exit at ≤45° leave a 45–135° hysteresis band: a slot +/// whose track falls in the ambiguous "curving" zone keeps the current +/// state, so a sharply curving push never flickers out mid-manoeuvre. +constexpr double PUSHBACK_EXIT_FWD_DIFF_DEG = 45.0; + +/// [°] threshold that decides, while in a pushback, WHICH source to use for +/// the rendered heading. +/// +/// RealTraffic's heading field is not consistent across aircraft: for some +/// it is the true nose direction (Mode S EHS), for others it is simply +/// course-over-ground. During a pushback these look completely different: +/// * true-nose feed: feedHdg is ~180° from the track (the aircraft moves +/// tail-first) — and it is a clean, smooth signal, immune to a single +/// noisy track sample. We trust it directly. +/// * course feed: feedHdg ≈ the track — useless as a nose reference; +/// we derive the nose as `track + 180°` instead. +/// So: if |feedHdg − track| exceeds this threshold the feed heading is the +/// true nose and is used as-is; otherwise it is course and the nose is +/// derived from the reversed track. 90° is the natural divider — beyond it +/// the feed points into the rear half-plane relative to the motion, which +/// only the true-nose interpretation explains. +constexpr double PUSHBACK_FEED_IS_NOSE_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°) diff --git a/Include/LTFlightData.h b/Include/LTFlightData.h index b659f7b0..ea560bc9 100644 --- a/Include/LTFlightData.h +++ b/Include/LTFlightData.h @@ -299,6 +299,28 @@ class LTFlightData /// before exiting avoids those false-exits. int groundNonStationaryCnt = 0; + // ---- Pushback state (see Constants.h `PUSHBACK_*`) -------------------- + // True while the aircraft is being pushed back from a gate. Entered in + // `CalcHeading` on the geometric signature (slow, on-ground, moving + // backwards relative to the nose); while true the slot heading is held + // at `track + 180°` so the rendered nose stays pointing away from the + // direction of travel. Exited only when a meaningful-motion slot shows + // the aircraft moving FORWARD again (track within + // `PUSHBACK_EXIT_FWD_DIFF_DEG` of the maintained nose heading) — a + // direction-reversal test, not a timer, so it copes with pushes of any + // length and never exits while the aircraft is still stopped. + bool bPushback = false; + /// [°] last heading observed while (near-)stationary on the ground. + /// Candidate "parked heading" reference for pushback detection — but + /// it can drift if the live feed updates the heading while the + /// aircraft is nominally parked. + double headingStable = NAN; + /// [°] last heading observed on a `SPOS_STARTUP` slot, i.e. the + /// apt.dat gate heading. Unlike `headingStable` this cannot drift — + /// it comes from static airport data, not the live feed — so it is + /// the more trustworthy "true nose while parked" reference. + double headingStartup = NAN; + // STATIC DATA (protected, access will be mutex-controlled for thread-safety) FDStaticData statData; diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index 30bab64e..58fea3a0 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -2123,11 +2123,35 @@ bool LTAircraft::CalcPPos() ppos.alt_m() = from.alt_m() * (1 - f) + to.alt_m() * f; ppos.pitch() = from.pitch() * (1 - f) + to.pitch() * f; - // Heading from spline tangent. 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); + // 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 the leg's start slot 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 `track + 180°` + // 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. Instead we interpolate the slot + // headings across the leg (shortest-path), preserving the + // intended nose direction while still drawing the smooth + // spline *position*. + if (from.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 { diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index d66214a2..315f42ef 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1532,6 +1532,154 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) // access guarded by a mutex std::lock_guard lock (dataAccessMutex); + // ---------------------------------------------------------------------- + // TEMPORARY DIAGNOSTIC (tag: PUSHBACK_DIAG) — remove once the + // pushback-detection redesign lands. + // + // For every slow on-ground slot, dump the values a parked-heading- + // anchored pushback detector would need: the feed heading, the + // position-derived track, the last (near-)stationary heading, the + // last apt.dat startup (gate) heading, and the angular gap of the + // track from each reference. The point is to learn from a real + // pushback which reference heading is trustworthy (stable vs + // startup) and what angular threshold actually separates a pushback + // from a forward power-out taxi. Search the log for "PUSHBACK_DIAG". + // ---------------------------------------------------------------------- + if (it->IsOnGnd() && it != posDeque.cbegin()) { + const positionTy& prePosDg = *std::prev(it); + if (prePosDg.IsOnGnd() && it->ts() > prePosDg.ts()) { + const double dgGs = prePosDg.speed_kt(*it); + const vectorTy dgVec = prePosDg.between(*it); + // Capture the reference headings while (near-)stationary. + if (!std::isnan(dgGs) && dgGs < GND_STATIONARY_GS_KT && + !std::isnan(it->heading())) { + headingStable = it->heading(); + if (it->f.specialPos == SPOS_STARTUP) + headingStartup = it->heading(); + } + // Log every slot in the slow-ground regime a pushback lives in. + if (!std::isnan(dgGs) && dgGs < GND_USE_FEED_HEADING_MAX_KT) { + const double dgTrack = dgVec.angle; + LOG_MSG(logDEBUG, + "PUSHBACK_DIAG %s gs=%.2fkt feedHdg=%.1f track=%.1f" + " stableHdg=%.1f startupHdg=%.1f" + " |trk-stable|=%.0f |trk-startup|=%.0f spos=%d dist=%.1fm", + key().c_str(), dgGs, it->heading(), + std::isnan(dgTrack) ? -1.0 : dgTrack, + headingStable, headingStartup, + (std::isnan(dgTrack) || std::isnan(headingStable)) ? -1.0 : + std::abs(HeadingDiff(headingStable, dgTrack)), + (std::isnan(dgTrack) || std::isnan(headingStartup)) ? -1.0 : + std::abs(HeadingDiff(headingStartup, dgTrack)), + (int)it->f.specialPos, dgVec.dist); + } + } + } + + // ---------------------------------------------------------------------- + // Pushback state machine. (Runs BEFORE the feed-heading logic below so + // that, once a pushback is recognised, it is fully authoritative for the + // heading and the feed-heading cross-check never gets to interfere.) + // + // A pushback at a gate has a clear geometric signature: the aircraft is + // on the ground, moving at tug pace, and the direction of motion (the + // track from pos[n-1]→pos[n]) is nearly opposite the heading the + // aircraft held just before it started moving. The default + // heading-from-track logic would rotate the rendered aircraft 180° to + // "face where it is going", producing an airliner that appears to taxi + // tail-first. + // + // ENTRY: not in pushback, this slot has meaningful motion at tug speed + // (GND_STATIONARY_GS_KT < gs ≤ PUSHBACK_DETECT_GS_MAX_KT), and the + // track is ≥ PUSHBACK_DETECT_HEAD_DIFF_DEG away from the predecessor's + // heading. For the first moving slot the predecessor IS the last + // parked slot, so this compares the push direction against the parked + // nose — and an aircraft physically cannot taxi forward 135° off its + // nose, so the test only ever fires on a genuine push (or tow). + // + // HOLD: while in pushback, pick the heading SOURCE per slot — + // * if the feed heading is well away from the track + // (|feedHdg − track| > PUSHBACK_FEED_IS_NOSE_DEG) the feed is + // reporting the true nose: trust it directly. This is the clean + // case — the feed nose rotates smoothly through a curved push and + // is immune to a single noisy track sample. + // * otherwise the feed heading ≈ the track, i.e. it is course-over- + // ground and useless as a nose reference: derive the nose as + // `track + 180°`. + // A sub-threshold-motion slot carries the predecessor heading forward + // (no reliable track from near-zero motion). `bHeadFixed` is set so + // the renderer uses this slot heading verbatim instead of overriding + // it with the ground-spline tangent (which points along the backward + // motion and would render the aircraft tail-first). + // + // EXIT: only when a meaningful-motion slot's track points FORWARD + // relative to the maintained nose — within PUSHBACK_EXIT_FWD_DIFF_DEG + // of the predecessor slot's (held) heading. That is the aircraft + // taxiing away under its own power. This direction-reversal test + // cannot fire while the aircraft is still stopped (a stopped aircraft + // produces only sub-threshold slots) and copes with pushes of any + // length. Entry ≥135° / exit ≤45° leave a hysteresis band so a + // sharply curving push never flickers out mid-manoeuvre. + // ---------------------------------------------------------------------- + if (!it->IsOnGnd()) { + // Airborne — any pushback is long over; clear the state defensively. + bPushback = false; + } + else if (it != posDeque.cbegin()) { + const positionTy& prePosPb = *std::prev(it); + if (prePosPb.IsOnGnd() && it->ts() > prePosPb.ts()) { + const vectorTy pbTrack = prePosPb.between(*it); + const double pbNose = prePosPb.heading(); // maintained nose / parked heading + const double pbFeedHdg = it->heading(); // feed-delivered heading, before we overwrite it + const bool bPbMotion = pbTrack.dist >= SIMILAR_POS_DIST; + const bool bPbTrackOK = bPbMotion && !std::isnan(pbTrack.angle) + && !std::isnan(pbNose); + + if (!bPushback) { + // --- ENTRY --- + const double pbGs_kt = prePosPb.speed_kt(*it); + if (bPbTrackOK && + !std::isnan(pbGs_kt) && + pbGs_kt > GND_STATIONARY_GS_KT && + pbGs_kt <= PUSHBACK_DETECT_GS_MAX_KT && + std::abs(HeadingDiff(pbNose, pbTrack.angle)) + >= PUSHBACK_DETECT_HEAD_DIFF_DEG) + { + bPushback = true; + } + } + else if (bPbTrackOK) { + // --- EXIT test --- only a meaningful-motion slot can be + // classified; sub-threshold slots keep the state. + if (std::abs(HeadingDiff(pbNose, pbTrack.angle)) + <= PUSHBACK_EXIT_FWD_DIFF_DEG) + bPushback = false; // moving forward — push over + } + + // --- HOLD --- drive the heading while the state is active. + if (bPushback) { + if (bPbMotion && !std::isnan(pbTrack.angle)) { + // Choose the heading source. A feed heading far from + // the track is the genuine nose — trust it (smooth, + // noise-free). A feed heading that tracks the motion + // is course-over-ground — derive the nose as the + // reverse of the track instead. + if (!std::isnan(pbFeedHdg) && + std::abs(HeadingDiff(pbFeedHdg, pbTrack.angle)) + > PUSHBACK_FEED_IS_NOSE_DEG) + it->heading() = pbFeedHdg; + else + it->heading() = HeadingNormalize(pbTrack.angle + 180.0); + } + else if (!std::isnan(pbNose)) { + it->heading() = pbNose; // sub-threshold motion — hold + } + it->f.bHeadFixed = true; + return; + } + } + } + // ---------------------------------------------------------------------- // Trust feed-provided heading at slow ground speed (with staleness check). // @@ -1738,60 +1886,6 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) } } - // ---------------------------------------------------------------------- - // Pushback detection (state-free). - // - // A genuine pushback at a gate has a very recognisable geometric - // signature: the aircraft is on the ground, moving slowly (a few - // knots — the tug's pace), and the direction of motion (the track - // derived from pos[n-1]→pos[n]) is nearly opposite to the - // aircraft's previous heading. In that situation the physical - // aircraft is moving *backwards* while its nose still points - // forward at the gate. - // - // The default heading-from-track logic would rotate the rendered - // aircraft by 180° to face the direction it is moving, producing - // a bizarre visual where the airliner appears to taxi tail-first. - // We catch this case here and freeze the heading to the - // predecessor's value — the rendered aircraft then keeps its nose - // pointed at the gate while its world position is interpolated - // backwards, which is exactly the correct visual. - // - // Thresholds: - // - speed in (GND_STATIONARY_GS_KT, PUSHBACK_DETECT_GS_MAX_KT] - // (faster than holding-stationary, slower than taxi) - // - |track − previous heading| ≥ PUSHBACK_DETECT_HEAD_DIFF_DEG - // (well inside the "going backwards" half-plane) - // - // This is intentionally state-free: every slot is classified on - // its own geometry. When the tug stops, the next slot fails the - // "moving slowly" check and normal heading logic takes over. When - // the aircraft begins forward taxi after pushback, the track - // realigns with heading and pushback no longer triggers. - // ---------------------------------------------------------------------- - if (it->IsOnGnd() && it != posDeque.cbegin()) { - const positionTy& prePos = *std::prev(it); - if (prePos.IsOnGnd() && it->ts() > prePos.ts()) { - const double gsDerived_kt = prePos.speed_kt(*it); - if (!std::isnan(gsDerived_kt) && - gsDerived_kt > GND_STATIONARY_GS_KT && - gsDerived_kt <= PUSHBACK_DETECT_GS_MAX_KT) - { - const vectorTy track = prePos.between(*it); - const double pHead = prePos.heading(); - if (!std::isnan(track.angle) && !std::isnan(pHead)) { - const double trackVsHead = - std::abs(HeadingDiff(pHead, track.angle)); - if (trackVsHead >= PUSHBACK_DETECT_HEAD_DIFF_DEG) { - // Pushback: keep nose pointing at gate. - it->heading() = pHead; - return; - } - } - } - } - } - // vectors to / from the position at "it" vectorTy vecTo, vecFrom; From 411ac3392fb028d8ae8f6eea171abd71678b155d Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sat, 16 May 2026 14:36:20 +1000 Subject: [PATCH 30/43] feat/RT: Use v6 operator-livery field for livery matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RealTraffic v6 added a hex-keyed ICAO operator/airline flag code to both the UDP RTTFC broadcast (field 43, RT App v11.1.452+) and the HTTP Direct API JSON array (field 48). It is sourced from RT's OperatorFlagCode column of the OpenSky-derived BaseStation aircraft database, keyed on the transponder hex ID rather than the operating callsign — so it remains correct under wet-lease and codeshare operations where the callsign-derived airline would be wrong. LiveTraffic now populates FDStaticData::opIcao from this field in both processing paths, bounds-checked so older RT App builds / sparser responses still parse. When present (≈55% of records in test, doc says ~70% — the rest are GA/private airframes RT does not have in its DB), it wins over the callsign-substring guess in airlineCode() and is used authoritatively for XPMP2 livery matching. When absent it falls through to the existing fallback path with no behaviour change. UDP enum (RT_RTTFC_FIELDS_TY): index 41 renamed from AUGMENTATION_STATUS to BARO_ALT_UNCORRECTED (v6 changed the slot's semantics; the old name was not referenced anywhere). Added AUTHENTICATION (=42, shares slot with the preserved MIN_TFC_FIELDS strict-parse gate) and OPERATOR (43). The min-fields gate stays at 42 so older RT App builds that do not send fields 42-43 still parse. HTTP Direct enum (RT_DRCT_FIELDS_TY): inserted RT_DRCT_Operator at index 48 ahead of the NUM_FIELDS sentinel. The existing strict gate at ProcessFetchedData picks up the new sentinel value cleanly because v6 Direct API responses always carry 49 fields per the doc. Verified in-session in both RT App (UDP) and Direct API (HTTP) modes: 840 RealTraffic adds, 461 with a populated operator code, 379 empty and falling through to the callsign-substring fallback as designed. --- Include/LTRealTraffic.h | 16 +++++++++++----- Src/LTRealTraffic.cpp | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Include/LTRealTraffic.h b/Include/LTRealTraffic.h index 3cf01c10..0558dd09 100644 --- a/Include/LTRealTraffic.h +++ b/Include/LTRealTraffic.h @@ -168,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 @@ -252,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) diff --git a/Src/LTRealTraffic.cpp b/Src/LTRealTraffic.cpp index 136a5c55..84d8af8a 100644 --- a/Src/LTRealTraffic.cpp +++ b/Src/LTRealTraffic.cpp @@ -781,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); @@ -2150,6 +2159,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); From 1c9715ca46bdfd2c13a5f18cbeb6774b6be30fc9 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sat, 16 May 2026 16:36:50 +1000 Subject: [PATCH 31/43] fix/Gate: Durable handoff eviction + skip re-seed for departed aircraft Two related guards to stop a previously evicted parked ghost from coming back, plus a debug channel for diagnosing position-feed issues: - SyntheticConnection: persist hex ids that gate-handoff has evicted in a session-scoped set (evictedHexIds). The Synthetic re-adoption path now skips marked ids, blocking the race where a FetchAllData pass running during the async SetInvalid teardown recreated the ghost ~40s after eviction. - ProcessParkedAcBuffer: refuses to re-seed a marked id, and as defence-in-depth refuses to re-seed any hex that is already live-tracked and has either left the ground or moved farther than GATE_REFEED_MAX_DIST_M (50 m) from the gate position RT is reporting. Blocks the TFL3NA-class symptom where the 5-minute parked re-fetch silently appended an SPOS_STARTUP gate-position seed to an airborne aircraft deque, causing the render clock to later walk into it and teleport the aircraft back to the gate. Adds GATE_REFEED_MAX_DIST_M constant in Constants.h with verbose rationale on why 50 m is the right footprint for still-at-the-stand. Also lands FEED_DIAG instrumentation across both RT paths (ProcessTrafficBuffer / HTTP-Direct and ProcessRTTFC / UDP RTTFC): per-aircraft monotonicity log marking NEW / OK / REPEAT / BACKWARDS for every accepted feed timestamp, plus the source msg_type and seen age. Drives the recent debug analyses (TFL3NA, KLM911, KLM99F, KLM48/KLM874 gate handoff) and remains intentionally tagged TEMPORARY in the source so it is easy to find and remove once the feed-jitter work settles. --- Include/Constants.h | 16 ++++++ Include/LTRealTraffic.h | 6 +++ Include/LTSynthetic.h | 34 +++++++++++++ Src/LTRealTraffic.cpp | 108 +++++++++++++++++++++++++++++++++++++++- Src/LTSynthetic.cpp | 56 +++++++++++++++++++++ 5 files changed, 218 insertions(+), 2 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index bc42b4c7..6106408e 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -76,6 +76,22 @@ 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; 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 diff --git a/Include/LTRealTraffic.h b/Include/LTRealTraffic.h index 0558dd09..8107899c 100644 --- a/Include/LTRealTraffic.h +++ b/Include/LTRealTraffic.h @@ -383,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 48aafa12..8bc070ef 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/Src/LTRealTraffic.cpp b/Src/LTRealTraffic.cpp index 84d8af8a..83d19a3c 100644 --- a/Src/LTRealTraffic.cpp +++ b/Src/LTRealTraffic.cpp @@ -858,6 +858,32 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) // add the static data fd.UpdateData(std::move(stat), pos.dist(posView)); + // --- TEMPORARY 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. + { + 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 %s [HTTP]", + fdKey.c_str(), callDg.c_str(), + posTime, srcMsg.c_str(), srcAge, dtFeed, flag); + lastFeedTs[fdKey.num] = posTime; + } + // add the dynamic data fd.AddDynData(dyn, 0, 0, &pos); @@ -866,7 +892,7 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) IncErrCnt(); } } - + return true; } @@ -982,7 +1008,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; @@ -990,6 +1027,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) ) { @@ -2216,6 +2292,34 @@ bool RealTrafficConnection::ProcessRTTFC (LTFlightData::FDKeyTy& fdKey, // add the static data fd.UpdateData(std::move(stat), dist); + // --- TEMPORARY 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. + { + 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 %s [UDP]", + fdKey.c_str(), tfc[RT_RTTFC_CS_ICAO].c_str(), + posTime, srcMsg.c_str(), srcAge, dtFeed, 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 cdd251cc..492033f0 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 @@ -156,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); From d36ba8cdc4240a2991c3aaec2c459a9175a27369 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sat, 16 May 2026 22:41:13 +1000 Subject: [PATCH 32/43] feat/Climb: Smooth takeoff/climb altitude + liftoff-blend ground lock Several intertwined changes that together fix the visible takeoff glitches users reported (discrete 100ft+ altitude jumps mid-climb, aircraft slamming back to runway after partial liftoff, premature rotation animation without altitude change). Smoothing the rendered climb-out altitude: - LookupAltAtTs in LTAircraft now fits altitude via a Gaussian- weighted local linear regression (sigma 5s) instead of returning the raw per-leg linear interpolation. ADS-B reports altitude in 25ft quantization steps and slots arrive at irregular 1-5s cadence, so the per-leg slope swings wildly even during a steady climb; an interpolating spline (PCHIP etc.) faithfully reproduces that as visible kinks every few seconds. The regression renders along the fitted trend instead. - Numerically stable implementation: works in (t - targetTs) so the variance and covariance sums stay in the +-30s range. The textbook Sum(w*t*t) - sumW*tMean*tMean form would subtract two values of order 3e19 (timestamps are around 1.78e9) to get a result of order 1e2, losing the signal to floating-point cancellation. - pastAltSamples_ archive: fd.posDeque only keeps one past slot at any moment (CalcNextPos pops aggressively), and a regression with near-unit weight on that one sample shifts discretely when it pops. The archive mirrors every slot we ever observe and does NOT pop, so the Gaussian weight on each sample fades out continuously over many frames as targetTs moves past it. No more step changes at the deque-pop boundary. Liftoff-blend ground lock + floor clamp in CalcFlightModel / CalcPPos: - During the 10-second liftoff blend, bOnGrnd is locked to false even if LookupAltAtTs momentarily dips below MDL_CLOSE_TO_GND above terrain. Without this guard, transient regression dips (smoothing-window rebalancing as past-ground samples weight-out against future-airborne samples) would re-flip bOnGrnd to true, clamp ppos.alt to terrain, regress the phase from FPH_LIFT_OFF to FPH_TO_ROLL, and the next frame's recovery would reset liftoffBlendStartTs and restart the blend from scratch. Visibly: aircraft climbs to ~Nft, slams to the runway, continues T/O roll, rotates again, climbs again. - The blend formula clamps (LookupAltAtTs - liftoffStartAlt) to be non-negative so the rendered altitude can never go below terrain during the blend even if the smoothed input dips below the frozen start. - ALT_DIAG log line on every bOnGrnd transition with the relevant state (PHeight, blend status, sinceLO, phase) so future debugging can verify there is one clean transition per real liftoff. RealTraffic ingest: - posTime = TimeStamp - PosAge on both HTTP-Direct and UDP RTTFC paths. The RT TimeStamp is when the record was generated server- side and PosAge is the ingest age of the underlying measurement. Different sources (ADS-B vs MLAT vs satellite multilateration) have very different ingest delays, so two consecutive records can have the same TimeStamp but represent measurements taken seconds apart. Normalising to actual measurement time aligns the deque with what-the-aircraft-did-when rather than when-RT-ingested-it. - FEED_DIAG now also includes alt, gnd flag, and reported vsi so debug analyses can see what the feed is actually saying. Takeoff/landing animation polish (carried over from earlier in the session, not yet shipped): - LIFTOFF_BLEND_TIME_S bumped from 1.5s to 10s for a gradual climb-away from the runway. With the spline / regression smoothing on top, the blend curve is C2-continuous at both endpoints and renders as a single smooth arc. - TOUCHDOWN_HOLD_PITCH_S = 5s: after FPH_TOUCH_DOWN the pitch.moveTo to GND_PITCH_DEG is deferred for 5s, modelling the aerobrake during which real airliners keep the nose up after the mains touch. Previously the nose was on the ground within ~3s of touchdown. --- Include/Constants.h | 55 ++++-- Include/LTAircraft.h | 61 +++++++ Src/LTAircraft.cpp | 379 +++++++++++++++++++++++++++++++++++++----- Src/LTRealTraffic.cpp | 56 ++++++- 4 files changed, 493 insertions(+), 58 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 6106408e..415c01bb 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -454,18 +454,51 @@ constexpr double GND_BEZIER_MIN_HEAD_DIFF = 1.0; /// (`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 100–500 ft 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"). +/// 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. 1.5 s is short -/// enough to match the visual expectation of "rotation → lift-off" (about -/// the same duration as the pitch-up walk driven by `pitch.max()` on -/// `FPH_ROTATE`) and long enough that the eye reads it as a gradual -/// transition rather than a teleport. -constexpr double LIFTOFF_BLEND_TIME_S = 1.5; +/// 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; + +/// [s] minimum time the nose is held pitched up at `PITCH_FLARE` after +/// touchdown before the de-rotation walk to `GND_PITCH_DEG` begins. +/// +/// Why this exists: real airliners aerobrake by holding the nose high +/// for several seconds after the main gear touches, until aerodynamic +/// braking loses authority and the nose-wheel is lowered for wheel +/// braking. Previously LiveTraffic called `pitch.moveTo(GND_PITCH_DEG)` +/// on the same frame that `FPH_TOUCH_DOWN` was entered, so the +/// `PITCH_RATE` walk started immediately and the nose was on the +/// ground within ~3 s of touchdown — visibly faster than real +/// aircraft. +/// +/// 5 s is the lower bound of typical airline practice (longer aircraft +/// often hold longer); we use it as a floor so even quick rollouts get +/// a recognisable aerobrake. The hold period sits entirely inside +/// `FPH_TOUCH_DOWN` / `FPH_ROLL_OUT`, both of which are already +/// excluded from the ground-attitude pitch override in `CalcAcPos`, +/// so the MovingParam keeps the pitch at its last-commanded value +/// (`PITCH_FLARE`) until the deferred `moveTo` fires. +constexpr double TOUCHDOWN_HOLD_PITCH_S = 5.0; //MARK: Flight Model diff --git a/Include/LTAircraft.h b/Include/LTAircraft.h index 8ddacf36..da427bc4 100644 --- a/Include/LTAircraft.h +++ b/Include/LTAircraft.h @@ -340,6 +340,50 @@ class LTAircraft : public XPMP2::Aircraft /// 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? @@ -455,6 +499,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/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index 58fea3a0..1f352bf8 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -1579,7 +1579,7 @@ 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 @@ -1592,15 +1592,15 @@ bool LTAircraft::CalcPPos() 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 = posList[0], - // to = posList[1], and the slot one beyond `to` is posList[2] + // 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 posList[2] live each frame. If the + // 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. @@ -1618,7 +1618,7 @@ bool LTAircraft::CalcPPos() 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]) { @@ -1626,7 +1626,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; } @@ -2040,7 +2040,7 @@ bool LTAircraft::CalcPPos() // 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 posList[2] live + // 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 @@ -2064,7 +2064,7 @@ bool LTAircraft::CalcPPos() // 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 posList[0] (== `from`) EXACTLY at the start of the + // 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 @@ -2155,17 +2155,31 @@ bool LTAircraft::CalcPPos() } } else { - // Air or air↔ground transition — linear interpolation (existing - // behaviour). Used for cruise legs and for the rare leg where - // exactly one endpoint is on the ground (touchdown / lift-off - // boundaries; the liftoff blend in the airborne branch later in - // this function smooths the altitude visual on top of this). - // Note that this calculation also works if we passed `to` - // already (due to no newer `to` available): we just keep going - // the same way. + // 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 @@ -2367,32 +2381,61 @@ bool LTAircraft::CalcPPos() // that interpolated altitude in a single frame — the "jumps // into the air on rotation" symptom. // - // We instead lerp from terrain altitude to the interpolated - // altitude using a cubic smoothstep easing (f(t) = t² (3-2t)), - // which is C¹-continuous at both endpoints — no visible kink - // when the blend starts or ends. After LIFTOFF_BLEND_TIME_S - // the blend is complete and we revert to the raw interpolated - // value (so we don't perpetually drag the aircraft back toward - // the runway). + // 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)) { + if (!std::isnan(liftoffBlendStartTs) && + !std::isnan(liftoffStartAlt_m)) + { const double sinceLiftoff = currCycle.simTime - liftoffBlendStartTs; - if (sinceLiftoff < LIFTOFF_BLEND_TIME_S && - ppos.alt_m() > terrainAlt_m) + if (sinceLiftoff < LIFTOFF_BLEND_TIME_S) { const double t = sinceLiftoff / LIFTOFF_BLEND_TIME_S; - // Cubic smoothstep: 0 at t=0, 1 at t=1, zero slope at - // both ends — visually identical to "ease in/out". - const double blend = t * t * (3.0 - 2.0 * t); - ppos.alt_m() = - terrainAlt_m + - (ppos.alt_m() - terrainAlt_m) * blend; + 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 (or terrain probe disagrees with our - // notion of liftoff — bail out rather than dragging the - // aircraft below its interpolated altitude). + // 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; } } } @@ -2427,6 +2470,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; } @@ -2483,6 +2553,27 @@ 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 (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; @@ -2495,6 +2586,12 @@ void LTAircraft::CalcFlightModel (const positionTy& /*from*/, const positionTy& // 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 @@ -2688,13 +2785,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; - // After the wheels are firmly down we want the nose to settle at - // the static ground attitude `GND_PITCH_DEG` rather than at level - // 0°. The MovingParam smoothly walks pitch from its FLARE value - // down to this new target over the next few frames; the - // ground-attitude override in `CalcAcPos` then keeps it pinned - // there during the rest of the taxi/parked life of the aircraft. + // 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 + TOUCHDOWN_HOLD_PITCH_S) + { pitch.moveTo(GND_PITCH_DEG); + touchdownTs = NAN; } // roll-out @@ -2834,6 +2950,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/LTRealTraffic.cpp b/Src/LTRealTraffic.cpp index 83d19a3c..0dc47e7b 100644 --- a/Src/LTRealTraffic.cpp +++ b/Src/LTRealTraffic.cpp @@ -744,7 +744,30 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) continue; // position time + // + // RealTraffic v6 carries two time-related fields per position: + // * RT_DRCT_TimeStamp — server timestamp of the position update + // record (when RT's ingestion produced this row). + // * RT_DRCT_PosAge — seconds since the underlying position + // was actually measured at the source. + // Different sources (ADS-B vs MLAT vs satellite multilateration + // etc.) have very different ingest latencies, so two consecutive + // records can have identical TimeStamp values while representing + // measurements taken seconds apart at the source. If we use the + // raw TimeStamp as the position's deque key, those measurement- + // time discrepancies show up as altitude jumps in the rendered + // climb (the position is placed at the wrong moment, then the + // next position arrives at "later" TimeStamp but with a much + // smaller alt delta than would be expected from the apparent + // time gap — or vice versa, depending on which source has the + // larger delay). Subtracting PosAge here normalises every + // position to its actual measurement time so the deque is + // ordered and spaced by *what the aircraft did when*, not by + // *when RT happened to ingest it*. double posTime = jag_n(pJAc, RT_DRCT_TimeStamp); + const double posAge = jag_n(pJAc, RT_DRCT_PosAge); + if (!std::isnan(posAge)) + posTime -= posAge; // (needs adjustment in case we are receiving historical data) posTime += tsAdjust; @@ -878,9 +901,13 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) : (dtFeed < 0.0) ? "BACKWARDS" : "REPEAT"; LOG_MSG(logDEBUG, - "FEED_DIAG %s cs=%s ts=%.1f src=%s seen=%.1f dt=%+.2f %s [HTTP]", + "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, flag); + posTime, srcMsg.c_str(), srcAge, dtFeed, + pos.alt_ft(), + pos.f.onGrnd == GND_ON ? 1 : 0, + dyn.vsi, + flag); lastFeedTs[fdKey.num] = posTime; } @@ -2177,7 +2204,24 @@ bool RealTrafficConnection::ProcessRTTFC (LTFlightData::FDKeyTy& fdKey, int nBuffer) { // *** position time *** + // Normalise to actual measurement time by subtracting RTTFC_SEEN + // (seconds since the underlying position was measured at source). + // See the HTTP-Direct path comment on RT_DRCT_TimeStamp / + // RT_DRCT_PosAge for the full rationale: different sources have + // different ingest delays, so two records with the same + // RT_RTTFC_TIMESTAMP can represent positions measured seconds + // apart at the source. Subtracting SEEN places each position at + // its actual measurement time in the deque. The SEEN field is + // optional in the compact 18-field RT App variant, so we only + // apply the adjustment when the field is present and parses. double posTime = std::stod(tfc[RT_RTTFC_TIMESTAMP]); + if (tfc.size() > RT_RTTFC_SEEN && !tfc[RT_RTTFC_SEEN].empty()) { + try { + const double posAge = std::stod(tfc[RT_RTTFC_SEEN]); + if (!std::isnan(posAge)) + posTime -= posAge; + } catch (...) { /* leave posTime as-is */ } + } AdjustTimestamp(posTime, nBuffer); // *** Process received data *** @@ -2314,9 +2358,13 @@ bool RealTrafficConnection::ProcessRTTFC (LTFlightData::FDKeyTy& fdKey, : (dtFeed < 0.0) ? "BACKWARDS" : "REPEAT"; LOG_MSG(logDEBUG, - "FEED_DIAG %s cs=%s ts=%.1f src=%s seen=%.1f dt=%+.2f %s [UDP]", + "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, flag); + posTime, srcMsg.c_str(), srcAge, dtFeed, + pos.alt_ft(), + pos.f.onGrnd == GND_ON ? 1 : 0, + dyn.vsi, + flag); lastFeedTs[fdKey.num] = posTime; } From 25c9e333137313686554742f97a9b0f2ac331c24 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sun, 17 May 2026 10:15:19 +1000 Subject: [PATCH 33/43] fix/Pitch: Cap rotate pitch at 10deg + keep LIFT_OFF in pitch override exception Two changes addressing reported visual pitch glitches on take-off. ROTATE_PITCH_MAX_DEG = 10 deg - FPH_ROTATE entry used to call pitch.max(), which walks the pitch MovingParam toward pMdl->PITCH_MAX (15 deg by default). 15 deg is past the tail-strike geometry of most narrow-bodies (B738 ~ 11 deg, A320 ~ 13.5 deg), and users were seeing rendered aircraft drag their tails during rotation. - New constant ROTATE_PITCH_MAX_DEG = 10 caps the rotation target. pitch.moveTo(ROTATE_PITCH_MAX_DEG) replaces pitch.max() in the ENTERED(FPH_ROTATE) block. - After FPH_LIFT_OFF, the in-air pitch logic in LTFlightData::CalcNextPos (around line 1700) takes over and walks pitch toward a VSI-derived target, still clamped to pMdl->PITCH_MAX. So steep climbs still reach the full 15 deg, just not while the gear is on the runway. FPH_LIFT_OFF added to the ground-attitude pitch override exception - CalcAcPos used to force pitch back to GND_PITCH_DEG whenever bOnGrnd was true and phase was not in {FPH_ROTATE, FPH_FLARE, FPH_TOUCH_DOWN, FPH_ROLL_OUT}. Phase can advance ROTATE -> LIFT_OFF via the V_Climbing branch in CalcFlightModel as soon as VSI crosses VSI_STABLE (100 fpm) - which happens BEFORE bOnGrnd actually flips false on takeoffs with smoothed altitude (the regression has not yet crossed MDL_CLOSE_TO_GND above terrain). - With FPH_LIFT_OFF missing from the exception list, the override forcibly reset ppos.pitch() to 2 deg for the frames between phase-becomes-LIFT_OFF and bOnGrnd-actually-flips-false. Visible as: nose pitches up, briefly flips level on the runway, then pitches up again once airborne. Reported on AAL2449. - Adding FPH_LIFT_OFF to the exception list keeps the MovingParam in control of pitch during the entire rotate-to-airborne window. --- Include/Constants.h | 19 +++++++++++++++ Src/LTAircraft.cpp | 56 ++++++++++++++++++++++++++++++++------------- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 415c01bb..7600c286 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -479,6 +479,25 @@ constexpr double GND_BEZIER_MIN_HEAD_DIFF = 1.0; /// "climbing away from the runway" instead of "popping into the sky". constexpr double LIFTOFF_BLEND_TIME_S = 10.0; +/// [°] maximum pitch angle during the take-off rotation phase +/// (`FPH_ROTATE`), before the aircraft physically leaves the runway. +/// +/// Why this exists (and is less than the per-flight-model +/// `PITCH_MAX`): without an explicit cap, `ENTERED(FPH_ROTATE)` +/// calls `pitch.max()` which walks the pitch MovingParam toward the +/// flight model's `PITCH_MAX` (15° by default). 15° is well past the +/// tail-strike geometry of most narrow-bodies — B738 ≈ 11°, A320 +/// ≈ 13.5° — so users were seeing rendered aircraft drag their tails +/// during rotation. Capping the rotate target at 10° keeps the nose +/// below the tail-strike envelope while still showing a recognisable +/// rotation animation. Once the aircraft transitions to +/// `FPH_LIFT_OFF`, the in-air pitch logic in `LTFlightData::CalcNextPos` +/// takes over and walks pitch toward the VSI-derived target — +/// clamped to the flight model's `PITCH_MAX` — so steep initial +/// climbs can still reach the full 15°, just not during the on- +/// runway rotation phase. +constexpr double ROTATE_PITCH_MAX_DEG = 10.0; + /// [s] minimum time the nose is held pitched up at `PITCH_FLARE` after /// touchdown before the de-rotation walk to `GND_PITCH_DEG` begins. /// diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index 1f352bf8..e8ad1e5d 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -2330,22 +2330,37 @@ bool LTAircraft::CalcPPos() // 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`), 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.max()` on rotate, - // `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. Roll is forced flat in all phases — - // ground aircraft never bank, so no exception is needed there. + // 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) @@ -2718,7 +2733,16 @@ 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 `ROTATE_PITCH_MAX_DEG` + // (10°) instead of `pMdl->PITCH_MAX` (15°). On the runway + // the latter exceeds the tail-strike geometry of most + // narrow-bodies. 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(ROTATE_PITCH_MAX_DEG); gearDeflection.min(); // and start easing up on the wheels } } From cf8c6c4a523a80e935d5a8b44e90dcb0d091883d Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sun, 17 May 2026 10:59:16 +1000 Subject: [PATCH 34/43] revert/RT: Drop posTime -= PosAge adjustment, use raw TimeStamp (idx 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the `posTime -= jag_n(pJAc, RT_DRCT_PosAge)` adjustment added in commit d36ba8c on both ingest paths (HTTP-Direct ProcessTrafficBuffer and UDP RTTFC ProcessRTTFC). The original adjustment was based on an incorrect interpretation of the two time-related fields in the RT data: it assumed RT_DRCT_TimeStamp (idx 10) was a server-side record-creation timestamp and RT_DRCT_PosAge (idx 39) was the ingest-pipeline latency back to the actual measurement time, so subtracting one from the other would normalise positions to "what the aircraft did when". That is not what these fields mean in RT v6: idx 10 is already the measurement time and is the only field that should be used for chronological comparisons. The subtraction was actively moving every position backwards by an amount that does not represent any real lag, breaking deque ordering — slots from sources with higher PosAge values were being placed too far in the past, manifesting as cascades of BACKWARDS rejections and deque-position artefacts in the FEED_DIAG log. posTime on both paths is now back to the raw RT_DRCT_TimeStamp / RT_RTTFC_TIMESTAMP value, exactly as it was before d36ba8c. FEED_DIAG still prints PosAge as `seen=` for informational purposes but the `ts=` field and the OK / BACKWARDS / REPEAT flags now reflect raw-timestamp deltas, which is the correct chronology to use. --- Src/LTRealTraffic.cpp | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/Src/LTRealTraffic.cpp b/Src/LTRealTraffic.cpp index 0dc47e7b..4107bba7 100644 --- a/Src/LTRealTraffic.cpp +++ b/Src/LTRealTraffic.cpp @@ -744,30 +744,7 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) continue; // position time - // - // RealTraffic v6 carries two time-related fields per position: - // * RT_DRCT_TimeStamp — server timestamp of the position update - // record (when RT's ingestion produced this row). - // * RT_DRCT_PosAge — seconds since the underlying position - // was actually measured at the source. - // Different sources (ADS-B vs MLAT vs satellite multilateration - // etc.) have very different ingest latencies, so two consecutive - // records can have identical TimeStamp values while representing - // measurements taken seconds apart at the source. If we use the - // raw TimeStamp as the position's deque key, those measurement- - // time discrepancies show up as altitude jumps in the rendered - // climb (the position is placed at the wrong moment, then the - // next position arrives at "later" TimeStamp but with a much - // smaller alt delta than would be expected from the apparent - // time gap — or vice versa, depending on which source has the - // larger delay). Subtracting PosAge here normalises every - // position to its actual measurement time so the deque is - // ordered and spaced by *what the aircraft did when*, not by - // *when RT happened to ingest it*. double posTime = jag_n(pJAc, RT_DRCT_TimeStamp); - const double posAge = jag_n(pJAc, RT_DRCT_PosAge); - if (!std::isnan(posAge)) - posTime -= posAge; // (needs adjustment in case we are receiving historical data) posTime += tsAdjust; @@ -2204,24 +2181,7 @@ bool RealTrafficConnection::ProcessRTTFC (LTFlightData::FDKeyTy& fdKey, int nBuffer) { // *** position time *** - // Normalise to actual measurement time by subtracting RTTFC_SEEN - // (seconds since the underlying position was measured at source). - // See the HTTP-Direct path comment on RT_DRCT_TimeStamp / - // RT_DRCT_PosAge for the full rationale: different sources have - // different ingest delays, so two records with the same - // RT_RTTFC_TIMESTAMP can represent positions measured seconds - // apart at the source. Subtracting SEEN places each position at - // its actual measurement time in the deque. The SEEN field is - // optional in the compact 18-field RT App variant, so we only - // apply the adjustment when the field is present and parses. double posTime = std::stod(tfc[RT_RTTFC_TIMESTAMP]); - if (tfc.size() > RT_RTTFC_SEEN && !tfc[RT_RTTFC_SEEN].empty()) { - try { - const double posAge = std::stod(tfc[RT_RTTFC_SEEN]); - if (!std::isnan(posAge)) - posTime -= posAge; - } catch (...) { /* leave posTime as-is */ } - } AdjustTimestamp(posTime, nBuffer); // *** Process received data *** From 7a072fa5eb83de48bb0c31a86e6d8293dcb7555b Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sun, 17 May 2026 17:50:56 +1000 Subject: [PATCH 35/43] =?UTF-8?q?feat/PB:=20Gate-parked=20aircraft=20pushb?= =?UTF-8?q?ack=20rendering=20=E2=80=94=20full=20pipeline=20rework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the full pipeline for correctly identifying parked aircraft at gates and rendering their pushback maneuver, replacing the previous heading-flip- based heuristic with a state machine driven by gate detection, distance- gated motion acceptance, and locked nose-source choice. Constants.h - GATE_DETECT_MAX_DIST_M (30 m): max distance from apt.dat startup-loc for a held position to count as "at a gate". Tight on purpose — keeps runway hold-shorts and taxi pauses from being misclassified. - GATE_HOLD_MIN_ACCEPT_M (30 m): minimum displacement before the first motion slot is accepted on a gate-parked aircraft. Distance-based, NOT counter-based, because real pushbacks roll at 0.4-1.4 kt — below the stationary threshold — and a non-stationary-slot counter never advances for a real slow push. - PB_MOTION_GS_KT (0.3): pushback state-machine bMotion threshold, distinct from the global GND_STATIONARY_GS_KT (1.5). The global threshold is too high for real slow pushbacks; the state machine needs its own. - PB_FEED_NOSE_AGREE_DEG (30): max delta between parked heading and first- motion feed heading for the feed to be treated as true-nose source. LTFlightData (CalcHeading PB block) - bMotion loosened: drop the per-slot dist>=SIMILAR_POS_DIST requirement, use PB_MOTION_GS_KT. Continuous slow motion stays in PB_ACTIVE instead of bouncing into PB_PAUSED on every sub-7m slot. - Nose source locked at entry: FEED if first-motion feedHdg is within PB_FEED_NOSE_AGREE_DEG of the prior parked heading (true-nose case), otherwise TRACK+180. Held for the entire push — no per-slot flipping. - Statistical 4-slot track filter (equal-weight centroid, reject 2 largest outliers, vector between 2 inliers). Replaces the noisy 2-point chord during slow ground motion. - Predecessor fallback: when posDeque is drained during a long stationary period and the GATE_RELEASE slot arrives alone, fall back to pAc->GetToPos() as virtual predecessor so the state machine can engage on the first accepted slot. - bHeadFixed pinning: stamp the predecessor slot (guarded against posDeque.cbegin) so the renderer interpolates across the entry leg. LTFlightData (AddNewPos) - Third gate-detection path: on bGroundHolding flip, one-shot LTAptFindStartupLoc lookup; if within GATE_DETECT_MAX_DIST_M, set bGateParked. Distance signal complements the SPOS_STARTUP path and catches live-tracked aircraft that were never in RT's parked snapshot. The lookup uses outDist (not isNormal()) as the success signal — startup-loc positionTy has ts=NaN/alt=NaN and isNormal() rejects it. - Distance-based gate-hold motion suppression: drop any slot with displacement < GATE_HOLD_MIN_ACCEPT_M from the latest accepted position while bGateParked && pbState==PB_NONE. On acceptance, also clear bGroundHolding so subsequent in-push slots flow through. - youngestTS advancement on drops: keep the freshness timestamp moving even when slots are filtered out. Without this, an aircraft sitting at a gate receiving valid feed updates (all trivial-dropped) outdates after GetAcOutdatedIntvl() and is removed despite the feed being alive. LTAircraft (ground spline branch) - Honour to.f.bHeadFixed as well as from.f.bHeadFixed when deciding whether to interpolate heading or use the spline tangent. The previous from-only check failed at the PB_NONE→PB_ACTIVE transition: from (posList[0]) was a live-feed parked slot with bHeadFixed=false, so the renderer used the motion-tangent (≈ direction of travel) and visually pivoted the aircraft to face the push direction at the gate. Honouring to.f.bHeadFixed covers the transition from the destination side, since pinning bHeadFixed retroactively on the predecessor isn't reachable when posDeque has been drained. LTMain - Cosmetic (whitespace) from earlier revert of rewind-detection feature. Diagnostic logging - GND_DIAG_GATE (HIT / NEAR / NOAPT / APT_UNAVAIL) at HOLDIN moment. - GND_DIAG_GATE_HOLD on suppressed motion at gate (with dist/threshold). - GND_DIAG_GATE_RELEASE on acceptance. - PUSHBACK_DIAG ENTRY with chosen nose source and entry values. - Per-slot PUSHBACK_DIAG (state, gs, track, heldNose, assigned). --- Include/Constants.h | 138 +++++--- Include/LTFlightData.h | 64 ++-- Src/LTAircraft.cpp | 32 +- Src/LTFlightData.cpp | 782 ++++++++++++++++++++++++++++++++++------- Src/LTMain.cpp | 6 +- 5 files changed, 800 insertions(+), 222 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 7600c286..169a0b4b 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -92,6 +92,81 @@ constexpr double GND_COLLISION_DIST = 10; // [m] If another aircraft comes /// 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; + +/// 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 @@ -378,60 +453,19 @@ constexpr double GND_PITCH_DEG = 0.0; /// gated behind `!IsOnGnd()` so this only applies on the ground. constexpr double GND_ROLL_DEG = 0.0; -/// [kn] upper bound on groundspeed for an event to be considered the START -/// of a pushback. This gates ENTRY into the pushback state only — once the -/// state is entered it is held regardless of speed, so a long or brisk push -/// never drops out. The real discriminator for a pushback is the DIRECTION -/// (the aircraft moving backwards relative to its nose — see -/// `PUSHBACK_DETECT_HEAD_DIFF_DEG`); nothing else moves backwards on the -/// ground, so the speed ceiling only needs to be generous enough that the -/// first feed slot of the push is caught even when the feed cadence is -/// sparse. Observed: with a sparse RT feed the first slot of a push can -/// already read 8–9 kn, so 12 kn is used — well above realistic tug speed -/// (which the direction gate would catch anyway) but a useful guard -/// against a glitchy high-speed position jump masquerading as a push. -constexpr double PUSHBACK_DETECT_GS_MAX_KT = 12.0; - -/// [°] minimum |track − nose-heading| to ENTER the pushback state. 135° is -/// well inside the "going backwards" half-plane (which begins at 90°) and -/// leaves margin so a sharply curving forward taxi never trips entry. The -/// track is derived from the current position delta; the nose heading is -/// the predecessor slot's (last-known-good) heading. -constexpr double PUSHBACK_DETECT_HEAD_DIFF_DEG = 135.0; - -/// [°] maximum |track − nose-heading| to EXIT the pushback state. While the -/// state is held, `CalcHeading` keeps the slot heading updated to -/// `track + 180°` — i.e. the predecessor slot's heading is always the -/// current nose direction. When a meaningful-motion slot's track points -/// within this angle of that nose heading, the aircraft is moving FORWARD -/// (taxiing away under its own power) — the push is over. This is a far -/// more reliable end-of-pushback signal than any elapsed-time proxy: it -/// cannot fire while the aircraft is stopped (tug still attached / just -/// disconnected produces only sub-threshold motion), and it copes with -/// pushes of any length or distance. +/// [°] 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. /// -/// Entry at ≥135° and exit at ≤45° leave a 45–135° hysteresis band: a slot -/// whose track falls in the ambiguous "curving" zone keeps the current -/// state, so a sharply curving push never flickers out mid-manoeuvre. -constexpr double PUSHBACK_EXIT_FWD_DIFF_DEG = 45.0; - -/// [°] threshold that decides, while in a pushback, WHICH source to use for -/// the rendered heading. +/// 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. /// -/// RealTraffic's heading field is not consistent across aircraft: for some -/// it is the true nose direction (Mode S EHS), for others it is simply -/// course-over-ground. During a pushback these look completely different: -/// * true-nose feed: feedHdg is ~180° from the track (the aircraft moves -/// tail-first) — and it is a clean, smooth signal, immune to a single -/// noisy track sample. We trust it directly. -/// * course feed: feedHdg ≈ the track — useless as a nose reference; -/// we derive the nose as `track + 180°` instead. -/// So: if |feedHdg − track| exceeds this threshold the feed heading is the -/// true nose and is used as-is; otherwise it is course and the nose is -/// derived from the reversed track. 90° is the natural divider — beyond it -/// the feed points into the rear half-plane relative to the motion, which -/// only the true-nose interpretation explains. -constexpr double PUSHBACK_FEED_IS_NOSE_DEG = 90.0; +/// 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°) diff --git a/Include/LTFlightData.h b/Include/LTFlightData.h index ea560bc9..bbec7a09 100644 --- a/Include/LTFlightData.h +++ b/Include/LTFlightData.h @@ -299,27 +299,49 @@ class LTFlightData /// before exiting avoids those false-exits. int groundNonStationaryCnt = 0; - // ---- Pushback state (see Constants.h `PUSHBACK_*`) -------------------- - // True while the aircraft is being pushed back from a gate. Entered in - // `CalcHeading` on the geometric signature (slow, on-ground, moving - // backwards relative to the nose); while true the slot heading is held - // at `track + 180°` so the rendered nose stays pointing away from the - // direction of travel. Exited only when a meaningful-motion slot shows - // the aircraft moving FORWARD again (track within - // `PUSHBACK_EXIT_FWD_DIFF_DEG` of the maintained nose heading) — a - // direction-reversal test, not a timer, so it copes with pushes of any - // length and never exits while the aircraft is still stopped. - bool bPushback = false; - /// [°] last heading observed while (near-)stationary on the ground. - /// Candidate "parked heading" reference for pushback detection — but - /// it can drift if the live feed updates the heading while the - /// aircraft is nominally parked. - double headingStable = NAN; - /// [°] last heading observed on a `SPOS_STARTUP` slot, i.e. the - /// apt.dat gate heading. Unlike `headingStable` this cannot drift — - /// it comes from static airport data, not the live feed — so it is - /// the more trustworthy "true nose while parked" reference. - double headingStartup = NAN; + // ---- 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; diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index e8ad1e5d..79df644b 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -2129,18 +2129,30 @@ bool LTAircraft::CalcPPos() // nose points along the rendered direction of motion, which // is what eliminates the "sideways through a turn" symptom. // - // EXCEPTION: if the leg's start slot carries `bHeadFixed`, + // 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 `track + 180°` - // 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. Instead we interpolate the slot - // headings across the leg (shortest-path), preserving the - // intended nose direction while still drawing the smooth - // spline *position*. - if (from.f.bHeadFixed) { + // 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); diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index 315f42ef..5ce94ce4 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1525,161 +1525,454 @@ 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 - std::lock_guard lock (dataAccessMutex); - - // ---------------------------------------------------------------------- - // TEMPORARY DIAGNOSTIC (tag: PUSHBACK_DIAG) — remove once the - // pushback-detection redesign lands. // - // For every slow on-ground slot, dump the values a parked-heading- - // anchored pushback detector would need: the feed heading, the - // position-derived track, the last (near-)stationary heading, the - // last apt.dat startup (gate) heading, and the angular gap of the - // track from each reference. The point is to learn from a real - // pushback which reference heading is trustworthy (stable vs - // startup) and what angular threshold actually separates a pushback - // from a forward power-out taxi. Search the log for "PUSHBACK_DIAG". - // ---------------------------------------------------------------------- - if (it->IsOnGnd() && it != posDeque.cbegin()) { - const positionTy& prePosDg = *std::prev(it); - if (prePosDg.IsOnGnd() && it->ts() > prePosDg.ts()) { - const double dgGs = prePosDg.speed_kt(*it); - const vectorTy dgVec = prePosDg.between(*it); - // Capture the reference headings while (near-)stationary. - if (!std::isnan(dgGs) && dgGs < GND_STATIONARY_GS_KT && - !std::isnan(it->heading())) { - headingStable = it->heading(); - if (it->f.specialPos == SPOS_STARTUP) - headingStartup = it->heading(); - } - // Log every slot in the slow-ground regime a pushback lives in. - if (!std::isnan(dgGs) && dgGs < GND_USE_FEED_HEADING_MAX_KT) { - const double dgTrack = dgVec.angle; - LOG_MSG(logDEBUG, - "PUSHBACK_DIAG %s gs=%.2fkt feedHdg=%.1f track=%.1f" - " stableHdg=%.1f startupHdg=%.1f" - " |trk-stable|=%.0f |trk-startup|=%.0f spos=%d dist=%.1fm", - key().c_str(), dgGs, it->heading(), - std::isnan(dgTrack) ? -1.0 : dgTrack, - headingStable, headingStartup, - (std::isnan(dgTrack) || std::isnan(headingStable)) ? -1.0 : - std::abs(HeadingDiff(headingStable, dgTrack)), - (std::isnan(dgTrack) || std::isnan(headingStartup)) ? -1.0 : - std::abs(HeadingDiff(headingStartup, dgTrack)), - (int)it->f.specialPos, dgVec.dist); - } - } - } + // 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. (Runs BEFORE the feed-heading logic below so - // that, once a pushback is recognised, it is fully authoritative for the - // heading and the feed-heading cross-check never gets to interfere.) - // - // A pushback at a gate has a clear geometric signature: the aircraft is - // on the ground, moving at tug pace, and the direction of motion (the - // track from pos[n-1]→pos[n]) is nearly opposite the heading the - // aircraft held just before it started moving. The default - // heading-from-track logic would rotate the rendered aircraft 180° to - // "face where it is going", producing an airliner that appears to taxi - // tail-first. - // - // ENTRY: not in pushback, this slot has meaningful motion at tug speed - // (GND_STATIONARY_GS_KT < gs ≤ PUSHBACK_DETECT_GS_MAX_KT), and the - // track is ≥ PUSHBACK_DETECT_HEAD_DIFF_DEG away from the predecessor's - // heading. For the first moving slot the predecessor IS the last - // parked slot, so this compares the push direction against the parked - // nose — and an aircraft physically cannot taxi forward 135° off its - // nose, so the test only ever fires on a genuine push (or tow). + // Pushback state machine (simplified). // - // HOLD: while in pushback, pick the heading SOURCE per slot — - // * if the feed heading is well away from the track - // (|feedHdg − track| > PUSHBACK_FEED_IS_NOSE_DEG) the feed is - // reporting the true nose: trust it directly. This is the clean - // case — the feed nose rotates smoothly through a curved push and - // is immune to a single noisy track sample. - // * otherwise the feed heading ≈ the track, i.e. it is course-over- - // ground and useless as a nose reference: derive the nose as - // `track + 180°`. - // A sub-threshold-motion slot carries the predecessor heading forward - // (no reliable track from near-zero motion). `bHeadFixed` is set so - // the renderer uses this slot heading verbatim instead of overriding - // it with the ground-spline tangent (which points along the backward - // motion and would render the aircraft tail-first). + // 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. // - // EXIT: only when a meaningful-motion slot's track points FORWARD - // relative to the maintained nose — within PUSHBACK_EXIT_FWD_DIFF_DEG - // of the predecessor slot's (held) heading. That is the aircraft - // taxiing away under its own power. This direction-reversal test - // cannot fire while the aircraft is still stopped (a stopped aircraft - // produces only sub-threshold slots) and copes with pushes of any - // length. Entry ≥135° / exit ≤45° leave a hysteresis band so a - // sharply curving push never flickers out mid-manoeuvre. + // 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. - bPushback = false; - } - else if (it != posDeque.cbegin()) { - const positionTy& prePosPb = *std::prev(it); - if (prePosPb.IsOnGnd() && it->ts() > prePosPb.ts()) { - const vectorTy pbTrack = prePosPb.between(*it); - const double pbNose = prePosPb.heading(); // maintained nose / parked heading - const double pbFeedHdg = it->heading(); // feed-delivered heading, before we overwrite it - const bool bPbMotion = pbTrack.dist >= SIMILAR_POS_DIST; - const bool bPbTrackOK = bPbMotion && !std::isnan(pbTrack.angle) - && !std::isnan(pbNose); - - if (!bPushback) { - // --- ENTRY --- - const double pbGs_kt = prePosPb.speed_kt(*it); - if (bPbTrackOK && - !std::isnan(pbGs_kt) && - pbGs_kt > GND_STATIONARY_GS_KT && - pbGs_kt <= PUSHBACK_DETECT_GS_MAX_KT && - std::abs(HeadingDiff(pbNose, pbTrack.angle)) - >= PUSHBACK_DETECT_HEAD_DIFF_DEG) - { - bPushback = true; + 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. + } } } - else if (bPbTrackOK) { - // --- EXIT test --- only a meaningful-motion slot can be - // classified; sub-threshold slots keep the state. - if (std::abs(HeadingDiff(pbNose, pbTrack.angle)) - <= PUSHBACK_EXIT_FWD_DIFF_DEG) - bPushback = false; // moving forward — push over + + 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); + }; + + 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; + + 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; } - // --- HOLD --- drive the heading while the state is active. - if (bPushback) { - if (bPbMotion && !std::isnan(pbTrack.angle)) { - // Choose the heading source. A feed heading far from - // the track is the genuine nose — trust it (smooth, - // noise-free). A feed heading that tracks the motion - // is course-over-ground — derive the nose as the - // reverse of the track instead. - if (!std::isnan(pbFeedHdg) && - std::abs(HeadingDiff(pbFeedHdg, pbTrack.angle)) - > PUSHBACK_FEED_IS_NOSE_DEG) - it->heading() = pbFeedHdg; - else - it->heading() = HeadingNormalize(pbTrack.angle + 180.0); - } - else if (!std::isnan(pbNose)) { - it->heading() = pbNose; // sub-threshold motion — hold + // 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. + 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)); } - it->f.bHeadFixed = true; 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). // @@ -2318,6 +2611,105 @@ void LTFlightData::AddNewPos ( positionTy& pos ) " (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; + 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"; + 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 @@ -2345,6 +2737,18 @@ void LTFlightData::AddNewPos ( positionTy& pos ) "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 { @@ -2371,6 +2775,75 @@ void LTFlightData::AddNewPos ( positionTy& pos ) 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) { + 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(); + 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 @@ -2507,6 +2980,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 diff --git a/Src/LTMain.cpp b/Src/LTMain.cpp index ccf7d56a..44d20ae1 100644 --- a/Src/LTMain.cpp +++ b/Src/LTMain.cpp @@ -1088,12 +1088,12 @@ 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(); From 8c9f16b049af0331df1a7c471e741d61b71fe88c Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sun, 17 May 2026 18:20:25 +1000 Subject: [PATCH 36/43] feat/PB: Safety-valve exit + placeholder-hex duplicate prune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PB safety-valve emergency exit - New constant PB_MAX_GS_KT = 10.0 in Constants.h. - In CalcHeading, when pbState ∈ {ACTIVE, PAUSED} and the slot gs exceeds PB_MAX_GS_KT, force exit to PB_NONE: clear pbHeldNose, pbUseFeedNose, bGateParked and log PUSHBACK_DIAG ... FORCE_EXIT. - Catches the failure mode where the held-nose source was chosen incorrectly at entry (TRACK+180 picked because feedHdg disagreed with parkedHdg, but the feed was actually right because the aircraft had already rotated during GATE_HOLD suppression). With a wrong reference, the directional PB_PAUSED→PB_NONE exit test never fires; the aircraft taxis out at 20+ kt while the renderer still shows the held nose, producing the visible tail-first / ass- forward symptom. 10 kt is a hard upper bound on physical pushback speed; anything above is definitively taxi. `FF****` placeholder-hex duplicate prune - New static LTFlightData::PrunePlaceholderHexDuplicates() (declared in LTFlightData.h, implemented in LTFlightData.cpp). - Two-pass scan of mapFd: pass 1 collects callsigns from non-FF entries; pass 2 invalidates any FF entry whose callsign matches. Two passes are necessary because the real-hex entry may appear after its FF counterpart in iteration order. SetInvalid(true) drops the rendered aircraft so the visual duplicate disappears immediately and the standard cleanup pipeline erases the entry. Uses GetUnsafeStat() for the callsign reads — racy but acceptable given the periodic nature of the scan (stale reads re-evaluated next pass). - Hooked from LTRegularUpdates with a 10 s throttle; the duplicate condition develops over many seconds and the per-flight-loop cadence would waste mapFd-mutex time. - Logs PRUNE_FF for visibility on each invalidation. --- Include/Constants.h | 23 +++++++++ Include/LTFlightData.h | 20 ++++++++ Src/LTFlightData.cpp | 112 +++++++++++++++++++++++++++++++++++++++++ Src/LTMain.cpp | 21 ++++++++ 4 files changed, 176 insertions(+) diff --git a/Include/Constants.h b/Include/Constants.h index 169a0b4b..164f9348 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -144,6 +144,29 @@ constexpr double PB_FEED_NOSE_AGREE_DEG = 30.0; /// 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. diff --git a/Include/LTFlightData.h b/Include/LTFlightData.h index bbec7a09..2d6e2695 100644 --- a/Include/LTFlightData.h +++ b/Include/LTFlightData.h @@ -541,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/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index 5ce94ce4..fbf9a5a1 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1775,6 +1775,52 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) 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) + { + 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: @@ -3910,6 +3956,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 44d20ae1..6462dfdf 100644 --- a/Src/LTMain.cpp +++ b/Src/LTMain.cpp @@ -1099,6 +1099,27 @@ void LTRegularUpdates() // 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(); From 232209217358cb8f25d7dd506ebc30a69233cb89 Mon Sep 17 00:00:00 2001 From: Balthasar Indermuehle Date: Sun, 17 May 2026 18:38:12 +1000 Subject: [PATCH 37/43] fix/CI: Guard empty-string FSC env vars in CMakeLists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions exposes repository secrets via env-var assignments unconditionally (`.github/workflows/build.yml:14-17`). When a fork PR doesn't have the FSC secrets configured, GA passes the env vars as the empty string — NOT as undefined. The previous `if(DEFINED ENV{...})` checks treat empty-string-defined as "set", so 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`; the Linux CI build fails with "expected primary-expression before ','" at line 48. Every fork PR hits this — trivially reproducible with no FSC secrets in the fork settings, succeeds in the upstream's own CI because the secrets ARE configured there, succeeds locally because the env vars aren't set at all (DEFINED is false → default fallback fires). Fix: add `AND NOT "$ENV{...}" STREQUAL ""` to each of the four FSC DEFINED-checks. Empty env vars now fall through to the defaults in the same code path as the not-defined case. Verified locally that both `unset` and `=""` simulate-as-CI cases produce the expected "FSCharter Production = Client ID: 3, Client Secret: INOP" output. --- CMakeLists.txt | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 23933667..c3b8e6fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 From d3a7ba84acad7c4fe8f7fcd26ff1166361dd16ba Mon Sep 17 00:00:00 2001 From: TwinFan Date: Sun, 17 May 2026 23:07:04 +0200 Subject: [PATCH 38/43] fix: Wait for weather thread during shutdown --- Src/LTWeather.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Src/LTWeather.cpp b/Src/LTWeather.cpp index e34c95c9..40515588 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) { From b3ad75def026e0ec134ccfb22f7982ab3ecf1299 Mon Sep 17 00:00:00 2001 From: TwinFan Date: Sun, 17 May 2026 23:09:00 +0200 Subject: [PATCH 39/43] chore: Replace ROTATE_PITCH_MAX_DEG with pMdl->PITCH_FLARE and reduced PITCH_FLARE for many models. Makes rotate pitch depending on aircraft size. --- Include/Constants.h | 19 ------------------- Resources/FlightModels.prf | 8 ++++---- Src/LTAircraft.cpp | 9 ++++----- 3 files changed, 8 insertions(+), 28 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 164f9348..132b4929 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -536,25 +536,6 @@ constexpr double GND_BEZIER_MIN_HEAD_DIFF = 1.0; /// "climbing away from the runway" instead of "popping into the sky". constexpr double LIFTOFF_BLEND_TIME_S = 10.0; -/// [°] maximum pitch angle during the take-off rotation phase -/// (`FPH_ROTATE`), before the aircraft physically leaves the runway. -/// -/// Why this exists (and is less than the per-flight-model -/// `PITCH_MAX`): without an explicit cap, `ENTERED(FPH_ROTATE)` -/// calls `pitch.max()` which walks the pitch MovingParam toward the -/// flight model's `PITCH_MAX` (15° by default). 15° is well past the -/// tail-strike geometry of most narrow-bodies — B738 ≈ 11°, A320 -/// ≈ 13.5° — so users were seeing rendered aircraft drag their tails -/// during rotation. Capping the rotate target at 10° keeps the nose -/// below the tail-strike envelope while still showing a recognisable -/// rotation animation. Once the aircraft transitions to -/// `FPH_LIFT_OFF`, the in-air pitch logic in `LTFlightData::CalcNextPos` -/// takes over and walks pitch toward the VSI-derived target — -/// clamped to the flight model's `PITCH_MAX` — so steep initial -/// climbs can still reach the full 15°, just not during the on- -/// runway rotation phase. -constexpr double ROTATE_PITCH_MAX_DEG = 10.0; - /// [s] minimum time the nose is held pitched up at `PITCH_FLARE` after /// touchdown before the de-rotation walk to `GND_PITCH_DEG` begins. /// diff --git a/Resources/FlightModels.prf b/Resources/FlightModels.prf index e43528bc..1663746a 100644 --- a/Resources/FlightModels.prf +++ b/Resources/FlightModels.prf @@ -42,7 +42,7 @@ 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_FLARE 7 # [°] pitch during flare, and also for rotate PITCH_RATE 3 # [°/s] pitch rate of change 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 @@ -118,7 +118,7 @@ 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_FLARE 5 # [°] pitch during flare, and also for rotate PITCH_RATE 3 # [°/s] pitch rate of change 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 @@ -149,7 +149,7 @@ 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_FLARE 7 # [°] pitch during flare, also for rotate PITCH_RATE 3 # [°/s] pitch rate of change 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 @@ -188,7 +188,7 @@ 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_FLARE 3 # [°] pitch during flare +PITCH_FLARE 3 # [°] pitch during flare, and also for rotate PITCH_RATE 1 # [°/s] pitch rate of change 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 diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index 79df644b..fb3acae3 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -2745,16 +2745,15 @@ 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) { - // Cap the rotate-phase pitch target at `ROTATE_PITCH_MAX_DEG` - // (10°) instead of `pMdl->PITCH_MAX` (15°). On the runway - // the latter exceeds the tail-strike geometry of most - // narrow-bodies. Once the aircraft transitions to + // Cap the rotate-phase pitch target at the same angle as + // during a flare to prevent tail strike. + // 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(ROTATE_PITCH_MAX_DEG); + pitch.moveTo(pMdl->PITCH_FLARE); gearDeflection.min(); // and start easing up on the wheels } } From 844d40c7cd4e500440900476b3f5d9415a1ddfac Mon Sep 17 00:00:00 2001 From: TwinFan Date: Mon, 18 May 2026 13:49:16 +0200 Subject: [PATCH 40/43] chore: Replace TOUCHDOWN_HOLD_PITCH with new pMdl->PITCH_HOLD_TOUCHDOWN to make the duration of pitch hold after touch-down depending on aircraft size. --- Include/Constants.h | 21 --------------------- Include/LTAircraft.h | 3 ++- Resources/FlightModels.prf | 7 +++++++ Src/LTAircraft.cpp | 3 ++- 4 files changed, 11 insertions(+), 23 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index 132b4929..fe627c45 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -536,27 +536,6 @@ constexpr double GND_BEZIER_MIN_HEAD_DIFF = 1.0; /// "climbing away from the runway" instead of "popping into the sky". constexpr double LIFTOFF_BLEND_TIME_S = 10.0; -/// [s] minimum time the nose is held pitched up at `PITCH_FLARE` after -/// touchdown before the de-rotation walk to `GND_PITCH_DEG` begins. -/// -/// Why this exists: real airliners aerobrake by holding the nose high -/// for several seconds after the main gear touches, until aerodynamic -/// braking loses authority and the nose-wheel is lowered for wheel -/// braking. Previously LiveTraffic called `pitch.moveTo(GND_PITCH_DEG)` -/// on the same frame that `FPH_TOUCH_DOWN` was entered, so the -/// `PITCH_RATE` walk started immediately and the nose was on the -/// ground within ~3 s of touchdown — visibly faster than real -/// aircraft. -/// -/// 5 s is the lower bound of typical airline practice (longer aircraft -/// often hold longer); we use it as a floor so even quick rollouts get -/// a recognisable aerobrake. The hold period sits entirely inside -/// `FPH_TOUCH_DOWN` / `FPH_ROLL_OUT`, both of which are already -/// excluded from the ground-attitude pitch override in `CalcAcPos`, -/// so the MovingParam keeps the pitch at its last-commanded value -/// (`PITCH_FLARE`) until the deferred `moveTo` fires. -constexpr double TOUCHDOWN_HOLD_PITCH_S = 5.0; - //MARK: Flight Model constexpr double MDL_ALT_MIN = -1500; // [ft] minimum allowed altitude diff --git a/Include/LTAircraft.h b/Include/LTAircraft.h index da427bc4..919720a7 100644 --- a/Include/LTAircraft.h +++ b/Include/LTAircraft.h @@ -234,8 +234,9 @@ 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_FLARE = 10; ///< [°] pitch during flare, and also used for rotate 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 diff --git a/Resources/FlightModels.prf b/Resources/FlightModels.prf index 1663746a..fd3442cb 100644 --- a/Resources/FlightModels.prf +++ b/Resources/FlightModels.prf @@ -44,6 +44,7 @@ PITCH_MAX_VSI 2000 # [ft/min] maximum vsi above which pitch is MDL_PITC PITCH_FLAP_ADD 4 # [°] to add if flaps extended PITCH_FLARE 7 # [°] pitch during flare, and also for rotate 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... @@ -62,6 +63,7 @@ MAX_FLIGHT_SPEED 600 # [kn] maximum flight speed, above that not consider PITCH_MAX 10 # [°] maximum pitch angle (aoa) PITCH_MAX_VSI 2500 # [ft/min] maximum vsi above which pitch is MDL_PITCH_MAX 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 +78,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 +91,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... @@ -120,6 +124,7 @@ PITCH_MAX_VSI 600 # [ft/min] maximum vsi above which pitch is MDL_PITC PITCH_FLAP_ADD 2 # [°] to add if flaps extended PITCH_FLARE 5 # [°] pitch during flare, and also for rotate 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 @@ -151,6 +156,7 @@ PITCH_MAX_VSI 2000 # [ft/min] maximum vsi above which pitch is MDL_PITC PITCH_FLAP_ADD 3 # [°] to add if flaps extended PITCH_FLARE 7 # [°] pitch during flare, also for rotate 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... @@ -190,6 +196,7 @@ PITCH_MAX_VSI 1000 # [ft/min] maximum vsi above which pitch is MDL_PITC PITCH_FLAP_ADD 1 # [°] to add if flaps extended PITCH_FLARE 3 # [°] pitch during flare, and also for rotate 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 diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index fb3acae3..b76f4e88 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -881,6 +881,7 @@ bool fm_processModelLine (const char* fileName, int ln, else FM_ASSIGN(PITCH_FLAP_ADD); 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); @@ -2842,7 +2843,7 @@ void LTAircraft::CalcFlightModel (const positionTy& /*from*/, const positionTy& // (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 + TOUCHDOWN_HOLD_PITCH_S) + currCycle.simTime >= touchdownTs + pMdl->PITCH_HOLD_TOUCHDOWN) { pitch.moveTo(GND_PITCH_DEG); touchdownTs = NAN; From b6fc6924806267db82f987425c53e7292b2ed423 Mon Sep 17 00:00:00 2001 From: TwinFan Date: Mon, 18 May 2026 14:22:45 +0200 Subject: [PATCH 41/43] chore: Added "Debug/Log Diagnostics" to Setting and tied all DIAG logging to it --- Include/DataRefs.h | 3 + Src/DataRefs.cpp | 2 + Src/LTAircraft.cpp | 3 +- Src/LTFlightData.cpp | 210 ++++++++++++++++++++++++------------------ Src/LTRealTraffic.cpp | 6 +- Src/SettingsUI.cpp | 2 + 6 files changed, 131 insertions(+), 95 deletions(-) diff --git a/Include/DataRefs.h b/Include/DataRefs.h index d734a592..e65898ff 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/Src/DataRefs.cpp b/Src/DataRefs.cpp index f3d88c92..6f9b367c 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; diff --git a/Src/LTAircraft.cpp b/Src/LTAircraft.cpp index b76f4e88..161caf82 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -2585,7 +2585,8 @@ void LTAircraft::CalcFlightModel (const positionTy& /*from*/, const positionTy& // 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 (bOnGrndPrev != bOnGrnd && bFPhPrev != FPH_UNKNOWN) { + if (dataRefs.ShallLogDiagnostics() && + bOnGrndPrev != bOnGrnd && bFPhPrev != FPH_UNKNOWN) { const bool blendActive = !std::isnan(liftoffBlendStartTs) && (currCycle.simTime - liftoffBlendStartTs) < LIFTOFF_BLEND_TIME_S; diff --git a/Src/LTFlightData.cpp b/Src/LTFlightData.cpp index fbf9a5a1..16378c40 100644 --- a/Src/LTFlightData.cpp +++ b/Src/LTFlightData.cpp @@ -1805,12 +1805,14 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) !std::isnan(pbGs_kt) && pbGs_kt > PB_MAX_GS_KT) { - 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"); + 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; @@ -1907,16 +1909,18 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) if (it != posDeque.cbegin()) std::prev(it)->f.bHeadFixed = true; - 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); + 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 @@ -1994,16 +1998,18 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) // Per-slot diagnostic line — emitted only on the // initial override pass (when bHeadFixed flips // false→true), not on every re-eval. - 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)); + 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; } @@ -2123,7 +2129,7 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) // outer-loop track-heading regime in LTAircraft::CalcAcPos owns // that range. Leave `trustFeed=false` so we fall through. - if (trustFeed) { + if (trustFeed && dataRefs.ShallLogDiagnostics()) { LOG_MSG(logDEBUG, "GND_DIAG_FEEDHDG %s ts=%.1f feedHdg=%.1f gs=%.2fkt" " track=%.1f (%s)", @@ -2182,21 +2188,23 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) const bool isolated = std::isnan(gsFromPrev_kt) || std::isnan(gsToNext_kt); - // ----- TEMPORARY GROUND DIAGNOSTIC LOGGING (tag: GND_DIAG_CHD) ----- + // ----- 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. - 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 (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))) @@ -2206,9 +2214,11 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) if (it != posDeque.cbegin()) { const double prevHead = std::prev(it)->heading(); if (!std::isnan(prevHead)) { - LOG_MSG(logDEBUG, - "GND_DIAG_FREEZE %s ts=%.1f kept prevHdg=%.1f", - key().c_str(), it->ts(), 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; } @@ -2315,12 +2325,14 @@ void LTFlightData::CalcHeading (dequePositionTy::iterator it) const double prevHead = std::prev(it)->heading(); if (!std::isnan(prevHead) && !std::isnan(it->heading())) { const double dHead = std::abs(HeadingDiff(prevHead, it->heading())); - // ----- TEMPORARY GROUND DIAGNOSTIC LOGGING (tag: GND_DIAG_HYST) - - 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" : ""); + // ----- 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; @@ -2619,13 +2631,13 @@ void LTFlightData::AddNewPos ( positionTy& pos ) !std::isnan(gs_kt) && gs_kt <= GND_STATIONARY_GS_KT; - // ----- TEMPORARY GROUND DIAGNOSTIC LOGGING (tag: GND_DIAG_ADD) - + // ----- 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) { + 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", @@ -2652,11 +2664,13 @@ void LTFlightData::AddNewPos ( positionTy& pos ) (pos.ts() - groundHoldingSinceTs) >= GND_HOLDING_TIMEOUT_S) { bGroundHolding = true; - LOG_MSG(logDEBUG, - "GND_DIAG_HOLDIN %s entering ground-holding" - " (stationary for %.1fs)", - key().c_str(), - pos.ts() - groundHoldingSinceTs); + 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). @@ -2726,13 +2740,15 @@ void LTFlightData::AddNewPos ( positionTy& pos ) gateDist <= GATE_DETECT_MAX_DIST_M) { bGateParked = true; - 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()); + 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 @@ -2746,15 +2762,17 @@ void LTFlightData::AddNewPos ( positionTy& pos ) if (!aptAvail) mode = "APT_UNAVAIL"; else if (!std::isnan(probeDist)) mode = "NEAR"; else mode = "NOAPT"; - 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); + 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); + } } } @@ -2779,10 +2797,12 @@ void LTFlightData::AddNewPos ( positionTy& pos ) if (bGroundHolding && dist_m < GND_HOLDING_TRIVIAL_DIST_M && pos.f.specialPos != SPOS_STARTUP) { - LOG_MSG(logDEBUG, - "GND_DIAG_DROP %s dropping trivial update" - " (dist=%.2fm, gs=%.2fkt)", - key().c_str(), dist_m, gs_kt); + 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) @@ -2814,11 +2834,13 @@ void LTFlightData::AddNewPos ( positionTy& pos ) // is timed from the resumption of stationarity (not // from the moment the original streak began long ago). groundHoldingSinceTs = pos.ts(); - LOG_MSG(logDEBUG, - "GND_DIAG_HOLDOUT %s exiting ground-holding" - " (consec=%d, dist=%.2fm, gs=%.2fkt)", - key().c_str(), groundNonStationaryCnt, - dist_m, gs_kt); + 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); + } } } @@ -2861,12 +2883,14 @@ void LTFlightData::AddNewPos ( positionTy& pos ) pos.f.specialPos != SPOS_STARTUP) { if (dist_m < GATE_HOLD_MIN_ACCEPT_M) { - 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); + 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) @@ -2882,12 +2906,14 @@ void LTFlightData::AddNewPos ( positionTy& pos ) if (bGroundHolding) { bGroundHolding = false; groundHoldingSinceTs = pos.ts(); - 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); + 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); + } } } } diff --git a/Src/LTRealTraffic.cpp b/Src/LTRealTraffic.cpp index e02c8d32..77e47a07 100644 --- a/Src/LTRealTraffic.cpp +++ b/Src/LTRealTraffic.cpp @@ -858,7 +858,7 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) // add the static data fd.UpdateData(std::move(stat), pos.dist(posView)); - // --- TEMPORARY FEED_DIAG (HTTP-Direct path) --- + // --- 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 @@ -866,6 +866,7 @@ bool RealTrafficConnection::ProcessTrafficBuffer (const JSON_Object* pBuf) // 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); @@ -2296,12 +2297,13 @@ bool RealTrafficConnection::ProcessRTTFC (LTFlightData::FDKeyTy& fdKey, // add the static data fd.UpdateData(std::move(stat), dist); - // --- TEMPORARY FEED_DIAG (UDP RTTFC path) --- + // --- 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; diff --git a/Src/SettingsUI.cpp b/Src/SettingsUI.cpp index 05fbac4b..655826ee 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)) { From 6ea6f7c060d1ba20d65417715e865679fb457e07 Mon Sep 17 00:00:00 2001 From: TwinFan Date: Mon, 18 May 2026 23:09:10 +0200 Subject: [PATCH 42/43] chore: Added PITCH_ROTATE to FlightModel and use for Rotate phase --- Include/Constants.h | 2 +- Include/LTAircraft.h | 3 ++- Resources/FlightModels.prf | 20 ++++++++++++++------ Src/LTAircraft.cpp | 11 +++++------ 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Include/Constants.h b/Include/Constants.h index fe627c45..052537a1 100644 --- a/Include/Constants.h +++ b/Include/Constants.h @@ -600,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/LTAircraft.h b/Include/LTAircraft.h index 919720a7..815a0c19 100644 --- a/Include/LTAircraft.h +++ b/Include/LTAircraft.h @@ -234,7 +234,8 @@ 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, and also used for rotate + 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 diff --git a/Resources/FlightModels.prf b/Resources/FlightModels.prf index fd3442cb..7ebe4357 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,7 +42,8 @@ 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 7 # [°] pitch during flare, and also for rotate +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) @@ -60,8 +61,10 @@ 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 @@ -122,7 +125,8 @@ 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, and also for rotate +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) @@ -154,7 +158,8 @@ 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 7 # [°] pitch during flare, also for rotate +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) @@ -194,7 +199,8 @@ 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_FLARE 3 # [°] pitch during flare, and also for rotate +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) @@ -220,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/LTAircraft.cpp b/Src/LTAircraft.cpp index 161caf82..a65309d5 100644 --- a/Src/LTAircraft.cpp +++ b/Src/LTAircraft.cpp @@ -879,6 +879,7 @@ 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); @@ -970,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 @@ -2747,15 +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) { - // Cap the rotate-phase pitch target at the same angle as - // during a flare to prevent tail strike. - // Once the aircraft transitions to - // FPH_LIFT_OFF the in-air pitch logic in + // 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_FLARE); + pitch.moveTo(pMdl->PITCH_ROTATE); gearDeflection.min(); // and start easing up on the wheels } } From 21703131aa7356e8baa4c7f3d82252595ea78a66 Mon Sep 17 00:00:00 2001 From: TwinFan Date: Mon, 18 May 2026 23:59:31 +0200 Subject: [PATCH 43/43] chore: v4.5.0 and release notes --- CMakeLists.txt | 2 +- Lib/XPMP2 | 2 +- LiveTraffic.xcodeproj/project.pbxproj | 8 ++++---- docs/readme.html | 27 ++++++++++++++++++++++++++- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e75d1041..56cf48e4 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) diff --git a/Lib/XPMP2 b/Lib/XPMP2 index 2bddcba2..d5727520 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 0ff63fbc..301fae46 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/docs/readme.html b/docs/readme.html index 3578b3bf..96ea5d6e 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.