Merge "Add DMAPulseSeparationReader for shooter tuning"
diff --git a/aos/events/logging/log_namer.cc b/aos/events/logging/log_namer.cc
index 1383aed..9a5d038 100644
--- a/aos/events/logging/log_namer.cc
+++ b/aos/events/logging/log_namer.cc
@@ -201,7 +201,7 @@
   const UUID &source_node_boot_uuid = state[node_index].boot_uuid;
   const Node *const source_node =
       configuration::GetNode(configuration_, node_index);
-  CHECK_EQ(LogFileHeader::MiniReflectTypeTable()->num_elems, 24u);
+  CHECK_EQ(LogFileHeader::MiniReflectTypeTable()->num_elems, 28u);
   flatbuffers::FlatBufferBuilder fbb;
   fbb.ForceDefaults(true);
 
@@ -258,41 +258,32 @@
 
   std::vector<flatbuffers::Offset<flatbuffers::String>> boot_uuid_offsets;
   boot_uuid_offsets.reserve(state.size());
-  for (const NewDataWriter::State &state : state) {
-    if (state.boot_uuid != UUID::Zero()) {
-      boot_uuid_offsets.emplace_back(state.boot_uuid.PackString(&fbb));
-    } else {
-      boot_uuid_offsets.emplace_back(fbb.CreateString(""));
-    }
-  }
 
-  flatbuffers::Offset<
-      flatbuffers::Vector<flatbuffers::Offset<flatbuffers::String>>>
-      boot_uuids_offset = fbb.CreateVector(boot_uuid_offsets);
-
-  int64_t *oldest_remote_monotonic_timestamps;
+  int64_t *unused;
   flatbuffers::Offset<flatbuffers::Vector<int64_t>>
       oldest_remote_monotonic_timestamps_offset = fbb.CreateUninitializedVector(
-          state.size(), &oldest_remote_monotonic_timestamps);
+          state.size(), &unused);
 
-  int64_t *oldest_local_monotonic_timestamps;
   flatbuffers::Offset<flatbuffers::Vector<int64_t>>
       oldest_local_monotonic_timestamps_offset = fbb.CreateUninitializedVector(
-          state.size(), &oldest_local_monotonic_timestamps);
+          state.size(), &unused);
 
-  int64_t *oldest_remote_unreliable_monotonic_timestamps;
   flatbuffers::Offset<flatbuffers::Vector<int64_t>>
       oldest_remote_unreliable_monotonic_timestamps_offset =
           fbb.CreateUninitializedVector(
-              state.size(), &oldest_remote_unreliable_monotonic_timestamps);
+              state.size(), &unused);
 
-  int64_t *oldest_local_unreliable_monotonic_timestamps;
   flatbuffers::Offset<flatbuffers::Vector<int64_t>>
       oldest_local_unreliable_monotonic_timestamps_offset =
           fbb.CreateUninitializedVector(
-              state.size(), &oldest_local_unreliable_monotonic_timestamps);
+              state.size(), &unused);
 
   for (size_t i = 0; i < state.size(); ++i) {
+    if (state[i].boot_uuid != UUID::Zero()) {
+      boot_uuid_offsets.emplace_back(state[i].boot_uuid.PackString(&fbb));
+    } else {
+      boot_uuid_offsets.emplace_back(fbb.CreateString(""));
+    }
     if (state[i].boot_uuid == UUID::Zero()) {
       CHECK_EQ(state[i].oldest_remote_monotonic_timestamp,
                monotonic_clock::max_time);
@@ -304,20 +295,32 @@
                monotonic_clock::max_time);
     }
 
-    oldest_remote_monotonic_timestamps[i] =
-        state[i].oldest_remote_monotonic_timestamp.time_since_epoch().count();
-    oldest_local_monotonic_timestamps[i] =
-        state[i].oldest_local_monotonic_timestamp.time_since_epoch().count();
-    oldest_remote_unreliable_monotonic_timestamps[i] =
-        state[i]
-            .oldest_remote_unreliable_monotonic_timestamp.time_since_epoch()
-            .count();
-    oldest_local_unreliable_monotonic_timestamps[i] =
-        state[i]
-            .oldest_local_unreliable_monotonic_timestamp.time_since_epoch()
-            .count();
+    flatbuffers::GetMutableTemporaryPointer(
+        fbb, oldest_remote_monotonic_timestamps_offset)
+        ->Mutate(i, state[i]
+                        .oldest_remote_monotonic_timestamp.time_since_epoch()
+                        .count());
+    flatbuffers::GetMutableTemporaryPointer(
+        fbb, oldest_local_monotonic_timestamps_offset)
+        ->Mutate(i, state[i]
+                        .oldest_local_monotonic_timestamp.time_since_epoch()
+                        .count());
+    flatbuffers::GetMutableTemporaryPointer(
+        fbb, oldest_remote_unreliable_monotonic_timestamps_offset)
+        ->Mutate(i, state[i]
+                        .oldest_remote_unreliable_monotonic_timestamp.time_since_epoch()
+                        .count());
+    flatbuffers::GetMutableTemporaryPointer(
+        fbb, oldest_local_unreliable_monotonic_timestamps_offset)
+        ->Mutate(i, state[i]
+                        .oldest_local_unreliable_monotonic_timestamp.time_since_epoch()
+                        .count());
   }
 
+  flatbuffers::Offset<
+      flatbuffers::Vector<flatbuffers::Offset<flatbuffers::String>>>
+      boot_uuids_offset = fbb.CreateVector(boot_uuid_offsets);
+
   aos::logger::LogFileHeader::Builder log_file_header_builder(fbb);
 
   log_file_header_builder.add_name(name_offset);
diff --git a/aos/events/logging/logfile_sorting.cc b/aos/events/logging/logfile_sorting.cc
index 3f637f8..aeced9f 100644
--- a/aos/events/logging/logfile_sorting.cc
+++ b/aos/events/logging/logfile_sorting.cc
@@ -89,7 +89,7 @@
 }
 
 bool ConfigOnly(const LogFileHeader *header) {
-  CHECK_EQ(LogFileHeader::MiniReflectTypeTable()->num_elems, 24u);
+  CHECK_EQ(LogFileHeader::MiniReflectTypeTable()->num_elems, 28u);
   if (header->has_monotonic_start_time()) return false;
   if (header->has_realtime_start_time()) return false;
   if (header->has_max_out_of_order_duration()) return false;
@@ -1238,8 +1238,15 @@
                   return a.second < b.second;
                 });
       new_parts.parts.reserve(parts.second.parts.size());
-      for (std::pair<std::string, int> &p : parts.second.parts) {
-        new_parts.parts.emplace_back(std::move(p.first));
+      {
+        int last_parts_index = -1;
+        for (std::pair<std::string, int> &p : parts.second.parts) {
+          CHECK_LT(last_parts_index, p.second)
+              << ": Found duplicate parts in '" << new_parts.parts.back()
+              << "' and '" << p.first << "'";
+          last_parts_index = p.second;
+          new_parts.parts.emplace_back(std::move(p.first));
+        }
       }
 
       CHECK(!parts.second.config_sha256.empty());
diff --git a/aos/events/logging/logger.fbs b/aos/events/logging/logger.fbs
index c71baad..6d9f7cb 100644
--- a/aos/events/logging/logger.fbs
+++ b/aos/events/logging/logger.fbs
@@ -136,13 +136,17 @@
   // For all channels sent from a specific node, these vectors hold the
   // timestamp of the oldest known message from that node, and the
   // corresponding monotonic timestamp for when that was received on our node.
-  oldest_remote_monotonic_timestamps:[int64] (id: 20);
-  oldest_local_monotonic_timestamps:[int64] (id: 21);
+  corrupted_oldest_remote_monotonic_timestamps:[int64] (id: 20, deprecated);
+  corrupted_oldest_local_monotonic_timestamps:[int64] (id: 21, deprecated);
+  oldest_remote_monotonic_timestamps:[int64] (id: 24);
+  oldest_local_monotonic_timestamps:[int64] (id: 25);
 
   // For all channels *excluding* the reliable channels (ttl == 0), record the
   // same quantity.
-  oldest_remote_unreliable_monotonic_timestamps:[int64] (id: 22);
-  oldest_local_unreliable_monotonic_timestamps:[int64] (id: 23);
+  corrupted_oldest_remote_unreliable_monotonic_timestamps:[int64] (id: 22, deprecated);
+  corrupted_oldest_local_unreliable_monotonic_timestamps:[int64] (id: 23, deprecated);
+  oldest_remote_unreliable_monotonic_timestamps:[int64] (id: 26);
+  oldest_local_unreliable_monotonic_timestamps:[int64] (id: 27);
 }
 
 // Table holding a message.
diff --git a/aos/events/logging/logger_test.cc b/aos/events/logging/logger_test.cc
index 226c084..8d8a2a6 100644
--- a/aos/events/logging/logger_test.cc
+++ b/aos/events/logging/logger_test.cc
@@ -2501,6 +2501,30 @@
   ConfirmReadable(pi1_single_direction_logfiles_);
 }
 
+// Tests that we explode if someone passes in a part file twice with a better
+// error than an out of order error.
+TEST_P(MultinodeLoggerTest, DuplicateLogFiles) {
+  time_converter_.AddMonotonic(
+      {BootTimestamp::epoch(),
+       BootTimestamp::epoch() + chrono::seconds(1000)});
+  {
+    LoggerState pi1_logger = MakeLogger(pi1_);
+
+    event_loop_factory_.RunFor(chrono::milliseconds(95));
+
+    StartLogger(&pi1_logger);
+
+    event_loop_factory_.RunFor(chrono::milliseconds(10000));
+  }
+
+  std::vector<std::string> duplicates;
+  for (const std::string &f : pi1_single_direction_logfiles_) {
+    duplicates.emplace_back(f);
+    duplicates.emplace_back(f);
+  }
+  EXPECT_DEATH({ SortParts(duplicates); }, "Found duplicate parts in");
+}
+
 // Tests that we properly handle a dead node.  Do this by just disconnecting it
 // and only using one nodes of logs.
 TEST_P(MultinodeLoggerTest, DeadNode) {
diff --git a/debian/matplotlib.BUILD b/debian/matplotlib.BUILD
index 881521c..ad44398 100644
--- a/debian/matplotlib.BUILD
+++ b/debian/matplotlib.BUILD
@@ -2,10 +2,5 @@
 
 build_matplotlib(
     "3",
-    tkinter_py_version = "3.5",
-)
-
-build_matplotlib(
-    "2.7",
-    copy_shared_files = False,
+    tkinter_py_version = "3.7",
 )
diff --git a/debian/matplotlib.bzl b/debian/matplotlib.bzl
index d94efd9..3ae0786 100644
--- a/debian/matplotlib.bzl
+++ b/debian/matplotlib.bzl
@@ -185,11 +185,19 @@
         name = "matplotlib" + version,
         srcs = _src_copied + [
             version + "/matplotlib/__init__.py",
-        ],
+        ] + native.glob(
+            include = ["usr/lib/python" + tkinter_py_version + "/**/*.py"],
+        ),
         data = _data_files + _builtin_so_copied + _system_so_copied + [
             ":usr/share/matplotlib/mpl-data/matplotlibrc",
-        ] + native.glob(["etc/**"]),
-        imports = ["usr/lib/python" + version + "/dist-packages", version, "."],
+        ] + native.glob(["etc/**", "usr/share/fonts/**"]),
+        imports = [
+            "rpathed3/usr/lib/python" + version + "/dist-packages",
+            "rpathed3/usr/lib/python" + version + ".7/lib-dynload",
+            version,
+            ".",
+            "usr/lib/python" + tkinter_py_version,
+        ],
         target_compatible_with = ["@platforms//cpu:x86_64"],
         visibility = ["//visibility:public"],
     )
diff --git a/debian/matplotlib_init.patch b/debian/matplotlib_init.patch
index 4cc155a..2818cd6 100644
--- a/debian/matplotlib_init.patch
+++ b/debian/matplotlib_init.patch
@@ -26,7 +26,7 @@
 +
 +# Tell fontconfig where to find matplotlib's sandboxed font files.
 +os.environ["FONTCONFIG_PATH"] = os.path.join(_matplotlib_base, "etc/fonts")
-+os.environ["FONTCONFIG_FILE"] = os.path.join(_matplotlib_base, "etc/fonts/fonts.conf")
++os.environ["FONTCONFIG_FILE"] = "fonts.conf"
 +os.environ["FONTCONFIG_SYSROOT"] = _matplotlib_base
 +
  try:
diff --git a/frc971/analysis/BUILD b/frc971/analysis/BUILD
index 2a945bf..5457405 100644
--- a/frc971/analysis/BUILD
+++ b/frc971/analysis/BUILD
@@ -47,6 +47,7 @@
         "//frc971/control_loops/drivetrain:robot_state_plotter",
         "//frc971/control_loops/drivetrain:spline_plotter",
         "//frc971/wpilib:imu_plotter",
+        "//y2020/control_loops/drivetrain:localizer_plotter",
         "//y2020/control_loops/superstructure:accelerator_plotter",
         "//y2020/control_loops/superstructure:finisher_plotter",
         "//y2020/control_loops/superstructure:hood_plotter",
diff --git a/frc971/analysis/plot_index.ts b/frc971/analysis/plot_index.ts
index 8b8c951..7b46a70 100644
--- a/frc971/analysis/plot_index.ts
+++ b/frc971/analysis/plot_index.ts
@@ -32,6 +32,8 @@
     'org_frc971/y2020/control_loops/superstructure/finisher_plotter'
 import {plotTurret} from
     'org_frc971/y2020/control_loops/superstructure/turret_plotter'
+import {plotLocalizer} from
+    'org_frc971/y2020/control_loops/drivetrain/localizer_plotter'
 import {plotAccelerator} from
     'org_frc971/y2020/control_loops/superstructure/accelerator_plotter'
 import {plotHood} from
@@ -100,6 +102,7 @@
   ['Accelerator', new PlotState(plotDiv, plotAccelerator)],
   ['Hood', new PlotState(plotDiv, plotHood)],
   ['Turret', new PlotState(plotDiv, plotTurret)],
+  ['2020 Localizer', new PlotState(plotDiv, plotLocalizer)],
   ['C++ Plotter', new PlotState(plotDiv, plotData)],
 ]);
 
diff --git a/frc971/control_loops/drivetrain/hybrid_ekf.h b/frc971/control_loops/drivetrain/hybrid_ekf.h
index 04ffa4e..ecdabb8 100644
--- a/frc971/control_loops/drivetrain/hybrid_ekf.h
+++ b/frc971/control_loops/drivetrain/hybrid_ekf.h
@@ -137,7 +137,7 @@
   };
   static constexpr int kNInputs = 4;
   // Number of previous samples to save.
-  static constexpr int kSaveSamples = 50;
+  static constexpr int kSaveSamples = 80;
   // Whether we should completely rerun the entire stored history of
   // kSaveSamples on every correction. Enabling this will increase overall CPU
   // usage substantially; however, leaving it disabled makes it so that we are
diff --git a/frc971/control_loops/python/BUILD b/frc971/control_loops/python/BUILD
index b6290d4..a4b8fb8 100644
--- a/frc971/control_loops/python/BUILD
+++ b/frc971/control_loops/python/BUILD
@@ -112,6 +112,7 @@
         ":drivetrain",
         ":python_init",
         "//external:python-glog",
+        "@matplotlib_repo//:matplotlib3",
     ],
 )
 
diff --git a/frc971/control_loops/python/field_images/2020.png b/frc971/control_loops/python/field_images/2020.png
index 166a1bb..2397fa0 100644
--- a/frc971/control_loops/python/field_images/2020.png
+++ b/frc971/control_loops/python/field_images/2020.png
Binary files differ
diff --git a/frc971/control_loops/python/path_edit.py b/frc971/control_loops/python/path_edit.py
index d743e0d..2e4e269 100755
--- a/frc971/control_loops/python/path_edit.py
+++ b/frc971/control_loops/python/path_edit.py
@@ -444,6 +444,12 @@
         # scale from point in field coordinates
         point = self.mousex, self.mousey
 
+        # This restricts the amount it can be scaled.
+        if self.transform.xx <= 0.4:
+            scale = max(scale, 1)
+        elif self.transform.xx >= 4:
+            scale = min(scale, 1)
+
         # move the origin to point
         self.transform.translate(point[0], point[1])
 
diff --git a/y2020/BUILD b/y2020/BUILD
index 45164b4..19debdc 100644
--- a/y2020/BUILD
+++ b/y2020/BUILD
@@ -232,6 +232,7 @@
         "//aos/network:timestamp_fbs",
         "//y2020/control_loops/superstructure:superstructure_goal_fbs",
         "//y2019/control_loops/drivetrain:target_selector_fbs",
+        "//y2020/control_loops/drivetrain:localizer_debug_fbs",
         "//y2020/control_loops/superstructure:superstructure_output_fbs",
         "//y2020/control_loops/superstructure:superstructure_position_fbs",
         "//y2020/control_loops/superstructure:superstructure_status_fbs",
diff --git a/y2020/actors/splines/README.md b/y2020/actors/splines/README.md
new file mode 100644
index 0000000..058f5bc
--- /dev/null
+++ b/y2020/actors/splines/README.md
@@ -0,0 +1,24 @@
+# Spline Descriptions
+This folder contains reference material for what each spline does
+
+<br>
+
+# Target Aligned - Infinite Recharge
+## After shooting balls initially, collects power cells and returns to shooting position
+
+### [Target Aligned 1](target_aligned_1.json)
+After shooting the pre-loaded balls, this spline directs the robot to the power cells, allowing the robot to collect all three balls available
+
+### [Target Aligned 2](target_aligned_2.json)
+After collecting the 3 balls using the target_aligned_1 spline, this spline directs the robot back towards the shooter tower, setting it up to shoot the balls it collected
+
+<br>
+
+# Target Offset - Infinite Recharge
+## After starting with 3 balls, collects power cells and heads to primary shooting position
+
+### [Target Offset 1](target_offset_1.json)
+When we start offset from the target with the 3 pre-loaded balls in the robot, this spline directs the robot to the additional 2 power cells, allowing the robot to collect both balls available
+
+### [Target Offset 2](target_offset_2.json)
+After collecting the 2 balls using the target_offset_1 spline, this spline directs the robot towards the shooter tower, setting it up to shoot the 5 balls.
\ No newline at end of file
diff --git a/y2020/actors/splines/target_aligned_1.json b/y2020/actors/splines/target_aligned_1.json
new file mode 100644
index 0000000..5a1d082
--- /dev/null
+++ b/y2020/actors/splines/target_aligned_1.json
@@ -0,0 +1 @@
+{"spline_count": 1, "spline_x": [3.340495531674842, 3.3229239110760016, 4.483964997108113, 6.058132772881773, 7.084092118999326, 8.012654807002901], "spline_y": [5.730681749413974, 6.367337321705237, 7.14109740201818, 7.627912651837696, 7.449284138182865, 7.4135680036085025], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 3.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 2.0}, {"constraint_type": "VOLTAGE", "value": 10.0}]}
\ No newline at end of file
diff --git a/y2020/actors/splines/target_aligned_2.json b/y2020/actors/splines/target_aligned_2.json
new file mode 100644
index 0000000..bca8965
--- /dev/null
+++ b/y2020/actors/splines/target_aligned_2.json
@@ -0,0 +1 @@
+{"spline_count": 1, "spline_x": [8.02107576923077, 7.157915384615384, 6.042122692307692, 5.010540769230768, 4.2315911538461535, 3.368430769230769], "spline_y": [7.389495, 7.431600384615384, 6.652650769230769, 6.1473861538461545, 5.7684376923076925, 5.7473849999999995], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 3.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 2.0}, {"constraint_type": "VOLTAGE", "value": 10.0}]} 
\ No newline at end of file
diff --git a/y2020/actors/splines/target_offset_1.json b/y2020/actors/splines/target_offset_1.json
new file mode 100644
index 0000000..7b56061
--- /dev/null
+++ b/y2020/actors/splines/target_offset_1.json
@@ -0,0 +1 @@
+{"spline_count": 1, "spline_x": [3.31276940587483, 3.2907022466964535, 4.149941560222061, 3.9732949238578668, 4.6679269035533, 5.751552791878172], "spline_y": [2.721120947225566, 1.4302342949805793, 1.6475505418770833, 1.4726197969543149, 0.8891289340101522, 0.8335583756345177], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 3.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 2.0}, {"constraint_type": "VOLTAGE", "value": 10.0}]}
\ No newline at end of file
diff --git a/y2020/actors/splines/target_offset_2.json b/y2020/actors/splines/target_offset_2.json
new file mode 100644
index 0000000..6292a7f
--- /dev/null
+++ b/y2020/actors/splines/target_offset_2.json
@@ -0,0 +1 @@
+{"spline_count": 1, "spline_x": [5.709874873096447, 4.5984637055837565, 4.112221319796954, 3.8482611675126903, 3.7232274111675125, 3.3481261421319797], "spline_y": [0.8057730964467005, 0.7779878172588832, 1.4170492385786801, 2.875776395939086, 4.167791878172588, 5.862693908629441], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 3.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 2.0}, {"constraint_type": "VOLTAGE", "value": 10.0}]}
\ No newline at end of file
diff --git a/y2020/control_loops/drivetrain/BUILD b/y2020/control_loops/drivetrain/BUILD
index 89d8044..7c4f99f 100644
--- a/y2020/control_loops/drivetrain/BUILD
+++ b/y2020/control_loops/drivetrain/BUILD
@@ -1,6 +1,7 @@
 load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
 load("//aos:config.bzl", "aos_config")
 load("//tools/build_rules:select.bzl", "compiler_select", "cpu_select")
+load("@npm_bazel_typescript//:defs.bzl", "ts_library")
 
 genrule(
     name = "genrule_drivetrain",
@@ -56,13 +57,22 @@
     ],
 )
 
+flatbuffer_cc_library(
+    name = "localizer_debug_fbs",
+    srcs = ["localizer_debug.fbs"],
+    gen_reflections = 1,
+    visibility = ["//visibility:public"],
+)
+
 cc_library(
     name = "localizer",
     srcs = ["localizer.cc"],
     hdrs = ["localizer.h"],
     target_compatible_with = ["@platforms//os:linux"],
     deps = [
+        ":localizer_debug_fbs",
         "//aos/containers:ring_buffer",
+        "//aos/containers:sized_array",
         "//aos/network:message_bridge_server_fbs",
         "//frc971/control_loops:profiled_subsystem_fbs",
         "//frc971/control_loops/drivetrain:hybrid_ekf",
@@ -195,3 +205,19 @@
         "@com_github_google_glog//:glog",
     ],
 )
+
+ts_library(
+    name = "localizer_plotter",
+    srcs = ["localizer_plotter.ts"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//aos:configuration_ts_fbs",
+        "//aos/network/www:aos_plotter",
+        "//aos/network/www:colors",
+        "//aos/network/www:plotter",
+        "//aos/network/www:proxy",
+        "//aos/network/www:reflection_ts",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+    ],
+)
diff --git a/y2020/control_loops/drivetrain/drivetrain_replay.cc b/y2020/control_loops/drivetrain/drivetrain_replay.cc
index 65fc0c4..913fbda 100644
--- a/y2020/control_loops/drivetrain/drivetrain_replay.cc
+++ b/y2020/control_loops/drivetrain/drivetrain_replay.cc
@@ -75,6 +75,8 @@
                             "frc971.control_loops.drivetrain.Status");
   reader.RemapLoggedChannel("/drivetrain",
                             "frc971.control_loops.drivetrain.Output");
+  reader.RemapLoggedChannel("/drivetrain",
+                            "y2020.control_loops.drivetrain.LocalizerDebug");
   reader.RemapLoggedChannel("/superstructure",
                             "y2020.control_loops.superstructure.Status");
   reader.RemapLoggedChannel("/superstructure",
diff --git a/y2020/control_loops/drivetrain/localizer.cc b/y2020/control_loops/drivetrain/localizer.cc
index 906bb4b..9e0e83f 100644
--- a/y2020/control_loops/drivetrain/localizer.cc
+++ b/y2020/control_loops/drivetrain/localizer.cc
@@ -23,6 +23,10 @@
   return result;
 }
 
+// Offset to add to the pi's yaw in its extrinsics, to account for issues in the
+// calibrated extrinsics.
+constexpr double kTurretPiOffset = 0.0;
+
 // Indices of the pis to use.
 const std::array<std::string, 5> kPisToUse{"pi1", "pi2", "pi3", "pi4", "pi5"};
 
@@ -72,7 +76,11 @@
       ekf_(dt_config),
       clock_offset_fetcher_(
           event_loop_->MakeFetcher<aos::message_bridge::ServerStatistics>(
-              "/aos")) {
+              "/aos")),
+      debug_sender_(
+          event_loop_
+              ->MakeSender<y2020::control_loops::drivetrain::LocalizerDebug>(
+                  "/drivetrain")) {
   // TODO(james): The down estimator has trouble handling situations where the
   // robot is constantly wiggling but not actually moving much, and can cause
   // drift when using accelerometer readings.
@@ -145,17 +153,32 @@
                        double gyro_rate, const Eigen::Vector3d &accel) {
   ekf_.UpdateEncodersAndGyro(left_encoder, right_encoder, gyro_rate,
                              U.cast<float>(), accel.cast<float>(), now);
+  auto builder = debug_sender_.MakeBuilder();
+  aos::SizedArray<flatbuffers::Offset<ImageMatchDebug>, 25> debug_offsets;
   for (size_t ii = 0; ii < kPisToUse.size(); ++ii) {
     auto &image_fetcher = image_fetchers_[ii];
     while (image_fetcher.FetchNext()) {
-      HandleImageMatch(kPisToUse[ii], *image_fetcher, now);
+      const auto offsets = HandleImageMatch(ii, kPisToUse[ii], *image_fetcher,
+                                            now, builder.fbb());
+      for (const auto offset : offsets) {
+        debug_offsets.push_back(offset);
+      }
     }
   }
+  const auto vector_offset =
+      builder.fbb()->CreateVector(debug_offsets.data(), debug_offsets.size());
+  LocalizerDebug::Builder debug_builder = builder.MakeBuilder<LocalizerDebug>();
+  debug_builder.add_matches(vector_offset);
+  CHECK(builder.Send(debug_builder.Finish()));
 }
 
-void Localizer::HandleImageMatch(
-    std::string_view pi, const frc971::vision::sift::ImageMatchResult &result,
-    aos::monotonic_clock::time_point now) {
+aos::SizedArray<flatbuffers::Offset<ImageMatchDebug>, 5>
+Localizer::HandleImageMatch(
+    size_t camera_index, std::string_view pi,
+    const frc971::vision::sift::ImageMatchResult &result,
+    aos::monotonic_clock::time_point now, flatbuffers::FlatBufferBuilder *fbb) {
+  aos::SizedArray<flatbuffers::Offset<ImageMatchDebug>, 5> debug_offsets;
+
   std::chrono::nanoseconds monotonic_offset(0);
   clock_offset_fetcher_.Fetch();
   if (clock_offset_fetcher_.get() != nullptr) {
@@ -177,11 +200,21 @@
           << capture_time;
   if (capture_time > now) {
     LOG(WARNING) << "Got camera frame from the future.";
-    return;
+    ImageMatchDebug::Builder builder(*fbb);
+    builder.add_camera(camera_index);
+    builder.add_accepted(false);
+    builder.add_rejection_reason(RejectionReason::IMAGE_FROM_FUTURE);
+    debug_offsets.push_back(builder.Finish());
+    return debug_offsets;
   }
   if (!result.has_camera_calibration()) {
     LOG(WARNING) << "Got camera frame without calibration data.";
-    return;
+    ImageMatchDebug::Builder builder(*fbb);
+    builder.add_camera(camera_index);
+    builder.add_accepted(false);
+    builder.add_rejection_reason(RejectionReason::NO_CALIBRATION);
+    debug_offsets.push_back(builder.Finish());
+    return debug_offsets;
   }
   // Per the ImageMatchResult specification, we can actually determine whether
   // the camera is the turret camera just from the presence of the
@@ -194,7 +227,12 @@
   // seems reasonable, but may be unnecessarily low or high.
   constexpr float kMaxTurretVelocity = 1.0;
   if (is_turret && std::abs(turret_data.velocity) > kMaxTurretVelocity) {
-    return;
+    ImageMatchDebug::Builder builder(*fbb);
+    builder.add_camera(camera_index);
+    builder.add_accepted(false);
+    builder.add_rejection_reason(RejectionReason::TURRET_TOO_FAST);
+    debug_offsets.push_back(builder.Finish());
+    return debug_offsets;
   }
   CHECK(result.camera_calibration()->has_fixed_extrinsics());
   const Eigen::Matrix<float, 4, 4> fixed_extrinsics =
@@ -206,19 +244,38 @@
   if (is_turret) {
     H_robot_camera = H_robot_camera *
                      frc971::control_loops::TransformationMatrixForYaw<float>(
-                         turret_data.position) *
+                         turret_data.position + kTurretPiOffset) *
                      FlatbufferToTransformationMatrix(
                          *result.camera_calibration()->turret_extrinsics());
   }
 
   if (!result.has_camera_poses()) {
-    return;
+    ImageMatchDebug::Builder builder(*fbb);
+    builder.add_camera(camera_index);
+    builder.add_accepted(false);
+    builder.add_rejection_reason(RejectionReason::NO_RESULTS);
+    debug_offsets.push_back(builder.Finish());
+    return debug_offsets;
   }
 
+  int index = -1;
   for (const frc971::vision::sift::CameraPose *vision_result :
        *result.camera_poses()) {
+    ++index;
+
+    ImageMatchDebug::Builder builder(*fbb);
+    builder.add_camera(camera_index);
+    builder.add_pose_index(index);
+    builder.add_local_image_capture_time_ns(result.image_monotonic_timestamp_ns());
+    builder.add_roborio_image_capture_time_ns(
+        capture_time.time_since_epoch().count());
+    builder.add_image_age_sec(aos::time::DurationInSeconds(now - capture_time));
+
     if (!vision_result->has_camera_to_target() ||
         !vision_result->has_field_to_target()) {
+      builder.add_accepted(false);
+      builder.add_rejection_reason(RejectionReason::NO_TRANSFORMS);
+      debug_offsets.push_back(builder.Finish());
       continue;
     }
     const Eigen::Matrix<float, 4, 4> H_camera_target =
@@ -257,7 +314,7 @@
     // populating some cross-correlation terms.
     // Note that these are the noise standard deviations (they are squared below
     // to get variances).
-    Eigen::Matrix<float, 3, 1> noises(2.0, 2.0, 0.2);
+    Eigen::Matrix<float, 3, 1> noises(2.0, 2.0, 0.5);
     // Augment the noise by the approximate rotational speed of the
     // camera. This should help account for the fact that, while we are
     // spinning, slight timing errors in the camera/turret data will tend to
@@ -271,9 +328,7 @@
     H.setZero();
     H(0, StateIdx::kX) = 1;
     H(1, StateIdx::kY) = 1;
-    // This is currently set to zero because we ignore the heading implied by
-    // the camera.
-    H(2, StateIdx::kTheta) = 0;
+    H(2, StateIdx::kTheta) = 1;
     VLOG(1) << "Pose implied by target: " << Z.transpose()
             << " and current pose " << x() << ", " << y() << ", " << theta()
             << " Heading/dist/skew implied by target: "
@@ -282,6 +337,9 @@
     // and don't use the correction.
     if (std::abs(aos::math::DiffAngle<float>(theta(), Z(2))) > M_PI_2) {
       AOS_LOG(WARNING, "Dropped image match due to heading mismatch.\n");
+      builder.add_accepted(false);
+      builder.add_rejection_reason(RejectionReason::HIGH_THETA_DIFFERENCE);
+      debug_offsets.push_back(builder.Finish());
       continue;
     }
     // In order to do the EKF correction, we determine the expected state based
@@ -293,24 +351,42 @@
         ekf_.LastStateBeforeTime(capture_time);
     if (!state_at_capture.has_value()) {
       AOS_LOG(WARNING, "Dropped image match due to age of image.\n");
+      builder.add_accepted(false);
+      builder.add_rejection_reason(RejectionReason::IMAGE_TOO_OLD);
+      debug_offsets.push_back(builder.Finish());
       continue;
     }
+
+    builder.add_implied_robot_x(Z(0));
+    builder.add_implied_robot_y(Z(1));
+    builder.add_implied_robot_theta(Z(2));
+
+    // Turret is zero when pointed backwards.
+    builder.add_implied_turret_goal(
+        aos::math::NormalizeAngle(M_PI + pose_robot_target.heading()));
+
+    std::optional<RejectionReason> correction_rejection;
     const Input U = ekf_.MostRecentInput();
     // For the correction step, instead of passing in the measurement directly,
     // we pass in (0, 0, 0) as the measurement and then for the expected
     // measurement (Zhat) we calculate the error between the implied and actual
     // poses. This doesn't affect any of the math, it just makes the code a bit
     // more convenient to write given the Correct() interface we already have.
+    // Note: If we start going back to doing back-in-time rewinds, then we can't
+    // get away with passing things by reference.
     ekf_.Correct(
         Eigen::Vector3f::Zero(), &U, {},
-        [H, H_field_target, pose_robot_target, state_at_capture](
+        [H, H_field_target, pose_robot_target, state_at_capture, Z, &correction_rejection](
             const State &, const Input &) -> Eigen::Vector3f {
-          const Eigen::Vector3f Z =
+          // TODO(james): Figure out how to use the implied pose for...
+          const Eigen::Vector3f Z_implied =
               CalculateImpliedPose(H_field_target, pose_robot_target);
+          (void)Z_implied;
           // Just in case we ever do encounter any, drop measurements if they
           // have non-finite numbers.
           if (!Z.allFinite()) {
             AOS_LOG(WARNING, "Got measurement with infinites or NaNs.\n");
+            correction_rejection = RejectionReason::NONFINITE_MEASUREMENT;
             return Eigen::Vector3f::Zero();
           }
           Eigen::Vector3f Zhat = H * state_at_capture.value() - Z;
@@ -324,12 +400,21 @@
           // because I primarily introduced it to make sure that any grossly
           // invalid measurements get thrown out.
           if (Zhat.squaredNorm() > std::pow(10.0, 2)) {
+            correction_rejection = RejectionReason::CORRECTION_TOO_LARGE;
             return Eigen::Vector3f::Zero();
           }
           return Zhat;
         },
         [H](const State &) { return H; }, R, now);
+    if (correction_rejection) {
+      builder.add_accepted(false);
+      builder.add_rejection_reason(*correction_rejection);
+    } else {
+      builder.add_accepted(true);
+    }
+    debug_offsets.push_back(builder.Finish());
   }
+  return debug_offsets;
 }
 
 }  // namespace drivetrain
diff --git a/y2020/control_loops/drivetrain/localizer.h b/y2020/control_loops/drivetrain/localizer.h
index c3b6464..19ee4ad 100644
--- a/y2020/control_loops/drivetrain/localizer.h
+++ b/y2020/control_loops/drivetrain/localizer.h
@@ -4,11 +4,13 @@
 #include <string_view>
 
 #include "aos/containers/ring_buffer.h"
+#include "aos/containers/sized_array.h"
 #include "aos/events/event_loop.h"
 #include "aos/network/message_bridge_server_generated.h"
 #include "frc971/control_loops/drivetrain/hybrid_ekf.h"
 #include "frc971/control_loops/drivetrain/localizer.h"
 #include "y2020/control_loops/superstructure/superstructure_status_generated.h"
+#include "y2020/control_loops/drivetrain/localizer_debug_generated.h"
 #include "y2020/vision/sift/sift_generated.h"
 
 namespace y2020 {
@@ -77,9 +79,11 @@
   };
 
   // Processes new image data from the given pi and updates the EKF.
-  void HandleImageMatch(std::string_view pi,
-                        const frc971::vision::sift::ImageMatchResult &result,
-                        aos::monotonic_clock::time_point now);
+  aos::SizedArray<flatbuffers::Offset<ImageMatchDebug>, 5> HandleImageMatch(
+      size_t camera_index, std::string_view pi,
+      const frc971::vision::sift::ImageMatchResult &result,
+      aos::monotonic_clock::time_point now,
+      flatbuffers::FlatBufferBuilder *fbb);
 
   // Processes the most recent turret position and stores it in the turret_data_
   // buffer.
@@ -98,6 +102,8 @@
 
   aos::Fetcher<aos::message_bridge::ServerStatistics> clock_offset_fetcher_;
 
+  aos::Sender<y2020::control_loops::drivetrain::LocalizerDebug> debug_sender_;
+
   // Buffer of recent turret data--this is used so that when we receive a camera
   // frame from the turret, we can back out what the turret angle was at that
   // time.
diff --git a/y2020/control_loops/drivetrain/localizer_debug.fbs b/y2020/control_loops/drivetrain/localizer_debug.fbs
new file mode 100644
index 0000000..89aea68
--- /dev/null
+++ b/y2020/control_loops/drivetrain/localizer_debug.fbs
@@ -0,0 +1,34 @@
+namespace y2020.control_loops.drivetrain;
+
+enum RejectionReason : byte {
+  IMAGE_FROM_FUTURE = 0,
+  NO_CALIBRATION = 1,
+  TURRET_TOO_FAST = 2,
+  NO_RESULTS = 3,
+  NO_TRANSFORMS = 4,
+  HIGH_THETA_DIFFERENCE = 5,
+  IMAGE_TOO_OLD = 6,
+  NONFINITE_MEASUREMENT = 7,
+  CORRECTION_TOO_LARGE = 8,
+}
+
+table ImageMatchDebug {
+  camera:uint8 (id: 0);
+  pose_index: uint8 (id: 1);
+  local_image_capture_time_ns:long (id: 2);
+  roborio_image_capture_time_ns:long (id: 3);
+  implied_robot_x:float (id: 4);
+  implied_robot_y:float (id: 5);
+  implied_robot_theta:float (id: 6);
+  implied_turret_goal:float (id: 7);
+  accepted:bool (id: 8);
+  rejection_reason:RejectionReason  (id: 9);
+  // Image age (more human-readable than trying to interpret roborio_image_capture_time_ns).
+  image_age_sec:float (id: 10);
+}
+
+table LocalizerDebug {
+  matches:[ImageMatchDebug] (id: 0);
+}
+
+root_type LocalizerDebug;
diff --git a/y2020/control_loops/drivetrain/localizer_plotter.ts b/y2020/control_loops/drivetrain/localizer_plotter.ts
new file mode 100644
index 0000000..4de2856
--- /dev/null
+++ b/y2020/control_loops/drivetrain/localizer_plotter.ts
@@ -0,0 +1,100 @@
+// Provides a plot for debugging robot state-related issues.
+import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
+import * as proxy from 'org_frc971/aos/network/www/proxy';
+import * as configuration from 'org_frc971/aos/configuration_generated';
+import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
+import {MessageHandler, TimestampedMessage} from 'org_frc971/aos/network/www/aos_plotter';
+import {Point} from 'org_frc971/aos/network/www/plotter';
+import {Table} from 'org_frc971/aos/network/www/reflection';
+import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
+
+import Connection = proxy.Connection;
+import Schema = configuration.reflection.Schema;
+
+const TIME = AosPlotter.TIME;
+const DEFAULT_WIDTH = AosPlotter.DEFAULT_WIDTH;
+const DEFAULT_HEIGHT = AosPlotter.DEFAULT_HEIGHT;
+
+
+export function plotLocalizer(conn: Connection, element: Element) : void {
+  const aosPlotter = new AosPlotter(conn);
+  const localizerDebug =
+      aosPlotter.addMessageSource('/drivetrain', 'y2020.control_loops.drivetrain.LocalizerDebug');
+  const imageMatch =
+      aosPlotter.addMessageSource('/pi1/camera', 'frc971.vision.sift.ImageMatchResult');
+
+  var currentTop = 0;
+
+  const imageAcceptedPlot = aosPlotter.addPlot(
+      element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
+  currentTop += DEFAULT_HEIGHT;
+  imageAcceptedPlot.plot.getAxisLabels().setTitle('Image Acceptance');
+  imageAcceptedPlot.plot.getAxisLabels().setXLabel(TIME);
+  imageAcceptedPlot.plot.getAxisLabels().setYLabel('[bool]');
+  imageAcceptedPlot.plot.setDefaultYRange([-0.05, 1.05]);
+
+  imageAcceptedPlot.addMessageLine(localizerDebug, ['matches[]', 'accepted'])
+      .setColor(RED)
+      .setDrawLine(false);
+
+  const impliedXPlot = aosPlotter.addPlot(
+      element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
+  currentTop += DEFAULT_HEIGHT;
+  impliedXPlot.plot.getAxisLabels().setTitle('Implied Robot X');
+  impliedXPlot.plot.getAxisLabels().setXLabel(TIME);
+  impliedXPlot.plot.getAxisLabels().setYLabel('[m]');
+
+  impliedXPlot.addMessageLine(localizerDebug, ['matches[]', 'implied_robot_x'])
+      .setColor(RED)
+      .setDrawLine(false);
+  impliedXPlot.addMessageLine(imageMatch, ['camera_poses[]', 'field_to_camera', 'data[3]'])
+      .setColor(BLUE)
+      .setDrawLine(false);
+
+  const impliedYPlot = aosPlotter.addPlot(
+      element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
+  currentTop += DEFAULT_HEIGHT;
+  impliedYPlot.plot.getAxisLabels().setTitle('Implied Robot Y');
+  impliedYPlot.plot.getAxisLabels().setXLabel(TIME);
+  impliedYPlot.plot.getAxisLabels().setYLabel('[m]');
+
+  impliedYPlot.addMessageLine(localizerDebug, ['matches[]', 'implied_robot_y'])
+      .setColor(RED)
+      .setDrawLine(false);
+  impliedYPlot.addMessageLine(imageMatch, ['camera_poses[]', 'field_to_camera', 'data[7]'])
+      .setColor(BLUE)
+      .setDrawLine(false);
+
+  const impliedHeadingPlot = aosPlotter.addPlot(
+      element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
+  currentTop += DEFAULT_HEIGHT;
+  impliedHeadingPlot.plot.getAxisLabels().setTitle('Implied Robot Theta');
+  impliedHeadingPlot.plot.getAxisLabels().setXLabel(TIME);
+  impliedHeadingPlot.plot.getAxisLabels().setYLabel('[rad]');
+
+  impliedHeadingPlot.addMessageLine(localizerDebug, ['matches[]', 'implied_robot_theta'])
+      .setColor(RED)
+      .setDrawLine(false);
+
+  const impliedTurretGoalPlot = aosPlotter.addPlot(
+      element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
+  currentTop += DEFAULT_HEIGHT;
+  impliedTurretGoalPlot.plot.getAxisLabels().setTitle('Implied Turret Goal');
+  impliedTurretGoalPlot.plot.getAxisLabels().setXLabel(TIME);
+  impliedTurretGoalPlot.plot.getAxisLabels().setYLabel('[rad]');
+
+  impliedTurretGoalPlot.addMessageLine(localizerDebug, ['matches[]', 'implied_turret_goal'])
+      .setColor(RED)
+      .setDrawLine(false);
+
+  const imageTimingPlot = aosPlotter.addPlot(
+      element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
+  currentTop += DEFAULT_HEIGHT;
+  imageTimingPlot.plot.getAxisLabels().setTitle('Timing Plot');
+  imageTimingPlot.plot.getAxisLabels().setXLabel(TIME);
+  imageTimingPlot.plot.getAxisLabels().setYLabel('[ns]');
+
+  imageTimingPlot.addMessageLine(localizerDebug, ['matches[]', 'image_age_sec'])
+      .setColor(RED)
+      .setDrawLine(false);
+}
diff --git a/y2020/control_loops/superstructure/BUILD b/y2020/control_loops/superstructure/BUILD
index 2d47cf4..db8a060 100644
--- a/y2020/control_loops/superstructure/BUILD
+++ b/y2020/control_loops/superstructure/BUILD
@@ -154,9 +154,13 @@
     srcs = ["turret_plotter.ts"],
     target_compatible_with = ["@platforms//os:linux"],
     deps = [
+        "//aos:configuration_ts_fbs",
         "//aos/network/www:aos_plotter",
         "//aos/network/www:colors",
+        "//aos/network/www:plotter",
         "//aos/network/www:proxy",
+        "//aos/network/www:reflection_ts",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
     ],
 )
 
diff --git a/y2020/control_loops/superstructure/superstructure.cc b/y2020/control_loops/superstructure/superstructure.cc
index ce816a0..98de31d 100644
--- a/y2020/control_loops/superstructure/superstructure.cc
+++ b/y2020/control_loops/superstructure/superstructure.cc
@@ -164,11 +164,16 @@
 
   if (output != nullptr) {
     // Friction is a pain and putting a really high burden on the integrator.
+    // TODO(james): I'm not sure how helpful this gain is.
     const double turret_velocity_sign =
         turret_status->velocity() * kTurretFrictionGain;
     output_struct.turret_voltage +=
         std::clamp(turret_velocity_sign, -kTurretFrictionVoltageLimit,
                    kTurretFrictionVoltageLimit);
+    const double time_sec =
+        aos::time::DurationInSeconds(position_timestamp.time_since_epoch());
+    output_struct.turret_voltage +=
+        kTurretDitherGain * std::sin(2.0 * M_PI * time_sec * 30.0);
     output_struct.turret_voltage =
         std::clamp(output_struct.turret_voltage, -turret_.operating_voltage(),
                    turret_.operating_voltage());
diff --git a/y2020/control_loops/superstructure/superstructure.h b/y2020/control_loops/superstructure/superstructure.h
index c03f7a7..e4a9cb6 100644
--- a/y2020/control_loops/superstructure/superstructure.h
+++ b/y2020/control_loops/superstructure/superstructure.h
@@ -28,6 +28,7 @@
   // voltage cap.
   static constexpr double kTurretFrictionGain = 10.0;
   static constexpr double kTurretFrictionVoltageLimit = 1.5;
+  static constexpr double kTurretDitherGain = 0.4;
 
   using PotAndAbsoluteEncoderSubsystem =
       ::frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystem<
diff --git a/y2020/control_loops/superstructure/superstructure_lib_test.cc b/y2020/control_loops/superstructure/superstructure_lib_test.cc
index 22dd40e..112780a 100644
--- a/y2020/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2020/control_loops/superstructure/superstructure_lib_test.cc
@@ -742,7 +742,7 @@
   superstructure_plant_.set_peak_intake_acceleration(0.2);
 
   superstructure_plant_.set_peak_turret_velocity(23.0);
-  superstructure_plant_.set_peak_turret_acceleration(0.2);
+  superstructure_plant_.set_peak_turret_acceleration(6.0);
 
   // Intake needs over 9 seconds to reach the goal
   RunFor(chrono::seconds(10));
@@ -971,7 +971,7 @@
 
   superstructure_status_fetcher_.Fetch();
   EXPECT_NEAR(kShotAngle, superstructure_status_fetcher_->turret()->position(),
-              1e-4);
+              5e-4);
   EXPECT_FLOAT_EQ(kShotAngle,
                   superstructure_status_fetcher_->aimer()->turret_position());
   EXPECT_FLOAT_EQ(0,
diff --git a/y2020/control_loops/superstructure/turret_plotter.ts b/y2020/control_loops/superstructure/turret_plotter.ts
index 3d975d8..948ca97 100644
--- a/y2020/control_loops/superstructure/turret_plotter.ts
+++ b/y2020/control_loops/superstructure/turret_plotter.ts
@@ -1,29 +1,82 @@
 // Provides a plot for debugging robot state-related issues.
 import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
 import * as proxy from 'org_frc971/aos/network/www/proxy';
+import * as configuration from 'org_frc971/aos/configuration_generated';
 import {BLUE, BROWN, CYAN, GREEN, PINK, RED, WHITE} from 'org_frc971/aos/network/www/colors';
+import {MessageHandler, TimestampedMessage} from 'org_frc971/aos/network/www/aos_plotter';
+import {Point} from 'org_frc971/aos/network/www/plotter';
+import {Table} from 'org_frc971/aos/network/www/reflection';
+import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
 
 import Connection = proxy.Connection;
+import Schema = configuration.reflection.Schema;
 
 const TIME = AosPlotter.TIME;
 const DEFAULT_WIDTH = AosPlotter.DEFAULT_WIDTH;
 const DEFAULT_HEIGHT = AosPlotter.DEFAULT_HEIGHT;
 
+class DerivativeMessageHandler extends MessageHandler {
+  // Calculated magnitude of the measured acceleration from the IMU.
+  private acceleration_magnitudes: Point[] = [];
+  constructor(private readonly schema: Schema) {
+    super(schema);
+  }
+  private readScalar(table: Table, fieldName: string): number {
+    return this.parser.readScalar(table, fieldName);
+  }
+
+  // Computes a numerical derivative for a given input.
+  private derivative(input: Point[]): Point[] {
+    const num_measurements = input.length;
+    const results = [];
+    for (let ii = 0; ii < num_measurements - 1; ++ii) {
+      const x0 = input[ii].x;
+      const x1 = input[ii + 1].x;
+      const y0 = input[ii].y;
+      const y1 = input[ii + 1].y;
+      results.push(new Point((x0 + x1) / 2.0, (y1 - y0) / (x1 - x0)));
+    }
+    return results;
+  }
+
+  getField(field: string[]): Point[] {
+    // Any requested input that ends with "_derivative" will get a derivative
+    // calculated for the provided field.
+    const derivative_suffix = "_derivative";
+    const num_fields = field.length;
+    const end_field = field[num_fields - 1];
+    if (end_field.endsWith(derivative_suffix)) {
+      const field_copy = [];
+      for (let ii = 0; ii < num_fields - 1; ++ii) {
+        field_copy.push(field[ii]);
+      }
+      field_copy.push(end_field.slice(0, end_field.length - derivative_suffix.length));
+      return this.derivative(this.getField(field_copy));
+    } else {
+      return super.getField(field);
+    }
+  }
+}
+
 export function plotTurret(conn: Connection, element: Element) : void {
   const aosPlotter = new AosPlotter(conn);
   const goal = aosPlotter.addMessageSource('/superstructure', 'y2020.control_loops.superstructure.Goal');
   const output = aosPlotter.addMessageSource('/superstructure', 'y2020.control_loops.superstructure.Output');
-  const status = aosPlotter.addMessageSource(
-      '/superstructure', 'y2020.control_loops.superstructure.Status');
+  const status = aosPlotter.addRawMessageSource(
+      '/superstructure', 'y2020.control_loops.superstructure.Status',
+    new DerivativeMessageHandler(conn.getSchema('y2020.control_loops.superstructure.Status'))
+  );
   const pdpValues =
       aosPlotter.addMessageSource('/roborio/aos', 'frc971.PDPValues');
+  const localizerDebug =
+      aosPlotter.addMessageSource('/drivetrain', 'y2020.control_loops.drivetrain.LocalizerDebug');
 
   var currentTop = 0;
 
   const turretPosPlot = aosPlotter.addPlot(
       element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
   currentTop += DEFAULT_HEIGHT;
-  turretPosPlot.plot.getAxisLabels().setTitle('Turret Goal Position');
+  turretPosPlot.plot.getAxisLabels().setTitle('Turret Position');
   turretPosPlot.plot.getAxisLabels().setXLabel(TIME);
   turretPosPlot.plot.getAxisLabels().setYLabel('rad');
 
@@ -33,8 +86,39 @@
   turretPosPlot.addMessageLine(status, ['turret', 'position'])
       .setColor(GREEN)
       .setPointSize(0.0);
+  turretPosPlot.addMessageLine(localizerDebug, ['matches[]', 'implied_turret_goal'])
+      .setColor(GREEN)
+      .setDrawLine(false);
   turretPosPlot.addMessageLine(status, ['turret', 'unprofiled_goal_position'])
       .setColor(BLUE)
+      .setDrawLine(false);
+
+  const turretVelPlot = aosPlotter.addPlot(
+      element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
+  currentTop += DEFAULT_HEIGHT;
+  turretVelPlot.plot.getAxisLabels().setTitle('Turret Velocity');
+  turretVelPlot.plot.getAxisLabels().setXLabel(TIME);
+  turretVelPlot.plot.getAxisLabels().setYLabel('rad / sec');
+
+  turretVelPlot.addMessageLine(status, ['aimer', 'turret_velocity'])
+      .setColor(RED)
+      .setPointSize(0.0);
+  turretVelPlot.addMessageLine(status, ['turret', 'velocity'])
+      .setColor(GREEN)
+      .setPointSize(0.0);
+  turretVelPlot.addMessageLine(status, ['turret', 'unprofiled_goal_velocity'])
+      .setColor(BLUE)
+      .setDrawLine(false);
+
+  const turretAccelPlot = aosPlotter.addPlot(
+      element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
+  currentTop += DEFAULT_HEIGHT;
+  turretAccelPlot.plot.getAxisLabels().setTitle('Turret Acceleration');
+  turretAccelPlot.plot.getAxisLabels().setXLabel(TIME);
+  turretAccelPlot.plot.getAxisLabels().setYLabel('rad / sec / sec');
+
+  turretAccelPlot.addMessageLine(status, ['aimer', 'turret_velocity_derivative'])
+      .setColor(RED)
       .setPointSize(0.0);
 
   const turretVoltagePlot = aosPlotter.addPlot(
@@ -44,6 +128,15 @@
   turretVoltagePlot.plot.getAxisLabels().setXLabel(TIME);
   turretVoltagePlot.plot.getAxisLabels().setYLabel('V');
 
+  turretVoltagePlot.addMessageLine(status, ['turret', 'voltage_error'])
+      .setColor(GREEN)
+      .setPointSize(0.0);
+  turretVoltagePlot.addMessageLine(status, ['turret', 'position_power'])
+      .setColor(BLUE)
+      .setPointSize(0.0);
+  turretVoltagePlot.addMessageLine(status, ['turret', 'velocity_power'])
+      .setColor(CYAN)
+      .setPointSize(0.0);
   turretVoltagePlot.addMessageLine(output, ['turret_voltage'])
       .setColor(RED)
       .setPointSize(0.0);
@@ -54,7 +147,7 @@
   currentPlot.plot.getAxisLabels().setTitle('Current');
   currentPlot.plot.getAxisLabels().setXLabel(TIME);
   currentPlot.plot.getAxisLabels().setYLabel('Amps');
-  currentPlot.plot.setDefaultYRange([0.0, 80.0]);
+  currentPlot.plot.setDefaultYRange([0.0, 40.0]);
 
   currentPlot.addMessageLine(pdpValues, ['currents[6]'])
       .setColor(GREEN)
@@ -83,4 +176,16 @@
   targetChoicePlot.addMessageLine(status, ['aimer', 'aiming_for_inner_port'])
       .setColor(RED)
       .setPointSize(0.0);
+
+  const imageAcceptedPlot = aosPlotter.addPlot(
+      element, [0, currentTop], [DEFAULT_WIDTH, DEFAULT_HEIGHT]);
+  currentTop += DEFAULT_HEIGHT;
+  imageAcceptedPlot.plot.getAxisLabels().setTitle('Image Acceptance');
+  imageAcceptedPlot.plot.getAxisLabels().setXLabel(TIME);
+  imageAcceptedPlot.plot.getAxisLabels().setYLabel('[bool]');
+  imageAcceptedPlot.plot.setDefaultYRange([-0.05, 1.05]);
+
+  imageAcceptedPlot.addMessageLine(localizerDebug, ['matches[]', 'accepted'])
+      .setColor(RED)
+      .setDrawLine(false);
 }
diff --git a/y2020/joystick_reader.cc b/y2020/joystick_reader.cc
index 44d8a42..bb57997 100644
--- a/y2020/joystick_reader.cc
+++ b/y2020/joystick_reader.cc
@@ -101,6 +101,7 @@
 
     double hood_pos = constants::Values::kHoodRange().middle();
     double intake_pos = -0.89;
+    double turret_pos = 0.0;
     float roller_speed = 0.0f;
     float roller_speed_compensation = 0.0f;
     double accelerator_speed = 0.0;
@@ -119,6 +120,12 @@
       }
     }
 
+    if (setpoint_fetcher_.get()) {
+      turret_pos = setpoint_fetcher_->turret();
+    } else {
+      turret_pos = 0.0;
+    }
+
     if (data.IsPressed(kShootFast)) {
       if (setpoint_fetcher_.get()) {
         accelerator_speed = setpoint_fetcher_->accelerator();
@@ -175,7 +182,7 @@
 
       flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
           turret_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
-              *builder.fbb(), 0.0,
+              *builder.fbb(), turret_pos,
               CreateProfileParameters(*builder.fbb(), 6.0, 20.0));
 
       flatbuffers::Offset<superstructure::ShooterGoal> shooter_offset =
diff --git a/y2020/setpoint.fbs b/y2020/setpoint.fbs
index 74164e5..9a9a0be 100644
--- a/y2020/setpoint.fbs
+++ b/y2020/setpoint.fbs
@@ -6,6 +6,8 @@
   finisher:double (id: 1);
 
   hood:double (id: 2);
+
+  turret:double (id: 3);
 }
 
 root_type Setpoint;
diff --git a/y2020/setpoint_setter.cc b/y2020/setpoint_setter.cc
index 3f493c9..1e1a7b8 100644
--- a/y2020/setpoint_setter.cc
+++ b/y2020/setpoint_setter.cc
@@ -6,6 +6,7 @@
 DEFINE_double(accelerator, 250.0, "Accelerator speed");
 DEFINE_double(finisher, 500.0, "Finsher speed");
 DEFINE_double(hood, 0.45, "Hood setpoint");
+DEFINE_double(turret, 0.0, "Turret setpoint");
 
 int main(int argc, char **argv) {
   aos::InitGoogle(&argc, &argv);
@@ -27,6 +28,7 @@
   setpoint_builder.add_accelerator(FLAGS_accelerator);
   setpoint_builder.add_finisher(FLAGS_finisher);
   setpoint_builder.add_hood(FLAGS_hood);
+  setpoint_builder.add_turret(FLAGS_turret);
   builder.Send(setpoint_builder.Finish());
 
   return 0;
diff --git a/y2020/vision/tools/python_code/calib_files/calibration_pi-971-1_2021-09-11_17-49-00.000000000.json b/y2020/vision/tools/python_code/calib_files/calibration_pi-971-1_2021-09-11_17-49-00.000000000.json
index a144853..39c7911 100644
--- a/y2020/vision/tools/python_code/calib_files/calibration_pi-971-1_2021-09-11_17-49-00.000000000.json
+++ b/y2020/vision/tools/python_code/calib_files/calibration_pi-971-1_2021-09-11_17-49-00.000000000.json
@@ -1,6 +1,6 @@
 {
  "node_name": "pi1",
- "team_number": 7971,
+ "team_number": 971,
  "intrinsics": [
   392.276093,
   0.0,
diff --git a/y2020/vision/tools/python_code/calib_files/reference_cal_pi_cam.json b/y2020/vision/tools/python_code/calib_files/reference_cal_pi_cam.json
new file mode 100644
index 0000000..8c9472e
--- /dev/null
+++ b/y2020/vision/tools/python_code/calib_files/reference_cal_pi_cam.json
@@ -0,0 +1,23 @@
+{
+ "node_name": "reference",
+ "team_number": 1971,
+ "intrinsics": [
+  388.0,
+  0.0,
+  320.0,
+  0.0,
+  388.0,
+  240.0,
+  0.0,
+  0.0,
+  1.0
+ ],
+ "dist_coeffs": [
+  0.0,
+  0.0,
+  0.0,
+  0.0,
+  0.0
+ ],
+ "calibration_timestamp": 1597994992500905688
+}
diff --git a/y2020/vision/tools/python_code/camera_definition.py b/y2020/vision/tools/python_code/camera_definition.py
index 33eb1a9..6172ae7 100644
--- a/y2020/vision/tools/python_code/camera_definition.py
+++ b/y2020/vision/tools/python_code/camera_definition.py
@@ -7,7 +7,7 @@
 
 import define_training_data as dtd
 
-glog.setLevel("WARN")
+glog.setLevel("INFO")
 
 
 class CameraIntrinsics:
@@ -36,24 +36,14 @@
 
 def load_camera_definitions():
     ### CAMERA DEFINITIONS
+    # We only load in cameras that have a calibration file
+    # These are stored in y2020/vision/tools/python_code/calib_files
+    # See reference_calibration_pi_cam.json for an example default file
+    #
+    # Or better yet, use //y2020/vision:calibration to calibrate the camera
+    #   using a Charuco target board
 
-    # Robot camera has:
-    # FOV_H = 93.*math.pi()/180.
-    # FOV_V = 70.*math.pi()/180.
-
-    # Create fake camera (based on USB webcam params)
-    fx = 810.
-    fy = 810.
-    cx = 320.
-    cy = 240.
-
-    # Define a web_cam
-    web_cam_int = CameraIntrinsics()
-    web_cam_int.camera_matrix = np.asarray([[fx, 0, cx], [0, fy, cy],
-                                            [0, 0, 1]])
-    web_cam_int.dist_coeffs = np.zeros((5, 1))
-
-    web_cam_ext = CameraExtrinsics()
+    # Extrinsic definition
     # Camera rotation from robot x,y,z to opencv (z, -x, -y)
     # This is extrinsics for the turret camera
     # camera pose relative to center, base of the turret
@@ -63,37 +53,30 @@
         [[np.cos(camera_pitch), 0.0, -np.sin(camera_pitch)], [0.0, 1.0, 0.0],
          [np.sin(camera_pitch), 0.0,
           np.cos(camera_pitch)]])
-    web_cam_ext.R = np.array(
+    turret_cam_ext = CameraExtrinsics()
+    turret_cam_ext.R = np.array(
         camera_pitch_matrix *
         np.matrix([[0., 0., 1.], [-1, 0, 0], [0, -1., 0]]))
-    #web_cam_ext.T = np.array([0., 0., 0.])
-    web_cam_ext.T = np.array([2.0 * 0.0254, -6.0 * 0.0254, 41.0 * 0.0254])
-    fixed_ext = CameraExtrinsics()
-    fixed_ext.R = np.array([[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0],
-                            [0.0, 0.0, 1.0]])
-    fixed_ext.T = np.array([0.0, 0.0, 0.0])
+    turret_cam_ext.T = np.array([15.0 * 0.0254, 15.0 * 0.0254, 41.0 * 0.0254])
+    default_cam_ext = CameraExtrinsics()
+    default_cam_ext.R = np.array([[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0],
+                                  [0.0, 0.0, 1.0]])
+    default_cam_ext.T = np.array([0.0, 0.0, 0.0])
 
-    web_cam_params = CameraParameters()
-    web_cam_params.camera_int = web_cam_int
-    # Fixed extrinsics are for the turret, which is centered on the robot and
-    # pointed straight backwards.
-    web_cam_params.camera_ext = fixed_ext
-    web_cam_params.turret_ext = web_cam_ext
+    default_cam_params = CameraParameters()
+    # Currently, all cameras have this same set of extrinsics
+    default_cam_params.camera_ext = default_cam_ext
+    default_cam_params.turret_ext = turret_cam_ext
 
     camera_list = []
 
-    # TODO<Jim>: Should probably make this a dict to make replacing easier
-    for team_number in (971, 7971, 8971, 9971):
-        for node_name in ("pi0", "pi1", "pi2", "pi3", "pi4", "pi5"):
-            camera_base = copy.deepcopy(web_cam_params)
-            camera_base.node_name = node_name
-            camera_base.team_number = team_number
-            camera_list.append(camera_base)
-
     dir_name = dtd.bazel_name_fix('calib_files')
+    glog.debug("Searching for calibration files in " + dir_name)
     for filename in os.listdir(dir_name):
         glog.debug("Inspecting %s", filename)
-        if ("cam-calib-int" in filename or 'calibration' in filename) and filename.endswith(".json"):
+        if ("cam-calib-int" in filename
+                or 'calibration' in filename) and filename.endswith(".json"):
+
             # Extract intrinsics from file
             calib_file = open(dir_name + "/" + filename, 'r')
             calib_dict = json.loads(calib_file.read())
@@ -104,6 +87,8 @@
             #     JSON.
             # See which one we have and parse accordingly.
             if 'hostname' in calib_dict:
+                glog.warn("WARNING: Using older calibration file.")
+                glog.warn("Preferred usage is y2020/vision:calibration")
                 hostname_split = calib_dict["hostname"].split("-")
                 team_number = int(hostname_split[1])
                 node_name = hostname_split[0] + hostname_split[2]
@@ -117,14 +102,13 @@
                 dist_coeffs = np.asarray(calib_dict["dist_coeffs"]).reshape(
                     (1, 5))
 
-            # Look for match, and replace camera_intrinsics
-            for camera_calib in camera_list:
-                if camera_calib.node_name == node_name and camera_calib.team_number == team_number:
-                    glog.info("Found calib for %s, team #%d" % (node_name,
-                                                                team_number))
-                    camera_calib.camera_int.camera_matrix = copy.copy(
-                        camera_matrix)
-                    camera_calib.camera_int.dist_coeffs = copy.copy(
-                        dist_coeffs)
+            glog.info("Found calib for " + node_name + ", team #" +
+                      str(team_number))
+            camera_base = copy.deepcopy(default_cam_params)
+            camera_base.node_name = node_name
+            camera_base.team_number = team_number
+            camera_base.camera_int.camera_matrix = copy.copy(camera_matrix)
+            camera_base.camera_int.dist_coeffs = copy.copy(dist_coeffs)
+            camera_list.append(camera_base)
 
     return camera_list
diff --git a/y2020/www/field_handler.ts b/y2020/www/field_handler.ts
index 4aaa05f..1a0b56a 100644
--- a/y2020/www/field_handler.ts
+++ b/y2020/www/field_handler.ts
@@ -191,6 +191,8 @@
     ctx.beginPath();
     ctx.moveTo(0.5, 0.5);
     ctx.lineTo(0, 0);
+    ctx.lineTo(100.0, 0);
+    ctx.lineTo(0, 0);
     ctx.lineTo(0.5, -0.5);
     ctx.stroke();
     ctx.beginPath();
@@ -218,13 +220,13 @@
       // Draw line in circle to show forwards.
       ctx.beginPath();
       ctx.moveTo(0, 0);
-      ctx.lineTo(turretRadius, 0);
+      ctx.lineTo(1000.0 * turretRadius, 0);
       ctx.stroke();
       ctx.restore();
     }
     ctx.beginPath();
     ctx.moveTo(0, 0);
-    ctx.lineTo(ROBOT_LENGTH / 2, 0);
+    ctx.lineTo(100.0 * ROBOT_LENGTH / 2, 0);
     ctx.stroke();
     ctx.restore();
   }
diff --git a/y2020/y2020_roborio.json b/y2020/y2020_roborio.json
index 5243d5a..db5da14 100644
--- a/y2020/y2020_roborio.json
+++ b/y2020/y2020_roborio.json
@@ -196,6 +196,14 @@
     },
     {
       "name": "/drivetrain",
+      "type": "y2020.control_loops.drivetrain.LocalizerDebug",
+      "source_node": "roborio",
+      "frequency": 250,
+      "max_size": 512,
+      "num_senders": 2
+    },
+    {
+      "name": "/drivetrain",
       "type": "frc971.IMUValuesBatch",
       "source_node": "roborio",
       "frequency": 250,
diff --git a/y2021_bot3/control_loops/python/BUILD b/y2021_bot3/control_loops/python/BUILD
index 7ade878..15dacaa 100644
--- a/y2021_bot3/control_loops/python/BUILD
+++ b/y2021_bot3/control_loops/python/BUILD
@@ -5,8 +5,10 @@
     srcs = [
         "drivetrain.py",
     ],
+    legacy_create_init = False,
     target_compatible_with = ["@platforms//cpu:x86_64"],
     deps = [
+        ":python_init",
         "//external:python-gflags",
         "//external:python-glog",
         "//frc971/control_loops/python:drivetrain",
@@ -19,8 +21,10 @@
         "drivetrain.py",
         "polydrivetrain.py",
     ],
+    legacy_create_init = False,
     target_compatible_with = ["@platforms//cpu:x86_64"],
     deps = [
+        ":python_init",
         "//external:python-gflags",
         "//external:python-glog",
         "//frc971/control_loops/python:polydrivetrain",
@@ -43,3 +47,11 @@
         "//frc971/control_loops/python:polydrivetrain",
     ],
 )
+
+py_library(
+    name = "python_init",
+    srcs = ["__init__.py"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = ["//y2020/control_loops:python_init"],
+)
diff --git a/y2021_bot3/control_loops/python/__init__.py b/y2021_bot3/control_loops/python/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/y2021_bot3/control_loops/python/__init__.py
diff --git a/y2021_bot3/control_loops/superstructure/BUILD b/y2021_bot3/control_loops/superstructure/BUILD
index 404555f..77cdf45 100644
--- a/y2021_bot3/control_loops/superstructure/BUILD
+++ b/y2021_bot3/control_loops/superstructure/BUILD
@@ -76,3 +76,29 @@
         "//aos/events:shm_event_loop",
     ],
 )
+
+cc_test(
+    name = "superstructure_lib_test",
+    srcs = [
+        "superstructure_lib_test.cc",
+    ],
+    data = [
+        "//y2021_bot3:config",
+    ],
+    deps = [
+        ":superstructure_goal_fbs",
+        ":superstructure_lib",
+        ":superstructure_output_fbs",
+        ":superstructure_position_fbs",
+        ":superstructure_status_fbs",
+        "//aos:math",
+        "//aos/events/logging:log_writer",
+        "//aos/testing:googletest",
+        "//aos/time",
+        "//frc971/control_loops:capped_test_plant",
+        "//frc971/control_loops:control_loop_test",
+        "//frc971/control_loops:position_sensor_sim",
+        "//frc971/control_loops:team_number_test_environment",
+        "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
+    ],
+)
\ No newline at end of file
diff --git a/y2021_bot3/control_loops/superstructure/superstructure.cc b/y2021_bot3/control_loops/superstructure/superstructure.cc
index aaf3def..22c7c44 100644
--- a/y2021_bot3/control_loops/superstructure/superstructure.cc
+++ b/y2021_bot3/control_loops/superstructure/superstructure.cc
@@ -16,7 +16,7 @@
   event_loop->SetRuntimeRealtimePriority(30);
 }
 
-void Superstructure::RunIteration(const Goal * /*unsafe_goal*/,
+void Superstructure::RunIteration(const Goal *unsafe_goal,
                                   const Position * /*position*/,
                                   aos::Sender<Output>::Builder *output,
                                   aos::Sender<Status>::Builder *status) {
@@ -24,8 +24,13 @@
     AOS_LOG(ERROR, "WPILib reset, restarting\n");
   }
 
-  if (output != nullptr) {
+  if (output != nullptr && unsafe_goal != nullptr) {
     OutputT output_struct;
+
+    output_struct.intake_volts =
+        std::clamp(unsafe_goal->intake_speed(), -12.0, 12.0);
+    output_struct.outtake_volts =
+        std::clamp(unsafe_goal->outtake_speed(), -12.0, 12.0);
     output->Send(Output::Pack(*output->fbb(), &output_struct));
   }
 
@@ -34,6 +39,11 @@
   status_builder.add_zeroed(true);
   status_builder.add_estopped(false);
 
+  if (unsafe_goal != nullptr) {
+    status_builder.add_intake_speed(unsafe_goal->intake_speed());
+    status_builder.add_outtake_speed(unsafe_goal->outtake_speed());
+  }
+
   status->Send(status_builder.Finish());
 }
 
diff --git a/y2021_bot3/control_loops/superstructure/superstructure_lib_test.cc b/y2021_bot3/control_loops/superstructure/superstructure_lib_test.cc
new file mode 100644
index 0000000..cd031c3
--- /dev/null
+++ b/y2021_bot3/control_loops/superstructure/superstructure_lib_test.cc
@@ -0,0 +1,158 @@
+#include <chrono>
+#include <memory>
+
+#include "aos/events/logging/log_writer.h"
+#include "frc971/control_loops/capped_test_plant.h"
+#include "frc971/control_loops/control_loop_test.h"
+#include "frc971/control_loops/position_sensor_sim.h"
+#include "frc971/control_loops/team_number_test_environment.h"
+#include "gtest/gtest.h"
+#include "y2021_bot3/control_loops/superstructure/superstructure.h"
+
+namespace y2021_bot3 {
+namespace control_loops {
+namespace superstructure {
+namespace testing {
+
+class SuperstructureTest : public ::frc971::testing::ControlLoopTest {
+ public:
+  SuperstructureTest()
+      : ::frc971::testing::ControlLoopTest(
+            aos::configuration::ReadConfig("y2021_bot3/config.json"),
+            std::chrono::microseconds(5050)),
+        superstructure_event_loop(MakeEventLoop("Superstructure")),
+        superstructure_(superstructure_event_loop.get()),
+        test_event_loop_(MakeEventLoop("test")),
+        superstructure_goal_fetcher_(
+            test_event_loop_->MakeFetcher<Goal>("/superstructure")),
+        superstructure_goal_sender_(
+            test_event_loop_->MakeSender<Goal>("/superstructure")),
+        superstructure_status_fetcher_(
+            test_event_loop_->MakeFetcher<Status>("/superstructure")),
+        superstructure_output_fetcher_(
+            test_event_loop_->MakeFetcher<Output>("/superstructure")),
+        superstructure_position_fetcher_(
+            test_event_loop_->MakeFetcher<Position>("/superstructure")),
+        superstructure_position_sender_(
+            test_event_loop_->MakeSender<Position>("/superstructure")) {
+    set_team_id(frc971::control_loops::testing::kTeamNumber);
+    SetEnabled(true);
+
+    phased_loop_handle_ = test_event_loop_->AddPhasedLoop(
+        [this](int) { SendPositionMessage(); }, dt());
+  }
+
+  void VerifyResults(double intake_voltage, double outtake_voltage,
+                     double intake_speed, double outtake_speed) {
+    superstructure_output_fetcher_.Fetch();
+    superstructure_status_fetcher_.Fetch();
+    ASSERT_TRUE(superstructure_output_fetcher_.get() != nullptr);
+    ASSERT_TRUE(superstructure_status_fetcher_.get() != nullptr);
+    EXPECT_EQ(superstructure_output_fetcher_->intake_volts(), intake_voltage);
+    EXPECT_EQ(superstructure_output_fetcher_->outtake_volts(), outtake_voltage);
+    EXPECT_EQ(superstructure_status_fetcher_->intake_speed(), intake_speed);
+    EXPECT_EQ(superstructure_status_fetcher_->outtake_speed(), outtake_speed);
+    EXPECT_EQ(superstructure_status_fetcher_->estopped(), false);
+    EXPECT_EQ(superstructure_status_fetcher_->zeroed(), true);
+  }
+
+  void SendPositionMessage() {
+    auto builder = superstructure_position_sender_.MakeBuilder();
+    Position::Builder position_builder = builder.MakeBuilder<Position>();
+    builder.Send(position_builder.Finish());
+  }
+
+  ::std::unique_ptr<::aos::EventLoop> superstructure_event_loop;
+  ::y2021_bot3::control_loops::superstructure::Superstructure superstructure_;
+  ::std::unique_ptr<::aos::EventLoop> test_event_loop_;
+  ::aos::PhasedLoopHandler *phased_loop_handle_ = nullptr;
+  ::aos::Fetcher<Goal> superstructure_goal_fetcher_;
+  ::aos::Sender<Goal> superstructure_goal_sender_;
+  ::aos::Fetcher<Status> superstructure_status_fetcher_;
+  ::aos::Fetcher<Output> superstructure_output_fetcher_;
+  ::aos::Fetcher<Position> superstructure_position_fetcher_;
+  ::aos::Sender<Position> superstructure_position_sender_;
+};
+
+// Tests running the intake and outtake separately
+TEST_F(SuperstructureTest, RunIntakeOrOuttake) {
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_intake_speed(10.0);
+    ASSERT_TRUE(builder.Send(goal_builder.Finish()));
+    SendPositionMessage();
+    RunFor(dt() * 2);
+    VerifyResults(10.0, 0.0, 10.0, 0.0);
+  }
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_outtake_speed(10.0);
+    ASSERT_TRUE(builder.Send(goal_builder.Finish()));
+    RunFor(dt() * 2);
+    VerifyResults(0.0, 10.0, 0.0, 10.0);
+  }
+}
+
+// Tests running both the intake and the outtake simultaneously
+TEST_F(SuperstructureTest, RunIntakeAndOuttake) {
+  auto builder = superstructure_goal_sender_.MakeBuilder();
+  Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+  goal_builder.add_intake_speed(10.0);
+  goal_builder.add_outtake_speed(5.0);
+  ASSERT_TRUE(builder.Send(goal_builder.Finish()));
+  RunFor(dt() * 2);
+  VerifyResults(10.0, 5.0, 10.0, 5.0);
+}
+
+// Tests for an invalid voltage (over 12 or under -12) to check that it defaults
+// to 12 or -12
+TEST_F(SuperstructureTest, InvalidVoltage) {
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_intake_speed(20.0);
+    goal_builder.add_outtake_speed(15.0);
+    ASSERT_TRUE(builder.Send(goal_builder.Finish()));
+    RunFor(dt() * 2);
+    VerifyResults(12.0, 12.0, 20.0, 15.0);
+  }
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+    goal_builder.add_intake_speed(-20.0);
+    goal_builder.add_outtake_speed(-15.0);
+    ASSERT_TRUE(builder.Send(goal_builder.Finish()));
+    RunFor(dt() * 2);
+    VerifyResults(-12.0, -12.0, -20.0, -15.0);
+  }
+}
+
+// Tests that there is no output when the goal is null
+TEST_F(SuperstructureTest, GoalIsNull) {
+  auto builder = superstructure_goal_sender_.MakeBuilder();
+  Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+  ASSERT_TRUE(builder.Send(goal_builder.Finish()));
+  RunFor(dt() * 2);
+  VerifyResults(0.0, 0.0, 0.0, 0.0);
+}
+
+// Tests that the robot behaves properly when disabled
+TEST_F(SuperstructureTest, Disabled) {
+  auto builder = superstructure_goal_sender_.MakeBuilder();
+  Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+  goal_builder.add_intake_speed(6.0);
+  goal_builder.add_outtake_speed(5.0);
+  ASSERT_TRUE(builder.Send(goal_builder.Finish()));
+  SetEnabled(false);
+  RunFor(dt() * 2);
+  VerifyResults(0.0, 0.0, 6.0, 5.0);
+}
+
+}  // namespace testing
+}  // namespace superstructure
+}  // namespace control_loops
+}  // namespace y2021_bot3
\ No newline at end of file