James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 1 | // This library provides a wrapper around our WebGL plotter that makes it |
| 2 | // easy to plot AOS messages/channels as time series. |
| 3 | // |
| 4 | // This is works by subscribing to each channel that we want to plot, storing |
| 5 | // all the messages for that channel, and then periodically running through |
| 6 | // every message and extracting the fields to plot. |
| 7 | // It is also possible to insert code to make modifications to the messages |
| 8 | // as we read/process them, as is the case for the IMU processing code (see |
| 9 | // //frc971/wpilib:imu*.ts) where each message is actually a batch of several |
| 10 | // individual messages that need to be plotted as separate points. |
| 11 | // |
| 12 | // The basic flow for using the AosPlotter is: |
| 13 | // // 1) Construct the plotter |
| 14 | // const aosPlotter = new AosPlotter(connection); |
| 15 | // // 2) Add messages sources that we'll want to subscribe to. |
| 16 | // const source = aosPlotter.addMessageSource('/aos', 'aos.timing.Report'); |
| 17 | // // 3) Create figures at defined positions within a given HTML element.. |
| 18 | // const timingPlot = aosPlotter.addPlot(parentDiv, [0, 0], [width, height]); |
| 19 | // // 4) Add specific signals to each figure, using the message sources you |
| 20 | // defined at the start. |
| 21 | // timingPlot.addMessageLine(source, ['pid']); |
| 22 | // |
| 23 | // The demo_plot.ts script has a basic example of using this library, with all |
| 24 | // the required boilerplate, as well as some extra examples about how to |
| 25 | // add axis labels and the such. |
James Kuszmaul | dac091f | 2022-03-22 09:35:06 -0700 | [diff] [blame] | 26 | import {Channel, Configuration} from 'org_frc971/aos/configuration_generated'; |
James Kuszmaul | 0d7df89 | 2021-04-09 22:19:49 -0700 | [diff] [blame] | 27 | import {Line, Plot, Point} from 'org_frc971/aos/network/www/plotter'; |
James Kuszmaul | dac091f | 2022-03-22 09:35:06 -0700 | [diff] [blame] | 28 | import {Connection} from 'org_frc971/aos/network/www/proxy'; |
| 29 | import {SubscriberRequest, ChannelRequest, TransferMethod} from 'org_frc971/aos/network/web_proxy_generated'; |
| 30 | import {Parser, Table} from 'org_frc971/aos/network/www/reflection' |
James Kuszmaul | 136aa2b | 2022-04-02 14:50:56 -0700 | [diff] [blame] | 31 | import {Schema} from 'flatbuffers_reflection/reflection_generated'; |
James Kuszmaul | dac091f | 2022-03-22 09:35:06 -0700 | [diff] [blame] | 32 | import {ByteBuffer} from 'flatbuffers'; |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 33 | |
| 34 | export class TimestampedMessage { |
| 35 | constructor( |
| 36 | public readonly message: Table, public readonly time: number) {} |
| 37 | } |
| 38 | |
| 39 | // The MessageHandler stores an array of every single message on a given channel |
| 40 | // and then supplies individual fields as arrays when requested. Currently this |
| 41 | // is very much unoptimized and re-processes the entire array of messages on |
| 42 | // every call to getField(). |
| 43 | export class MessageHandler { |
| 44 | protected parser: Parser; |
| 45 | protected messages: TimestampedMessage[] = []; |
| 46 | constructor(schema: Schema) { |
| 47 | this.parser = new Parser(schema); |
| 48 | } |
| 49 | addMessage(data: Uint8Array, time: number): void { |
| 50 | this.messages.push( |
| 51 | new TimestampedMessage(Table.getRootTable(new ByteBuffer(data)), time)); |
| 52 | } |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 53 | private parseFieldName(rawName: string): [string, boolean, number|null] { |
| 54 | // If the fieldName includes an array index at the end, attempt to read a |
| 55 | // vector. |
| 56 | // The indices can either be in the form [X] for some index X, or be an |
| 57 | // empty [], which is a request to return all values. |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 58 | const regex = /(.*)\[([0-9]*)\]/; |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 59 | const match = rawName.match(regex); |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 60 | if (match) { |
| 61 | const name = match[1]; |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 62 | const requestFullVector = match[2] === ''; |
| 63 | const requestedIndex = requestFullVector ? null : parseInt(match[2]); |
| 64 | return [name, true, requestedIndex]; |
| 65 | } else { |
| 66 | return [rawName, false, null]; |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 67 | } |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 68 | } |
| 69 | private readField<T>( |
| 70 | typeIndex: number, fieldName: string, |
| 71 | normalReader: (typeIndex: number, name: string) => (t: Table) => T | null, |
| 72 | vectorReader: (typeIndex: number, name: string) => (t: Table) => T[] | |
| 73 | null): (t: Table) => T[] | null { |
| 74 | const [name, isVector, vectorIndex] = this.parseFieldName(fieldName); |
| 75 | if (isVector) { |
| 76 | const vectorLambda = vectorReader(typeIndex, name); |
| 77 | const requestFullVector = vectorIndex === null; |
| 78 | return (message: Table) => { |
| 79 | const vector = vectorLambda(message); |
| 80 | if (vector === null) { |
| 81 | return null; |
| 82 | } |
| 83 | if (requestFullVector) { |
| 84 | return vector; |
| 85 | } else { |
| 86 | return (vectorIndex < vector.length) ? [vector[vectorIndex]] : null; |
| 87 | } |
| 88 | }; |
| 89 | } |
| 90 | const singleLambda = normalReader(typeIndex, fieldName); |
| 91 | return (message: Table) => { |
| 92 | // For single values, return as a 1-vector so that the type is |
| 93 | // consistent with that used for vectors. |
| 94 | const singleValue = singleLambda(message); |
| 95 | return (singleValue === null) ? null : [singleValue]; |
| 96 | }; |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 97 | } |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 98 | // Returns a time-series of every single instance of the given field. Format |
| 99 | // of the return value is [time0, value0, time1, value1,... timeN, valueN], |
| 100 | // to match with the Line.setPoint() interface. |
| 101 | // By convention, NaN is used to indicate that a message existed at a given |
| 102 | // timestamp but the requested field was not populated. |
James Kuszmaul | f3727e8 | 2021-03-06 16:52:51 -0800 | [diff] [blame] | 103 | // If you want to retrieve a single signal from a vector, you can specify it |
| 104 | // as "field_name[index]". |
James Kuszmaul | 0d7df89 | 2021-04-09 22:19:49 -0700 | [diff] [blame] | 105 | getField(field: string[]): Point[] { |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 106 | if (this.messages.length == 0) { |
| 107 | return []; |
| 108 | } |
| 109 | const rootType = this.messages[0].message.typeIndex; |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 110 | const fieldName = field[field.length - 1]; |
| 111 | const subMessage = field.slice(0, field.length - 1); |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 112 | |
| 113 | const lambdas = []; |
| 114 | let currentType = rootType; |
| 115 | for (const subMessageName of subMessage) { |
| 116 | lambdas.push(this.readField( |
| 117 | currentType, subMessageName, |
| 118 | (typeIndex: number, name: string) => |
| 119 | this.parser.readTableLambda(typeIndex, name), |
| 120 | (typeIndex: number, name: string) => |
| 121 | this.parser.readVectorOfTablesLambda(typeIndex, name))); |
| 122 | const [name, isVector, vectorIndex] = this.parseFieldName(subMessageName); |
| 123 | currentType = |
| 124 | this.parser.getField(name, currentType).type().index(); |
| 125 | } |
| 126 | const fieldLambda = this.readField( |
| 127 | currentType, fieldName, |
| 128 | (typeIndex: number, name: string) => |
| 129 | this.parser.readScalarLambda(typeIndex, name), |
| 130 | (typeIndex: number, name: string) => |
| 131 | this.parser.readVectorOfScalarsLambda(typeIndex, name)); |
| 132 | |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 133 | const results = []; |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 134 | for (let ii = 0; ii < this.messages.length; ++ii) { |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 135 | let tables = [this.messages[ii].message]; |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 136 | for (const lambda of lambdas) { |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 137 | let nextTables = []; |
| 138 | for (const table of tables) { |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 139 | const nextTable = lambda(table); |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 140 | if (nextTable === null) { |
| 141 | continue; |
| 142 | } |
| 143 | nextTables = nextTables.concat(nextTable); |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 144 | } |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 145 | tables = nextTables; |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 146 | } |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 147 | |
| 148 | const values = []; |
| 149 | for (const table of tables) { |
| 150 | const value = fieldLambda(table); |
| 151 | if (value !== null) { |
| 152 | values.push(value); |
| 153 | } |
| 154 | } |
James Kuszmaul | 8073c0d | 2021-03-07 20:14:41 -0800 | [diff] [blame] | 155 | const time = this.messages[ii].time; |
| 156 | if (tables.length === 0) { |
James Kuszmaul | 0d7df89 | 2021-04-09 22:19:49 -0700 | [diff] [blame] | 157 | results.push(new Point(time, NaN)); |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 158 | } else { |
James Kuszmaul | f8355ff | 2021-12-19 22:08:45 -0800 | [diff] [blame] | 159 | for (const valueVector of values) { |
| 160 | for (const value of valueVector) { |
| 161 | results.push(new Point(time, (value === null) ? NaN : value)); |
James Kuszmaul | f3727e8 | 2021-03-06 16:52:51 -0800 | [diff] [blame] | 162 | } |
| 163 | } |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 164 | } |
| 165 | } |
James Kuszmaul | 0d7df89 | 2021-04-09 22:19:49 -0700 | [diff] [blame] | 166 | return results; |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 167 | } |
| 168 | numMessages(): number { |
| 169 | return this.messages.length; |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | class MessageLine { |
James Kuszmaul | 3e382c0 | 2021-04-09 22:34:36 -0700 | [diff] [blame] | 174 | private _lastNumMessages: number = 0; |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 175 | constructor( |
| 176 | public readonly messages: MessageHandler, public readonly line: Line, |
| 177 | public readonly field: string[]) {} |
James Kuszmaul | 3e382c0 | 2021-04-09 22:34:36 -0700 | [diff] [blame] | 178 | hasUpdate(): boolean { |
| 179 | const updated = this._lastNumMessages != this.messages.numMessages(); |
| 180 | this._lastNumMessages = this.messages.numMessages(); |
| 181 | return updated; |
| 182 | } |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 183 | } |
| 184 | |
| 185 | class AosPlot { |
| 186 | private lines: MessageLine[] = []; |
| 187 | constructor( |
| 188 | private readonly plotter: AosPlotter, public readonly plot: Plot) {} |
| 189 | |
| 190 | // Adds a line to the figure. |
| 191 | // message specifies what channel/data source to pull from, and field |
| 192 | // specifies the field within that channel. field is an array specifying |
| 193 | // the full path to the field within the message. For instance, to |
| 194 | // plot whether the drivetrain is currently zeroed based on the drivetrain |
| 195 | // status message, you would specify the ['zeroing', 'zeroed'] field to |
| 196 | // get the DrivetrainStatus.zeroing().zeroed() member. |
| 197 | // Currently, this interface does not provide any support for non-numeric |
| 198 | // fields or for repeated fields (or sub-messages) of any sort. |
James Kuszmaul | b26abe6 | 2022-01-30 16:38:07 -0800 | [diff] [blame] | 199 | addMessageLine(message: MessageHandler|null, field: string[]): Line { |
| 200 | // Construct the line regardless so that we have something to return to the |
| 201 | // user. |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 202 | const line = this.plot.getDrawer().addLine(); |
James Kuszmaul | b26abe6 | 2022-01-30 16:38:07 -0800 | [diff] [blame] | 203 | if (message === null) { |
| 204 | console.warn( |
| 205 | 'Not plotting field ' + field.join('.') + |
| 206 | ' because of an invalid MessageHandler.'); |
| 207 | return line; |
| 208 | } |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 209 | line.setLabel(field.join('.')); |
| 210 | this.lines.push(new MessageLine(message, line, field)); |
| 211 | return line; |
| 212 | } |
| 213 | |
| 214 | draw(): void { |
| 215 | // Only redraw lines if the number of points has changed--because getField() |
| 216 | // is a relatively expensive call, we don't want to do it any more than |
| 217 | // necessary. |
| 218 | for (const line of this.lines) { |
James Kuszmaul | 3e382c0 | 2021-04-09 22:34:36 -0700 | [diff] [blame] | 219 | if (line.hasUpdate()) { |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 220 | line.line.setPoints(line.messages.getField(line.field)); |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | export class AosPlotter { |
milind upadhyay | 9bd381d | 2021-01-23 13:44:13 -0800 | [diff] [blame] | 227 | public static readonly TIME: string = "Monotonic Time (sec)"; |
| 228 | |
| 229 | public static readonly DEFAULT_WIDTH: number = 900; |
| 230 | public static readonly DEFAULT_HEIGHT: number = 400; |
| 231 | |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 232 | private plots: AosPlot[] = []; |
| 233 | private messages = new Set<MessageHandler>(); |
| 234 | constructor(private readonly connection: Connection) { |
| 235 | // Set up to redraw at some regular interval. The exact rate is unimportant. |
| 236 | setInterval(() => { |
| 237 | this.draw(); |
| 238 | }, 100); |
| 239 | } |
| 240 | |
| 241 | // Sets up an AOS channel as a message source. Returns a handler that can |
| 242 | // be passed to addMessageLine(). |
James Kuszmaul | b26abe6 | 2022-01-30 16:38:07 -0800 | [diff] [blame] | 243 | addMessageSource(name: string, type: string): MessageHandler|null { |
| 244 | let schema = null; |
| 245 | try { |
| 246 | schema = this.connection.getSchema(type); |
| 247 | } catch (e) { |
| 248 | console.error(e); |
| 249 | return null; |
| 250 | } |
| 251 | return this.addRawMessageSource(name, type, new MessageHandler(schema)); |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 252 | } |
| 253 | |
| 254 | // Same as addMessageSource, but allows you to specify a custom MessageHandler |
| 255 | // that does some processing on the requested message. This allows you to |
| 256 | // create post-processed versions of individual channels. |
| 257 | addRawMessageSource( |
| 258 | name: string, type: string, |
James Kuszmaul | b26abe6 | 2022-01-30 16:38:07 -0800 | [diff] [blame] | 259 | messageHandler: MessageHandler|null): MessageHandler { |
| 260 | if (messageHandler === null) { |
| 261 | return null; |
| 262 | } |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 263 | this.messages.add(messageHandler); |
| 264 | // Use a "reliable" handler so that we get *all* the data when we are |
| 265 | // plotting from a logfile. |
| 266 | this.connection.addReliableHandler( |
| 267 | name, type, (data: Uint8Array, time: number) => { |
| 268 | messageHandler.addMessage(data, time); |
| 269 | }); |
| 270 | return messageHandler; |
| 271 | } |
| 272 | // Add a new figure at the provided position with the provided size within |
| 273 | // parentElement. |
James Kuszmaul | 9dbb99a | 2021-03-07 20:01:27 -0800 | [diff] [blame] | 274 | addPlot( |
Austin Schuh | c2e9c50 | 2021-11-25 21:23:24 -0800 | [diff] [blame] | 275 | parentElement: Element, |
James Kuszmaul | 9dbb99a | 2021-03-07 20:01:27 -0800 | [diff] [blame] | 276 | size: number[] = [AosPlotter.DEFAULT_WIDTH, AosPlotter.DEFAULT_HEIGHT]): |
| 277 | AosPlot { |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 278 | const div = document.createElement("div"); |
Austin Schuh | c2e9c50 | 2021-11-25 21:23:24 -0800 | [diff] [blame] | 279 | div.style.position = 'relative'; |
| 280 | div.style.width = size[0].toString() + "px"; |
| 281 | div.style.height = size[1].toString() + "px"; |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 282 | parentElement.appendChild(div); |
Austin Schuh | c2e9c50 | 2021-11-25 21:23:24 -0800 | [diff] [blame] | 283 | const newPlot = new Plot(div); |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 284 | for (let plot of this.plots.values()) { |
| 285 | newPlot.linkXAxis(plot.plot); |
| 286 | } |
| 287 | this.plots.push(new AosPlot(this, newPlot)); |
James Kuszmaul | 5f5e123 | 2020-12-22 20:58:00 -0800 | [diff] [blame] | 288 | return this.plots[this.plots.length - 1]; |
| 289 | } |
| 290 | private draw(): void { |
| 291 | for (const plot of this.plots) { |
| 292 | plot.draw(); |
| 293 | } |
| 294 | } |
| 295 | } |