From 255b296ecceaa6b9f03c8e7a20890e9501791651 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Sun, 26 Jul 2026 10:16:38 -0400 Subject: [PATCH] fix(live): merge stint deltas instead of replacing stint history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The F1 feed sends TimingAppData stints as sparse deltas keyed by stint index — a mid-stint update is just {"1": {"TotalLaps": 14}}. The parser replaced the whole stint slice with whatever a delta carried, so pit history collapsed to a single entry and tyre age was pinned near zero for the entire race. A partial delta was also dropped outright, because the parser required a Compound field that mid-stint updates do not send. Observed live at lap 49 of the 70-lap 2026 Hungarian GP: all 22 drivers reported exactly one stint with age 0 or 3, after most had pitted twice. With the fix, the same feed at lap 51 yields 3 stints for 15 drivers and 2 for 6, ages spread 1-30 — e.g. car 1 as MEDIUM 17 / HARD 22 / HARD 11. Stints now merge by index, and Compound, New and TotalLaps each apply only when the delta actually carries them. Also stop folding non-numeric keys into index 0 in indexedRawValues. The feed's "_kf" key-frame marker parsed as 0 and overwrote the first entry; only the CurrentTyres path guarded against it, leaving the other five callers exposed. This feeds the tyre column, the deg model, stint history and the pit window, all of which were reading near-zero tyre age all race. Co-Authored-By: Claude Opus 5 --- internal/live/parser_test.go | 56 ++++++++++++++++++++++++++++++++++++ internal/live/state.go | 52 ++++++++++++++++++++++++--------- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go index e483e8a..11453f1 100644 --- a/internal/live/parser_test.go +++ b/internal/live/parser_test.go @@ -472,6 +472,62 @@ func TestProcessTopicTimingAppData(t *testing.T) { } } +// The feed sends stints as sparse deltas keyed by stint index. Replacing the +// slice on each delta collapsed pit history to one entry and pinned tyre age +// near zero — observed live at lap 49 of a 70-lap race, where every driver +// reported a single stint of age 0 despite having pitted. +func TestProcessTopicTimingAppDataMergesSparseStintDeltas(t *testing.T) { + state := live.NewState() + state.ProcessTopic("TimingAppData", json.RawMessage(`{ + "Lines": {"4": {"Stints": {"0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0}}}} + }`)) + // Stint 0 runs to 18 laps, then the driver pits onto a new hard. + state.ProcessTopic("TimingAppData", json.RawMessage(`{ + "Lines": {"4": {"Stints": {"0": {"TotalLaps": 18}}}} + }`)) + state.ProcessTopic("TimingAppData", json.RawMessage(`{ + "Lines": {"4": {"Stints": {"1": {"Compound": "HARD", "New": "true", "TotalLaps": 0}}}} + }`)) + state.ProcessTopic("TimingAppData", json.RawMessage(`{ + "Lines": {"4": {"Stints": {"1": {"TotalLaps": 12}}}} + }`)) + + snap := state.Snapshot() + stints := snap.Stints["4"] + if len(stints) != 2 { + t.Fatalf("expected 2 stints after a pit stop, got %d: %+v", len(stints), stints) + } + if stints[0].Compound != "MEDIUM" || stints[0].Laps != 18 { + t.Errorf("first stint lost across deltas: %+v", stints[0]) + } + if stints[1].Compound != "HARD" || stints[1].Laps != 12 { + t.Errorf("second stint = %+v", stints[1]) + } + if tyre := snap.Tyres["4"]; tyre.Compound != "HARD" || tyre.Age != 12 { + t.Errorf("current tyre should track the latest stint, got %+v", tyre) + } +} + +func TestProcessTopicTimingAppDataIgnoresNonNumericStintKeys(t *testing.T) { + state := live.NewState() + state.ProcessTopic("TimingAppData", json.RawMessage(`{ + "Lines": {"4": {"Stints": {"0": {"Compound": "SOFT", "New": "true", "TotalLaps": 9}}}} + }`)) + // "_kf" is a feed key-frame marker, not a stint index. Parsing it as 0 + // would overwrite the real first stint. + state.ProcessTopic("TimingAppData", json.RawMessage(`{ + "Lines": {"4": {"Stints": {"_kf": {"Compound": "HARD", "TotalLaps": 99}}}} + }`)) + + stints := state.Snapshot().Stints["4"] + if len(stints) != 1 { + t.Fatalf("expected 1 stint, got %d: %+v", len(stints), stints) + } + if stints[0].Compound != "SOFT" || stints[0].Laps != 9 { + t.Errorf("key-frame marker corrupted stint 0: %+v", stints[0]) + } +} + func TestProcessTopicTimingStats(t *testing.T) { state := live.NewState() state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"} diff --git a/internal/live/state.go b/internal/live/state.go index ab7ba49..8388cb0 100644 --- a/internal/live/state.go +++ b/internal/live/state.go @@ -10,6 +10,7 @@ import ( "io" "log" "sort" + "strconv" "strings" "time" ) @@ -404,22 +405,42 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { Stints json.RawMessage `json:"Stints"` } if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil { - var driverStints []LiveStintData + // The feed sends stints as sparse deltas keyed by stint index: + // a mid-stint update is just {"1": {"TotalLaps": 14}}. Merge + // each entry into the stint it addresses. Replacing the slice + // wholesale discarded every earlier stint, so pit history + // collapsed to one entry and tyre age stuck near zero for the + // whole race. + driverStints := append([]LiveStintData(nil), s.Stints[num]...) + changed := false for _, sRaw := range indexedRawValues(line.Stints) { var st struct { - Compound string `json:"Compound"` - New string `json:"New"` - TotalLaps int `json:"TotalLaps"` + Compound *string `json:"Compound"` + New *string `json:"New"` + TotalLaps *int `json:"TotalLaps"` } - if json.Unmarshal(sRaw.Raw, &st) == nil && st.Compound != "" { - driverStints = append(driverStints, LiveStintData{ - Compound: st.Compound, - New: st.New == "true" || st.New == "True", - Laps: st.TotalLaps, - }) + if json.Unmarshal(sRaw.Raw, &st) != nil { + continue } + if st.Compound == nil && st.New == nil && st.TotalLaps == nil { + continue + } + for len(driverStints) <= sRaw.Index { + driverStints = append(driverStints, LiveStintData{}) + } + entry := &driverStints[sRaw.Index] + if st.Compound != nil && *st.Compound != "" { + entry.Compound = *st.Compound + } + if st.New != nil { + entry.New = *st.New == "true" || *st.New == "True" + } + if st.TotalLaps != nil { + entry.Laps = *st.TotalLaps + } + changed = true } - if len(driverStints) > 0 { + if changed { s.Stints[num] = driverStints lastStint := driverStints[len(driverStints)-1] t := s.Tyres[num] @@ -874,8 +895,13 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw { if err := json.Unmarshal(raw, &obj); err == nil { values := make([]indexedRaw, 0, len(obj)) for k, v := range obj { - i := 0 - fmt.Sscanf(k, "%d", &i) + // Keys are array indices in the feed's delta form. Non-numeric keys + // are feed metadata — "_kf" (key frame) is the common one — and must + // not be folded in as index 0, which would clobber the first entry. + i, err := strconv.Atoi(k) + if err != nil || i < 0 { + continue + } values = append(values, indexedRaw{Index: i, Raw: v}) } sort.Slice(values, func(i, j int) bool {