Provide utilities for convenient plotting of channels
Implement a set of utilities (along with basic examples) to
make it so that we can readily make plots of individual channels
from logfiles (or live on the robot).
Change-Id: Ic648c9ccb9dcb73419dc2c8c4c395fdea0536110
diff --git a/aos/network/BUILD b/aos/network/BUILD
index c68f3cc..fa5cacc 100644
--- a/aos/network/BUILD
+++ b/aos/network/BUILD
@@ -441,18 +441,12 @@
name = "log_web_proxy_main",
srcs = ["log_web_proxy_main.cc"],
args = [
- "--config=aos/network/www/test_config.json",
"--data_dir=aos/network/www",
],
copts = [
"-DWEBRTC_POSIX",
"-Wno-unused-parameter",
],
- data = [
- "//aos/network/www:plotting_sample",
- "//y2020:config",
- "@com_github_google_flatbuffers//:flatjs",
- ],
deps = [
":gen_embedded",
":web_proxy",
diff --git a/aos/network/log_web_proxy_main.cc b/aos/network/log_web_proxy_main.cc
index 525eb29..dda9ae4 100644
--- a/aos/network/log_web_proxy_main.cc
+++ b/aos/network/log_web_proxy_main.cc
@@ -16,7 +16,6 @@
#include "seasocks/Server.h"
#include "seasocks/WebSocket.h"
-DEFINE_string(config, "./config.json", "File path of aos configuration");
DEFINE_string(data_dir, "www", "Directory to serve data files from");
DEFINE_string(node, "", "Directory to serve data files from");
DEFINE_int32(buffer_size, -1, "-1 if infinite, in # of messages / channel.");
diff --git a/aos/network/www/BUILD b/aos/network/www/BUILD
index 8f5bd49..a153ff3 100644
--- a/aos/network/www/BUILD
+++ b/aos/network/www/BUILD
@@ -102,11 +102,12 @@
)
ts_library(
- name = "graph_main",
+ name = "aos_plotter",
srcs = [
- "graph_main.ts",
+ "aos_plotter.ts",
],
target_compatible_with = ["@platforms//os:linux"],
+ visibility = ["//visibility:public"],
deps = [
":plotter",
":proxy",
@@ -118,13 +119,23 @@
],
)
-rollup_bundle(
- name = "graph_main_bundle",
- enable_code_splitting = False,
- entry_point = "graph_main.ts",
+ts_library(
+ name = "demo_plot",
+ srcs = [
+ "demo_plot.ts",
+ ],
target_compatible_with = ["@platforms//os:linux"],
+ visibility = ["//visibility:public"],
deps = [
- ":graph_main",
+ ":aos_plotter",
+ ":plotter",
+ ":proxy",
+ ":reflection_ts",
+ "//aos:configuration_ts_fbs",
+ "//aos/network:connect_ts_fbs",
+ "//aos/network:web_proxy_ts_fbs",
+ "//frc971/wpilib:imu_batch_ts_fbs",
+ "@com_github_google_flatbuffers//ts:flatbuffers_ts",
],
)
@@ -136,6 +147,7 @@
"//aos:json_to_flatbuffer_flatbuffer",
],
target_compatible_with = ["@platforms//os:linux"],
+ visibility = ["//visibility:public"],
deps = [
"//aos/events:config",
],
@@ -155,8 +167,6 @@
name = "web_proxy_demo",
srcs = ["web_proxy_demo.sh"],
data = [
- ":graph.html",
- ":graph_main_bundle.min.js",
":reflection_test.html",
":reflection_test_bundle.min.js",
":test_config",
@@ -165,14 +175,3 @@
],
target_compatible_with = ["@platforms//os:linux"],
)
-
-filegroup(
- name = "plotting_sample",
- srcs = [
- "graph.html",
- "graph_main_bundle.min.js",
- "styles.css",
- "test_config",
- ],
- visibility = ["//visibility:public"],
-)
diff --git a/aos/network/www/aos_plotter.ts b/aos/network/www/aos_plotter.ts
new file mode 100644
index 0000000..33a3de1
--- /dev/null
+++ b/aos/network/www/aos_plotter.ts
@@ -0,0 +1,185 @@
+// This library provides a wrapper around our WebGL plotter that makes it
+// easy to plot AOS messages/channels as time series.
+//
+// This is works by subscribing to each channel that we want to plot, storing
+// all the messages for that channel, and then periodically running through
+// every message and extracting the fields to plot.
+// It is also possible to insert code to make modifications to the messages
+// as we read/process them, as is the case for the IMU processing code (see
+// //frc971/wpilib:imu*.ts) where each message is actually a batch of several
+// individual messages that need to be plotted as separate points.
+//
+// The basic flow for using the AosPlotter is:
+// // 1) Construct the plotter
+// const aosPlotter = new AosPlotter(connection);
+// // 2) Add messages sources that we'll want to subscribe to.
+// const source = aosPlotter.addMessageSource('/aos', 'aos.timing.Report');
+// // 3) Create figures at defined positions within a given HTML element..
+// const timingPlot = aosPlotter.addPlot(parentDiv, [0, 0], [width, height]);
+// // 4) Add specific signals to each figure, using the message sources you
+// defined at the start.
+// timingPlot.addMessageLine(source, ['pid']);
+//
+// The demo_plot.ts script has a basic example of using this library, with all
+// the required boilerplate, as well as some extra examples about how to
+// add axis labels and the such.
+import * as configuration from 'org_frc971/aos/configuration_generated';
+import {Line, Plot} from 'org_frc971/aos/network/www/plotter';
+import * as proxy from 'org_frc971/aos/network/www/proxy';
+import * as web_proxy from 'org_frc971/aos/network/web_proxy_generated';
+import * as reflection from 'org_frc971/aos/network/www/reflection'
+import * as flatbuffers_builder from 'org_frc971/external/com_github_google_flatbuffers/ts/builder';
+import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
+
+import Channel = configuration.aos.Channel;
+import Connection = proxy.Connection;
+import Configuration = configuration.aos.Configuration;
+import Schema = configuration.reflection.Schema;
+import Parser = reflection.Parser;
+import Table = reflection.Table;
+import SubscriberRequest = web_proxy.aos.web_proxy.SubscriberRequest;
+import ChannelRequest = web_proxy.aos.web_proxy.ChannelRequest;
+import TransferMethod = web_proxy.aos.web_proxy.TransferMethod;
+
+export class TimestampedMessage {
+ constructor(
+ public readonly message: Table, public readonly time: number) {}
+}
+
+// The MessageHandler stores an array of every single message on a given channel
+// and then supplies individual fields as arrays when requested. Currently this
+// is very much unoptimized and re-processes the entire array of messages on
+// every call to getField().
+export class MessageHandler {
+ protected parser: Parser;
+ protected messages: TimestampedMessage[] = [];
+ constructor(schema: Schema) {
+ this.parser = new Parser(schema);
+ }
+ addMessage(data: Uint8Array, time: number): void {
+ this.messages.push(
+ new TimestampedMessage(Table.getRootTable(new ByteBuffer(data)), time));
+ }
+ // Returns a time-series of every single instance of the given field. Format
+ // of the return value is [time0, value0, time1, value1,... timeN, valueN],
+ // to match with the Line.setPoint() interface.
+ // By convention, NaN is used to indicate that a message existed at a given
+ // timestamp but the requested field was not populated.
+ getField(field: string[]): Float32Array {
+ const fieldName = field[field.length - 1];
+ const subMessage = field.slice(0, field.length - 1);
+ const results = new Float32Array(this.messages.length * 2);
+ for (let ii = 0; ii < this.messages.length; ++ii) {
+ let message = this.messages[ii].message;
+ for (const subMessageName of subMessage) {
+ message = this.parser.readTable(message, subMessageName);
+ if (message === undefined) {
+ break;
+ }
+ }
+ results[ii * 2] = this.messages[ii].time;
+ if (message === undefined) {
+ results[ii * 2 + 1] = NaN;
+ } else {
+ results[ii * 2 + 1] = this.parser.readScalar(message, fieldName);
+ }
+ }
+ return results;
+ }
+ numMessages(): number {
+ return this.messages.length;
+ }
+}
+
+class MessageLine {
+ constructor(
+ public readonly messages: MessageHandler, public readonly line: Line,
+ public readonly field: string[]) {}
+}
+
+class AosPlot {
+ private lines: MessageLine[] = [];
+ constructor(
+ private readonly plotter: AosPlotter, public readonly plot: Plot) {}
+
+ // Adds a line to the figure.
+ // message specifies what channel/data source to pull from, and field
+ // specifies the field within that channel. field is an array specifying
+ // the full path to the field within the message. For instance, to
+ // plot whether the drivetrain is currently zeroed based on the drivetrain
+ // status message, you would specify the ['zeroing', 'zeroed'] field to
+ // get the DrivetrainStatus.zeroing().zeroed() member.
+ // Currently, this interface does not provide any support for non-numeric
+ // fields or for repeated fields (or sub-messages) of any sort.
+ addMessageLine(message: MessageHandler, field: string[]): Line {
+ const line = this.plot.getDrawer().addLine();
+ line.setLabel(field.join('.'));
+ this.lines.push(new MessageLine(message, line, field));
+ return line;
+ }
+
+ draw(): void {
+ // Only redraw lines if the number of points has changed--because getField()
+ // is a relatively expensive call, we don't want to do it any more than
+ // necessary.
+ for (const line of this.lines) {
+ if (line.messages.numMessages() * 2 != line.line.getPoints().length) {
+ line.line.setPoints(line.messages.getField(line.field));
+ }
+ }
+ }
+}
+
+export class AosPlotter {
+ private plots: AosPlot[] = [];
+ private messages = new Set<MessageHandler>();
+ constructor(private readonly connection: Connection) {
+ // Set up to redraw at some regular interval. The exact rate is unimportant.
+ setInterval(() => {
+ this.draw();
+ }, 100);
+ }
+
+ // Sets up an AOS channel as a message source. Returns a handler that can
+ // be passed to addMessageLine().
+ addMessageSource(name: string, type: string): MessageHandler {
+ return this.addRawMessageSource(
+ name, type, new MessageHandler(this.connection.getSchema(type)));
+ }
+
+ // Same as addMessageSource, but allows you to specify a custom MessageHandler
+ // that does some processing on the requested message. This allows you to
+ // create post-processed versions of individual channels.
+ addRawMessageSource(
+ name: string, type: string,
+ messageHandler: MessageHandler): MessageHandler {
+ this.messages.add(messageHandler);
+ // Use a "reliable" handler so that we get *all* the data when we are
+ // plotting from a logfile.
+ this.connection.addReliableHandler(
+ name, type, (data: Uint8Array, time: number) => {
+ messageHandler.addMessage(data, time);
+ });
+ return messageHandler;
+ }
+ // Add a new figure at the provided position with the provided size within
+ // parentElement.
+ addPlot(parentElement: Element, topLeft: number[], size: number[]): AosPlot {
+ const div = document.createElement("div");
+ div.style.top = topLeft[1].toString();
+ div.style.left = topLeft[0].toString();
+ div.style.position = 'absolute';
+ parentElement.appendChild(div);
+ const newPlot = new Plot(div, size[0], size[1]);
+ for (let plot of this.plots.values()) {
+ newPlot.linkXAxis(plot.plot);
+ }
+ this.plots.push(new AosPlot(this, newPlot));
+ return this.plots[this.plots.length - 1];
+ }
+ private draw(): void {
+ for (const plot of this.plots) {
+ plot.draw();
+ }
+ }
+}
diff --git a/aos/network/www/config_handler.ts b/aos/network/www/config_handler.ts
index df2d4f8..73b6b89 100644
--- a/aos/network/www/config_handler.ts
+++ b/aos/network/www/config_handler.ts
@@ -72,14 +72,8 @@
const channel = this.config.channels(Number(index));
this.connection.addHandler(
channel.name(), channel.type(), (data, time) => {
- const config = this.connection.getConfig();
- let schema = null;
- for (let ii = 0; ii < config.channelsLength(); ++ii) {
- if (config.channels(ii).type() === channel.type()) {
- schema = config.channels(ii).schema();
- }
- }
- const parser = new Parser(schema);
+ const parser =
+ new Parser(this.connection.getSchema(channel.type()));
console.log(
parser.toObject(Table.getRootTable(new ByteBuffer(data))));
});
diff --git a/aos/network/www/demo_plot.ts b/aos/network/www/demo_plot.ts
new file mode 100644
index 0000000..d223757
--- /dev/null
+++ b/aos/network/www/demo_plot.ts
@@ -0,0 +1,74 @@
+// This file provides a basic demonstration of the plotting functionality.
+// The plotDemo() function provided here is called by
+// //frc971/analysis:plot_index.ts
+// To view the demo plot, run
+// bazel run -c opt //frc971/analysis:live_web_plotter_demo
+// And then navigate to
+// http://localhost:8080/?plot=Demo
+// The plot=Demo isn't structly necessary, but ensures that this plot is
+// selected by default--otherwise, you'll need to select "Demo" from the
+// drop-down menu.
+//
+// This example shows how to:
+// (a) Make use of the AosPlotter to plot a shmem message as a time-series.
+// (b) Define your own custom plot with whatever data you want.
+import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
+import {Plot} from 'org_frc971/aos/network/www/plotter';
+import * as proxy from 'org_frc971/aos/network/www/proxy';
+
+import Connection = proxy.Connection;
+
+export function plotDemo(conn: Connection, parentDiv: Element): void {
+ const width = 900;
+ const height = 400;
+
+ const benchmarkDiv = document.createElement('div');
+ benchmarkDiv.style.top = height.toString();
+ benchmarkDiv.style.left = '0';
+ benchmarkDiv.style.position = 'absolute';
+ parentDiv.appendChild(benchmarkDiv);
+
+ const benchmarkPlot = new Plot(benchmarkDiv, width, height);
+
+ const aosPlotter = new AosPlotter(conn);
+
+ {
+ // Setup a plot that just shows the PID of each timing report message.
+ // For the basic live_web_plotter_demo, this will be a boring line showing
+ // just the PID of the proxy process. On a real system, or against a logfile,
+ // this would show the PIDs of all active processes.
+ const timingReport =
+ aosPlotter.addMessageSource('/aos', 'aos.timing.Report');
+ const timingPlot =
+ aosPlotter.addPlot(parentDiv, [0, 0], [width, height]);
+ timingPlot.plot.getAxisLabels().setTitle('Timing Report PID');
+ timingPlot.plot.getAxisLabels().setYLabel('PID');
+ timingPlot.plot.getAxisLabels().setXLabel('Monotonic Send Time (sec)');
+ const msgLine = timingPlot.addMessageLine(timingReport, ['pid']);
+ msgLine.setDrawLine(false);
+ msgLine.setPointSize(5);
+ }
+
+ // Set up and draw the benchmarking plot.
+ benchmarkPlot.getAxisLabels().setTitle(
+ 'Benchmarking plot (1M points per line)');
+ const line1 = benchmarkPlot.getDrawer().addLine();
+ // For demonstration purposes, make line1 only have points and line2 only have
+ // lines.
+ line1.setDrawLine(false);
+ line1.setLabel('LINE ONE');
+ const line2 = benchmarkPlot.getDrawer().addLine();
+ line2.setPointSize(0);
+ line2.setLabel('LINE TWO');
+ const NUM_POINTS = 1000000;
+ const points1 = [];
+ const points2 = [];
+ for (let ii = 0; ii < NUM_POINTS; ++ii) {
+ points1.push(ii);
+ points2.push(ii);
+ points1.push(Math.sin(ii * 10 / NUM_POINTS));
+ points2.push(Math.cos(ii * 10 / NUM_POINTS));
+ }
+ line1.setPoints(new Float32Array(points1));
+ line2.setPoints(new Float32Array(points2));
+}
diff --git a/aos/network/www/graph.html b/aos/network/www/graph.html
deleted file mode 100644
index 0f66bb0..0000000
--- a/aos/network/www/graph.html
+++ /dev/null
@@ -1,9 +0,0 @@
-<html>
- <head>
- <script src="graph_main_bundle.min.js" defer></script>
- <link rel="stylesheet" href="styles.css">
- </head>
- <body>
- </body>
-</html>
-
diff --git a/aos/network/www/graph_main.ts b/aos/network/www/graph_main.ts
deleted file mode 100644
index 2548e1d..0000000
--- a/aos/network/www/graph_main.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-// This file creates a set of two demonstration plots for testing out the
-// plotting utility.
-// To run this and see a demo, run:
-// bazel run -c opt //aos/network/www:web_proxy_demo
-// And then open localhost:8080/graph.html
-//
-// The plots are just the plots of the handler latency / run times of the first
-// timer in the the /aos timing report message (this message was chosen because
-// it is already being published by the web proxy process, so the demo requires
-// very little setup).
-import * as configuration from 'org_frc971/aos/configuration_generated';
-import {Line, Plot} from 'org_frc971/aos/network/www/plotter';
-import * as proxy from 'org_frc971/aos/network/www/proxy';
-import * as web_proxy from 'org_frc971/aos/network/web_proxy_generated';
-import * as reflection from 'org_frc971/aos/network/www/reflection'
-import * as flatbuffers_builder from 'org_frc971/external/com_github_google_flatbuffers/ts/builder';
-import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
-
-import Channel = configuration.aos.Channel;
-import Connection = proxy.Connection;
-import Configuration = configuration.aos.Configuration;
-import Parser = reflection.Parser;
-import Table = reflection.Table;
-import SubscriberRequest = web_proxy.aos.web_proxy.SubscriberRequest;
-import ChannelRequest = web_proxy.aos.web_proxy.ChannelRequest;
-import TransferMethod = web_proxy.aos.web_proxy.TransferMethod;
-
-const width = 900;
-const height = 400;
-
-const helpDiv = document.createElement('div');
-helpDiv.style.top = '0';
-helpDiv.style.left = '0';
-helpDiv.style.position = 'absolute';
-document.body.appendChild(helpDiv);
-helpDiv.innerHTML =
- 'Help: click + drag to pan, double click to reset, scroll to zoom. ' +
- 'Hold the x/y keys to only pan/zoom along the x/y axes.<br>' +
- 'X-axes of the top two plots are linked together.';
-
-const div = document.createElement('div');
-div.style.top = '40';
-div.style.left = '0';
-div.style.position = 'absolute';
-document.body.appendChild(div);
-
-const div2 = document.createElement('div');
-div2.style.top = (parseFloat(div.style.top) + height).toString();
-div2.style.left = '0';
-div2.style.position = 'absolute';
-document.body.appendChild(div2);
-
-const benchmarkDiv = document.createElement('div');
-benchmarkDiv.style.top = (parseFloat(div2.style.top) + height).toString();
-benchmarkDiv.style.left = '0';
-benchmarkDiv.style.position = 'absolute';
-document.body.appendChild(benchmarkDiv);
-
-const handlerTimePlot = new Plot(div, width, height);
-const latencyPlot = new Plot(div2, width, height);
-const benchmarkPlot = new Plot(benchmarkDiv, width, height);
-
-// Class to store the lines for plotting a Statistics message.
-class StatLines {
- private max: Line;
- private min: Line;
- private average: Line;
- private maxPoints: number[] = [];
- private minPoints: number[] = [];
- private averagePoints: number[] = [];
- constructor(plotter: Plot) {
- this.max = plotter.getDrawer().addLine();
- this.min = plotter.getDrawer().addLine();
- this.average = plotter.getDrawer().addLine();
-
- this.max.setLabel("Max");
- this.min.setLabel("Min");
- this.average.setLabel("Average");
-
- this.max.setColor([1, 0, 0]);
- this.min.setColor([0, 1, 0]);
- this.average.setColor([0, 0, 1]);
- }
- addPoints(parser: Parser, statsTable: Table, time: number) {
- this.maxPoints.push(time);
- this.minPoints.push(time);
- this.averagePoints.push(time);
- this.maxPoints.push(parser.readScalar(statsTable, "max") * 1000);
- this.minPoints.push(parser.readScalar(statsTable, "min") * 1000);
- this.averagePoints.push(parser.readScalar(statsTable, "average") * 1000);
-
-
- // TODO: These memory allocations absolutely kill performance.
- this.max.setPoints(new Float32Array(this.maxPoints));
- this.min.setPoints(new Float32Array(this.minPoints));
- this.average.setPoints(new Float32Array(this.averagePoints));
- }
-}
-
-function setupPlot(plotter: Plot, title: string): StatLines {
- plotter.getAxisLabels().setXLabel("Monotonic send time since start (sec)");
- plotter.getAxisLabels().setYLabel("Time (msec)");
- plotter.getAxisLabels().setTitle(title);
- return new StatLines(plotter);
-}
-
-// Sets of the two x-axes to be shared; remove this to be able to zoom/pan the
-// x-axes independently.
-handlerTimePlot.linkXAxis(latencyPlot);
-
-const handlerLines = setupPlot(handlerTimePlot, "Handler Run Times");
-const latencyLines = setupPlot(latencyPlot, "Handler Latencies");
-
-const conn = new Connection();
-
-conn.connect();
-
-const timingReport = {
- name: '/aos',
- type: 'aos.timing.Report',
-};
-
-let reportParser = null;
-let startTime = null;
-
-conn.addConfigHandler((config: Configuration) => {
- // Locate the timing report schema so that we can read the received messages.
- let reportSchema = null;
- for (let ii = 0; ii < config.channelsLength(); ++ii) {
- if (config.channels(ii).type() === timingReport.type) {
- reportSchema = config.channels(ii).schema();
- }
- }
- if (reportSchema === null) {
- throw new Error('Couldn\'t find timing report schema in config.');
- }
- reportParser = new Parser(reportSchema);
-
- conn.addReliableHandler(
- timingReport.name, timingReport.type,
- (data: Uint8Array, time: number) => {
- if (startTime === null) {
- startTime = time;
- }
- time = time - startTime;
- const table = Table.getRootTable(new ByteBuffer(data));
-
- const timer = reportParser.readVectorOfTables(table, 'timers')[0];
- handlerLines.addPoints(
- reportParser, reportParser.readTable(timer, 'handler_time'), time);
- latencyLines.addPoints(
- reportParser, reportParser.readTable(timer, 'wakeup_latency'),
- time);
- });
-});
-
-// Set up and draw the benchmarking plot
-benchmarkPlot.getAxisLabels().setTitle(
- 'Benchmarkping plot (1M points per line)');
-const line1 = benchmarkPlot.getDrawer().addLine();
-// For demonstration purposes, make line1 only have points and line2 only have
-// lines.
-line1.setDrawLine(false);
-line1.setLabel('LINE ONE');
-const line2 = benchmarkPlot.getDrawer().addLine();
-line2.setPointSize(0);
-line2.setLabel('LINE TWO');
-const NUM_POINTS = 1000000;
-const points1 = [];
-const points2 = [];
-for (let ii = 0; ii < NUM_POINTS; ++ii) {
- points1.push(ii);
- points2.push(ii);
- points1.push(Math.sin(ii * 10 / NUM_POINTS));
- points2.push(Math.cos(ii * 10 / NUM_POINTS));
-}
-line1.setPoints(new Float32Array(points1));
-line2.setPoints(new Float32Array(points2));
diff --git a/aos/network/www/plotter.ts b/aos/network/www/plotter.ts
index d33953c..bbf5205 100644
--- a/aos/network/www/plotter.ts
+++ b/aos/network/www/plotter.ts
@@ -130,6 +130,10 @@
}
}
+ getPoints(): Float32Array {
+ return this.points;
+ }
+
// Get/set the label to use for the line when drawing the legend.
setLabel(label: string) {
this._label = label;
@@ -231,7 +235,7 @@
// Location, in pixels, of the legend in the text canvas.
private location: number[] = [0, 0];
constructor(private ctx: CanvasRenderingContext2D, private lines: Line[]) {
- this.location = [this.ctx.canvas.width - 100, 30];
+ this.location = [80, 30];
}
setPosition(location: number[]): void {
@@ -501,9 +505,6 @@
for (let line of this.lines) {
minValues = cwiseOp(minValues, line.minValues(), Math.min);
}
- if (!isFinite(minValues[0]) || !isFinite(minValues[1])) {
- return [0, 0];
- }
return minValues;
}
@@ -512,9 +513,6 @@
for (let line of this.lines) {
maxValues = cwiseOp(maxValues, line.maxValues(), Math.max);
}
- if (!isFinite(maxValues[0]) || !isFinite(maxValues[1])) {
- return [0, 0];
- }
return maxValues;
}
@@ -740,6 +738,7 @@
private autoFollow: boolean = true;
private linkedXAxes: Plot[] = [];
private lastTimeMs: number = 0;
+ private defaultYRange: number[]|null = null;
constructor(wrapperDiv: HTMLDivElement, width: number, height: number) {
wrapperDiv.appendChild(this.canvas);
@@ -889,9 +888,35 @@
this.setZoom(scale, offset);
}
+ setDefaultYRange(range: number[]|null) {
+ if (range == null) {
+ this.defaultYRange = null;
+ return;
+ }
+ if (range.length != 2) {
+ throw new Error('Range should contain exactly two values.');
+ }
+ this.defaultYRange = range;
+ }
+
resetZoom() {
const minValues = this.drawer.minValues();
const maxValues = this.drawer.maxValues();
+ for (const plot of this.linkedXAxes) {
+ const otherMin = plot.drawer.minValues();
+ const otherMax = plot.drawer.maxValues();
+ // For linked x-axes, only adjust the x limits.
+ minValues[0] = Math.min(minValues[0], otherMin[0]);
+ maxValues[0] = Math.max(maxValues[0], otherMax[0]);
+ }
+ if (!isFinite(minValues[0]) || !isFinite(maxValues[0])) {
+ minValues[0] = 0;
+ maxValues[0] = 0;
+ }
+ if (!isFinite(minValues[1]) || !isFinite(maxValues[1])) {
+ minValues[1] = 0;
+ maxValues[1] = 0;
+ }
if (minValues[0] == maxValues[0]) {
minValues[0] -= 1;
maxValues[0] += 1;
@@ -900,6 +925,10 @@
minValues[1] -= 1;
maxValues[1] += 1;
}
+ if (this.defaultYRange != null) {
+ minValues[1] = this.defaultYRange[0];
+ maxValues[1] = this.defaultYRange[1];
+ }
this.setZoomCorners(minValues, maxValues);
this.autoFollow = true;
for (let plot of this.linkedXAxes) {
diff --git a/aos/network/www/proxy.ts b/aos/network/www/proxy.ts
index 4fbba85..948f8db 100644
--- a/aos/network/www/proxy.ts
+++ b/aos/network/www/proxy.ts
@@ -5,6 +5,7 @@
import ChannelFb = configuration.aos.Channel;
import Configuration = configuration.aos.Configuration;
+import Schema = configuration.reflection.Schema;
import MessageHeader = web_proxy.aos.web_proxy.MessageHeader;
import WebSocketIce = web_proxy.aos.web_proxy.WebSocketIce;
import WebSocketMessage = web_proxy.aos.web_proxy.WebSocketMessage;
@@ -83,7 +84,7 @@
private readonly configHandlers = new Set<(config: Configuration) => void>();
private readonly handlerFuncs =
- new Map<string, (data: Uint8Array, sentTime: number) => void>();
+ new Map<string, ((data: Uint8Array, sentTime: number) => void)[]>();
private readonly handlers = new Set<Handler>();
private subscribedChannels: ChannelRequest[] = [];
@@ -122,10 +123,27 @@
handler: (data: Uint8Array, sentTime: number) => void): void {
const channel = new Channel(name, type);
const request = new ChannelRequest(channel, method);
- this.handlerFuncs.set(channel.key(), handler);
+ if (!this.handlerFuncs.has(channel.key())) {
+ this.handlerFuncs.set(channel.key(), []);
+ }
+ this.handlerFuncs.get(channel.key()).push(handler);
this.subscribeToChannel(request);
}
+ getSchema(typeName: string): Schema {
+ let schema = null;
+ const config = this.getConfig();
+ for (let ii = 0; ii < config.channelsLength(); ++ii) {
+ if (config.channels(ii).type() === typeName) {
+ schema = config.channels(ii).schema();
+ }
+ }
+ if (schema === null) {
+ throw new Error('Unable to find schema for ' + typeName);
+ }
+ return schema;
+ }
+
subscribeToChannel(channel: ChannelRequest): void {
this.subscribedChannels.push(channel);
if (this.configInternal === null) {
@@ -183,8 +201,10 @@
onDataChannel(ev: RTCDataChannelEvent): void {
const channel = ev.channel;
const name = channel.label;
- const handlerFunc = this.handlerFuncs.get(name);
- this.handlers.add(new Handler(handlerFunc, channel));
+ const handlers = this.handlerFuncs.get(name);
+ for (const handler of handlers) {
+ this.handlers.add(new Handler(handler, channel));
+ }
}
onIceCandidate(e: RTCPeerConnectionIceEvent): void {
diff --git a/aos/network/www/reflection.ts b/aos/network/www/reflection.ts
index 9c21a17..2e1ed8f 100644
--- a/aos/network/www/reflection.ts
+++ b/aos/network/www/reflection.ts
@@ -120,6 +120,16 @@
static getRootTable(bb: ByteBuffer): Table {
return new Table(bb, -1, bb.readInt32(bb.position()) + bb.position());
}
+ static getNamedTable(
+ bb: ByteBuffer, schema: reflection.Schema, type: string,
+ offset: number): Table {
+ for (let ii = 0; ii < schema.objectsLength(); ++ii) {
+ if (schema.objects(ii).name() == type) {
+ return new Table(bb, ii, offset);
+ }
+ }
+ throw new Error('Unable to find type ' + type + ' in schema.');
+ }
// Reads a scalar of a given type at a given offset.
readScalar(fieldType: reflection.BaseType, offset: number) {
switch (fieldType) {
diff --git a/frc971/analysis/BUILD b/frc971/analysis/BUILD
index 61355b0..465df0d 100644
--- a/frc971/analysis/BUILD
+++ b/frc971/analysis/BUILD
@@ -1,6 +1,8 @@
package(default_visibility = ["//visibility:public"])
+load("@npm_bazel_typescript//:defs.bzl", "ts_library")
load("@com_google_protobuf//:protobuf.bzl", "py_proto_library")
+load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary", "rollup_bundle")
py_binary(
name = "plot_action",
@@ -81,3 +83,54 @@
target_compatible_with = ["@platforms//os:linux"],
deps = [":plot"],
)
+
+ts_library(
+ name = "plot_index",
+ srcs = ["plot_index.ts"],
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ "//aos:configuration_ts_fbs",
+ "//aos/network/www:demo_plot",
+ "//aos/network/www:proxy",
+ "//frc971/wpilib:imu_plotter",
+ ],
+)
+
+rollup_bundle(
+ name = "plot_index_bundle",
+ enable_code_splitting = False,
+ entry_point = "plot_index.ts",
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ ":plot_index",
+ ],
+)
+
+filegroup(
+ name = "plotter_files",
+ srcs = [
+ "index.html",
+ "plot_index_bundle.min.js",
+ ],
+)
+
+sh_binary(
+ name = "web_plotter",
+ srcs = ["web_plotter.sh"],
+ data = [
+ ":plotter_files",
+ "//aos/network:log_web_proxy_main",
+ ],
+ target_compatible_with = ["@platforms//os:linux"],
+)
+
+sh_binary(
+ name = "live_web_plotter_demo",
+ srcs = ["live_web_plotter_demo.sh"],
+ data = [
+ ":plotter_files",
+ "//aos/network:web_proxy_main",
+ "//aos/network/www:test_config",
+ ],
+ target_compatible_with = ["@platforms//os:linux"],
+)
diff --git a/frc971/analysis/index.html b/frc971/analysis/index.html
new file mode 100644
index 0000000..edd2483
--- /dev/null
+++ b/frc971/analysis/index.html
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <script src="plot_index_bundle.min.js" defer></script>
+ </head>
+ <body>
+ </body>
+</html>
+
diff --git a/frc971/analysis/live_web_plotter_demo.sh b/frc971/analysis/live_web_plotter_demo.sh
new file mode 100755
index 0000000..4c4c9c4
--- /dev/null
+++ b/frc971/analysis/live_web_plotter_demo.sh
@@ -0,0 +1 @@
+./aos/network/web_proxy_main --config=aos/network/www/test_config.json --data_dir=frc971/analysis
diff --git a/frc971/analysis/plot_index.ts b/frc971/analysis/plot_index.ts
new file mode 100644
index 0000000..759202a
--- /dev/null
+++ b/frc971/analysis/plot_index.ts
@@ -0,0 +1,118 @@
+// This file provides an index of all standard plot configs.
+// In the future, this may be split into more pieces (e.g., to have
+// year-specific indices). For now, the pattern for creating a new plot
+// is that you provide an exported function (a la the plot*() functions imported
+// below) which, when called, will generate the plot in the provided div.
+// This file handles providing a master list of all known plots so that
+// the user can just open a single web-page and select the plot that they want
+// from a drop-down. A given plot will not be loaded until it has been selected
+// once, at which point it will stay loaded. This means that if the user wants
+// to switch between several plot configs, they don't incur any performance
+// penalty associated with swapping.
+// By default, no plot is selected, but the plot= URL parameter may be used
+// to specify a specific plot, so that people can create links to a specific
+// plot.
+// The plot*() functions are called *after* we have already received a valid
+// config from the web server, so config handlers do not need to be used.
+//
+// The exact setup of this will be in flux as we add more configs and figure out
+// what setups work best--we will likely end up with separate index files for
+// each robot year, and may even end up allowing plots to be specified solely
+// using JSON rather than requiring people to write a script just to create
+// a plot.
+import * as configuration from 'org_frc971/aos/configuration_generated';
+import * as proxy from 'org_frc971/aos/network/www/proxy';
+import {plotImu} from 'org_frc971/frc971/wpilib/imu_plotter';
+import {plotDemo} from 'org_frc971/aos/network/www/demo_plot';
+
+import Connection = proxy.Connection;
+import Configuration = configuration.aos.Configuration;
+
+const rootDiv = document.createElement('div');
+document.body.appendChild(rootDiv);
+
+const helpDiv = document.createElement('div');
+rootDiv.appendChild(helpDiv);
+helpDiv.innerHTML =
+ 'Help: click + drag to pan, double click to reset, scroll to zoom. ' +
+ 'Hold the x/y keys to only pan/zoom along the x/y axes.';
+
+class PlotState {
+ public readonly div: HTMLElement;
+ private initialized = false;
+ constructor(
+ parentDiv: HTMLElement,
+ private readonly initializer:
+ (conn: Connection, element: Element) => void) {
+ this.div = document.createElement('div');
+ parentDiv.appendChild(this.div);
+ this.hide();
+ }
+ initialize(conn: Connection): void {
+ if (!this.initialized) {
+ this.initializer(conn, this.div);
+ this.initialized = true;
+ }
+ }
+ hide(): void {
+ this.div.style.display = "none";
+ }
+ show(): void {
+ this.div.style.display = "block";
+ }
+}
+
+const plotSelect = document.createElement('select');
+rootDiv.appendChild(plotSelect);
+
+const plotDiv = document.createElement('div');
+plotDiv.style.top = (plotSelect.getBoundingClientRect().bottom + 10).toString();
+plotDiv.style.left = '0';
+plotDiv.style.position = 'absolute';
+rootDiv.appendChild(plotDiv);
+
+// The master list of all the plots that we provide. For a given config, it
+// is possible that not all of these plots will be usable depending on the
+// presence of certain channels.
+const plotIndex = new Map<string, PlotState>([
+ ['Demo', new PlotState(plotDiv, plotDemo)],
+ ['IMU', new PlotState(plotDiv, plotImu)]
+]);
+
+const invalidSelectValue = 'null';
+function getDefaultPlot(): string {
+ const urlParams = (new URL(document.URL)).searchParams;
+ const urlParamKey = 'plot';
+ if (!urlParams.has(urlParamKey)) {
+ return invalidSelectValue;
+ }
+ const desiredPlot = urlParams.get(urlParamKey);
+ if (!plotIndex.has(desiredPlot)) {
+ return invalidSelectValue;
+ }
+ return desiredPlot;
+}
+
+const conn = new Connection();
+
+conn.connect();
+
+conn.addConfigHandler((config: Configuration) => {
+ plotSelect.add(new Option("Select Plot", invalidSelectValue));
+ for (const name of plotIndex.keys()) {
+ plotSelect.add(new Option(name, name));
+ }
+ plotSelect.addEventListener('input', () => {
+ for (const plot of plotIndex.values()) {
+ plot.hide();
+ }
+ if (plotSelect.value == invalidSelectValue) {
+ return;
+ }
+ plotIndex.get(plotSelect.value).initialize(conn);
+ plotIndex.get(plotSelect.value).show();
+ });
+ plotSelect.value = getDefaultPlot();
+ // Force the event to occur once at the start.
+ plotSelect.dispatchEvent(new Event('input'));
+});
diff --git a/frc971/analysis/web_plotter.sh b/frc971/analysis/web_plotter.sh
new file mode 100755
index 0000000..b284431
--- /dev/null
+++ b/frc971/analysis/web_plotter.sh
@@ -0,0 +1,4 @@
+# This script provides basic plotting of a logfile.
+# Basic usage:
+# $ bazel run -c opt //frc971/analysis:web_plotter -- --node node_name /path/to/logfile
+./aos/network/log_web_proxy_main --data_dir=frc971/analysis $@
diff --git a/frc971/wpilib/BUILD b/frc971/wpilib/BUILD
index e7c52bf..087c413 100644
--- a/frc971/wpilib/BUILD
+++ b/frc971/wpilib/BUILD
@@ -1,6 +1,7 @@
package(default_visibility = ["//visibility:public"])
-load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library")
+load("@npm_bazel_typescript//:defs.bzl", "ts_library")
+load("@com_github_google_flatbuffers//:build_defs.bzl", "flatbuffer_cc_library", "flatbuffer_ts_library")
load("//aos:config.bzl", "aos_config")
flatbuffer_cc_library(
@@ -286,6 +287,15 @@
target_compatible_with = ["@platforms//os:linux"],
)
+flatbuffer_ts_library(
+ name = "imu_batch_ts_fbs",
+ srcs = ["imu_batch.fbs"],
+ includes = [
+ ":imu_fbs_includes",
+ ],
+ target_compatible_with = ["@platforms//os:linux"],
+)
+
cc_library(
name = "ADIS16470",
srcs = [
@@ -422,3 +432,27 @@
"@com_github_google_glog//:glog",
],
)
+
+ts_library(
+ name = "imu_plot_utils",
+ srcs = ["imu_plot_utils.ts"],
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ ":imu_batch_ts_fbs",
+ "//aos:configuration_ts_fbs",
+ "//aos/network/www:aos_plotter",
+ "//aos/network/www:reflection_ts",
+ "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+ ],
+)
+
+ts_library(
+ name = "imu_plotter",
+ srcs = ["imu_plotter.ts"],
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ ":imu_plot_utils",
+ "//aos/network/www:aos_plotter",
+ "//aos/network/www:proxy",
+ ],
+)
diff --git a/frc971/wpilib/imu_plot_utils.ts b/frc971/wpilib/imu_plot_utils.ts
new file mode 100644
index 0000000..4e6c371
--- /dev/null
+++ b/frc971/wpilib/imu_plot_utils.ts
@@ -0,0 +1,34 @@
+// This script provides a basic utility for de-batching the IMUValues
+// message. See imu_plotter.ts for usage.
+import * as configuration from 'org_frc971/aos/configuration_generated';
+import * as imu from 'org_frc971/frc971/wpilib/imu_batch_generated';
+import {MessageHandler, TimestampedMessage} from 'org_frc971/aos/network/www/aos_plotter';
+import {Table} from 'org_frc971/aos/network/www/reflection';
+import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
+
+import Schema = configuration.reflection.Schema;
+import IMUValuesBatch = imu.frc971.IMUValuesBatch;
+import IMUValues = imu.frc971.IMUValues;
+
+export class ImuMessageHandler extends MessageHandler {
+ constructor(private readonly schema: Schema) {
+ super(schema);
+ }
+ addMessage(data: Uint8Array, time: number): void {
+ const batch = IMUValuesBatch.getRootAsIMUValuesBatch(
+ new ByteBuffer(data) as unknown as flatbuffers.ByteBuffer);
+ for (let ii = 0; ii < batch.readingsLength(); ++ii) {
+ const message = batch.readings(ii);
+ const table = Table.getNamedTable(
+ message.bb as unknown as ByteBuffer, this.schema, 'frc971.IMUValues',
+ message.bb_pos);
+ if (this.parser.readScalar(table, "monotonic_timestamp_ns") == null) {
+ console.log('Ignoring unpopulated IMU values: ');
+ console.log(this.parser.toObject(table));
+ continue;
+ }
+ this.messages.push(new TimestampedMessage(
+ table, message.monotonicTimestampNs().toFloat64() * 1e-9));
+ }
+ }
+}
diff --git a/frc971/wpilib/imu_plotter.ts b/frc971/wpilib/imu_plotter.ts
new file mode 100644
index 0000000..af23ed9
--- /dev/null
+++ b/frc971/wpilib/imu_plotter.ts
@@ -0,0 +1,80 @@
+// Provides a basic plot for debugging IMU-related issues on a robot.
+import {AosPlotter} from 'org_frc971/aos/network/www/aos_plotter';
+import {ImuMessageHandler} from 'org_frc971/frc971/wpilib/imu_plot_utils';
+import * as proxy from 'org_frc971/aos/network/www/proxy';
+
+import Connection = proxy.Connection;
+
+export function plotImu(conn: Connection, element: Element): void {
+ const width = 900;
+ const height = 400;
+ const aosPlotter = new AosPlotter(conn);
+
+ const accelPlot = aosPlotter.addPlot(element, [0, 0], [width, height]);
+ accelPlot.plot.getAxisLabels().setTitle('Accelerometer Readings');
+ accelPlot.plot.getAxisLabels().setYLabel('Acceleration (g)');
+ accelPlot.plot.getAxisLabels().setXLabel('Monotonic Reading Time (sec)');
+
+ const drivetrainStatus = aosPlotter.addMessageSource(
+ '/drivetrain', 'frc971.control_loops.drivetrain.Status');
+
+ const imu = aosPlotter.addRawMessageSource(
+ '/drivetrain', 'frc971.IMUValuesBatch',
+ new ImuMessageHandler(conn.getSchema('frc971.IMUValuesBatch')));
+
+ const accelX = accelPlot.addMessageLine(imu, ['accelerometer_x']);
+ accelX.setColor([1, 0, 0]);
+ const accelY = accelPlot.addMessageLine(imu, ['accelerometer_y']);
+ accelY.setColor([0, 1, 0]);
+ const accelZ = accelPlot.addMessageLine(imu, ['accelerometer_z']);
+ accelZ.setColor([0, 0, 1]);
+
+ const gyroPlot = aosPlotter.addPlot(element, [0, height], [width, height]);
+ gyroPlot.plot.getAxisLabels().setTitle('Gyro Readings');
+ gyroPlot.plot.getAxisLabels().setYLabel('Angular Velocity (rad / sec)');
+ gyroPlot.plot.getAxisLabels().setXLabel('Monotonic Reading Time (sec)');
+
+ const gyroZeroX =
+ gyroPlot.addMessageLine(drivetrainStatus, ['zeroing', 'gyro_x_average']);
+ gyroZeroX.setColor([1, 0, 0]);
+ gyroZeroX.setPointSize(0);
+ gyroZeroX.setLabel('Gyro X Zero');
+ const gyroZeroY =
+ gyroPlot.addMessageLine(drivetrainStatus, ['zeroing', 'gyro_y_average']);
+ gyroZeroY.setColor([0, 1, 0]);
+ gyroZeroY.setPointSize(0);
+ gyroZeroY.setLabel('Gyro Y Zero');
+ const gyroZeroZ =
+ gyroPlot.addMessageLine(drivetrainStatus, ['zeroing', 'gyro_z_average']);
+ gyroZeroZ.setColor([0, 0, 1]);
+ gyroZeroZ.setPointSize(0);
+ gyroZeroZ.setLabel('Gyro Z Zero');
+
+ const gyroX = gyroPlot.addMessageLine(imu, ['gyro_x']);
+ gyroX.setColor([1, 0, 0]);
+ const gyroY = gyroPlot.addMessageLine(imu, ['gyro_y']);
+ gyroY.setColor([0, 1, 0]);
+ const gyroZ = gyroPlot.addMessageLine(imu, ['gyro_z']);
+ gyroZ.setColor([0, 0, 1]);
+
+ const tempPlot = aosPlotter.addPlot(element, [0, height * 2], [width, height / 2]);
+ tempPlot.plot.getAxisLabels().setTitle('IMU Temperature');
+ tempPlot.plot.getAxisLabels().setYLabel('Temperature (deg C)');
+ tempPlot.plot.getAxisLabels().setXLabel('Monotonic Reading Time (sec)');
+
+ tempPlot.addMessageLine(imu, ['temperature']);
+
+ const statePlot = aosPlotter.addPlot(element, [0, height * 2.5], [width, height / 2]);
+ statePlot.plot.getAxisLabels().setTitle('IMU State');
+ statePlot.plot.getAxisLabels().setXLabel('Monotonic Sent Time (sec)');
+ statePlot.plot.setDefaultYRange([-0.1, 1.1]);
+
+ const zeroedLine =
+ statePlot.addMessageLine(drivetrainStatus, ['zeroing', 'zeroed']);
+ zeroedLine.setColor([1, 0, 0]);
+ zeroedLine.setDrawLine(false);
+ const faultedLine =
+ statePlot.addMessageLine(drivetrainStatus, ['zeroing', 'faulted']);
+ faultedLine.setColor([0, 1, 0]);
+ faultedLine.setPointSize(0);
+}