Calculate event lateness periodically to improve accuracy.

This commit is contained in:
Patrick Fairbank
2020-03-28 18:32:46 -07:00
parent 244ddce9e4
commit 14c9815980
17 changed files with 231 additions and 102 deletions

View File

@@ -13,15 +13,19 @@ import (
"github.com/Team254/cheesy-arena/partner"
"github.com/Team254/cheesy-arena/plc"
"log"
"math"
"time"
)
const (
arenaLoopPeriodMs = 10
dsPacketPeriodMs = 250
periodicTaskPeriodSec = 30
matchEndScoreDwellSec = 3
postTimeoutSec = 4
preLoadNextMatchDelaySec = 5
earlyLateThresholdMin = 2.5
MaxMatchGapMin = 20
)
// Progression of match states.
@@ -59,6 +63,8 @@ type Arena struct {
RedRealtimeScore *RealtimeScore
BlueRealtimeScore *RealtimeScore
lastDsPacketTime time.Time
lastPeriodicTaskTime time.Time
EventStatusMessage string
FieldVolunteers bool
FieldReset bool
AudienceDisplayMode string
@@ -512,6 +518,10 @@ func (arena *Arena) Run() {
for {
arena.Update()
if time.Since(arena.lastPeriodicTaskTime).Seconds() >= periodicTaskPeriodSec {
arena.lastPeriodicTaskTime = time.Now()
go arena.runPeriodicTasks()
}
time.Sleep(time.Millisecond * arenaLoopPeriodMs)
}
}
@@ -854,3 +864,73 @@ func (arena *Arena) alliancePostMatchScoreReady(alliance string) bool {
numPanels := arena.ScoringPanelRegistry.GetNumPanels(alliance)
return numPanels > 0 && arena.ScoringPanelRegistry.GetNumScoreCommitted(alliance) >= numPanels
}
// Performs any actions that need to run at the interval specified by periodicTaskPeriodSec.
func (arena *Arena) runPeriodicTasks() {
// Check how early or late the event is running and publish an update to the displays that show it.
newEventStatusMessage := arena.getEventStatusMessage()
if newEventStatusMessage != arena.EventStatusMessage {
arena.EventStatusMessage = newEventStatusMessage
arena.EventStatusNotifier.Notify()
}
}
// Updates the string that indicates how early or late the event is running.
func (arena *Arena) getEventStatusMessage() string {
currentMatch := arena.CurrentMatch
if currentMatch.Type != "practice" && currentMatch.Type != "qualification" {
// Only practice and qualification matches have a strict schedule.
return ""
}
if currentMatch.Status == "complete" {
// This is a replay or otherwise unpredictable situation.
return ""
}
var minutesLate float64
if arena.MatchState > PreMatch && arena.MatchState < PostMatch {
// The match is in progress; simply calculate lateness from its start time.
minutesLate = currentMatch.StartedAt.Sub(currentMatch.Time).Minutes()
} else {
// We need to check the adjacent matches to accurately determine lateness.
matches, _ := arena.Database.GetMatchesByType(currentMatch.Type)
previousMatchIndex := -1
nextMatchIndex := len(matches)
for i, match := range matches {
if match.Id == currentMatch.Id {
previousMatchIndex = i - 1
nextMatchIndex = i + 1
break
}
}
if arena.MatchState == PreMatch {
currentMinutesLate := time.Now().Sub(currentMatch.Time).Minutes()
if previousMatchIndex >= 0 &&
currentMatch.Time.Sub(matches[previousMatchIndex].Time).Minutes() <= MaxMatchGapMin {
previousMatch := matches[previousMatchIndex]
previousMinutesLate := previousMatch.StartedAt.Sub(previousMatch.Time).Minutes()
minutesLate = math.Max(previousMinutesLate, currentMinutesLate)
} else {
minutesLate = math.Max(currentMinutesLate, 0)
}
} else if arena.MatchState == PostMatch {
currentMinutesLate := currentMatch.StartedAt.Sub(currentMatch.Time).Minutes()
if nextMatchIndex < len(matches) {
nextMatch := matches[nextMatchIndex]
nextMinutesLate := time.Now().Sub(nextMatch.Time).Minutes()
minutesLate = math.Max(currentMinutesLate, nextMinutesLate)
} else {
minutesLate = currentMinutesLate
}
}
}
if minutesLate > earlyLateThresholdMin {
return fmt.Sprintf("Event is running %d minutes late", int(minutesLate))
} else if minutesLate < -earlyLateThresholdMin {
return fmt.Sprintf("Event is running %d minutes early", int(-minutesLate))
}
return "Event is running on schedule"
}

View File

@@ -20,6 +20,7 @@ type ArenaNotifiers struct {
ArenaStatusNotifier *websocket.Notifier
AudienceDisplayModeNotifier *websocket.Notifier
DisplayConfigurationNotifier *websocket.Notifier
EventStatusNotifier *websocket.Notifier
LowerThirdNotifier *websocket.Notifier
MatchLoadNotifier *websocket.Notifier
MatchTimeNotifier *websocket.Notifier
@@ -57,6 +58,7 @@ func (arena *Arena) configureNotifiers() {
arena.generateAudienceDisplayModeMessage)
arena.DisplayConfigurationNotifier = websocket.NewNotifier("displayConfiguration",
arena.generateDisplayConfigurationMessage)
arena.EventStatusNotifier = websocket.NewNotifier("eventStatus", arena.generateEventStatusMessage)
arena.LowerThirdNotifier = websocket.NewNotifier("lowerThird", arena.generateLowerThirdMessage)
arena.MatchLoadNotifier = websocket.NewNotifier("matchLoad", arena.generateMatchLoadMessage)
arena.MatchTimeNotifier = websocket.NewNotifier("matchTime", arena.generateMatchTimeMessage)
@@ -117,6 +119,10 @@ func (arena *Arena) generateDisplayConfigurationMessage() interface{} {
return &DisplayConfigurationMessage{displaysCopy, displayUrls}
}
func (arena *Arena) generateEventStatusMessage() interface{} {
return arena.EventStatusMessage
}
func (arena *Arena) generateLowerThirdMessage() interface{} {
return &struct {
LowerThird *model.LowerThird

View File

@@ -644,3 +644,100 @@ func TestSaveTeamHasConnected(t *testing.T) {
assert.Equal(t, "San Jose", teams[5].City)
}
}
func TestEventStatusMessage(t *testing.T) {
arena := setupTestArena(t)
arena.LoadTestMatch()
assert.Equal(t, "", arena.getEventStatusMessage())
arena.Database.CreateMatch(&model.Match{Type: "qualification", DisplayName: "1"})
arena.Database.CreateMatch(&model.Match{Type: "qualification", DisplayName: "2"})
matches, _ := arena.Database.GetMatchesByType("qualification")
assert.Equal(t, 2, len(matches))
setMatch(arena.Database, &matches[0], time.Now().Add(300*time.Second), time.Time{}, false)
arena.CurrentMatch = &matches[0]
arena.MatchState = PreMatch
assert.Equal(t, "Event is running on schedule", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[0], time.Now().Add(60*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running on schedule", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[0], time.Now().Add(-60*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running on schedule", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[0], time.Now().Add(-120*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running on schedule", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[0], time.Now().Add(-180*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running 3 minutes late", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[0], time.Now().Add(181*time.Second), time.Now(), false)
arena.MatchState = AutoPeriod
assert.Equal(t, "Event is running 3 minutes early", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[0], time.Now().Add(-300*time.Second), time.Now().Add(-601*time.Second), false)
setMatch(arena.Database, &matches[1], time.Now().Add(481*time.Second), time.Time{}, false)
arena.MatchState = PostMatch
assert.Equal(t, "Event is running 5 minutes early", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now().Add(181*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running 3 minutes early", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now().Add(-60*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running on schedule", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now().Add(-180*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running 3 minutes late", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[0], time.Now().Add(-300*time.Second), time.Now().Add(-601*time.Second), true)
assert.Equal(t, "", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now().Add(900*time.Second), time.Time{}, false)
arena.CurrentMatch = &matches[1]
arena.MatchState = PreMatch
assert.Equal(t, "Event is running on schedule", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now().Add(899*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running 5 minutes early", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now().Add(60*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running on schedule", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now().Add(-120*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running on schedule", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now().Add(-180*time.Second), time.Time{}, false)
assert.Equal(t, "Event is running 3 minutes late", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now().Add(-180*time.Second), time.Now().Add(-541*time.Second), false)
arena.MatchState = TeleopPeriod
assert.Equal(t, "Event is running 6 minutes early", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now(), time.Now().Add(481*time.Second), false)
arena.MatchState = PostMatch
assert.Equal(t, "Event is running 8 minutes late", arena.getEventStatusMessage())
setMatch(arena.Database, &matches[1], time.Now(), time.Now().Add(481*time.Second), true)
assert.Equal(t, "", arena.getEventStatusMessage())
// Check other match types.
arena.MatchState = PreMatch
arena.CurrentMatch = &model.Match{Type: "practice", Time: time.Now().Add(-181 * time.Second)}
assert.Equal(t, "Event is running 3 minutes late", arena.getEventStatusMessage())
arena.CurrentMatch = &model.Match{Type: "elimination", Time: time.Now().Add(-181 * time.Second)}
assert.Equal(t, "", arena.getEventStatusMessage())
}
func setMatch(database *model.Database, match *model.Match, matchTime time.Time, startedAt time.Time, isComplete bool) {
match.Time = matchTime
match.StartedAt = startedAt
if isComplete {
match.Status = "complete"
} else {
match.Status = ""
}
_ = database.SaveMatch(match)
}

View File

@@ -75,3 +75,11 @@ body {
padding-left: 0;
padding-right: 0;
}
#eventStatusMessage {
margin-top: 10px;
font-size: 25px;
font-family: "FuturaLTBold";
color: #fff;
text-align: center;
text-transform: uppercase;
}

View File

@@ -54,7 +54,7 @@ h1 {
.avatars {
line-height: 51px;
}
#footer {
#eventStatusMessage {
font-size: 25px;
font-family: "FuturaLTBold";
color: #fff;

View File

@@ -225,6 +225,11 @@ var handleAllianceStationDisplayMode = function(data) {
$("input[name=allianceStationDisplay][value=" + data + "]").prop("checked", true);
};
// Handles a websocket message to update the event status message.
var handleEventStatus = function(data) {
$("#eventStatusMessage").text(data);
};
$(function() {
// Activate tooltips above the status headers.
$("[data-toggle=tooltip]").tooltip({"placement": "top"});
@@ -234,9 +239,10 @@ $(function() {
allianceStationDisplayMode: function(event) { handleAllianceStationDisplayMode(event.data); },
arenaStatus: function(event) { handleArenaStatus(event.data); },
audienceDisplayMode: function(event) { handleAudienceDisplayMode(event.data); },
eventStatus: function(event) { handleEventStatus(event.data); },
matchTime: function(event) { handleMatchTime(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); },
realtimeScore: function(event) { handleRealtimeScore(event.data); },
scoringStatus: function(event) { handleScoringStatus(event.data); }
scoringStatus: function(event) { handleScoringStatus(event.data); },
});
});

View File

@@ -79,13 +79,20 @@ var setHighestPlayedMatch = function(highestPlayedMatch) {
}
};
// Handles a websocket message to update the event status message.
var handleEventStatus = function(data) {
$("#eventStatusMessage").text(data);
};
$(function() {
// Read the configuration for this display from the URL query string.
var urlParams = new URLSearchParams(window.location.search);
scrollMsPerRow = urlParams.get("scrollMsPerRow");
// Set up the websocket back to the server. Used only for remote forcing of reloads.
websocket = new CheesyWebsocket("/displays/pit/websocket", {});
websocket = new CheesyWebsocket("/displays/pit/websocket", {
eventStatus: function(event) { handleEventStatus(event.data); },
});
updateStaticRankings();
});

View File

@@ -28,11 +28,17 @@ var handleMatchTime = function(data) {
});
};
// Handles a websocket message to update the event status message.
var handleEventStatus = function(data) {
$("#eventStatusMessage").text(data);
};
$(function() {
// Set up the websocket back to the server.
websocket = new CheesyWebsocket("/displays/queueing/websocket", {
eventStatus: function(event) { handleEventStatus(event.data); },
matchLoad: function(event) { handleMatchLoad(event.data); },
matchTime: function(event) { handleMatchTime(event.data); },
matchTiming: function(event) { handleMatchTiming(event.data); }
matchTiming: function(event) { handleMatchTiming(event.data); },
});
});

View File

@@ -216,6 +216,9 @@
</button>
</div>
</div>
<div class="row">
<div id="eventStatusMessage" class="col-lg-12"></div>
</div>
</div>
</div>
</div>

View File

@@ -46,6 +46,7 @@
<span id="highestPlayedMatch"></span>
</div>
</div>
<div id="eventStatusMessage"></div>
</div>
<script id="standingsTemplate" type="text/x-handlebars-template">
<tbody>

View File

@@ -70,7 +70,7 @@
</div>
</div>
{{end}}
<div id="footer" class="col-lg-10 col-lg-offset-1">{{.StatusMessage}}</div>
<div id="eventStatusMessage" class="col-lg-10 col-lg-offset-1"></div>
</body>
<script src="/static/js/lib/jquery.min.js"></script>
<script src="/static/js/lib/jquery.json-2.4.min.js"></script>

View File

@@ -177,7 +177,7 @@ func (web *Web) matchPlayWebsocketHandler(w http.ResponseWriter, r *http.Request
// Subscribe the websocket to the notifiers whose messages will be passed on to the client, in a separate goroutine.
go ws.HandleNotifiers(web.arena.MatchTimingNotifier, web.arena.ArenaStatusNotifier, web.arena.MatchTimeNotifier,
web.arena.RealtimeScoreNotifier, web.arena.ScoringStatusNotifier, web.arena.AudienceDisplayModeNotifier,
web.arena.AllianceStationDisplayModeNotifier)
web.arena.AllianceStationDisplayModeNotifier, web.arena.EventStatusNotifier)
// Loop, waiting for commands and responding to them, until the client closes the connection.
for {

View File

@@ -258,6 +258,7 @@ func TestMatchPlayWebsocketCommands(t *testing.T) {
readWebsocketType(t, ws, "scoringStatus")
readWebsocketType(t, ws, "audienceDisplayMode")
readWebsocketType(t, ws, "allianceStationDisplayMode")
readWebsocketType(t, ws, "eventStatus")
// Test that a server-side error is communicated to the client.
ws.Write("nonexistenttype", nil)
@@ -348,6 +349,7 @@ func TestMatchPlayWebsocketNotifications(t *testing.T) {
readWebsocketType(t, ws, "scoringStatus")
readWebsocketType(t, ws, "audienceDisplayMode")
readWebsocketType(t, ws, "allianceStationDisplayMode")
readWebsocketType(t, ws, "eventStatus")
web.arena.AllianceStations["R1"].Bypass = true
web.arena.AllianceStations["R2"].Bypass = true

View File

@@ -49,5 +49,6 @@ func (web *Web) pitDisplayWebsocketHandler(w http.ResponseWriter, r *http.Reques
defer ws.Close()
// Subscribe the websocket to the notifiers whose messages will be passed on to the client.
ws.HandleNotifiers(web.arena.DisplayConfigurationNotifier, web.arena.ReloadDisplaysNotifier)
ws.HandleNotifiers(web.arena.EventStatusNotifier, web.arena.DisplayConfigurationNotifier,
web.arena.ReloadDisplaysNotifier)
}

View File

@@ -29,6 +29,7 @@ func TestPitDisplayWebsocket(t *testing.T) {
ws := websocket.NewTestWebsocket(conn)
// Should get a few status updates right after connection.
readWebsocketType(t, ws, "eventStatus")
readWebsocketType(t, ws, "displayConfiguration")
// Check forced reloading as that is the only purpose the pit websocket serves.

View File

@@ -6,7 +6,7 @@
package web
import (
"fmt"
"github.com/Team254/cheesy-arena/field"
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/websocket"
"net/http"
@@ -14,9 +14,7 @@ import (
)
const (
earlyLateThresholdMin = 2
maxGapMin = 20
numMatchesToShow = 5
numMatchesToShow = 5
)
// Renders the queueing display that shows upcoming matches and timing information.
@@ -41,7 +39,7 @@ func (web *Web) queueingDisplayHandler(w http.ResponseWriter, r *http.Request) {
}
// Don't include any more matches if there is a significant gap before the next one.
if i+1 < len(matches) && matches[i+1].Time.Sub(match.Time) > maxGapMin*time.Minute {
if i+1 < len(matches) && matches[i+1].Time.Sub(match.Time) > field.MaxMatchGapMin*time.Minute {
break
}
}
@@ -56,9 +54,7 @@ func (web *Web) queueingDisplayHandler(w http.ResponseWriter, r *http.Request) {
*model.EventSettings
MatchTypePrefix string
Matches []model.Match
StatusMessage string
}{web.arena.EventSettings, web.arena.CurrentMatch.TypePrefix(), upcomingMatches,
generateEventStatusMessage(web.arena.CurrentMatch.Type, matches)}
}{web.arena.EventSettings, web.arena.CurrentMatch.TypePrefix(), upcomingMatches}
err = template.ExecuteTemplate(w, "queueing_display.html", data)
if err != nil {
handleWebErr(w, err)
@@ -84,35 +80,5 @@ func (web *Web) queueingDisplayWebsocketHandler(w http.ResponseWriter, r *http.R
// Subscribe the websocket to the notifiers whose messages will be passed on to the client.
ws.HandleNotifiers(web.arena.MatchTimingNotifier, web.arena.MatchLoadNotifier, web.arena.MatchTimeNotifier,
web.arena.DisplayConfigurationNotifier, web.arena.ReloadDisplaysNotifier)
}
// Returns a message indicating how early or late the event is running.
func generateEventStatusMessage(matchType string, matches []model.Match) string {
if matchType != "practice" && matchType != "qualification" {
// Only practice and qualification matches have a strict schedule.
return ""
}
if len(matches) == 0 || matches[len(matches)-1].Status == "complete" {
// All matches of the current type are complete.
return ""
}
for i := len(matches) - 1; i >= 0; i-- {
match := matches[i]
if match.Status == "complete" {
if i+1 < len(matches) && matches[i+1].Time.Sub(match.Time) > maxGapMin*time.Minute {
break
} else {
minutesLate := match.StartedAt.Sub(match.Time).Minutes()
if minutesLate > earlyLateThresholdMin {
return fmt.Sprintf("Event is running %d minutes late", int(minutesLate))
} else if minutesLate < -earlyLateThresholdMin {
return fmt.Sprintf("Event is running %d minutes early", int(-minutesLate))
}
}
}
}
return "Event is running on schedule"
web.arena.EventStatusNotifier, web.arena.DisplayConfigurationNotifier, web.arena.ReloadDisplaysNotifier)
}

View File

@@ -4,12 +4,10 @@
package web
import (
"github.com/Team254/cheesy-arena/model"
"github.com/Team254/cheesy-arena/websocket"
gorillawebsocket "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"testing"
"time"
)
func TestQueueingDisplay(t *testing.T) {
@@ -34,59 +32,6 @@ func TestQueueingDisplayWebsocket(t *testing.T) {
readWebsocketType(t, ws, "matchTiming")
readWebsocketType(t, ws, "matchLoad")
readWebsocketType(t, ws, "matchTime")
readWebsocketType(t, ws, "eventStatus")
readWebsocketType(t, ws, "displayConfiguration")
}
func TestQueueingStatusMessage(t *testing.T) {
assert.Equal(t, "", generateEventStatusMessage("practice", []model.Match{}))
matches := make([]model.Match, 3)
assert.Equal(t, "Event is running on schedule", generateEventStatusMessage("practice", matches))
// Check within threshold considered to be on time.
setMatchLateness(&matches[1], 0)
assert.Equal(t, "Event is running on schedule", generateEventStatusMessage("qualification", matches))
setMatchLateness(&matches[1], 60)
assert.Equal(t, "Event is running on schedule", generateEventStatusMessage("practice", matches))
setMatchLateness(&matches[1], -60)
assert.Equal(t, "Event is running on schedule", generateEventStatusMessage("qualification", matches))
setMatchLateness(&matches[1], 90)
assert.Equal(t, "Event is running on schedule", generateEventStatusMessage("qualification", matches))
setMatchLateness(&matches[1], -90)
assert.Equal(t, "Event is running on schedule", generateEventStatusMessage("qualification", matches))
setMatchLateness(&matches[1], 110)
assert.Equal(t, "Event is running on schedule", generateEventStatusMessage("practice", matches))
setMatchLateness(&matches[1], -110)
assert.Equal(t, "Event is running on schedule", generateEventStatusMessage("qualification", matches))
// Check lateness.
setMatchLateness(&matches[1], 130)
assert.Equal(t, "Event is running 2 minutes late", generateEventStatusMessage("practice", matches))
setMatchLateness(&matches[1], 3601)
assert.Equal(t, "Event is running 60 minutes late", generateEventStatusMessage("qualification", matches))
// Check earliness.
setMatchLateness(&matches[1], -130)
assert.Equal(t, "Event is running 2 minutes early", generateEventStatusMessage("qualification", matches))
setMatchLateness(&matches[1], -3601)
assert.Equal(t, "Event is running 60 minutes early", generateEventStatusMessage("practice", matches))
// Check other match types.
assert.Equal(t, "", generateEventStatusMessage("test", matches))
assert.Equal(t, "", generateEventStatusMessage("elimination", matches))
// Check that later matches supersede earlier ones.
matches = append(matches, model.Match{})
setMatchLateness(&matches[2], 180)
assert.Equal(t, "Event is running 3 minutes late", generateEventStatusMessage("qualification", matches))
// Check that a lateness before a large gap is ignored.
matches[3].Time = time.Now().Add(time.Minute * 25)
assert.Equal(t, "Event is running on schedule", generateEventStatusMessage("qualification", matches))
}
func setMatchLateness(match *model.Match, secondsLate int) {
match.Time = time.Now()
match.StartedAt = time.Now().Add(time.Second * time.Duration(secondsLate))
match.Status = "complete"
}