Move generic packages from frc971/analysis to aos
To eliminate a dependency of aos on frc971.
Signed-off-by: Stephan Pleines <pleines.stephan@gmail.com>
Change-Id: Ic4a8f75da29f8e8c6675d96f02824cd08a9b99be
diff --git a/aos/analysis/BUILD b/aos/analysis/BUILD
new file mode 100644
index 0000000..63f2d57
--- /dev/null
+++ b/aos/analysis/BUILD
@@ -0,0 +1,108 @@
+load("//aos/flatbuffers:generate.bzl", "static_flatbuffer")
+load("//tools/build_rules:js.bzl", "ts_project")
+load("@com_github_google_flatbuffers//:typescript.bzl", "flatbuffer_ts_library")
+load("//aos:config.bzl", "aos_config")
+
+package(default_visibility = ["//visibility:public"])
+
+cc_binary(
+ name = "py_log_reader.so",
+ srcs = ["py_log_reader.cc"],
+ linkshared = True,
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ "//aos:configuration",
+ "//aos:json_to_flatbuffer",
+ "//aos/events:shm_event_loop",
+ "//aos/events:simulated_event_loop",
+ "//aos/events/logging:log_reader",
+ "//third_party/python",
+ "@com_github_google_glog//:glog",
+ ],
+)
+
+py_test(
+ name = "log_reader_test",
+ srcs = ["log_reader_test.py"],
+ data = [
+ ":py_log_reader.so",
+ "@sample_logfile//file",
+ ],
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = ["//aos:configuration_fbs_python"],
+)
+
+static_flatbuffer(
+ name = "plot_data_fbs",
+ srcs = [
+ "plot_data.fbs",
+ ],
+ target_compatible_with = ["@platforms//os:linux"],
+)
+
+flatbuffer_ts_library(
+ name = "plot_data_ts_fbs",
+ srcs = [
+ "plot_data.fbs",
+ ],
+ target_compatible_with = ["@platforms//os:linux"],
+)
+
+ts_project(
+ name = "plot_data_utils",
+ srcs = ["plot_data_utils.ts"],
+ visibility = ["//visibility:public"],
+ deps = [
+ ":plot_data_ts_fbs",
+ "//aos:configuration_ts_fbs",
+ "//aos/network/www:aos_plotter",
+ "//aos/network/www:plotter",
+ "//aos/network/www:proxy",
+ "@com_github_google_flatbuffers//reflection:reflection_ts_fbs",
+ "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+ ],
+)
+
+aos_config(
+ name = "plotter",
+ src = "plotter_config.json",
+ flatbuffers = [":plot_data_fbs"],
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = ["//aos/events:aos_config"],
+)
+
+cc_library(
+ name = "in_process_plotter",
+ srcs = ["in_process_plotter.cc"],
+ hdrs = ["in_process_plotter.h"],
+ data = [
+ ":plotter",
+ "//aos/analysis/cpp_plot:cpp_plot_files",
+ ],
+ deps = [
+ ":plot_data_fbs",
+ "//aos/events:simulated_event_loop",
+ "//aos/network:web_proxy",
+ ],
+)
+
+cc_binary(
+ name = "in_process_plotter_demo",
+ srcs = ["in_process_plotter_demo.cc"],
+ deps = [
+ ":in_process_plotter",
+ "//aos:init",
+ ],
+)
+
+cc_binary(
+ name = "local_foxglove",
+ srcs = ["local_foxglove.cc"],
+ data = ["@foxglove_studio"],
+ deps = [
+ "//aos:init",
+ "//aos/network:gen_embedded",
+ "//aos/seasocks:seasocks_logger",
+ "//third_party/seasocks",
+ ],
+)
diff --git a/aos/analysis/cpp_plot/BUILD b/aos/analysis/cpp_plot/BUILD
new file mode 100644
index 0000000..7286aaf
--- /dev/null
+++ b/aos/analysis/cpp_plot/BUILD
@@ -0,0 +1,42 @@
+load("//tools/build_rules:js.bzl", "rollup_bundle", "ts_project")
+
+package(default_visibility = ["//visibility:public"])
+
+ts_project(
+ name = "cpp_plot",
+ srcs = ["cpp_plot.ts"],
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ "//aos:configuration_ts_fbs",
+ "//aos/analysis:plot_data_utils",
+ "//aos/network/www:proxy",
+ ],
+)
+
+rollup_bundle(
+ name = "cpp_plot_bundle",
+ entry_point = "cpp_plot.ts",
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ ":cpp_plot",
+ ],
+)
+
+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/aos/analysis/cpp_plot/cpp_plot.ts b/aos/analysis/cpp_plot/cpp_plot.ts
new file mode 100644
index 0000000..08e2b1c
--- /dev/null
+++ b/aos/analysis/cpp_plot/cpp_plot.ts
@@ -0,0 +1,15 @@
+// Plotter for the C++ in-process plotter.
+import {Configuration} from '../../../aos/configuration_generated';
+import {Connection} from '../../../aos/network/www/proxy';
+import {plotData} from '../plot_data_utils';
+
+const rootDiv = document.createElement('div');
+rootDiv.classList.add('aos_cpp_plot');
+document.body.appendChild(rootDiv);
+
+const conn = new Connection();
+conn.connect();
+
+conn.addConfigHandler((config: Configuration) => {
+ plotData(conn, rootDiv);
+});
diff --git a/aos/analysis/cpp_plot/index.html b/aos/analysis/cpp_plot/index.html
new file mode 100644
index 0000000..fbb5199
--- /dev/null
+++ b/aos/analysis/cpp_plot/index.html
@@ -0,0 +1,9 @@
+<html>
+ <head>
+ <script src="cpp_plot_bundle.min.js" defer></script>
+ <link rel="stylesheet" href="styles.css">
+ </head>
+ <body>
+ </body>
+</html>
+
diff --git a/aos/analysis/in_process_plotter.cc b/aos/analysis/in_process_plotter.cc
new file mode 100644
index 0000000..a2ed68c
--- /dev/null
+++ b/aos/analysis/in_process_plotter.cc
@@ -0,0 +1,170 @@
+#include "aos/analysis/in_process_plotter.h"
+
+#include "aos/configuration.h"
+
+namespace frc971::analysis {
+
+namespace {
+const char *kDataPath = "frc971/analysis/cpp_plot";
+const char *kConfigPath = "frc971/analysis/plotter.json";
+} // namespace
+
+Plotter::Plotter()
+ : config_(aos::configuration::ReadConfig(kConfigPath)),
+ event_loop_factory_(&config_.message()),
+ event_loop_(event_loop_factory_.MakeEventLoop("plotter")),
+ plot_sender_(event_loop_->MakeSender<Plot>("/analysis")),
+ web_proxy_(event_loop_.get(), event_loop_factory_.scheduler_epoll(),
+ aos::web_proxy::StoreHistory::kYes, -1),
+ builder_(plot_sender_.MakeBuilder()) {
+ web_proxy_.SetDataPath(kDataPath);
+ event_loop_->SkipTimingReport();
+
+ 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() {
+ // Set non-infinite replay rate to avoid pegging a full CPU.
+ event_loop_factory_.SetRealtimeReplayRate(1.0);
+ event_loop_factory_.Run();
+}
+
+void Plotter::Title(std::string_view title) {
+ title_ = builder_.fbb()->CreateString(title);
+}
+
+void Plotter::AddFigure(std::string_view title, double width, double height) {
+ MaybeFinishFigure();
+
+ if (!title.empty()) {
+ figure_title_ = builder_.fbb()->CreateString(title);
+ }
+
+ // For positioning, just stack figures vertically.
+ auto position_builder = builder_.MakeBuilder<Position>();
+ position_builder.add_top(next_top_);
+ position_builder.add_left(0);
+ position_builder.add_width(width);
+ position_builder.add_height(height);
+ position_ = position_builder.Finish();
+
+ next_top_ += height;
+}
+
+void Plotter::XLabel(std::string_view label) {
+ xlabel_ = builder_.fbb()->CreateString(label);
+}
+
+void Plotter::YLabel(std::string_view label) {
+ ylabel_ = builder_.fbb()->CreateString(label);
+}
+
+void Plotter::AddLine(const std::vector<double> &x,
+ const std::vector<double> &y, LineOptions options) {
+ CHECK_EQ(x.size(), y.size()) << ": " << options.label;
+ CHECK(!position_.IsNull())
+ << "You must call AddFigure() before calling AddLine().";
+
+ flatbuffers::Offset<flatbuffers::String> label_offset;
+ if (!options.label.empty()) {
+ label_offset = builder_.fbb()->CreateString(options.label);
+ }
+
+ std::vector<Point> points;
+ for (size_t ii = 0; ii < x.size(); ++ii) {
+ points.emplace_back(x[ii], y[ii]);
+ }
+ const flatbuffers::Offset<flatbuffers::Vector<const Point *>> points_offset =
+ builder_.fbb()->CreateVectorOfStructs(points);
+
+ 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 (options.line_style.find('*') != options.line_style.npos) {
+ style_builder.add_point_size(options.point_size);
+ } else {
+ style_builder.add_point_size(0.0);
+ }
+ 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>();
+ line_builder.add_label(label_offset);
+ line_builder.add_points(points_offset);
+ line_builder.add_color(color);
+ line_builder.add_style(style_offset);
+ lines_.push_back(line_builder.Finish());
+}
+
+void Plotter::MaybeFinishFigure() {
+ if (!lines_.empty()) {
+ const flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<Line>>>
+ lines_offset = builder_.fbb()->CreateVector(lines_);
+ auto figure_builder = builder_.MakeBuilder<Figure>();
+ figure_builder.add_title(figure_title_);
+ figure_builder.add_position(position_);
+ figure_builder.add_lines(lines_offset);
+ figure_builder.add_xlabel(xlabel_);
+ figure_builder.add_share_x_axis(share_x_axis_);
+ figure_builder.add_ylabel(ylabel_);
+ figures_.push_back(figure_builder.Finish());
+ }
+ lines_.clear();
+ figure_title_.o = 0;
+ xlabel_.o = 0;
+ ylabel_.o = 0;
+ position_.o = 0;
+ share_x_axis_ = false;
+ color_wheel_position_ = 0;
+}
+
+void Plotter::Publish() {
+ MaybeFinishFigure();
+ const flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<Figure>>>
+ figures_offset = builder_.fbb()->CreateVector(figures_);
+
+ auto plot_builder = builder_.MakeBuilder<Plot>();
+ plot_builder.add_title(title_);
+ plot_builder.add_figures(figures_offset);
+
+ CHECK_EQ(builder_.Send(plot_builder.Finish()), aos::RawSender::Error::kOk);
+
+ builder_ = plot_sender_.MakeBuilder();
+
+ title_.o = 0;
+ figures_.clear();
+ next_top_ = 0;
+}
+
+} // namespace frc971::analysis
diff --git a/aos/analysis/in_process_plotter.h b/aos/analysis/in_process_plotter.h
new file mode 100644
index 0000000..fdfde8c
--- /dev/null
+++ b/aos/analysis/in_process_plotter.h
@@ -0,0 +1,99 @@
+#ifndef FRC971_ANALYSIS_IN_PROCESS_PLOTTER_H_
+#define FRC971_ANALYSIS_IN_PROCESS_PLOTTER_H_
+
+#include <vector>
+
+#include "aos/analysis/plot_data_generated.h"
+#include "aos/events/simulated_event_loop.h"
+#include "aos/network/web_proxy.h"
+
+namespace frc971::analysis {
+
+// This class wraps the WebProxy class to provide a convenient C++ interface to
+// dynamically generate plots.
+// Currently, the main useful interface that this provides is a matplotlib-like
+// interface--see in_process_plotter_demo.cc for sample usage. It doesn't
+// precisely follow matplotlib's conventions, but the basic style does mimic
+// matplotlib. Future iterations may ditch this in favor of a more modern
+// interface where we actually return handles for plots and lines and the such.
+//
+// Note that currently the port for the seb server is hard-coded to 8080, so
+// only one instance of the Plotter can be present at once.
+//
+// You must call Spin() for the web server to actually do anything helpful.
+class Plotter {
+ public:
+ Plotter();
+
+ // matplotlib-like interface
+ // The basic pattern is:
+ // 1) Call Figure()
+ // 2) Set up the lines, labels, etc. for the figure.
+ // 3) Repeat 1-2 however many times.
+ // 4) Call Publish().
+ // 5) Repeat 1-5 however many times.
+ //
+ // Publish() actually pushes the figures that you setup to the web-page,
+ // either with an autogenerated title or the title set by Title(). All state
+ // is cleared (or should be cleared) by the call to Publish().
+
+ // Sets the title for the current set of plots; if you
+ 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 = "";
+ double point_size = 3.0;
+ };
+
+ void AddLine(const std::vector<double> &x, const std::vector<double> &y,
+ 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();
+
+ aos::FlatbufferDetachedBuffer<aos::Configuration> config_;
+ aos::SimulatedEventLoopFactory event_loop_factory_;
+ std::unique_ptr<aos::EventLoop> event_loop_;
+ aos::Sender<Plot> plot_sender_;
+ aos::web_proxy::WebProxy web_proxy_;
+
+ aos::Sender<Plot>::Builder builder_;
+ flatbuffers::Offset<flatbuffers::String> title_;
+ flatbuffers::Offset<flatbuffers::String> figure_title_;
+ flatbuffers::Offset<flatbuffers::String> xlabel_;
+ flatbuffers::Offset<flatbuffers::String> ylabel_;
+ bool share_x_axis_ = false;
+ float next_top_ = 0;
+ flatbuffers::Offset<Position> position_;
+ 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<ColorWheelColor> color_wheel_;
+};
+
+} // namespace frc971::analysis
+#endif // FRC971_ANALYSIS_IN_PROCESS_PLOTTER_H_
diff --git a/aos/analysis/in_process_plotter_demo.cc b/aos/analysis/in_process_plotter_demo.cc
new file mode 100644
index 0000000..a02451a
--- /dev/null
+++ b/aos/analysis/in_process_plotter_demo.cc
@@ -0,0 +1,41 @@
+#include "aos/analysis/in_process_plotter.h"
+#include "aos/init.h"
+
+// To run this example, do:
+// bazel run -c opt //frc971/analysis:in_process_plotter_demo
+// And then open localhost:8080, select "C++ Plotter" from the drop-down, and
+// then select "TITLE!" or "Trig Functions" from the second drop-down to see
+// each plot.
+int main(int argc, char *argv[]) {
+ aos::InitGoogle(&argc, &argv);
+ frc971::analysis::Plotter plotter;
+ plotter.Title("TITLE!");
+ plotter.AddFigure("Fig Foo");
+ plotter.ShareXAxis(true);
+ plotter.AddLine({1, 2, 3, 4, 5}, {1, 2, 3, 4, 5}, "y = x");
+ plotter.AddLine({5, 4, 3, 2, 1}, {1, 2, 3, 4, 5}, "y = -x");
+ plotter.YLabel("Y Axis");
+ plotter.AddFigure("Fig Bar");
+ plotter.ShareXAxis(true);
+ plotter.AddLine({1, 2, 3}, {3, 4, 5}, "y = x + 2");
+ plotter.XLabel("X Axis (Linked to both above plots)");
+ plotter.Publish();
+
+ plotter.Title("Trig Functions");
+
+ plotter.AddFigure("Sin & Cos");
+ std::vector<double> x;
+ std::vector<double> sinx;
+ std::vector<double> cosx;
+ constexpr int kNumPoints = 100000;
+ for (int ii = 0; ii < kNumPoints; ++ii) {
+ x.push_back(ii * 2 * M_PI / kNumPoints);
+ sinx.push_back(std::sin(x.back()));
+ cosx.push_back(std::cos(x.back()));
+ }
+ plotter.AddLine(x, sinx, "sin(x)");
+ plotter.AddLine(x, cosx, "cos(x)");
+ plotter.Publish();
+
+ plotter.Spin();
+}
diff --git a/aos/analysis/local_foxglove.cc b/aos/analysis/local_foxglove.cc
new file mode 100644
index 0000000..ade56a5
--- /dev/null
+++ b/aos/analysis/local_foxglove.cc
@@ -0,0 +1,19 @@
+#include "glog/logging.h"
+
+#include "aos/init.h"
+#include "aos/seasocks/seasocks_logger.h"
+#include "internal/Embedded.h"
+#include "seasocks/Server.h"
+
+DEFINE_string(data_path, "external/foxglove_studio",
+ "Path to foxglove studio files to serve.");
+DEFINE_uint32(port, 8000, "Port to serve files at.");
+
+int main(int argc, char *argv[]) {
+ aos::InitGoogle(&argc, &argv);
+ // Magic for seasocks.
+ findEmbeddedContent("");
+ ::seasocks::Server server(std::make_shared<aos::seasocks::SeasocksLogger>(
+ ::seasocks::Logger::Level::Info));
+ server.serve(FLAGS_data_path.c_str(), FLAGS_port);
+}
diff --git a/aos/analysis/log_reader_test.py b/aos/analysis/log_reader_test.py
new file mode 100644
index 0000000..69627aa
--- /dev/null
+++ b/aos/analysis/log_reader_test.py
@@ -0,0 +1,115 @@
+#!/usr/bin/python3
+import json
+import unittest
+
+from aos.analysis.py_log_reader import LogReader
+
+
+class LogReaderTest(unittest.TestCase):
+
+ def setUp(self):
+ self.reader = LogReader("external/sample_logfile/file/log.fbs")
+ # A list of all the channels in the logfile--this is used to confirm that
+ # we did indeed read the config correctly.
+ self.all_channels = [
+ ("/aos", "aos.JoystickState"), ("/aos", "aos.RobotState"),
+ ("/aos", "aos.timing.Report"), ("/aos", "frc971.PDPValues"),
+ ("/aos", "frc971.wpilib.PneumaticsToLog"),
+ ("/autonomous", "aos.common.actions.Status"),
+ ("/autonomous", "frc971.autonomous.AutonomousMode"),
+ ("/autonomous", "frc971.autonomous.Goal"),
+ ("/camera", "y2019.CameraLog"),
+ ("/camera", "y2019.control_loops.drivetrain.CameraFrame"),
+ ("/drivetrain", "frc971.IMUValues"),
+ ("/drivetrain", "frc971.control_loops.drivetrain.Goal"),
+ ("/drivetrain",
+ "frc971.control_loops.drivetrain.LocalizerControl"),
+ ("/drivetrain", "frc971.control_loops.drivetrain.Output"),
+ ("/drivetrain", "frc971.control_loops.drivetrain.Position"),
+ ("/drivetrain", "frc971.control_loops.drivetrain.Status"),
+ ("/drivetrain", "frc971.sensors.GyroReading"),
+ ("/drivetrain",
+ "y2019.control_loops.drivetrain.TargetSelectorHint"),
+ ("/superstructure", "y2019.StatusLight"),
+ ("/superstructure", "y2019.control_loops.superstructure.Goal"),
+ ("/superstructure", "y2019.control_loops.superstructure.Output"),
+ ("/superstructure", "y2019.control_loops.superstructure.Position"),
+ ("/superstructure", "y2019.control_loops.superstructure.Status")
+ ]
+ # A channel that is known to have data on it which we will use for testing.
+ self.test_channel = ("/aos", "aos.timing.Report")
+ # A non-existent channel
+ self.bad_channel = ("/aos", "aos.timing.FooBar")
+
+ def test_do_nothing(self):
+ """Tests that we sanely handle doing nothing.
+
+ A previous iteration of the log reader seg faulted when doing this."""
+ pass
+
+ @unittest.skip("broken by flatbuffer upgrade")
+ def test_read_config(self):
+ """Tests that we can read the configuration from the logfile."""
+ config_bytes = self.reader.configuration()
+ config = Configuration.GetRootAsConfiguration(config_bytes, 0)
+
+ channel_set = set(self.all_channels)
+ for ii in range(config.ChannelsLength()):
+ channel = config.Channels(ii)
+ # Will raise KeyError if the channel does not exist
+ channel_set.remove((channel.Name().decode("utf-8"),
+ channel.Type().decode("utf-8")))
+
+ self.assertEqual(0, len(channel_set))
+
+ def test_empty_process(self):
+ """Tests running process() without subscribing to anything succeeds."""
+ self.reader.process()
+ for channel in self.all_channels:
+ with self.assertRaises(ValueError) as context:
+ self.reader.get_data_for_channel(channel[0], channel[1])
+
+ def test_subscribe(self):
+ """Tests that we can subscribe to a channel and get data out."""
+ name = self.test_channel[0]
+ message_type = self.test_channel[1]
+ self.assertTrue(self.reader.subscribe(name, message_type))
+ self.reader.process()
+ data = self.reader.get_data_for_channel(name, message_type)
+ self.assertLess(100, len(data))
+ last_monotonic_time = 0
+ for entry in data:
+ monotonic_time = entry[0]
+ realtime_time = entry[1]
+ json_data = entry[2].replace('nan', '\"nan\"')
+ self.assertLess(last_monotonic_time, monotonic_time)
+ # Sanity check that the realtime times are in the correct range.
+ self.assertLess(1500000000e9, realtime_time)
+ self.assertGreater(2000000000e9, realtime_time)
+ parsed_json = json.loads(json_data)
+ self.assertIn("name", parsed_json)
+
+ last_monotonic_time = monotonic_time
+
+ def test_bad_subscribe(self):
+ """Tests that we return false when subscribing to a non-existent channel."""
+ self.assertFalse(
+ self.reader.subscribe(self.bad_channel[0], self.bad_channel[1]),
+ self.bad_channel)
+
+ def test_subscribe_after_process(self):
+ """Tests that an exception is thrown if we subscribe after calling process()."""
+ self.reader.process()
+ for channel in self.all_channels:
+ with self.assertRaises(RuntimeError) as context:
+ self.reader.subscribe(channel[0], channel[1])
+
+ def test_get_data_before_processj(self):
+ """Tests that an exception is thrown if we retrieve data before calling process()."""
+ for channel in self.all_channels:
+ with self.assertRaises(RuntimeError) as context:
+ self.reader.get_data_for_channel(channel[0], channel[1])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/aos/analysis/plot_data.fbs b/aos/analysis/plot_data.fbs
new file mode 100644
index 0000000..641cd6e
--- /dev/null
+++ b/aos/analysis/plot_data.fbs
@@ -0,0 +1,59 @@
+// This flatbuffer defines the interface that is used by the in-process
+// web plotter to plot data dynamically. Both the structure of the plot and
+// the data to plot is all packaged within a single Plot message. Each Plot
+// message will correspond to a single view/tab on the web-page, and can have
+// multiple figures, each of which can have multiple lines.
+namespace frc971.analysis;
+
+// Position within the web-page to plot a figure at. [0, 0] will be the upper
+// left corner of the allowable places where plots can be put, and should
+// generally be the default location. All values in pixels.
+table Position {
+ top:float (id: 0);
+ left:float (id: 1);
+ width:float (id: 2);
+ height:float (id: 3);
+}
+
+struct Point {
+ x:double (id: 0);
+ y:double (id: 1);
+}
+
+// RGB values are in the range [0, 1].
+struct Color {
+ r:float (id: 0);
+ g:float (id: 1);
+ b:float (id: 2);
+}
+
+table LineStyle {
+ point_size:float (id: 0);
+ draw_line:bool (id: 1);
+}
+
+table Line {
+ label:string (id: 0);
+ points:[Point] (id: 1);
+ color:Color (id: 2);
+ style:LineStyle (id: 3);
+}
+
+table Figure {
+ position:Position (id: 0);
+ lines:[Line] (id: 1);
+ // Specifies whether to link the x-axis of this Figure with that of other
+ // figures in this Plot. Only the axes of Figure's with this flag set will
+ // be linked.
+ share_x_axis:bool (id: 2);
+ title:string (id: 3);
+ xlabel:string (id: 4);
+ ylabel:string (id: 5);
+}
+
+table Plot {
+ figures:[Figure] (id: 0);
+ title:string (id: 1);
+}
+
+root_type Plot;
diff --git a/aos/analysis/plot_data_utils.ts b/aos/analysis/plot_data_utils.ts
new file mode 100644
index 0000000..25131fb
--- /dev/null
+++ b/aos/analysis/plot_data_utils.ts
@@ -0,0 +1,110 @@
+// Provides a plot which handles plotting the plot defined by a
+// frc971.analysis.Plot message.
+import {Plot as PlotFb} from './plot_data_generated';
+import {MessageHandler, TimestampedMessage} from '../../aos/network/www/aos_plotter';
+import {ByteBuffer} from 'flatbuffers';
+import {Plot, Point} from '../../aos/network/www/plotter';
+import {Connection} from '../../aos/network/www/proxy';
+import {Schema} from 'flatbuffers_reflection/reflection_generated';
+
+export function plotData(conn: Connection, parentDiv: Element) {
+ // Set up a selection box to allow the user to choose between plots to show.
+ const plotSelect = document.createElement('select');
+ parentDiv.appendChild(plotSelect);
+ const plots = new Map<string, HTMLElement>();
+ const invalidSelectValue = 'null';
+ plotSelect.addEventListener('input', () => {
+ for (const plot of plots.values()) {
+ plot.style.display = 'none';
+ }
+ if (plotSelect.value == invalidSelectValue) {
+ return;
+ }
+ plots.get(plotSelect.value).style.display = 'block';
+ });
+ plotSelect.add(new Option('Select Plot', invalidSelectValue));
+
+ const plotDiv = document.createElement('div');
+ parentDiv.appendChild(plotDiv);
+
+ conn.addReliableHandler(
+ '/analysis', 'frc971.analysis.Plot', (data: Uint8Array, time: number) => {
+ const plotFb = PlotFb.getRootAsPlot(new ByteBuffer(data));
+ const name = (!plotFb.title()) ? 'Plot ' + plots.size : plotFb.title();
+ const div = document.createElement('div');
+ div.style.display = 'none';
+ plots.set(name, div);
+ plotDiv.appendChild(div);
+ plotSelect.add(new Option(name, name));
+
+ const linkedXAxes: Plot[] = [];
+
+ for (let ii = 0; ii < plotFb.figuresLength(); ++ii) {
+ const figure = plotFb.figures(ii);
+ const figureDiv = document.createElement('div');
+ if (figure.position().width() == 0) {
+ figureDiv.style.width = '100%';
+ } else {
+ figureDiv.style.width = figure.position().width().toString() + 'px';
+ }
+ if (figure.position().height() == 0) {
+ figureDiv.style.height = '100%';
+ } else {
+ figureDiv.style.height =
+ figure.position().height().toString() + 'px';
+ }
+ figureDiv.style.position = 'relative';
+ div.appendChild(figureDiv);
+ const plot = new Plot(figureDiv);
+
+ if (figure.title()) {
+ plot.getAxisLabels().setTitle(figure.title());
+ }
+ if (figure.xlabel()) {
+ plot.getAxisLabels().setXLabel(figure.xlabel());
+ }
+ if (figure.ylabel()) {
+ plot.getAxisLabels().setYLabel(figure.ylabel());
+ }
+ if (figure.shareXAxis()) {
+ for (const other of linkedXAxes) {
+ plot.linkXAxis(other);
+ }
+ linkedXAxes.push(plot);
+ }
+
+ for (let jj = 0; jj < figure.linesLength(); ++jj) {
+ const lineFb = figure.lines(jj);
+ const line = plot.getDrawer().addLine();
+ if (lineFb.label()) {
+ line.setLabel(lineFb.label());
+ }
+ const points = [];
+ for (let kk = 0; kk < lineFb.pointsLength(); ++kk) {
+ const point = lineFb.points(kk);
+ points.push(new Point(point.x(), point.y()));
+ }
+ if (lineFb.color()) {
+ line.setColor(
+ [lineFb.color().r(), lineFb.color().g(), lineFb.color().b()]);
+ }
+ if (lineFb.style()) {
+ if (lineFb.style().pointSize() !== null) {
+ line.setPointSize(lineFb.style().pointSize());
+ }
+ if (lineFb.style().drawLine() !== null) {
+ line.setDrawLine(lineFb.style().drawLine());
+ }
+ }
+ line.setPoints(points);
+ }
+ }
+
+ // If this is the first new element (ignoring the placeholder up top),
+ // select it by default.
+ if (plotSelect.length == 2) {
+ plotSelect.value = name;
+ plotSelect.dispatchEvent(new Event('input'));
+ }
+ });
+}
diff --git a/aos/analysis/plotter_config.json b/aos/analysis/plotter_config.json
new file mode 100644
index 0000000..fef4a50
--- /dev/null
+++ b/aos/analysis/plotter_config.json
@@ -0,0 +1,12 @@
+{
+ "channels": [
+ {
+ "name": "/analysis",
+ "type": "frc971.analysis.Plot",
+ "max_size": 1000000000
+ }
+ ],
+ "imports": [
+ "../../aos/events/aos.json"
+ ]
+}
diff --git a/aos/analysis/py_log_reader.cc b/aos/analysis/py_log_reader.cc
new file mode 100644
index 0000000..57d57d0
--- /dev/null
+++ b/aos/analysis/py_log_reader.cc
@@ -0,0 +1,306 @@
+// This file provides a Python module for reading logfiles. See
+// log_reader_test.py for usage.
+//
+// NOTE: This code has not been maintained recently, and so is missing key
+// features to support reading multi-node logfiles (namely, it assumes the the
+// logfile is just a single file). Updating this code should not be difficult,
+// but hasn't been needed thus far.
+//
+// This reader works by having the user specify exactly what channels they want
+// data for. We then process the logfile and store all the data on that channel
+// into a list of timestamps + JSON message data. The user can then use an
+// accessor method (get_data_for_channel) to retrieve the cached data.
+
+// Defining PY_SSIZE_T_CLEAN seems to be suggested by most of the Python
+// documentation.
+#define PY_SSIZE_T_CLEAN
+// Note that Python.h needs to be included before anything else.
+#include <Python.h>
+
+#include <cerrno>
+#include <memory>
+
+#include "aos/configuration.h"
+#include "aos/events/logging/log_reader.h"
+#include "aos/events/simulated_event_loop.h"
+#include "aos/flatbuffer_merge.h"
+#include "aos/init.h"
+#include "aos/json_to_flatbuffer.h"
+
+namespace frc971::analysis {
+namespace {
+
+// All the data corresponding to a single message.
+struct MessageData {
+ aos::monotonic_clock::time_point monotonic_sent_time;
+ aos::realtime_clock::time_point realtime_sent_time;
+ // JSON representation of the message.
+ std::string json_data;
+};
+
+// Data corresponding to an entire channel.
+struct ChannelData {
+ std::string name;
+ std::string type;
+ // Each message published on the channel, in order by monotonic time.
+ std::vector<MessageData> messages;
+};
+
+// All the objects that we need for managing reading a logfile.
+struct LogReaderTools {
+ std::unique_ptr<aos::logger::LogReader> reader;
+ // Event loop to use for subscribing to buses.
+ std::unique_ptr<aos::EventLoop> event_loop;
+ std::vector<ChannelData> channel_data;
+ // Whether we have called process() on the reader yet.
+ bool processed = false;
+};
+
+struct LogReaderType {
+ PyObject_HEAD;
+ LogReaderTools *tools = nullptr;
+};
+
+void LogReader_dealloc(LogReaderType *self) {
+ LogReaderTools *tools = self->tools;
+ delete tools;
+ Py_TYPE(self)->tp_free((PyObject *)self);
+}
+
+PyObject *LogReader_new(PyTypeObject *type, PyObject * /*args*/,
+ PyObject * /*kwds*/) {
+ LogReaderType *self;
+ self = (LogReaderType *)type->tp_alloc(type, 0);
+ if (self != nullptr) {
+ self->tools = new LogReaderTools();
+ if (self->tools == nullptr) {
+ return nullptr;
+ }
+ }
+ return (PyObject *)self;
+}
+
+int LogReader_init(LogReaderType *self, PyObject *args, PyObject *kwds) {
+ int count = 1;
+ if (!aos::IsInitialized()) {
+ // Fake out argc and argv to let InitGoogle run properly to instrument
+ // malloc, setup glog, and such.
+ char *name = program_invocation_name;
+ char **argv = &name;
+ aos::InitGoogle(&count, &argv);
+ }
+
+ const char *kwlist[] = {"log_file_name", nullptr};
+
+ const char *log_file_name;
+ if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast<char **>(kwlist),
+ &log_file_name)) {
+ return -1;
+ }
+
+ LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+ tools->reader = std::make_unique<aos::logger::LogReader>(log_file_name);
+ tools->reader->Register();
+
+ if (aos::configuration::MultiNode(tools->reader->configuration())) {
+ tools->event_loop = tools->reader->event_loop_factory()->MakeEventLoop(
+ "data_fetcher",
+ aos::configuration::GetNode(tools->reader->configuration(), "roborio"));
+ } else {
+ tools->event_loop =
+ tools->reader->event_loop_factory()->MakeEventLoop("data_fetcher");
+ }
+ tools->event_loop->SkipTimingReport();
+ tools->event_loop->SkipAosLog();
+
+ return 0;
+}
+
+PyObject *LogReader_get_data_for_channel(LogReaderType *self, PyObject *args,
+ PyObject *kwds) {
+ const char *kwlist[] = {"name", "type", nullptr};
+
+ const char *name;
+ const char *type;
+ if (!PyArg_ParseTupleAndKeywords(args, kwds, "ss",
+ const_cast<char **>(kwlist), &name, &type)) {
+ return nullptr;
+ }
+
+ LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+
+ if (!tools->processed) {
+ PyErr_SetString(PyExc_RuntimeError,
+ "Called get_data_for_bus before calling process().");
+ return nullptr;
+ }
+
+ for (const auto &channel : tools->channel_data) {
+ if (channel.name == name && channel.type == type) {
+ PyObject *list = PyList_New(channel.messages.size());
+ for (size_t ii = 0; ii < channel.messages.size(); ++ii) {
+ const auto &message = channel.messages[ii];
+ PyObject *monotonic_time = PyLong_FromLongLong(
+ std::chrono::duration_cast<std::chrono::nanoseconds>(
+ message.monotonic_sent_time.time_since_epoch())
+ .count());
+ PyObject *realtime_time = PyLong_FromLongLong(
+ std::chrono::duration_cast<std::chrono::nanoseconds>(
+ message.realtime_sent_time.time_since_epoch())
+ .count());
+ PyObject *json_data = PyUnicode_FromStringAndSize(
+ message.json_data.data(), message.json_data.size());
+ PyObject *entry =
+ PyTuple_Pack(3, monotonic_time, realtime_time, json_data);
+ if (PyList_SetItem(list, ii, entry) != 0) {
+ return nullptr;
+ }
+ }
+ return list;
+ }
+ }
+ PyErr_SetString(PyExc_ValueError,
+ "The provided channel was never subscribed to.");
+ return nullptr;
+}
+
+PyObject *LogReader_subscribe(LogReaderType *self, PyObject *args,
+ PyObject *kwds) {
+ const char *kwlist[] = {"name", "type", nullptr};
+
+ const char *name;
+ const char *type;
+ if (!PyArg_ParseTupleAndKeywords(args, kwds, "ss",
+ const_cast<char **>(kwlist), &name, &type)) {
+ return nullptr;
+ }
+
+ LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+
+ if (tools->processed) {
+ PyErr_SetString(PyExc_RuntimeError,
+ "Called subscribe after calling process().");
+ return nullptr;
+ }
+
+ const aos::Channel *const channel = aos::configuration::GetChannel(
+ tools->reader->configuration(), name, type, "", nullptr);
+ if (channel == nullptr) {
+ return Py_False;
+ }
+ const int index = tools->channel_data.size();
+ tools->channel_data.push_back({.name = name, .type = type, .messages = {}});
+ tools->event_loop->MakeRawWatcher(
+ channel, [channel, index, tools](const aos::Context &context,
+ const void *message) {
+ tools->channel_data[index].messages.push_back(
+ {.monotonic_sent_time = context.monotonic_event_time,
+ .realtime_sent_time = context.realtime_event_time,
+ .json_data = aos::FlatbufferToJson(
+ channel->schema(), static_cast<const uint8_t *>(message))});
+ });
+ return Py_True;
+}
+
+static PyObject *LogReader_process(LogReaderType *self,
+ PyObject *Py_UNUSED(ignored)) {
+ LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+
+ if (tools->processed) {
+ PyErr_SetString(PyExc_RuntimeError, "process() may only be called once.");
+ return nullptr;
+ }
+
+ tools->processed = true;
+
+ tools->reader->event_loop_factory()->Run();
+
+ Py_RETURN_NONE;
+}
+
+static PyObject *LogReader_configuration(LogReaderType *self,
+ PyObject *Py_UNUSED(ignored)) {
+ LogReaderTools *tools = CHECK_NOTNULL(self->tools);
+
+ // I have no clue if the Configuration that we get from the log reader is in a
+ // contiguous chunk of memory, and I'm too lazy to either figure it out or
+ // figure out how to extract the actual data buffer + offset.
+ // Instead, copy the flatbuffer and return a copy of the new buffer.
+ aos::FlatbufferDetachedBuffer<aos::Configuration> buffer =
+ aos::CopyFlatBuffer(tools->reader->configuration());
+
+ return PyBytes_FromStringAndSize(
+ reinterpret_cast<const char *>(buffer.span().data()),
+ buffer.span().size());
+}
+
+static PyMethodDef LogReader_methods[] = {
+ {"configuration", (PyCFunction)LogReader_configuration, METH_NOARGS,
+ "Return a bytes buffer for the Configuration of the logfile."},
+ {"process", (PyCFunction)LogReader_process, METH_NOARGS,
+ "Processes the logfile and all the subscribed to channels."},
+ {"subscribe", (PyCFunction)LogReader_subscribe,
+ METH_VARARGS | METH_KEYWORDS,
+ "Attempts to subscribe to the provided channel name + type. Returns True "
+ "if successful."},
+ {"get_data_for_channel", (PyCFunction)LogReader_get_data_for_channel,
+ METH_VARARGS | METH_KEYWORDS,
+ "Returns the logged data for a given channel. Raises an exception if you "
+ "did not subscribe to the provided channel. Returned data is a list of "
+ "tuples where each tuple is of the form (monotonic_nsec, realtime_nsec, "
+ "json_message_data)."},
+ {nullptr, 0, 0, nullptr} /* Sentinel */
+};
+
+#ifdef __clang__
+// These extensions to C++ syntax do surprising things in C++, but for these
+// uses none of them really matter I think, and the alternatives are really
+// annoying.
+#pragma clang diagnostic ignored "-Wc99-designator"
+#endif
+
+static PyTypeObject LogReaderType = {
+ PyVarObject_HEAD_INIT(NULL, 0)
+ // The previous macro initializes some fields, leave a comment to help
+ // clang-format not make this uglier.
+ .tp_name = "py_log_reader.LogReader",
+ .tp_basicsize = sizeof(LogReaderType),
+ .tp_itemsize = 0,
+ .tp_dealloc = (destructor)LogReader_dealloc,
+ .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,
+ .tp_doc = "LogReader objects",
+ .tp_methods = LogReader_methods,
+ .tp_init = (initproc)LogReader_init,
+ .tp_new = LogReader_new,
+};
+
+static PyModuleDef log_reader_module = {
+ PyModuleDef_HEAD_INIT,
+ .m_name = "py_log_reader",
+ .m_doc = "Example module that creates an extension type.",
+ .m_size = -1,
+};
+
+PyObject *InitModule() {
+ PyObject *m;
+ if (PyType_Ready(&LogReaderType) < 0) return nullptr;
+
+ m = PyModule_Create(&log_reader_module);
+ if (m == nullptr) return nullptr;
+
+ Py_INCREF(&LogReaderType);
+ if (PyModule_AddObject(m, "LogReader", (PyObject *)&LogReaderType) < 0) {
+ Py_DECREF(&LogReaderType);
+ Py_DECREF(m);
+ return nullptr;
+ }
+
+ return m;
+}
+
+} // namespace
+} // namespace frc971::analysis
+
+PyMODINIT_FUNC PyInit_py_log_reader(void) {
+ return frc971::analysis::InitModule();
+}