Get ready to deploy DriverRank.jl as part of the scouting app
This patch almost sets us up to run the DriverRank.jl script on the
scouting server. There are a few pieces still missing though:
- The scouting server actually needs to call into the new
`driver_ranking.GenerateFullDriverRanking() function.
- We need to get Julia working on the scouting server.
- The `DriverRank.jl` script needs to parse command line arguments to
specify input and output CSV files.
All that work will be done in future patches. This patch here just
sets up the wrapper logic for Go code to communicate with the Julia
code via CSV files.
Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: I327a359a089993670b2526ac304339e33c1ee6ab
diff --git a/scouting/DriverRank/BUILD b/scouting/DriverRank/BUILD
new file mode 100644
index 0000000..e82fbfb
--- /dev/null
+++ b/scouting/DriverRank/BUILD
@@ -0,0 +1,7 @@
+filegroup(
+ name = "driver_rank_script",
+ srcs = [
+ "src/DriverRank.jl",
+ ],
+ visibility = ["//scouting:__subpackages__"],
+)
diff --git a/scouting/DriverRank/src/DriverRank.jl b/scouting/DriverRank/src/DriverRank.jl
index ee7f09a..c99be1f 100644
--- a/scouting/DriverRank/src/DriverRank.jl
+++ b/scouting/DriverRank/src/DriverRank.jl
@@ -101,6 +101,7 @@
end
function rank()
+ # TODO(phil): Make the input path configurable.
df = DataFrame(CSV.File("./data/2022_madtown.csv"))
rank1 = "Rank 1 (best)"
@@ -132,6 +133,7 @@
:score=>Optim.minimizer(res),
) |>
x -> sort!(x, [:score], rev=true)
+ # TODO(phil): Save the output to a CSV file.
show(ranking_points, allrows=true)
end
diff --git a/scouting/webserver/driver_ranking/BUILD b/scouting/webserver/driver_ranking/BUILD
new file mode 100644
index 0000000..cb98782
--- /dev/null
+++ b/scouting/webserver/driver_ranking/BUILD
@@ -0,0 +1,38 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+ name = "driver_ranking",
+ srcs = ["driver_ranking.go"],
+ data = [
+ "//scouting/DriverRank:driver_rank_script",
+ ],
+ importpath = "github.com/frc971/971-Robot-Code/scouting/webserver/driver_ranking",
+ target_compatible_with = ["@platforms//cpu:x86_64"],
+ visibility = ["//visibility:public"],
+ deps = [
+ "//scouting/db",
+ "@io_bazel_rules_go//go/runfiles:go_default_library",
+ ],
+)
+
+go_test(
+ name = "driver_ranking_test",
+ srcs = ["driver_ranking_test.go"],
+ data = [
+ ":fake_driver_rank_script",
+ ],
+ embed = [":driver_ranking"],
+ target_compatible_with = ["@platforms//cpu:x86_64"],
+ deps = [
+ "//scouting/db",
+ "@com_github_davecgh_go_spew//spew",
+ ],
+)
+
+py_binary(
+ name = "fake_driver_rank_script",
+ testonly = True,
+ srcs = [
+ "fake_driver_rank_script.py",
+ ],
+)
diff --git a/scouting/webserver/driver_ranking/driver_ranking.go b/scouting/webserver/driver_ranking/driver_ranking.go
new file mode 100644
index 0000000..005078f
--- /dev/null
+++ b/scouting/webserver/driver_ranking/driver_ranking.go
@@ -0,0 +1,138 @@
+package driver_ranking
+
+import (
+ "encoding/csv"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+
+ "github.com/bazelbuild/rules_go/go/runfiles"
+ "github.com/frc971/971-Robot-Code/scouting/db"
+)
+
+const (
+ DEFAULT_SCRIPT_PATH = "org_frc971/scouting/DriverRank/src/DriverRank.jl"
+)
+
+type Database interface {
+ ReturnAllDriverRankings() ([]db.DriverRankingData, error)
+ AddParsedDriverRanking(data db.ParsedDriverRankingData) error
+}
+
+func writeToCsv(filename string, records [][]string) error {
+ file, err := os.Create(filename)
+ if err != nil {
+ return errors.New(fmt.Sprintf("Failed to create %s: %v", filename, err))
+ }
+ defer file.Close()
+
+ writer := csv.NewWriter(file)
+ writer.WriteAll(records)
+ if err := writer.Error(); err != nil {
+ return errors.New(fmt.Sprintf("Failed to write to %s: %v", filename, err))
+ }
+
+ return nil
+}
+
+func readFromCsv(filename string) (records [][]string, err error) {
+ file, err := os.Open(filename)
+ if err != nil {
+ return nil, errors.New(fmt.Sprintf("Failed to open %s: %v", filename, err))
+ }
+ defer file.Close()
+
+ reader := csv.NewReader(file)
+ records, err = reader.ReadAll()
+ if err != nil {
+ return nil, errors.New(fmt.Sprintf("Failed to parse %s as CSV: %v", filename, err))
+ }
+
+ return
+}
+
+// Runs the specified script on the DriverRankingData that the scouts collected
+// and dumps the results in the ParsedDriverRankingData table. If the script is
+// not specified (i.e. empty string) then the
+// scouting/DriverRank/src/DriverRank.jl script is called instead.
+func GenerateFullDriverRanking(database Database, scriptPath string) {
+ rawRankings, err := database.ReturnAllDriverRankings()
+ if err != nil {
+ log.Println("Failed to get raw driver ranking data: ", err)
+ return
+ }
+
+ records := [][]string{
+ {"Timestamp", "Scout Name", "Match Number", "Alliance", "Rank 1 (best)", "Rank 2", "Rank 3 (worst)"},
+ }
+
+ // Populate the CSV data.
+ for _, ranking := range rawRankings {
+ records = append(records, []string{
+ // Most of the data is unused so we just fill in empty
+ // strings.
+ "", "", "", "",
+ strconv.Itoa(int(ranking.Rank1)),
+ strconv.Itoa(int(ranking.Rank2)),
+ strconv.Itoa(int(ranking.Rank3)),
+ })
+ }
+
+ dir, err := os.MkdirTemp("", "driver_ranking_eval")
+ if err != nil {
+ log.Println("Failed to create temporary driver_ranking_eval dir: ", err)
+ return
+ }
+ defer os.RemoveAll(dir)
+
+ inputCsvFile := filepath.Join(dir, "input.csv")
+ outputCsvFile := filepath.Join(dir, "output.csv")
+
+ if err := writeToCsv(inputCsvFile, records); err != nil {
+ log.Println("Failed to write input CSV: ", err)
+ return
+ }
+
+ // If the user didn't specify a script, use the default one.
+ if scriptPath == "" {
+ scriptPath, err = runfiles.Rlocation(DEFAULT_SCRIPT_PATH)
+ if err != nil {
+ log.Println("Failed to find runfiles entry for ", DEFAULT_SCRIPT_PATH, ": ", err)
+ return
+ }
+ }
+
+ // Run the analysis script.
+ cmd := exec.Command(scriptPath, inputCsvFile, outputCsvFile)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ log.Println("Failed to run the driver ranking script: ", err)
+ return
+ }
+
+ // Grab the output from the analysis script and insert it into the
+ // database.
+ outputRecords, err := readFromCsv(outputCsvFile)
+
+ for _, record := range outputRecords {
+ score, err := strconv.ParseFloat(record[1], 32)
+ if err != nil {
+ log.Println("Failed to parse score for team ", record[0], ": ", record[1], ": ", err)
+ return
+ }
+
+ err = database.AddParsedDriverRanking(db.ParsedDriverRankingData{
+ TeamNumber: record[0],
+ Score: float32(score),
+ })
+ if err != nil {
+ log.Println("Failed to insert driver ranking score for team ", record[0], ": ", err)
+ return
+ }
+ }
+}
diff --git a/scouting/webserver/driver_ranking/driver_ranking_test.go b/scouting/webserver/driver_ranking/driver_ranking_test.go
new file mode 100644
index 0000000..47a412e
--- /dev/null
+++ b/scouting/webserver/driver_ranking/driver_ranking_test.go
@@ -0,0 +1,58 @@
+package driver_ranking
+
+import (
+ "math"
+ "testing"
+
+ "github.com/davecgh/go-spew/spew"
+ "github.com/frc971/971-Robot-Code/scouting/db"
+)
+
+type MockDatabase struct {
+ rawRankings []db.DriverRankingData
+ parsedRankings []db.ParsedDriverRankingData
+}
+
+func (database *MockDatabase) ReturnAllDriverRankings() ([]db.DriverRankingData, error) {
+ return database.rawRankings, nil
+}
+
+func (database *MockDatabase) AddParsedDriverRanking(data db.ParsedDriverRankingData) error {
+ database.parsedRankings = append(database.parsedRankings, data)
+ return nil
+}
+
+// Validates that we can call out to an external script to parse the raw driver
+// rankings and turn them into meaningful driver rankings. We don't call the
+// real DriverRank.jl script here because we don't have Julia support.
+func TestDriverRankingRun(t *testing.T) {
+ var database MockDatabase
+ database.rawRankings = []db.DriverRankingData{
+ db.DriverRankingData{MatchNumber: 1, Rank1: 1234, Rank2: 1235, Rank3: 1236},
+ db.DriverRankingData{MatchNumber: 2, Rank1: 971, Rank2: 972, Rank3: 973},
+ }
+
+ GenerateFullDriverRanking(&database, "./fake_driver_rank_script")
+
+ // This is the data that fake_driver_rank_script generates.
+ expected := []db.ParsedDriverRankingData{
+ db.ParsedDriverRankingData{TeamNumber: "1234", Score: 1.5},
+ db.ParsedDriverRankingData{TeamNumber: "1235", Score: 2.75},
+ db.ParsedDriverRankingData{TeamNumber: "1236", Score: 4.0},
+ db.ParsedDriverRankingData{TeamNumber: "971", Score: 5.25},
+ db.ParsedDriverRankingData{TeamNumber: "972", Score: 6.5},
+ db.ParsedDriverRankingData{TeamNumber: "973", Score: 7.75},
+ }
+ if len(expected) != len(database.parsedRankings) {
+ t.Fatalf(spew.Sprintf("Got %#v,\nbut expected %#v.", database.parsedRankings, expected))
+ }
+
+ // Compare each row manually because the floating point values might
+ // not match perfectly.
+ for i := range expected {
+ if expected[i].TeamNumber != database.parsedRankings[i].TeamNumber ||
+ math.Abs(float64(expected[i].Score-database.parsedRankings[i].Score)) > 0.001 {
+ t.Fatalf(spew.Sprintf("Got %#v,\nbut expected %#v.", database.parsedRankings, expected))
+ }
+ }
+}
diff --git a/scouting/webserver/driver_ranking/fake_driver_rank_script.py b/scouting/webserver/driver_ranking/fake_driver_rank_script.py
new file mode 100644
index 0000000..8f72267
--- /dev/null
+++ b/scouting/webserver/driver_ranking/fake_driver_rank_script.py
@@ -0,0 +1,54 @@
+"""A dummy script that helps validate driver_ranking.go logic.
+
+Since we don't have Julia support, we can't run the real script.
+"""
+
+import argparse
+import csv
+import sys
+from pathlib import Path
+
+EXPECTED_INPUT = [
+ [
+ 'Timestamp', 'Scout Name', 'Match Number', 'Alliance', 'Rank 1 (best)',
+ 'Rank 2', 'Rank 3 (worst)'
+ ],
+ ["", "", "", "", "1234", "1235", "1236"],
+ ["", "", "", "", "971", "972", "973"],
+]
+
+OUTPUT = [
+ ("1234", "1.5"),
+ ("1235", "2.75"),
+ ("1236", "4.0"),
+ ("971", "5.25"),
+ ("972", "6.5"),
+ ("973", "7.75"),
+]
+
+
+def main(argv):
+ parser = argparse.ArgumentParser()
+ parser.add_argument("input_csv", type=Path)
+ parser.add_argument("output_csv", type=Path)
+ args = parser.parse_args(argv[1:])
+
+ print("Reading input CSV")
+ with args.input_csv.open("r") as input_csv:
+ reader = csv.reader(input_csv)
+ input_data = [row for row in reader]
+
+ if EXPECTED_INPUT != input_data:
+ raise ValueError("Input data mismatch. Got: " + str(input_data))
+
+ print("Generating output CSV")
+ with args.output_csv.open("w") as output_csv:
+ writer = csv.writer(output_csv)
+ for row in OUTPUT:
+ writer.writerow(row)
+
+ print("Successfully generated fake parsed driver ranking data.")
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv))