mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
feat: detect race replay chapters
Add deterministic server-side chapter detection for starts, flag periods, pit phases, decisive top-five swings, and finishes, then expose chapters on the race-hub payload. Spike result: race-hub already loads race control, positions, and laps in one read model. The requested Detect signature does not include pit stops, so pit phases use IsPitOutLap clusters as the local deterministic pit-stop proxy.
This commit is contained in:
@@ -156,6 +156,7 @@ describe('CommandCenterPage', () => {
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
chapters: [],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ const raceHub: RaceHub = {
|
||||
is_pit_out_lap: false,
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
}
|
||||
|
||||
const weekend: Weekend = {
|
||||
|
||||
@@ -89,6 +89,17 @@ export interface RaceHub {
|
||||
race_control: RaceControlMessage[]
|
||||
weather: WeatherSample[]
|
||||
laps: Lap[]
|
||||
chapters: Chapter[]
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
|
||||
title: string
|
||||
start_lap: number
|
||||
end_lap: number
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
driver_numbers: number[]
|
||||
}
|
||||
|
||||
export interface Stint {
|
||||
|
||||
628
internal/chapters/chapters.go
Normal file
628
internal/chapters/chapters.go
Normal file
@@ -0,0 +1,628 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
KindStart = "start"
|
||||
KindSafetyCar = "safety_car"
|
||||
KindVirtualSafetyCar = "virtual_safety_car"
|
||||
KindRedFlag = "red_flag"
|
||||
KindPitPhase = "pit_phase"
|
||||
KindDecisiveSwing = "decisive_swing"
|
||||
KindFinish = "finish"
|
||||
|
||||
pitPhaseWindowLaps = 3
|
||||
pitPhaseShare = 0.30
|
||||
minPitPhaseStops = 2
|
||||
maxDecisiveSwings = 3
|
||||
decisiveAfterLap = 5
|
||||
structuralPriority = 110
|
||||
flagPriority = 100
|
||||
pitPhasePriority = 50
|
||||
decisivePriority = 40
|
||||
)
|
||||
|
||||
type RaceControl = models.RaceControl
|
||||
type PositionSample = models.Position
|
||||
type Lap = models.Lap
|
||||
|
||||
// Chapter is a deterministic replay segment derived from timing and race-control data.
|
||||
type Chapter struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
StartLap int `json:"start_lap"`
|
||||
EndLap int `json:"end_lap"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
DriverNumbers []int `json:"driver_numbers"`
|
||||
}
|
||||
|
||||
// Detect builds replay chapters from already-loaded race-hub datasets.
|
||||
func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter {
|
||||
totalLaps = normalizeTotalLaps(totalLaps, laps, rc)
|
||||
if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 {
|
||||
return []Chapter{}
|
||||
}
|
||||
if totalLaps <= 0 {
|
||||
totalLaps = 1
|
||||
}
|
||||
|
||||
lapIndex := buildLapIndex(laps)
|
||||
chapters := []Chapter{
|
||||
{
|
||||
Kind: KindStart,
|
||||
Title: "Start",
|
||||
StartLap: 1,
|
||||
EndLap: minInt(1, totalLaps),
|
||||
StartTime: lapIndex.lapStart(1),
|
||||
EndTime: lapIndex.lapEnd(1),
|
||||
},
|
||||
}
|
||||
chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...)
|
||||
chapters = append(chapters, detectPitPhases(laps, lapIndex)...)
|
||||
chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...)
|
||||
chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps))
|
||||
|
||||
return resolveConflicts(chapters)
|
||||
}
|
||||
|
||||
func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int {
|
||||
for _, l := range laps {
|
||||
if l.LapNumber > totalLaps {
|
||||
totalLaps = l.LapNumber
|
||||
}
|
||||
}
|
||||
for _, msg := range rc {
|
||||
if msg.LapNumber != nil && *msg.LapNumber > totalLaps {
|
||||
totalLaps = *msg.LapNumber
|
||||
}
|
||||
}
|
||||
return totalLaps
|
||||
}
|
||||
|
||||
type lapIndex struct {
|
||||
byLap map[int]string
|
||||
events []lapEvent
|
||||
}
|
||||
|
||||
type lapEvent struct {
|
||||
lap int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func buildLapIndex(laps []Lap) lapIndex {
|
||||
idx := lapIndex{byLap: map[int]string{}}
|
||||
for _, l := range laps {
|
||||
if l.LapNumber <= 0 || l.DateStart == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := idx.byLap[l.LapNumber]; !ok {
|
||||
idx.byLap[l.LapNumber] = l.DateStart
|
||||
}
|
||||
at, ok := parseTime(l.DateStart)
|
||||
if ok {
|
||||
idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at})
|
||||
}
|
||||
}
|
||||
sort.Slice(idx.events, func(i, j int) bool {
|
||||
if idx.events[i].at.Equal(idx.events[j].at) {
|
||||
return idx.events[i].lap < idx.events[j].lap
|
||||
}
|
||||
return idx.events[i].at.Before(idx.events[j].at)
|
||||
})
|
||||
return idx
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapStart(lap int) string {
|
||||
return idx.byLap[lap]
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapEnd(lap int) string {
|
||||
if v := idx.byLap[lap+1]; v != "" {
|
||||
return v
|
||||
}
|
||||
return idx.byLap[lap]
|
||||
}
|
||||
|
||||
func (idx lapIndex) lapForTime(raw string) int {
|
||||
at, ok := parseTime(raw)
|
||||
if !ok || len(idx.events) == 0 {
|
||||
return 0
|
||||
}
|
||||
lap := 0
|
||||
for _, event := range idx.events {
|
||||
if event.at.After(at) {
|
||||
break
|
||||
}
|
||||
lap = event.lap
|
||||
}
|
||||
if lap == 0 {
|
||||
return idx.events[0].lap
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
type flagState struct {
|
||||
startLap int
|
||||
startTime string
|
||||
}
|
||||
|
||||
func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter {
|
||||
var chapters []Chapter
|
||||
active := map[string]flagState{}
|
||||
for _, msg := range rc {
|
||||
kind, ok := flagKind(msg)
|
||||
if !ok && greenFlagClear(msg) {
|
||||
for activeKind, st := range active {
|
||||
lap := messageLap(msg, idx)
|
||||
if lap <= 0 {
|
||||
lap = st.startLap
|
||||
}
|
||||
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: activeKind,
|
||||
Title: flagTitle(activeKind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||
})
|
||||
delete(active, activeKind)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lap := messageLap(msg, idx)
|
||||
if lap <= 0 {
|
||||
lap = 1
|
||||
}
|
||||
if flagCleared(msg) {
|
||||
st, ok := active[kind]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
endLap := clampLap(lap, st.startLap, totalLaps)
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: kind,
|
||||
Title: flagTitle(kind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)),
|
||||
})
|
||||
delete(active, kind)
|
||||
continue
|
||||
}
|
||||
if flagStarted(msg) {
|
||||
active[kind] = flagState{
|
||||
startLap: clampLap(lap, 1, totalLaps),
|
||||
startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)),
|
||||
}
|
||||
}
|
||||
}
|
||||
for kind, st := range active {
|
||||
endLap := totalLaps
|
||||
chapters = append(chapters, Chapter{
|
||||
Kind: kind,
|
||||
Title: flagTitle(kind, st.startLap, endLap),
|
||||
StartLap: st.startLap,
|
||||
EndLap: endLap,
|
||||
StartTime: st.startTime,
|
||||
EndTime: idx.lapEnd(endLap),
|
||||
})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
func flagKind(msg RaceControl) (string, bool) {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") {
|
||||
return KindVirtualSafetyCar, true
|
||||
}
|
||||
if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) {
|
||||
return KindRedFlag, true
|
||||
}
|
||||
if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar {
|
||||
return KindSafetyCar, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func flagStarted(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(text, "DEPLOY") ||
|
||||
strings.Contains(text, "RED FLAG") ||
|
||||
strings.Contains(text, "VIRTUAL SAFETY CAR") ||
|
||||
strings.Contains(text, "VSC") ||
|
||||
strings.Contains(text, "SAFETY CAR") ||
|
||||
string(msg.Flag) == string(models.FlagRed)
|
||||
}
|
||||
|
||||
func flagCleared(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Category), string(msg.Flag), msg.Message)
|
||||
return strings.Contains(text, "CLEAR") ||
|
||||
strings.Contains(text, "ENDING") ||
|
||||
strings.Contains(text, "IN THIS LAP") ||
|
||||
strings.Contains(text, "GREEN")
|
||||
}
|
||||
|
||||
func greenFlagClear(msg RaceControl) bool {
|
||||
text := upperText(string(msg.Flag), msg.Message)
|
||||
return strings.Contains(text, "GREEN")
|
||||
}
|
||||
|
||||
func flagTitle(kind string, startLap, endLap int) string {
|
||||
name := "Flag period"
|
||||
switch kind {
|
||||
case KindSafetyCar:
|
||||
name = "Safety Car"
|
||||
case KindVirtualSafetyCar:
|
||||
name = "Virtual Safety Car"
|
||||
case KindRedFlag:
|
||||
name = "Red Flag"
|
||||
}
|
||||
return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap)
|
||||
}
|
||||
|
||||
func detectPitPhases(laps []Lap, idx lapIndex) []Chapter {
|
||||
type pitOut struct {
|
||||
lap int
|
||||
driver int
|
||||
}
|
||||
var stops []pitOut
|
||||
for _, l := range laps {
|
||||
if l.IsPitOutLap && l.LapNumber > 0 {
|
||||
stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber})
|
||||
}
|
||||
}
|
||||
if len(stops) < minPitPhaseStops {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(stops, func(i, j int) bool {
|
||||
if stops[i].lap == stops[j].lap {
|
||||
return stops[i].driver < stops[j].driver
|
||||
}
|
||||
return stops[i].lap < stops[j].lap
|
||||
})
|
||||
needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare))
|
||||
if needed < minPitPhaseStops {
|
||||
needed = minPitPhaseStops
|
||||
}
|
||||
|
||||
var windows []Chapter
|
||||
for i := 0; i < len(stops); i++ {
|
||||
start := stops[i].lap
|
||||
end := start + pitPhaseWindowLaps - 1
|
||||
drivers := map[int]bool{}
|
||||
count := 0
|
||||
for _, stop := range stops {
|
||||
if stop.lap < start || stop.lap > end {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
drivers[stop.driver] = true
|
||||
}
|
||||
if count < needed {
|
||||
continue
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindPitPhase,
|
||||
Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end),
|
||||
StartLap: start,
|
||||
EndLap: end,
|
||||
StartTime: idx.lapStart(start),
|
||||
EndTime: idx.lapEnd(end),
|
||||
DriverNumbers: sortedDriverNumbers(drivers),
|
||||
}
|
||||
if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 {
|
||||
last := &windows[len(windows)-1]
|
||||
if ch.EndLap > last.EndLap {
|
||||
last.EndLap = ch.EndLap
|
||||
last.EndTime = idx.lapEnd(last.EndLap)
|
||||
}
|
||||
drivers := sliceToSet(last.DriverNumbers)
|
||||
for _, driver := range ch.DriverNumbers {
|
||||
drivers[driver] = true
|
||||
}
|
||||
last.DriverNumbers = sortedDriverNumbers(drivers)
|
||||
last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap)
|
||||
continue
|
||||
}
|
||||
windows = append(windows, ch)
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
type swingCandidate struct {
|
||||
chapter Chapter
|
||||
significance int
|
||||
}
|
||||
|
||||
func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter {
|
||||
if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap {
|
||||
return nil
|
||||
}
|
||||
snapshots := buildPositionSnapshots(positions, idx, totalLaps)
|
||||
if len(snapshots) == 0 {
|
||||
return nil
|
||||
}
|
||||
final := snapshots[totalLaps]
|
||||
if len(final) == 0 {
|
||||
for lap := totalLaps - 1; lap >= 1; lap-- {
|
||||
if len(snapshots[lap]) > 0 {
|
||||
final = snapshots[lap]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var candidates []swingCandidate
|
||||
seenDriver := map[int]bool{}
|
||||
for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ {
|
||||
prev := snapshots[lap-1]
|
||||
curr := snapshots[lap]
|
||||
if len(prev) == 0 || len(curr) == 0 {
|
||||
continue
|
||||
}
|
||||
for driver, pos := range curr {
|
||||
prevPos, ok := prev[driver]
|
||||
if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] {
|
||||
continue
|
||||
}
|
||||
finalPos, ok := final[driver]
|
||||
if !ok || finalPos > pos {
|
||||
continue
|
||||
}
|
||||
overtaken := driverAtPosition(curr, prevPos, driver)
|
||||
drivers := []int{driver}
|
||||
if overtaken != 0 {
|
||||
drivers = append(drivers, overtaken)
|
||||
}
|
||||
candidates = append(candidates, swingCandidate{
|
||||
chapter: Chapter{
|
||||
Kind: KindDecisiveSwing,
|
||||
Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap),
|
||||
StartLap: lap,
|
||||
EndLap: lap,
|
||||
StartTime: idx.lapStart(lap),
|
||||
EndTime: idx.lapEnd(lap),
|
||||
DriverNumbers: drivers,
|
||||
},
|
||||
significance: (prevPos-pos)*10 + (6 - pos),
|
||||
})
|
||||
seenDriver[driver] = true
|
||||
}
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].significance == candidates[j].significance {
|
||||
return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap
|
||||
}
|
||||
return candidates[i].significance > candidates[j].significance
|
||||
})
|
||||
if len(candidates) > maxDecisiveSwings {
|
||||
candidates = candidates[:maxDecisiveSwings]
|
||||
}
|
||||
out := make([]Chapter, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
out = append(out, c.chapter)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int {
|
||||
byLap := map[int][]PositionSample{}
|
||||
for _, p := range positions {
|
||||
if p.Position <= 0 {
|
||||
continue
|
||||
}
|
||||
lap := idx.lapForTime(p.Date)
|
||||
if lap <= 0 || lap > totalLaps {
|
||||
continue
|
||||
}
|
||||
byLap[lap] = append(byLap[lap], p)
|
||||
}
|
||||
last := map[int]int{}
|
||||
snapshots := map[int]map[int]int{}
|
||||
for lap := 1; lap <= totalLaps; lap++ {
|
||||
for _, p := range byLap[lap] {
|
||||
last[p.DriverNumber] = p.Position
|
||||
}
|
||||
if len(last) == 0 {
|
||||
continue
|
||||
}
|
||||
cp := make(map[int]int, len(last))
|
||||
for driver, pos := range last {
|
||||
cp[driver] = pos
|
||||
}
|
||||
snapshots[lap] = cp
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func driverAtPosition(snapshot map[int]int, pos int, exclude int) int {
|
||||
for driver, driverPos := range snapshot {
|
||||
if driver != exclude && driverPos == pos {
|
||||
return driver
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter {
|
||||
finishLap := totalLaps
|
||||
finishTime := idx.lapEnd(totalLaps)
|
||||
for _, msg := range rc {
|
||||
text := upperText(string(msg.Flag), msg.Message)
|
||||
if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) {
|
||||
if lap := messageLap(msg, idx); lap > 0 {
|
||||
finishLap = lap
|
||||
}
|
||||
finishTime = firstNonEmpty(msg.Date, finishTime)
|
||||
}
|
||||
}
|
||||
startLap := finishLap - 1
|
||||
if startLap < 1 {
|
||||
startLap = 1
|
||||
}
|
||||
return Chapter{
|
||||
Kind: KindFinish,
|
||||
Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap),
|
||||
StartLap: startLap,
|
||||
EndLap: finishLap,
|
||||
StartTime: idx.lapStart(startLap),
|
||||
EndTime: finishTime,
|
||||
}
|
||||
}
|
||||
|
||||
func resolveConflicts(chapters []Chapter) []Chapter {
|
||||
normalized := make([]Chapter, 0, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
if ch.StartLap <= 0 {
|
||||
ch.StartLap = 1
|
||||
}
|
||||
if ch.EndLap <= 0 {
|
||||
ch.EndLap = ch.StartLap
|
||||
}
|
||||
if ch.EndLap < ch.StartLap {
|
||||
ch.EndLap = ch.StartLap
|
||||
}
|
||||
if ch.DriverNumbers == nil {
|
||||
ch.DriverNumbers = []int{}
|
||||
}
|
||||
normalized = append(normalized, ch)
|
||||
}
|
||||
sort.SliceStable(normalized, func(i, j int) bool {
|
||||
if normalized[i].StartLap == normalized[j].StartLap {
|
||||
return priority(normalized[i].Kind) > priority(normalized[j].Kind)
|
||||
}
|
||||
return normalized[i].StartLap < normalized[j].StartLap
|
||||
})
|
||||
|
||||
out := make([]Chapter, 0, len(normalized))
|
||||
for _, ch := range normalized {
|
||||
if len(out) == 0 {
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
last := &out[len(out)-1]
|
||||
if ch.StartLap > last.EndLap {
|
||||
out = append(out, ch)
|
||||
continue
|
||||
}
|
||||
if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) {
|
||||
continue
|
||||
}
|
||||
if priority(ch.Kind) > priority(last.Kind) {
|
||||
if last.StartLap < ch.StartLap {
|
||||
last.EndLap = ch.StartLap - 1
|
||||
out = append(out, ch)
|
||||
} else {
|
||||
*last = ch
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch.EndLap > last.EndLap {
|
||||
ch.StartLap = last.EndLap + 1
|
||||
if ch.StartLap <= ch.EndLap {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isFlag(kind string) bool {
|
||||
return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag
|
||||
}
|
||||
|
||||
func priority(kind string) int {
|
||||
switch kind {
|
||||
case KindStart, KindFinish:
|
||||
return structuralPriority
|
||||
case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag:
|
||||
return flagPriority
|
||||
case KindPitPhase:
|
||||
return pitPhasePriority
|
||||
case KindDecisiveSwing:
|
||||
return decisivePriority
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func messageLap(msg RaceControl, idx lapIndex) int {
|
||||
if msg.LapNumber != nil && *msg.LapNumber > 0 {
|
||||
return *msg.LapNumber
|
||||
}
|
||||
return idx.lapForTime(msg.Date)
|
||||
}
|
||||
|
||||
func clampLap(lap, minLap, maxLap int) int {
|
||||
if lap < minLap {
|
||||
return minLap
|
||||
}
|
||||
if maxLap > 0 && lap > maxLap {
|
||||
return maxLap
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
func parseTime(raw string) (time.Time, bool) {
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return at, true
|
||||
}
|
||||
|
||||
func upperText(parts ...string) string {
|
||||
return strings.ToUpper(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortedDriverNumbers(drivers map[int]bool) []int {
|
||||
out := make([]int, 0, len(drivers))
|
||||
for driver := range drivers {
|
||||
out = append(out, driver)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sliceToSet(values []int) map[int]bool {
|
||||
out := make(map[int]bool, len(values))
|
||||
for _, value := range values {
|
||||
out[value] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
218
internal/chapters/chapters_test.go
Normal file
218
internal/chapters/chapters_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
func TestDetectStructuralChapters(t *testing.T) {
|
||||
chapters := Detect(nil, nil, testLaps(10, nil), 10)
|
||||
|
||||
if len(chapters) < 2 {
|
||||
t.Fatalf("chapters len = %d, want at least start and finish", len(chapters))
|
||||
}
|
||||
if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 {
|
||||
t.Fatalf("start chapter = %+v, want L1-L1", got)
|
||||
}
|
||||
got := chapters[len(chapters)-1]
|
||||
if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 {
|
||||
t.Fatalf("finish chapter = %+v, want L9-L10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReturnsEmptyWithoutData(t *testing.T) {
|
||||
if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 {
|
||||
t.Fatalf("chapters = %+v, want empty", chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFlagPeriods(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start RaceControl
|
||||
end RaceControl
|
||||
wantKind string
|
||||
wantTitle string
|
||||
}{
|
||||
{
|
||||
name: "safety car",
|
||||
start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
wantKind: KindSafetyCar,
|
||||
wantTitle: "Safety Car (L12-L15)",
|
||||
},
|
||||
{
|
||||
name: "virtual safety car",
|
||||
start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||
end: rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||
wantKind: KindVirtualSafetyCar,
|
||||
wantTitle: "Virtual Safety Car (L22-L24)",
|
||||
},
|
||||
{
|
||||
name: "red flag",
|
||||
start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||
end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||
wantKind: KindRedFlag,
|
||||
wantTitle: "Red Flag (L31-L33)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40)
|
||||
got := findKind(chapters, tt.wantKind)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind)
|
||||
}
|
||||
if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle {
|
||||
t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) {
|
||||
pitOuts := map[int][]int{
|
||||
5: {1},
|
||||
10: {2},
|
||||
20: {3},
|
||||
21: {4},
|
||||
22: {5},
|
||||
30: {6},
|
||||
35: {7},
|
||||
40: {8},
|
||||
45: {9},
|
||||
50: {10},
|
||||
}
|
||||
chapters := Detect(nil, nil, testLaps(55, pitOuts), 55)
|
||||
|
||||
got := findKind(chapters, KindPitPhase)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want pit phase", chapters)
|
||||
}
|
||||
if got.StartLap != 20 || got.EndLap != 22 {
|
||||
t.Fatalf("pit phase = %+v, want L20-L22", *got)
|
||||
}
|
||||
if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 {
|
||||
t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) {
|
||||
positions := []PositionSample{
|
||||
pos(1, 1, 1),
|
||||
pos(1, 16, 4),
|
||||
pos(1, 55, 3),
|
||||
pos(6, 16, 3),
|
||||
pos(6, 55, 4),
|
||||
pos(8, 44, 5),
|
||||
pos(8, 63, 6),
|
||||
pos(10, 44, 6),
|
||||
}
|
||||
|
||||
chapters := Detect(nil, positions, testLaps(12, nil), 12)
|
||||
got := findKind(chapters, KindDecisiveSwing)
|
||||
if got == nil {
|
||||
t.Fatalf("chapters = %+v, want decisive swing", chapters)
|
||||
}
|
||||
if got.StartLap != 6 || got.EndLap != 6 {
|
||||
t.Fatalf("swing lap = %+v, want L6", *got)
|
||||
}
|
||||
if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 {
|
||||
t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) {
|
||||
pitOuts := map[int][]int{
|
||||
12: {1, 2},
|
||||
13: {3, 4},
|
||||
14: {5, 6},
|
||||
}
|
||||
rcs := []RaceControl{
|
||||
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
}
|
||||
|
||||
chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20)
|
||||
if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 {
|
||||
t.Fatalf("chapters = %+v, want safety car L12-L15", chapters)
|
||||
}
|
||||
if got := findKind(chapters, KindPitPhase); got != nil {
|
||||
t.Fatalf("pit phase = %+v, want omitted under safety car", *got)
|
||||
}
|
||||
for i := 1; i < len(chapters); i++ {
|
||||
if chapters[i].StartLap <= chapters[i-1].EndLap {
|
||||
t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rcLap(r RaceControl) int {
|
||||
if r.LapNumber == nil {
|
||||
return 0
|
||||
}
|
||||
return *r.LapNumber
|
||||
}
|
||||
|
||||
func findKind(chapters []Chapter, kind string) *Chapter {
|
||||
for i := range chapters {
|
||||
if chapters[i].Kind == kind {
|
||||
return &chapters[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testLaps(total int, pitOuts map[int][]int) []Lap {
|
||||
var laps []Lap
|
||||
for lap := 1; lap <= total; lap++ {
|
||||
drivers := []int{1}
|
||||
if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 {
|
||||
drivers = pitDrivers
|
||||
}
|
||||
for _, driver := range drivers {
|
||||
laps = append(laps, Lap{
|
||||
DriverNumber: driver,
|
||||
LapNumber: lap,
|
||||
DateStart: lapTime(lap),
|
||||
IsPitOutLap: containsDriver(pitOuts[lap], driver),
|
||||
})
|
||||
}
|
||||
}
|
||||
return laps
|
||||
}
|
||||
|
||||
func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl {
|
||||
return RaceControl{
|
||||
Category: category,
|
||||
Flag: flag,
|
||||
Message: message,
|
||||
LapNumber: &lap,
|
||||
Date: lapTime(lap),
|
||||
}
|
||||
}
|
||||
|
||||
func pos(lap int, driver int, position int) PositionSample {
|
||||
return PositionSample{
|
||||
DriverNumber: driver,
|
||||
Position: position,
|
||||
Date: lapTime(lap),
|
||||
}
|
||||
}
|
||||
|
||||
func lapTime(lap int) string {
|
||||
minute := lap - 1
|
||||
return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute)
|
||||
}
|
||||
|
||||
func containsDriver(drivers []int, driver int) bool {
|
||||
for _, candidate := range drivers {
|
||||
if candidate == driver {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
@@ -52,6 +53,7 @@ type RaceHub struct {
|
||||
RaceControl []models.RaceControl `json:"race_control"`
|
||||
Weather []models.Weather `json:"weather"`
|
||||
Laps []models.Lap `json:"laps"`
|
||||
Chapters []chapters.Chapter `json:"chapters"`
|
||||
}
|
||||
|
||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
||||
@@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
RaceControl: []models.RaceControl{},
|
||||
Weather: []models.Weather{},
|
||||
Laps: []models.Lap{},
|
||||
Chapters: []chapters.Chapter{},
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(sessionKey)
|
||||
@@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
||||
}
|
||||
|
||||
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||
hub.Source = responseSource(hub.Datasets)
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||
total := 0
|
||||
for _, result := range results {
|
||||
if result.NumberOfLaps > total {
|
||||
total = result.NumberOfLaps
|
||||
}
|
||||
}
|
||||
for _, lap := range laps {
|
||||
if lap.LapNumber > total {
|
||||
total = lap.LapNumber
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/chapters"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/query"
|
||||
)
|
||||
@@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub {
|
||||
Drivers: []models.Driver{},
|
||||
Results: []query.EnrichedResult{},
|
||||
StartingGrid: []query.EnrichedGrid{},
|
||||
Chapters: []chapters.Chapter{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedRaceHubStore(t, st)
|
||||
sessionKey := 9472
|
||||
meetingKey := 1229
|
||||
|
||||
for lap := 1; lap <= 12; lap++ {
|
||||
if err := st.UpsertLap(store.Lap{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
LapNumber: lap,
|
||||
DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339),
|
||||
LapDuration: 75,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertLap(%d) error = %v", lap, err)
|
||||
}
|
||||
}
|
||||
for _, sample := range []store.PositionSample{
|
||||
{SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1},
|
||||
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4},
|
||||
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3},
|
||||
{SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3},
|
||||
{SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4},
|
||||
} {
|
||||
if err := st.UpsertPositionSample(sample); err != nil {
|
||||
t.Fatalf("UpsertPositionSample() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := testServer(t, st)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRaceHub(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var hub query.RaceHub
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(hub.Chapters) == 0 {
|
||||
t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters)
|
||||
}
|
||||
if hub.Chapters[0].Kind != "start" {
|
||||
t.Fatalf("first chapter = %+v, want start", hub.Chapters[0])
|
||||
}
|
||||
foundSwing := false
|
||||
for _, chapter := range hub.Chapters {
|
||||
if chapter.Kind == "decisive_swing" {
|
||||
foundSwing = true
|
||||
}
|
||||
}
|
||||
if !foundSwing {
|
||||
t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMeetingsSourceLocal(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedRaceHubStore(t, st)
|
||||
|
||||
Reference in New Issue
Block a user