Merge "Run yapf on Spline UI"
diff --git a/aos/events/logging/BUILD b/aos/events/logging/BUILD
index b00d2ad..9f6234a 100644
--- a/aos/events/logging/BUILD
+++ b/aos/events/logging/BUILD
@@ -497,3 +497,13 @@
     gen_reflections = 1,
     target_compatible_with = ["@platforms//os:linux"],
 )
+
+cc_binary(
+    name = "timestamp_plot",
+    srcs = ["timestamp_plot.cc"],
+    deps = [
+        "//aos:init",
+        "//frc971/analysis:in_process_plotter",
+        "@com_google_absl//absl/strings",
+    ],
+)
diff --git a/aos/events/logging/boot_timestamp.h b/aos/events/logging/boot_timestamp.h
index d0fde73..e818fb9 100644
--- a/aos/events/logging/boot_timestamp.h
+++ b/aos/events/logging/boot_timestamp.h
@@ -4,6 +4,7 @@
 #include <iostream>
 
 #include "aos/time/time.h"
+#include "glog/logging.h"
 
 namespace aos::logger {
 
@@ -24,6 +25,18 @@
     return {boot, duration - d};
   }
 
+  BootDuration operator-(BootDuration d) const {
+    CHECK_EQ(d.boot, boot);
+    return {boot, duration - d.duration};
+  }
+
+  BootDuration operator+(BootDuration d) const {
+    CHECK_EQ(d.boot, boot);
+    return {boot, duration + d.duration};
+  }
+
+  BootDuration operator/(int x) const { return {boot, duration / x}; }
+
   bool operator==(const BootDuration &m2) const {
     return boot == m2.boot && duration == m2.duration;
   }
diff --git a/aos/events/logging/timestamp_plot.cc b/aos/events/logging/timestamp_plot.cc
new file mode 100644
index 0000000..63c3de4
--- /dev/null
+++ b/aos/events/logging/timestamp_plot.cc
@@ -0,0 +1,220 @@
+#include "absl/strings/str_cat.h"
+#include "absl/strings/str_split.h"
+#include "aos/init.h"
+#include "aos/util/file.h"
+#include "frc971/analysis/in_process_plotter.h"
+
+using frc971::analysis::Plotter;
+
+// Simple C++ application to read the CSV files and use the in process plotter
+// to plot them.  This smokes the pants off gnuplot in terms of interactivity.
+
+namespace aos {
+
+std::pair<std::vector<double>, std::vector<double>> ReadSamples(
+    std::string_view node1, std::string_view node2, bool flip) {
+  std::vector<double> samplefile12_t;
+  std::vector<double> samplefile12_o;
+
+  const std::string file = aos::util::ReadFileToStringOrDie(absl::StrCat(
+      "/tmp/timestamp_noncausal_", node1, "_", node2, "_samples.csv"));
+  bool first = true;
+  std::vector<std::string_view> lines = absl::StrSplit(file, '\n');
+  samplefile12_t.reserve(lines.size());
+  for (const std::string_view n : lines) {
+    if (first) {
+      first = false;
+      continue;
+    }
+    if (n == "") {
+      continue;
+    }
+
+    std::vector<std::string_view> l = absl::StrSplit(n, ", ");
+    CHECK_EQ(l.size(), 4u);
+    double t;
+    double o;
+    CHECK(absl::SimpleAtod(l[0], &t));
+    CHECK(absl::SimpleAtod(l[1], &o));
+    samplefile12_t.emplace_back(t);
+    samplefile12_o.emplace_back(flip ? -o : o);
+  }
+  return std::make_pair(samplefile12_t, samplefile12_o);
+}
+
+std::pair<std::vector<double>, std::vector<double>> ReadLines(
+    std::string_view node1, std::string_view node2, bool flip) {
+  std::vector<double> samplefile12_t;
+  std::vector<double> samplefile12_o;
+
+  const std::string file = aos::util::ReadFileToStringOrDie(
+      absl::StrCat("/tmp/timestamp_noncausal_", node1, "_", node2, ".csv"));
+  bool first = true;
+  std::vector<std::string_view> lines = absl::StrSplit(file, '\n');
+  samplefile12_t.reserve(lines.size());
+  for (const std::string_view n : lines) {
+    if (first) {
+      first = false;
+      continue;
+    }
+    if (n == "") {
+      continue;
+    }
+
+    std::vector<std::string_view> l = absl::StrSplit(n, ", ");
+    CHECK_EQ(l.size(), 3u);
+    double t;
+    double o;
+    CHECK(absl::SimpleAtod(l[0], &t));
+    CHECK(absl::SimpleAtod(l[2], &o));
+    samplefile12_t.emplace_back(t);
+    samplefile12_o.emplace_back(flip ? -o : o);
+  }
+  return std::make_pair(samplefile12_t, samplefile12_o);
+}
+
+std::pair<std::vector<double>, std::vector<double>> ReadOffset(
+    std::string_view node1, std::string_view node2) {
+  int node1_index = -1;
+  int node2_index = -1;
+
+  {
+    const std::string start_time_file = aos::util::ReadFileToStringOrDie(
+        "/tmp/timestamp_noncausal_starttime.csv");
+    std::vector<std::string_view> nodes = absl::StrSplit(start_time_file, '\n');
+
+    int index = 0;
+    for (const std::string_view n : nodes) {
+      if (n == "") {
+        continue;
+      }
+
+      std::vector<std::string_view> l = absl::StrSplit(n, ", ");
+      CHECK_EQ(l.size(), 2u) << "'" << n << "'";
+      if (l[0] == node1) {
+        node1_index = index;
+      }
+      if (l[0] == node2) {
+        node2_index = index;
+      }
+      ++index;
+    }
+  }
+  CHECK_NE(node1_index, -1) << ": Unknown node " << node1;
+  CHECK_NE(node2_index, -1) << ": Unknown node " << node2;
+  std::vector<double> offsetfile_t;
+  std::vector<double> offsetfile_o;
+
+  const std::string file =
+      aos::util::ReadFileToStringOrDie("/tmp/timestamp_noncausal_offsets.csv");
+  bool first = true;
+  std::vector<std::string_view> lines = absl::StrSplit(file, '\n');
+  offsetfile_t.reserve(lines.size());
+  for (const std::string_view n : lines) {
+    if (first) {
+      first = false;
+      continue;
+    }
+    if (n == "") {
+      continue;
+    }
+
+    std::vector<std::string_view> l = absl::StrSplit(n, ", ");
+    CHECK_LT(static_cast<size_t>(node1_index + 1), l.size());
+    CHECK_LT(static_cast<size_t>(node2_index + 1), l.size());
+    double t;
+    double o1;
+    double o2;
+    CHECK(absl::SimpleAtod(l[0], &t));
+    CHECK(absl::SimpleAtod(l[1 + node1_index], &o1));
+    CHECK(absl::SimpleAtod(l[1 + node2_index], &o2));
+    offsetfile_t.emplace_back(t);
+    offsetfile_o.emplace_back(o2 - o1);
+  }
+  return std::make_pair(offsetfile_t, offsetfile_o);
+}
+
+void AddNodes(Plotter *plotter, std::string_view node1,
+              std::string_view node2) {
+  const std::pair<std::vector<double>, std::vector<double>> samplefile12 =
+      ReadSamples(node1, node2, false);
+  const std::pair<std::vector<double>, std::vector<double>> samplefile21 =
+      ReadSamples(node2, node1, true);
+
+  const std::pair<std::vector<double>, std::vector<double>> noncausalfile12 =
+      ReadLines(node1, node2, false);
+  const std::pair<std::vector<double>, std::vector<double>> noncausalfile21 =
+      ReadLines(node2, node1, true);
+
+  const std::pair<std::vector<double>, std::vector<double>> offsetfile =
+      ReadOffset(node1, node2);
+
+  CHECK_EQ(samplefile12.first.size(), samplefile12.second.size());
+  CHECK_EQ(samplefile21.first.size(), samplefile21.second.size());
+  CHECK_EQ(noncausalfile12.first.size(), noncausalfile12.second.size());
+  CHECK_EQ(noncausalfile21.first.size(), noncausalfile21.second.size());
+
+  LOG(INFO) << samplefile12.first.size() + samplefile21.first.size() +
+                   noncausalfile12.first.size() + noncausalfile21.first.size()
+            << " points";
+  plotter->AddLine(
+      samplefile12.first, samplefile12.second,
+      Plotter::LineOptions{.label = absl::StrCat("sample ", node1, " ", node2),
+                           .line_style = "*",
+                           .color = "purple"});
+  plotter->AddLine(
+      samplefile21.first, samplefile21.second,
+      Plotter::LineOptions{.label = absl::StrCat("sample ", node2, " ", node1),
+                           .line_style = "*",
+                           .color = "green"});
+
+  plotter->AddLine(
+      noncausalfile12.first, noncausalfile12.second,
+      Plotter::LineOptions{.label = absl::StrCat("nc ", node1, " ", node2),
+                           .line_style = "-",
+                           .color = "blue"});
+  plotter->AddLine(
+      noncausalfile21.first, noncausalfile21.second,
+      Plotter::LineOptions{.label = absl::StrCat("nc ", node2, " ", node1),
+                           .line_style = "-",
+                           .color = "orange"});
+
+  plotter->AddLine(offsetfile.first, offsetfile.second,
+                   Plotter::LineOptions{
+                       .label = absl::StrCat("filter ", node2, " ", node1),
+                       // TODO(austin): roboRIO compiler wants all the fields
+                       // filled out, but other compilers don't...  Sigh.
+                       .line_style = "*-",
+                       .color = "yellow"});
+}
+
+int Main(int argc, const char *const *argv) {
+  CHECK_EQ(argc, 3);
+
+  LOG(INFO) << argv[1];
+  LOG(INFO) << argv[2];
+
+  // TODO(austin): Find all node pairs and plot them...
+
+  const std::string_view node1 = argv[1];
+  const std::string_view node2 = argv[2];
+
+  Plotter plotter;
+  plotter.AddFigure("Time");
+
+  AddNodes(&plotter, node1, node2);
+
+  plotter.Publish();
+
+  plotter.Spin();
+
+  return 0;
+}
+
+}  // namespace aos
+
+int main(int argc, char **argv) {
+  aos::InitGoogle(&argc, &argv);
+
+  aos::Main(argc, argv);
+}
diff --git a/aos/network/timestamp_filter.cc b/aos/network/timestamp_filter.cc
index c1cf612..4ec9e0a 100644
--- a/aos/network/timestamp_filter.cc
+++ b/aos/network/timestamp_filter.cc
@@ -769,37 +769,7 @@
 chrono::nanoseconds NoncausalTimestampFilter::ExtrapolateOffset(
     std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p0,
     monotonic_clock::time_point ta) {
-  const chrono::nanoseconds dt = ta - std::get<0>(p0);
-  if (dt <= std::chrono::nanoseconds(0)) {
-    // Extrapolate backwards, using the (positive) MaxVelocity slope
-    // We've been asked to extrapolate the offset to a time before our first
-    // sample point.  To be conservative, we'll return an extrapolated
-    // offset that is less than (less tight an estimate of the network delay)
-    // than our sample offset, bound by the max slew velocity we allow
-    //       p0
-    //      /
-    //     /
-    //   ta
-    // Since dt < 0, we shift by dt * slope to get that value
-    return std::get<1>(p0) +
-           chrono::nanoseconds(static_cast<int64_t>(
-               (absl::int128(dt.count() - MaxVelocityRatio::den / 2) *
-                absl::int128(MaxVelocityRatio::num)) /
-               absl::int128(MaxVelocityRatio::den)));
-  } else {
-    // Extrapolate forwards, using the (negative) MaxVelocity slope
-    // Same concept, except going foward past our last (most recent) sample:
-    //       pN
-    //         |
-    //          |
-    //           ta
-    // Since dt > 0, we shift by - dt * slope to get that value
-    return std::get<1>(p0) -
-           chrono::nanoseconds(static_cast<int64_t>(
-               (absl::int128(dt.count() + MaxVelocityRatio::den / 2) *
-                absl::int128(MaxVelocityRatio::num)) /
-               absl::int128(MaxVelocityRatio::den)));
-  }
+  return ExtrapolateOffset(p0, ta, 0.0).first;
 }
 
 chrono::nanoseconds NoncausalTimestampFilter::InterpolateOffset(
@@ -831,24 +801,12 @@
 
 chrono::nanoseconds NoncausalTimestampFilter::InterpolateOffset(
     std::tuple<monotonic_clock::time_point, chrono::nanoseconds> p0,
-    std::tuple<monotonic_clock::time_point, chrono::nanoseconds> /*p1*/,
-    monotonic_clock::time_point /*ta_base*/, double /*ta*/) {
-  // For the double variant, we want to split the result up into a large integer
-  // portion, and the rest.  We want to do this without introducing numerical
-  // precision problems.
-  //
-  // One way would be to carefully compute the integer portion, and then compute
-  // the double portion in such a way that the two are guaranteed to add up
-  // correctly.
-  //
-  // The simpler way is to simply just use the offset from p0 as the integer
-  // portion, and make the rest be the double portion.  It will get us most of
-  // the way there for a lot less work, and we can revisit if this breaks down.
-  //
-  // oa = p0.o + (ta - p0.t) * (p1.o - p0.o) / (p1.t - p0.t)
-  //      ^^^^
-  // TODO(austin): Use 128 bit math and the remainder to be more accurate here.
-  return std::get<1>(p0);
+    std::tuple<monotonic_clock::time_point, chrono::nanoseconds> p1,
+    monotonic_clock::time_point ta_base, double ta) {
+  DCHECK_GE(ta, 0.0);
+  DCHECK_LT(ta, 1.0);
+
+  return InterpolateOffset(p0, p1, ta_base);
 }
 
 double NoncausalTimestampFilter::InterpolateOffsetRemainder(
@@ -857,42 +815,111 @@
     monotonic_clock::time_point ta_base, double ta) {
   const chrono::nanoseconds time_in = ta_base - std::get<0>(p0);
   const chrono::nanoseconds dt = std::get<0>(p1) - std::get<0>(p0);
+  const chrono::nanoseconds doffset = std::get<1>(p1) - std::get<1>(p0);
 
-  // The remainder then is the rest of the equation.
-  //
-  // oa = p0.o + (ta - p0.t) * (p1.o - p0.o) / (p1.t - p0.t)
-  //             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-  // TODO(austin): Use 128 bit math and the remainder to be more accurate here.
-  return static_cast<double>(ta + time_in.count()) /
-         static_cast<double>(dt.count()) *
-         (std::get<1>(p1) - std::get<1>(p0)).count();
+  // Compute the remainder of the division in InterpolateOffset above, and then
+  // use double math to compute it accurately.
+  absl::int128 numerator =
+      absl::int128(time_in.count()) * absl::int128(doffset.count());
+  numerator += numerator > 0 ? absl::int128(dt.count() / 2)
+                             : -absl::int128(dt.count() / 2);
+  return static_cast<double>(numerator % absl::int128(dt.count())) /
+             dt.count() +
+         (numerator > 0 ? -0.5 : 0.5) +
+         ta * static_cast<double>(doffset.count()) /
+             static_cast<double>(dt.count());
 }
 
-chrono::nanoseconds NoncausalTimestampFilter::ExtrapolateOffset(
-    std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p0,
-    monotonic_clock::time_point /*ta_base*/, double /*ta*/) {
-  // TODO(austin): 128 bit math again? ...
-  // For this version, use the base offset from p0 as the base for the offset
-  return std::get<1>(p0);
+chrono::nanoseconds NoncausalTimestampFilter::BoundOffset(
+    std::tuple<monotonic_clock::time_point, chrono::nanoseconds> p0,
+    std::tuple<monotonic_clock::time_point, chrono::nanoseconds> p1,
+    monotonic_clock::time_point ta) {
+  // We are trying to solve for worst case offset given the two known points.
+  // This is on the two worst case lines from the two points, and we switch
+  // lines at the interstection.  This is equivilent to the lowest of the two
+  // lines.
+  return std::max(NoncausalTimestampFilter::ExtrapolateOffset(p0, ta),
+                  NoncausalTimestampFilter::ExtrapolateOffset(p1, ta));
 }
 
-double NoncausalTimestampFilter::ExtrapolateOffsetRemainder(
+std::pair<chrono::nanoseconds, double> NoncausalTimestampFilter::BoundOffset(
+    std::tuple<monotonic_clock::time_point, chrono::nanoseconds> p0,
+    std::tuple<monotonic_clock::time_point, chrono::nanoseconds> p1,
+    monotonic_clock::time_point ta_base, double ta) {
+  DCHECK_GE(ta, 0.0);
+  DCHECK_LT(ta, 1.0);
+
+  const std::pair<chrono::nanoseconds, double> o0 =
+      NoncausalTimestampFilter::ExtrapolateOffset(p0, ta_base, ta);
+  const std::pair<chrono::nanoseconds, double> o1 =
+      NoncausalTimestampFilter::ExtrapolateOffset(p1, ta_base, ta);
+
+  // Want to calculate max(o0 + o0r, o1 + o1r) without precision problems.
+  if (static_cast<double>((o0.first - o1.first).count()) >
+      o1.second - o0.second) {
+    // Ok, o0 is now > o1.  We want the max, so return o0.
+    return o0;
+  } else {
+    return o1;
+  }
+}
+
+std::pair<chrono::nanoseconds, double>
+NoncausalTimestampFilter::ExtrapolateOffset(
     std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p0,
     monotonic_clock::time_point ta_base, double ta) {
-  // Compute the remainder portion of this offset
-  // oa = p0.o +/- ((ta + ta_base) - p0.t)) * kMaxVelocity()
-  //               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-  // But compute (ta + ta_base - p0.t) as (ta + (ta_base - p0.t))
-  // to handle numerical precision
-  const chrono::nanoseconds time_in = ta_base - std::get<0>(p0);
-  const double dt = static_cast<double>(ta + time_in.count());
-  if (dt < 0.0) {
-    // Extrapolate backwards with max (positive) slope (which means
-    // the returned offset should be negative)
-    return dt * kMaxVelocity();
+  DCHECK_GE(ta, 0.0);
+  DCHECK_LT(ta, 1.0);
+  // Since the point (p0) is an integer, we now can guarantee that ta won't put
+  // us on a different side of p0.  This is because ta is between 0 and 1, and
+  // always positive.  Compute the integer and double portions and return them.
+  const chrono::nanoseconds dt = ta_base - std::get<0>(p0);
+
+  if (dt < std::chrono::nanoseconds(0)) {
+    // Extrapolate backwards, using the (positive) MaxVelocity slope
+    // We've been asked to extrapolate the offset to a time before our first
+    // sample point.  To be conservative, we'll return an extrapolated
+    // offset that is less than (less tight an estimate of the network delay)
+    // than our sample offset, bound by the max slew velocity we allow
+    //       p0
+    //      /
+    //     /
+    //   ta
+    // Since dt < 0, we shift by dt * slope to get that value
+    //
+    // Take the remainder of the math in ExtrapolateOffset above and compute it
+    // with floating point math.  Our tests are good enough to confirm that this
+    // works as designed.
+    const absl::int128 numerator =
+        (absl::int128(dt.count() - MaxVelocityRatio::den / 2) *
+         absl::int128(MaxVelocityRatio::num));
+    return std::make_pair(
+        std::get<1>(p0) + chrono::nanoseconds(static_cast<int64_t>(
+                              numerator / absl::int128(MaxVelocityRatio::den))),
+        static_cast<double>(numerator % absl::int128(MaxVelocityRatio::den)) /
+                static_cast<double>(MaxVelocityRatio::den) +
+            0.5 + ta * kMaxVelocity());
   } else {
-    // Extrapolate forwards with max (negative) slope
-    return -dt * kMaxVelocity();
+    // Extrapolate forwards, using the (negative) MaxVelocity slope
+    // Same concept, except going foward past our last (most recent) sample:
+    //       pN
+    //         |
+    //          |
+    //           ta
+    // Since dt > 0, we shift by - dt * slope to get that value
+    //
+    // Take the remainder of the math in ExtrapolateOffset above and compute it
+    // with floating point math.  Our tests are good enough to confirm that this
+    // works as designed.
+    const absl::int128 numerator =
+        absl::int128(dt.count() + MaxVelocityRatio::den / 2) *
+        absl::int128(MaxVelocityRatio::num);
+    return std::make_pair(
+        std::get<1>(p0) - chrono::nanoseconds(static_cast<int64_t>(
+                              numerator / absl::int128(MaxVelocityRatio::den))),
+        -static_cast<double>(numerator % absl::int128(MaxVelocityRatio::den)) /
+                static_cast<double>(MaxVelocityRatio::den) +
+            0.5 - ta * kMaxVelocity());
   }
 }
 
@@ -931,12 +958,9 @@
     std::pair<Pointer,
               std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds>>
         reference_timestamp = GetReferenceTimestamp(ta_base, ta);
-    return std::make_pair(
-        reference_timestamp.first,
-        std::make_pair(NoncausalTimestampFilter::ExtrapolateOffset(
-                           reference_timestamp.second, ta_base, ta),
-                       NoncausalTimestampFilter::ExtrapolateOffsetRemainder(
-                           reference_timestamp.second, ta_base, ta)));
+    return std::make_pair(reference_timestamp.first,
+                          NoncausalTimestampFilter::ExtrapolateOffset(
+                              reference_timestamp.second, ta_base, ta));
   }
 
   std::pair<
@@ -1061,25 +1085,22 @@
     auto reference_timestamp = GetReferenceTimestamp(ta_base, ta);
 
     // Special case size = 1 or ta before first timestamp, so we extrapolate
-    const chrono::nanoseconds offset_base =
+    const std::pair<chrono::nanoseconds, double> offset =
         NoncausalTimestampFilter::ExtrapolateOffset(reference_timestamp.second,
                                                     ta_base, ta);
-    const double offset_remainder =
-        NoncausalTimestampFilter::ExtrapolateOffsetRemainder(
-            reference_timestamp.second, ta_base, ta);
 
     // We want to do offset + ta > tb, but we need to do it with minimal
     // numerical precision problems.
     // See below for why this is a >=
-    if (static_cast<double>((offset_base + ta_base - tb_base).count()) >=
-        tb - ta - offset_remainder) {
+    if (static_cast<double>((offset.first + ta_base - tb_base).count()) >=
+        tb - ta - offset.second) {
       LOG(ERROR) << node_names_ << " "
-                 << TimeString(ta_base, ta, offset_base, offset_remainder)
+                 << TimeString(ta_base, ta, offset.first, offset.second)
                  << " > solution time "
                  << tb_base + chrono::nanoseconds(
                                   static_cast<int64_t>(std::round(tb)))
                  << ", " << tb - std::round(tb) << " foo";
-      LOG(INFO) << "Remainder " << offset_remainder;
+      LOG(INFO) << "Remainder " << offset.second;
       return false;
     }
     return true;
diff --git a/aos/network/timestamp_filter.h b/aos/network/timestamp_filter.h
index fd430ce..a056fce 100644
--- a/aos/network/timestamp_filter.h
+++ b/aos/network/timestamp_filter.h
@@ -16,9 +16,6 @@
 namespace aos {
 namespace message_bridge {
 
-// TODO<jim>: Should do something to help with precision, like make it an
-// integer and divide by the value (e.g., / 1000)
-
 // Max velocity to clamp the filter to in seconds/second.
 typedef std::ratio<1, 1000> MaxVelocityRatio;
 inline constexpr double kMaxVelocity() {
@@ -577,6 +574,11 @@
       std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p1,
       monotonic_clock::time_point ta);
 
+  static std::chrono::nanoseconds BoundOffset(
+      std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p0,
+      std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p1,
+      monotonic_clock::time_point ta);
+
   static std::chrono::nanoseconds ExtrapolateOffset(
       std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p0,
       monotonic_clock::time_point ta);
@@ -591,11 +593,12 @@
       std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> /*p1*/,
       monotonic_clock::time_point ta_base, double ta);
 
-  static std::chrono::nanoseconds ExtrapolateOffset(
+  static std::pair<std::chrono::nanoseconds, double> BoundOffset(
       std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p0,
-      monotonic_clock::time_point /*ta_base*/, double /*ta*/);
+      std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p1,
+      monotonic_clock::time_point ta_base, double ta);
 
-  static double ExtrapolateOffsetRemainder(
+  static std::pair<std::chrono::nanoseconds, double> ExtrapolateOffset(
       std::tuple<monotonic_clock::time_point, std::chrono::nanoseconds> p0,
       monotonic_clock::time_point ta_base, double ta);
 
diff --git a/aos/network/timestamp_filter_test.cc b/aos/network/timestamp_filter_test.cc
index 5932e3d..1669e62 100644
--- a/aos/network/timestamp_filter_test.cc
+++ b/aos/network/timestamp_filter_test.cc
@@ -36,6 +36,28 @@
   }
 };
 
+void NormalizeTimestamps(monotonic_clock::time_point *ta_base, double *ta) {
+  double ta_orig = *ta;
+  chrono::nanoseconds ta_digits(static_cast<int64_t>(std::floor(*ta)));
+  *ta_base += ta_digits;
+  *ta -= static_cast<double>(ta_digits.count());
+
+  // Sign, numerical precision wins again.
+  //   *ta_base=1000.300249970sec, *ta=-1.35525e-20
+  // We then promptly round this to
+  //   *ta_base=1000.300249969sec, *ta=1
+  // The 1.0 then breaks the LT assumption below, so we kersplat.
+  //
+  // Detect this case directly and move the 1.0 back into ta_base.
+  if (*ta == 1.0) {
+    *ta = 0.0;
+    *ta_base += chrono::nanoseconds(1);
+  }
+
+  CHECK_GE(*ta, 0.0) << ta_digits.count() << "ns " << ta_orig;
+  CHECK_LT(*ta, 1.0);
+}
+
 // Tests that adding samples tracks more negative offsets down quickly, and
 // slowly comes back up.
 TEST(TimestampFilterTest, Sample) {
@@ -775,11 +797,9 @@
 
   const monotonic_clock::time_point t1 = e + chrono::nanoseconds(10000);
   const chrono::nanoseconds o1 = chrono::nanoseconds(100);
-  const double o1d = static_cast<double>(o1.count());
 
   const monotonic_clock::time_point t2 = t1 + chrono::nanoseconds(1000);
   const chrono::nanoseconds o2 = chrono::nanoseconds(150);
-  const double o2d = static_cast<double>(o2.count());
 
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1),
@@ -796,10 +816,10 @@
             o2);
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2), t2, 0.0),
-            o1);
+            o2);
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffsetRemainder(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2), t2, 0.0),
-            o2d - o1d);
+            0.0);
 
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2),
@@ -808,56 +828,112 @@
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2),
                 t1 + chrono::nanoseconds(500), 0.0),
-            o1);
+            o1 + chrono::nanoseconds(25));
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffsetRemainder(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2),
                 t1 + chrono::nanoseconds(500), 0.0),
-            25.);
+            0.0);
 
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2),
                 t1 + chrono::nanoseconds(-200)),
             chrono::nanoseconds(90));
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
-                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1, -200.),
-            o1);
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2),
+                t1 - chrono::nanoseconds(200), 0.0),
+            o1 - chrono::nanoseconds(10));
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffsetRemainder(
-                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1, -200.),
-            -10.);
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2),
+                t1 - chrono::nanoseconds(200), 0.0),
+            0.0);
 
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2),
                 t1 + chrono::nanoseconds(200)),
             chrono::nanoseconds(110));
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
-                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1, 200.),
-            o1);
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2),
+                t1 + chrono::nanoseconds(200), 0.0),
+            o1 + chrono::nanoseconds(10));
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffsetRemainder(
-                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1, 200.),
-            10.);
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2),
+                t1 + chrono::nanoseconds(200), 0.0),
+            0.0);
 
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2),
                 t1 + chrono::nanoseconds(800)),
             chrono::nanoseconds(140));
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
-                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1, 800.),
-            o1);
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2),
+                t1 + chrono::nanoseconds(800), 0.0),
+            o1 + chrono::nanoseconds(40));
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffsetRemainder(
-                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1, 800.),
-            40.);
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2),
+                t1 + chrono::nanoseconds(800), 0.0),
+            0.0);
 
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
                 std::make_tuple(t1, o1), std::make_tuple(t2, o2),
                 t1 + chrono::nanoseconds(1200)),
             chrono::nanoseconds(160));
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffset(
-                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1, 1200.),
-            o1);
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2),
+                t1 + chrono::nanoseconds(1200), 0.0),
+            o1 + chrono::nanoseconds(60));
   EXPECT_EQ(NoncausalTimestampFilter::InterpolateOffsetRemainder(
-                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1, 1200.),
-            60.);
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2),
+                t1 + chrono::nanoseconds(1200), 0.0),
+            0.0);
 
+  for (int i = -MaxVelocityRatio::den * MaxVelocityRatio::num * 6;
+       i <
+       MaxVelocityRatio::den * MaxVelocityRatio::num * 6 + (t2 - t1).count();
+       ++i) {
+    monotonic_clock::time_point ta_base = t1;
+    const double ta_orig = static_cast<double>(i) / 3.0;
+    double ta = ta_orig;
+
+    NormalizeTimestamps(&ta_base, &ta);
+    CHECK_GE(ta, 0.0);
+    CHECK_LT(ta, 1.0);
+
+    const chrono::nanoseconds expected_offset =
+        NoncausalTimestampFilter::InterpolateOffset(
+            std::make_tuple(t1, o1), std::make_tuple(t2, o2), ta_base);
+
+    EXPECT_EQ(expected_offset, NoncausalTimestampFilter::InterpolateOffset(
+                                   std::make_tuple(t1, o1),
+                                   std::make_tuple(t2, o2), ta_base, ta));
+
+    const double expected_double_offset =
+        static_cast<double>(o1.count()) +
+        static_cast<double>(ta_orig) / static_cast<double>((t2 - t1).count()) *
+            (o2 - o1).count();
+
+    EXPECT_NEAR(
+        static_cast<double>(
+            NoncausalTimestampFilter::InterpolateOffset(
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2), ta_base, ta)
+                .count()) +
+            NoncausalTimestampFilter::InterpolateOffsetRemainder(
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2), ta_base, ta),
+        expected_double_offset, 1e-9)
+        << ": i " << i << " t " << ta_base << " " << ta << " t1 " << t1
+        << " o1 " << o1.count() << "ns t2 " << t2 << " o2 " << o2.count()
+        << "ns Non-rounded: " << expected_offset.count() << "ns";
+  }
+}
+
+// Tests that all variants of ExtrapolateOffset do reasonable things.
+TEST_F(NoncausalTimestampFilterTest, ExtrapolateOffset) {
+  const monotonic_clock::time_point e = monotonic_clock::epoch();
+
+  const monotonic_clock::time_point t1 = e + chrono::nanoseconds(10000);
+  const chrono::nanoseconds o1 = chrono::nanoseconds(100);
+
+  const monotonic_clock::time_point t2 = t1 + chrono::nanoseconds(1000);
+  const chrono::nanoseconds o2 = chrono::nanoseconds(150);
   // Test extrapolation functions before t1 and after t2
   EXPECT_EQ(
       NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t1, o1), t1),
@@ -889,24 +965,14 @@
   // Test base + double version
   EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t1, o1),
                                                         e, 0.),
-            o1);
-  EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffsetRemainder(
-                std::make_tuple(t1, o1), e, 0.),
-            -(t1 - e).count() * kMaxVelocity());
-
+            std::make_pair(chrono::nanoseconds(90), 0.0));
   EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t1, o1),
                                                         t1, 0.),
-            o1);
-  EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffsetRemainder(
-                std::make_tuple(t1, o1), t1, 0.),
-            0.);
+            std::make_pair(o1, 0.0));
 
   EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t1, o1),
-                                                        t1, -1000.),
-            o1);
-  EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffsetRemainder(
-                std::make_tuple(t1, o1), t1, -1000.),
-            -1000. * kMaxVelocity());
+                                                        t1, 0.5),
+            std::make_pair(o1, -0.5 * kMaxVelocity()));
 
   EXPECT_EQ(
       NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t2, o2), t2),
@@ -914,10 +980,7 @@
 
   EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t2, o2),
                                                         t2, 0.0),
-            o2);
-  EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffsetRemainder(
-                std::make_tuple(t2, o2), t2, 0.0),
-            0.0);
+            std::make_pair(o2, 0.0));
 
   // Test points past our last sample
   EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffset(
@@ -925,12 +988,117 @@
             chrono::nanoseconds(
                 static_cast<int64_t>(o2.count() - 10000. * kMaxVelocity())));
 
-  EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t2, o2),
-                                                        t2, 100.0),
+  EXPECT_EQ(
+      NoncausalTimestampFilter::ExtrapolateOffset(
+          std::make_tuple(t2, o2), t2 + chrono::nanoseconds(10000), 0.5),
+      std::make_pair(o2 - chrono::nanoseconds(10), -0.5 * kMaxVelocity()));
+
+  // Now, test that offset + remainder functions add up to the right answer for
+  // a lot of cases.  This is enough to catch all the various rounding cases.
+  for (int i = -MaxVelocityRatio::den * MaxVelocityRatio::num * 6;
+       i < MaxVelocityRatio::den * MaxVelocityRatio::num * 4; ++i) {
+    monotonic_clock::time_point ta_base = t1;
+    const double ta_orig = static_cast<double>(i) / 3.0;
+    double ta = ta_orig;
+
+    NormalizeTimestamps(&ta_base, &ta);
+    CHECK_GE(ta, 0.0);
+    CHECK_LT(ta, 1.0);
+
+    const chrono::nanoseconds expected_offset =
+        NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t1, o1),
+                                                    ta_base);
+
+
+    std::pair<chrono::nanoseconds, double> offset =
+        NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t1, o1),
+                                                    ta_base, ta);
+
+    EXPECT_EQ(expected_offset, offset.first);
+    EXPECT_NEAR(
+        static_cast<double>(offset.first.count()) + offset.second,
+        static_cast<double>(o1.count()) - std::abs(ta_orig) * kMaxVelocity(),
+        1e-9)
+        << ": i " << i << " t " << ta_base << " " << ta
+        << " Non-rounded: " << expected_offset.count() << "ns";
+  }
+}
+
+// Tests that all variants of BoundOffset do reasonable things.
+TEST_F(NoncausalTimestampFilterTest, BoundOffset) {
+  const monotonic_clock::time_point e = monotonic_clock::epoch();
+
+  const monotonic_clock::time_point t1 = e + chrono::nanoseconds(10000);
+  const chrono::nanoseconds o1 = chrono::nanoseconds(100);
+
+  const monotonic_clock::time_point t2 = t1 + chrono::nanoseconds(100000);
+  const chrono::nanoseconds o2 = chrono::nanoseconds(150);
+
+  EXPECT_EQ(NoncausalTimestampFilter::BoundOffset(std::make_tuple(t1, o1),
+                                                  std::make_tuple(t2, o2), t1),
+            o1);
+  EXPECT_EQ(NoncausalTimestampFilter::BoundOffset(
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t1, 0.0),
+            std::pair(o1, 0.0));
+
+  EXPECT_EQ(NoncausalTimestampFilter::BoundOffset(std::make_tuple(t1, o1),
+                                                  std::make_tuple(t2, o2), t2),
             o2);
-  EXPECT_EQ(NoncausalTimestampFilter::ExtrapolateOffsetRemainder(
-                std::make_tuple(t2, o2), t2, 100.0),
-            -100.0 * kMaxVelocity());
+  EXPECT_EQ(NoncausalTimestampFilter::BoundOffset(
+                std::make_tuple(t1, o1), std::make_tuple(t2, o2), t2, 0.0),
+            std::pair(o2, 0.0));
+
+  // Iterate from before t1 to after t2 and confirm that the solution is right.
+  // We must always be >= than interpolation, and must also be equal to the max
+  // of extrapolating both.  Since the numbers are small enough (by
+  // construction!), the double calculation will be close enough that we can
+  // trust it.
+
+  for (int i = -MaxVelocityRatio::den * MaxVelocityRatio::num * 6;
+       i <
+       MaxVelocityRatio::den * MaxVelocityRatio::num * 6 + (t2 - t1).count();
+       ++i) {
+    monotonic_clock::time_point ta_base = t1;
+    const double ta_orig = static_cast<double>(i) / 3.0;
+    double ta = ta_orig;
+
+    NormalizeTimestamps(&ta_base, &ta);
+    CHECK_GE(ta, 0.0);
+    CHECK_LT(ta, 1.0);
+
+    const chrono::nanoseconds expected_offset_1 =
+        NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t1, o1),
+                                                    ta_base);
+    const chrono::nanoseconds expected_offset_2 =
+        NoncausalTimestampFilter::ExtrapolateOffset(std::make_tuple(t2, o2),
+                                                    ta_base);
+
+    // Each of the extrapolation functions have their max at the points.  They
+    // slope up before and down after.  So, we want the max.
+    //
+    //
+    //   p0  p1                                                               |
+    //  /  \/  \                                                              |
+    // /        \                                                             |
+
+    const std::pair<chrono::nanoseconds, double> offset =
+        NoncausalTimestampFilter::BoundOffset(
+            std::make_tuple(t1, o1), std::make_tuple(t2, o2), ta_base, ta);
+
+    EXPECT_EQ(std::max(expected_offset_1, expected_offset_2), offset.first);
+
+    const double expected_double_offset = std::max(
+        static_cast<double>(o1.count()) - std::abs(ta_orig) * kMaxVelocity(),
+        static_cast<double>(o2.count()) -
+            std::abs(ta_orig - (t2 - t1).count()) * kMaxVelocity());
+
+    EXPECT_NEAR(static_cast<double>(offset.first.count()) + offset.second,
+                expected_double_offset, 1e-9)
+        << ": i " << i << " t " << ta_base << " " << ta << " t1 " << t1
+        << " o1 " << o1.count() << "ns t2 " << t2 << " o2 " << o2.count()
+        << "ns Non-rounded: "
+        << std::max(expected_offset_1, expected_offset_2).count() << "ns";
+  }
 }
 
 // Tests that FindTimestamps finds timestamps in a sequence.
@@ -1114,17 +1282,17 @@
 
   // Confirm the problem statement is reasonable...  We've had enough trouble
   // here in the past.
-  EXPECT_TRUE(
-      filter_a.ValidateSolution(&filter_b, Pointer(), t1_a, t1_a + o1_a + chrono::nanoseconds(1)));
-  EXPECT_TRUE(
-      filter_a.ValidateSolution(&filter_b, Pointer(), t2_a, t2_a + o2_a + chrono::nanoseconds(1)));
+  EXPECT_TRUE(filter_a.ValidateSolution(&filter_b, Pointer(), t1_a,
+                                        t1_a + o1_a + chrono::nanoseconds(1)));
+  EXPECT_TRUE(filter_a.ValidateSolution(&filter_b, Pointer(), t2_a,
+                                        t2_a + o2_a + chrono::nanoseconds(1)));
 
-  EXPECT_TRUE(
-      filter_b.ValidateSolution(&filter_a, Pointer(), t1_b, t1_b + o1_b + chrono::nanoseconds(1)));
-  EXPECT_TRUE(
-      filter_b.ValidateSolution(&filter_a, Pointer(), t2_b, t2_b + o2_b + chrono::nanoseconds(1)));
-  EXPECT_TRUE(
-      filter_b.ValidateSolution(&filter_a, Pointer(), t3_b, t3_b + o3_b + chrono::nanoseconds(1)));
+  EXPECT_TRUE(filter_b.ValidateSolution(&filter_a, Pointer(), t1_b,
+                                        t1_b + o1_b + chrono::nanoseconds(1)));
+  EXPECT_TRUE(filter_b.ValidateSolution(&filter_a, Pointer(), t2_b,
+                                        t2_b + o2_b + chrono::nanoseconds(1)));
+  EXPECT_TRUE(filter_b.ValidateSolution(&filter_a, Pointer(), t3_b,
+                                        t3_b + o3_b + chrono::nanoseconds(1)));
 
   // Before the start
   result = filter_a.FindTimestamps(&filter_b, Pointer(),
@@ -1186,15 +1354,12 @@
   // doesn't modify the timestamps.
   const BootTimestamp t1 = e + chrono::nanoseconds(1000);
   const BootDuration o1{0, chrono::nanoseconds(100)};
-  const double o1d = static_cast<double>(o1.duration.count());
 
   const BootTimestamp t2 = e + chrono::microseconds(2000);
   const BootDuration o2{0, chrono::nanoseconds(150)};
-  const double o2d = static_cast<double>(o2.duration.count());
 
   const BootTimestamp t3 = e + chrono::microseconds(3000);
   const BootDuration o3{0, chrono::nanoseconds(50)};
-  const double o3d = static_cast<double>(o3.duration.count());
 
   const BootTimestamp t4 = e + chrono::microseconds(4000);
 
@@ -1211,14 +1376,18 @@
   const double offset_pre = -(t1.time - e.time).count() * kMaxVelocity();
   EXPECT_EQ(filter.Offset(nullptr, Pointer(), e, 0),
             o1 + chrono::nanoseconds(static_cast<int64_t>(offset_pre)));
-  EXPECT_EQ(filter.Offset(nullptr, Pointer(), e, 0.0, 0),
-            std::make_pair(o1, offset_pre));
+  EXPECT_EQ(
+      filter.Offset(nullptr, Pointer(), e, 0.0, 0),
+      std::make_pair(o1 + chrono::nanoseconds(static_cast<int64_t>(offset_pre)),
+                     0.0));
 
   double offset_post = -(t2.time - t1.time).count() * kMaxVelocity();
   EXPECT_EQ(filter.Offset(nullptr, Pointer(), t2, 0),
             o1 + chrono::nanoseconds(static_cast<int64_t>(offset_post)));
-  EXPECT_EQ(filter.Offset(nullptr, Pointer(), t2, 0.0, 0),
-            std::make_pair(o1, offset_post));
+  EXPECT_EQ(
+      filter.Offset(nullptr, Pointer(), t2, 0.0, 0),
+      std::make_pair(
+          o1 + chrono::nanoseconds(static_cast<int64_t>(offset_post)), 0.0));
 
   filter.Sample(t2, o2);
   filter.Sample(t3, o3);
@@ -1234,7 +1403,7 @@
       filter.Offset(nullptr, Pointer(),
                     e + (t2.time_since_epoch() + t1.time_since_epoch()) / 2,
                     0.0, 0),
-      std::make_pair(o1, (o2d - o1d) / 2.));
+      std::make_pair(o1 + (o2 - o1) / 2, 0.0));
 
   EXPECT_EQ(filter.Offset(nullptr, Pointer(), t2, 0.0, 0),
             std::make_pair(o2, 0.0));
@@ -1243,7 +1412,7 @@
       filter.Offset(nullptr, Pointer(),
                     e + (t2.time_since_epoch() + t3.time_since_epoch()) / 2,
                     0.0, 0),
-      std::make_pair(o2, (o2d + o3d) / 2. - o2d));
+      std::make_pair((o2 + o3) / 2, 0.0));
 
   EXPECT_EQ(filter.Offset(nullptr, Pointer(), t3, 0.0, 0),
             std::make_pair(o3, 0.0));
@@ -1251,14 +1420,18 @@
   // Check that we still get same answer for times before our sample data...
   EXPECT_EQ(filter.Offset(nullptr, Pointer(), e, 0),
             o1 + chrono::nanoseconds(static_cast<int64_t>(offset_pre)));
-  EXPECT_EQ(filter.Offset(nullptr, Pointer(), e, 0.0, 0),
-            std::make_pair(o1, offset_pre));
+  EXPECT_EQ(
+      filter.Offset(nullptr, Pointer(), e, 0.0, 0),
+      std::make_pair(o1 + chrono::nanoseconds(static_cast<int64_t>(offset_pre)),
+                     0.0));
   // ... and after
   offset_post = -(t4.time - t3.time).count() * kMaxVelocity();
   EXPECT_EQ(filter.Offset(nullptr, Pointer(), t4, 0),
             (o3 + chrono::nanoseconds(static_cast<int64_t>(offset_post))));
-  EXPECT_EQ(filter.Offset(nullptr, Pointer(), t4, 0.0, 0),
-            std::make_pair(o3, offset_post));
+  EXPECT_EQ(
+      filter.Offset(nullptr, Pointer(), t4, 0.0, 0),
+      std::make_pair(
+          o3 + chrono::nanoseconds(static_cast<int64_t>(offset_post)), 0.0));
 }
 
 // Tests that adding duplicates gets correctly deduplicated.
diff --git a/aos/network/www/BUILD b/aos/network/www/BUILD
index 442e8be..03f41a5 100644
--- a/aos/network/www/BUILD
+++ b/aos/network/www/BUILD
@@ -2,6 +2,8 @@
 load("//tools/build_rules:js.bzl", "rollup_bundle")
 load("//aos:config.bzl", "aos_config")
 
+exports_files(["styles.css"])
+
 filegroup(
     name = "files",
     srcs = glob([
diff --git a/aos/network/www/plotter.ts b/aos/network/www/plotter.ts
index 8d462f3..89b360c 100644
--- a/aos/network/www/plotter.ts
+++ b/aos/network/www/plotter.ts
@@ -105,6 +105,7 @@
   private colorLocation: WebGLUniformLocation | null;
   private pointSizeLocation: WebGLUniformLocation | null;
   private _label: string|null = null;
+  private _hidden: boolean = false;
   constructor(
       private readonly ctx: WebGLRenderingContext,
       private readonly program: WebGLProgram,
@@ -190,6 +191,15 @@
     }
   }
 
+  hidden(): boolean {
+    return this._hidden;
+  }
+
+  setHidden(hidden: boolean) {
+    this._hasUpdate = true;
+    this._hidden = hidden;
+  }
+
   getPoints(): Point[] {
     return this.points;
   }
@@ -220,6 +230,10 @@
       return;
     }
 
+    if (this._hidden) {
+      return;
+    }
+
     this.ctx.bindBuffer(this.ctx.ARRAY_BUFFER, this.buffer);
     // Note: if this is generating errors associated with the buffer size,
     // confirm that this.points really is a Float32Array.
@@ -297,92 +311,120 @@
 export class Legend {
   // Location, in pixels, of the legend in the text canvas.
   private location: number[] = [0, 0];
-  constructor(private ctx: CanvasRenderingContext2D, private lines: Line[]) {
-    this.location = [80, 30];
+  constructor(
+      private plot: Plot, private lines: Line[],
+      private legend: HTMLDivElement) {
+    this.setPosition([80, 30]);
   }
 
   setPosition(location: number[]): void {
     this.location = location;
+    this.legend.style.left = location[0] + 'px';
+    this.legend.style.top = location[1] + 'px';
   }
 
   draw(): void {
-    this.ctx.save();
+    // First, figure out if anything has changed.  The legend is created and
+    // then titles are changed afterwords, so we have to do this lazily.
+    let needsUpdate = false;
+    {
+      let child = 0;
+      for (let line of this.lines) {
+        if (line.label() === null) {
+          continue;
+        }
 
-    this.ctx.translate(this.location[0], this.location[1]);
+        if (child >= this.legend.children.length) {
+          needsUpdate = true;
+          break;
+        }
 
-    // Space between rows of the legend.
-    const step = 20;
-
-    let maxWidth = 0;
-
-    // In the legend, we render both a small line of the appropriate color as
-    // well as the text label--start/endPoint are the relative locations of the
-    // endpoints of the miniature line within the row, and textStart is where
-    // we begin rendering the text within the row.
-    const startPoint = [0, 0];
-    const endPoint = [10, -10];
-    const textStart = endPoint[0] + 5;
-
-    // Calculate how wide the legend needs to be to fit all the text.
-    this.ctx.textAlign = 'left';
-    let numLabels = 0;
-    for (let line of this.lines) {
-      if (line.label() === null) {
-        continue;
+        // Make sure both have text in the right spot.  Don't be too picky since
+        // nothing should really be changing here, and it's handy to let the
+        // user edit the HTML for testing.
+        if (this.legend.children[child].lastChild.textContent.length == 0 &&
+            line.label().length != 0) {
+          needsUpdate = true;
+          break;
+        }
+        child += 1;
       }
-      ++numLabels;
-      const width =
-          textStart + this.ctx.measureText(line.label()).actualBoundingBoxRight;
-      maxWidth = Math.max(width, maxWidth);
-    }
 
-    if (numLabels === 0) {
-      this.ctx.restore();
+      // If we got through everything, we should be pointed past the last child.
+      // If not, more children exists than lines.
+      if (child != this.legend.children.length) {
+        needsUpdate = true;
+      }
+    }
+    if (!needsUpdate) {
       return;
     }
 
-    // Total height of the body of the legend.
-    const height = step * numLabels;
+    // Nuke the old legend.
+    while (this.legend.firstChild) {
+      this.legend.removeChild(this.legend.firstChild);
+    }
 
-    // Set the legend background to be white and opaque.
-    this.ctx.fillStyle = 'rgba(255, 255, 255, 1.0)';
-    const backgroundBuffer = 5;
-    this.ctx.fillRect(
-        -backgroundBuffer, 0, maxWidth + 2.0 * backgroundBuffer,
-        height + backgroundBuffer);
-
-    // Go through each line and render the little lines and text for each Line.
+    // Now, build up a new legend.
     for (let line of this.lines) {
       if (line.label() === null) {
         continue;
       }
-      this.ctx.translate(0, step);
+
+      // The legend is a div containing both a canvas for the style/color, and a
+      // div for the text.  Make those, color in the canvas, and add it to the
+      // page.
+      let l = document.createElement('div');
+      l.classList.add('aos_legend_line');
+      let text = document.createElement('div');
+      text.textContent = line.label();
+
+      l.appendChild(text);
+      this.legend.appendChild(l);
+
+      let c = document.createElement('canvas');
+      c.width = text.offsetHeight;
+      c.height = text.offsetHeight;
+
+      const linestyleContext = c.getContext("2d");
+      linestyleContext.clearRect(0, 0, c.width, c.height);
+
       const color = line.color();
-      this.ctx.strokeStyle = `rgb(${255.0 * color[0]}, ${255.0 * color[1]}, ${255.0 * color[2]})`;
-      this.ctx.fillStyle = this.ctx.strokeStyle;
-      if (line.drawLine()) {
-        this.ctx.beginPath();
-        this.ctx.moveTo(startPoint[0], startPoint[1]);
-        this.ctx.lineTo(endPoint[0], endPoint[1]);
-        this.ctx.closePath();
-        this.ctx.stroke();
-      }
+      linestyleContext.strokeStyle = `rgb(${255.0 * color[0]}, ${
+          255.0 * color[1]}, ${255.0 * color[2]})`;
+      linestyleContext.fillStyle = linestyleContext.strokeStyle;
+
       const pointSize = line.pointSize();
-      if (pointSize > 0) {
-        this.ctx.fillRect(
-            startPoint[0] - pointSize / 2.0, startPoint[1] - pointSize / 2.0,
-            pointSize, pointSize);
-        this.ctx.fillRect(
-            endPoint[0] - pointSize / 2.0, endPoint[1] - pointSize / 2.0,
-            pointSize, pointSize);
+      const kDistanceIn = pointSize / 2.0;
+
+      if (line.drawLine()) {
+        linestyleContext.beginPath();
+        linestyleContext.moveTo(0, 0);
+        linestyleContext.lineTo(c.height, c.width);
+        linestyleContext.closePath();
+        linestyleContext.stroke();
       }
 
-      this.ctx.fillStyle = 'black';
-      this.ctx.textAlign = 'left';
-      this.ctx.fillText(line.label(), textStart, 0);
-    }
+      if (pointSize > 0) {
+        linestyleContext.fillRect(0, 0, pointSize, pointSize);
+        linestyleContext.fillRect(
+            c.height - 1 - pointSize, c.width - 1 - pointSize, pointSize,
+            pointSize);
+      }
 
-    this.ctx.restore();
+      c.addEventListener('click', (e) => {
+        if (!line.hidden()) {
+          l.classList.add('aos_legend_line_hidden');
+        } else {
+          l.classList.remove('aos_legend_line_hidden');
+        }
+
+        line.setHidden(!line.hidden());
+        this.plot.draw();
+      });
+
+      l.prepend(c);
+    }
   }
 }
 
@@ -440,7 +482,7 @@
     return divideVec(subtractVec(canvasPos, this.zoom.offset), this.zoom.scale);
   }
 
-  // Tehse return the max/min rendered points, in plot-space (this is helpful
+  // These return the max/min rendered points, in plot-space (this is helpful
   // for drawing axis labels).
   maxVisiblePoint(): number[] {
     return this.canvasToPlotCoordinates([1.0, 1.0]);
@@ -850,6 +892,7 @@
 export class Plot {
   private canvas = document.createElement('canvas');
   private textCanvas = document.createElement('canvas');
+  private legendDiv = document.createElement('div');
   private lineDrawerContext: WebGLRenderingContext;
   private drawer: LineDrawer;
   private static keysPressed:
@@ -873,24 +916,20 @@
   constructor(wrapperDiv: HTMLDivElement) {
     wrapperDiv.appendChild(this.canvas);
     wrapperDiv.appendChild(this.textCanvas);
+    this.legendDiv.classList.add('aos_legend');
+    wrapperDiv.appendChild(this.legendDiv);
     this.lastTimeMs = (new Date()).getTime();
 
     this.canvas.style.paddingLeft = this.axisLabelBuffer.left.toString() + "px";
     this.canvas.style.paddingRight = this.axisLabelBuffer.right.toString() + "px";
     this.canvas.style.paddingTop = this.axisLabelBuffer.top.toString() + "px";
     this.canvas.style.paddingBottom = this.axisLabelBuffer.bottom.toString() + "px";
-    this.canvas.style.width = "100%";
-    this.canvas.style.height = "100%";
-    this.canvas.style.boxSizing = "border-box";
+    this.canvas.classList.add('aos_plot');
 
-    this.canvas.style.position = 'absolute';
     this.lineDrawerContext = this.canvas.getContext('webgl');
     this.drawer = new LineDrawer(this.lineDrawerContext);
 
-    this.textCanvas.style.position = 'absolute';
-    this.textCanvas.style.width = "100%";
-    this.textCanvas.style.height = "100%";
-    this.textCanvas.style.pointerEvents = 'none';
+    this.textCanvas.classList.add('aos_plot_text');
 
     this.canvas.addEventListener('dblclick', (e) => {
       this.handleDoubleClick(e);
@@ -922,7 +961,7 @@
     const textCtx = this.textCanvas.getContext("2d");
     this.axisLabels =
         new AxisLabels(textCtx, this.drawer, this.axisLabelBuffer);
-    this.legend = new Legend(textCtx, this.drawer.getLines());
+    this.legend = new Legend(this, this.drawer.getLines(), this.legendDiv);
 
     this.zoomRectangle = this.getDrawer().addLine(false);
     this.zoomRectangle.setColor(Colors.WHITE);
diff --git a/aos/network/www/styles.css b/aos/network/www/styles.css
index 23ceb21..547ec94 100644
--- a/aos/network/www/styles.css
+++ b/aos/network/www/styles.css
@@ -3,3 +3,59 @@
   border-bottom: 1px solid;
   font-size: 24px;
 }
+
+.aos_plot {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+}
+
+.aos_plot_text {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+}
+
+.aos_legend {
+  position: absolute;
+  z-index: 1;
+  pointer-events: none;
+}
+
+.aos_legend_line {
+  background: white;
+  padding: 2px;
+  border-radius: 2px;
+  margin-top: 3px;
+  margin-bottom: 3px;
+  font-size: 12;
+}
+
+.aos_legend_line>div {
+  display: inline-block;
+  vertical-align: middle;
+  margin-left: 5px;
+}
+.aos_legend_line>canvas {
+  vertical-align: middle;
+  pointer-events: all;
+}
+
+.aos_legend_line_hidden {
+  filter: contrast(0.75);
+}
+
+.aos_cpp_plot {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  align-items: flex-start;
+}
+
+.aos_cpp_plot>div {
+  flex: 1;
+  width: 100%;
+}
diff --git a/frc971/analysis/BUILD b/frc971/analysis/BUILD
index 2d71410..abb950b 100644
--- a/frc971/analysis/BUILD
+++ b/frc971/analysis/BUILD
@@ -70,11 +70,21 @@
     ],
 )
 
+genrule(
+    name = "copy_css",
+    srcs = [
+        "//aos/network/www:styles.css",
+    ],
+    outs = ["styles.css"],
+    cmd = "cp $< $@",
+)
+
 filegroup(
     name = "plotter_files",
     srcs = [
         "index.html",
         "plot_index_bundle.min.js",
+        "styles.css",
     ],
 )
 
diff --git a/frc971/analysis/cpp_plot/BUILD b/frc971/analysis/cpp_plot/BUILD
index 156594c..d6e74c3 100644
--- a/frc971/analysis/cpp_plot/BUILD
+++ b/frc971/analysis/cpp_plot/BUILD
@@ -23,10 +23,21 @@
     ],
 )
 
+genrule(
+    name = "copy_css",
+    srcs = [
+        "//aos/network/www:styles.css",
+    ],
+    outs = ["styles.css"],
+    cmd = "cp $< $@",
+)
+
 filegroup(
     name = "cpp_plot_files",
     srcs = [
+        "cpp_plot_bundle.js",
         "cpp_plot_bundle.min.js",
         "index.html",
+        "styles.css",
     ],
 )
diff --git a/frc971/analysis/cpp_plot/cpp_plot.ts b/frc971/analysis/cpp_plot/cpp_plot.ts
index ffbd485..a704e89 100644
--- a/frc971/analysis/cpp_plot/cpp_plot.ts
+++ b/frc971/analysis/cpp_plot/cpp_plot.ts
@@ -4,7 +4,7 @@
 import {plotData} from 'org_frc971/frc971/analysis/plot_data_utils';
 
 const rootDiv = document.createElement('div');
-rootDiv.style.width = '100%';
+rootDiv.classList.add('aos_cpp_plot');
 document.body.appendChild(rootDiv);
 
 const conn = new Connection();
diff --git a/frc971/analysis/cpp_plot/index.html b/frc971/analysis/cpp_plot/index.html
index 776c103..fbb5199 100644
--- a/frc971/analysis/cpp_plot/index.html
+++ b/frc971/analysis/cpp_plot/index.html
@@ -1,6 +1,7 @@
 <html>
   <head>
     <script src="cpp_plot_bundle.min.js" defer></script>
+    <link rel="stylesheet" href="styles.css">
   </head>
   <body>
   </body>
diff --git a/frc971/analysis/in_process_plotter.cc b/frc971/analysis/in_process_plotter.cc
index 585c933..5f08e5d 100644
--- a/frc971/analysis/in_process_plotter.cc
+++ b/frc971/analysis/in_process_plotter.cc
@@ -19,15 +19,26 @@
       builder_(plot_sender_.MakeBuilder()) {
   web_proxy_.SetDataPath(kDataPath);
   event_loop_->SkipTimingReport();
-  color_wheel_.push_back(Color(1, 0, 0));
-  color_wheel_.push_back(Color(0, 1, 0));
-  color_wheel_.push_back(Color(0, 0, 1));
-  color_wheel_.push_back(Color(1, 1, 0));
-  color_wheel_.push_back(Color(0, 1, 1));
-  color_wheel_.push_back(Color(1, 0, 1));
-  color_wheel_.push_back(Color(1, 0.6, 0));
-  color_wheel_.push_back(Color(0.6, 0.3, 0));
-  color_wheel_.push_back(Color(1, 1, 1));
+
+  color_wheel_.emplace_back(ColorWheelColor{.name = "red", .color = {1, 0, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "green", .color = {0, 1, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "purple", .color = {0.54, 0.3, 0.75}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "blue", .color = {0, 0, 1}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "yellow", .color = {1, 1, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "teal", .color = {0, 1, 1}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "pink", .color = {1, 0, 1}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "orange", .color = {1, 0.6, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "brown", .color = {0.6, 0.3, 0}});
+  color_wheel_.emplace_back(
+      ColorWheelColor{.name = "white", .color = {1, 1, 1}});
 }
 
 void Plotter::Spin() { event_loop_factory_.Run(); }
@@ -63,15 +74,14 @@
 }
 
 void Plotter::AddLine(const std::vector<double> &x,
-                      const std::vector<double> &y, std::string_view label,
-                      std::string_view line_style) {
+                      const std::vector<double> &y, LineOptions options) {
   CHECK_EQ(x.size(), y.size());
   CHECK(!position_.IsNull())
       << "You must call AddFigure() before calling AddLine().";
 
   flatbuffers::Offset<flatbuffers::String> label_offset;
-  if (!label.empty()) {
-    label_offset = builder_.fbb()->CreateString(label);
+  if (!options.label.empty()) {
+    label_offset = builder_.fbb()->CreateString(options.label);
   }
 
   std::vector<Point> points;
@@ -81,16 +91,28 @@
   const flatbuffers::Offset<flatbuffers::Vector<const Point *>> points_offset =
       builder_.fbb()->CreateVectorOfStructs(points);
 
-  const Color *color = &color_wheel_.at(color_wheel_position_);
-  color_wheel_position_ = (color_wheel_position_ + 1) % color_wheel_.size();
+  const Color *color;
+  if (options.color.empty()) {
+    color = &color_wheel_.at(color_wheel_position_).color;
+    color_wheel_position_ = (color_wheel_position_ + 1) % color_wheel_.size();
+  } else {
+    auto it = std::find_if(
+        color_wheel_.begin(), color_wheel_.end(),
+        [options_color = options.color](const ColorWheelColor &color) {
+          return color.name == options_color;
+        });
+    CHECK(it != color_wheel_.end()) << ": Failed to find " << options.color;
+    color = &(it->color);
+  }
 
   LineStyle::Builder style_builder = builder_.MakeBuilder<LineStyle>();
-  if (line_style.find('*') != line_style.npos) {
+  if (options.line_style.find('*') != options.line_style.npos) {
     style_builder.add_point_size(3.0);
   } else {
     style_builder.add_point_size(0.0);
   }
-  style_builder.add_draw_line(line_style.find('-') != line_style.npos);
+  style_builder.add_draw_line(options.line_style.find('-') !=
+                              options.line_style.npos);
   const flatbuffers::Offset<LineStyle> style_offset = style_builder.Finish();
 
   auto line_builder = builder_.MakeBuilder<Line>();
@@ -132,8 +154,7 @@
   plot_builder.add_title(title_);
   plot_builder.add_figures(figures_offset);
 
-  CHECK_EQ(builder_.Send(plot_builder.Finish()),
-           aos::RawSender::Error::kOk);
+  CHECK_EQ(builder_.Send(plot_builder.Finish()), aos::RawSender::Error::kOk);
 
   builder_ = plot_sender_.MakeBuilder();
 
diff --git a/frc971/analysis/in_process_plotter.h b/frc971/analysis/in_process_plotter.h
index 8d4c84c..b78a8fb 100644
--- a/frc971/analysis/in_process_plotter.h
+++ b/frc971/analysis/in_process_plotter.h
@@ -42,14 +42,30 @@
   void Title(std::string_view title);
   void AddFigure(std::string_view title = "", double width = 0,
                  double height = 0);
+  struct LineOptions {
+    std::string_view label = "";
+    std::string_view line_style = "*-";
+    std::string_view color = "";
+  };
+
   void AddLine(const std::vector<double> &x, const std::vector<double> &y,
-               std::string_view label = "", std::string_view line_style = "*-");
+               std::string_view label) {
+    AddLine(x, y, LineOptions{.label = label});
+  }
+  void AddLine(const std::vector<double> &x, const std::vector<double> &y,
+               std::string_view label, std::string_view line_style) {
+    AddLine(x, y, LineOptions{.label = label, .line_style = line_style});
+  }
+  void AddLine(const std::vector<double> &x, const std::vector<double> &y,
+               LineOptions options);
+
   void ShareXAxis(bool share) { share_x_axis_ = share; }
   void XLabel(std::string_view label);
   void YLabel(std::string_view label);
   void Publish();
 
   void Spin();
+
  private:
   void MaybeFinishFigure();
 
@@ -70,8 +86,13 @@
   std::vector<flatbuffers::Offset<Figure>> figures_;
   std::vector<flatbuffers::Offset<Line>> lines_;
 
+  struct ColorWheelColor {
+    std::string name;
+    Color color;
+  };
+
   size_t color_wheel_position_ = 0;
-  std::vector<Color> color_wheel_;
+  std::vector<ColorWheelColor> color_wheel_;
 };
 
 }  // namespace analysis
diff --git a/frc971/analysis/index.html b/frc971/analysis/index.html
index edd2483..22b42c7 100644
--- a/frc971/analysis/index.html
+++ b/frc971/analysis/index.html
@@ -1,6 +1,7 @@
 <html>
   <head>
     <script src="plot_index_bundle.min.js" defer></script>
+    <link rel="stylesheet" href="styles.css">
   </head>
   <body>
   </body>
diff --git a/frc971/analysis/plot_data_utils.ts b/frc971/analysis/plot_data_utils.ts
index f8debe7..1362a09 100644
--- a/frc971/analysis/plot_data_utils.ts
+++ b/frc971/analysis/plot_data_utils.ts
@@ -48,10 +48,7 @@
             figureDiv.style.width = figure.position().width().toString() + 'px';
           }
           if (figure.position().height() == 0) {
-            // TODO(austin): I don't know the css for 100%, excluding other
-            // stuff in the div...  Just go with a little less for now, it's
-            // good enough and quite helpful.
-            figureDiv.style.height = '97%';
+            figureDiv.style.height = '100%';
           } else {
             figureDiv.style.height =
                 figure.position().height().toString() + 'px';
diff --git a/frc971/analysis/plotter_config.json b/frc971/analysis/plotter_config.json
index 49266ee..fef4a50 100644
--- a/frc971/analysis/plotter_config.json
+++ b/frc971/analysis/plotter_config.json
@@ -3,7 +3,7 @@
     {
       "name": "/analysis",
       "type": "frc971.analysis.Plot",
-      "max_size": 10000000
+      "max_size": 1000000000
     }
   ],
   "imports": [