This document contains explanations of various technical details regarding the process of reading data from a module, organizing and manipulating it, as well as various standards used throughout the project.
The BioForce project uses Maven for generating project files, building the codebase, and packaging into executables. Launch4J, which builds .exes, is configured entirely in pom.xml
and is bundled with Oracle's Java 8 for compatibility. Windows installers are generated by NSIS using the script tools/install.nsi
.
The Dashboard may have compatibility issues with different versions of Java 8, so when in doubt, use Oracle JDK 8u202.
GitHub Actions is used for CI/CD via Maven to automatically run tests, build executables, and publish releases when code is pushed to the repository. The jobs that are run can be configured in .github/workflows
.
Unit tests are located at src/test
and should be written incrementally to self-check the codebase when adding new features.
SerialComm
handles all communications between the computer and the module over a USB serial port.
A data sample is stored in the module as a 16-bit unsigned integer, meaning a value between 0 and 65535. Each data sample is composed of two bytes (2 * 8 bits in a byte = 16 bits).
Example:
ax1
ax2
are the two bytes composing a single acceleration data sample for the X axis.
To convert from byte format to integer format, multiply the first byte by 256 and then add the second byte.
Example:
ax
=ax1
* 256 +ax2
.
The bytes are stored chronologically in the finalData
array in the following order, with all samples in a given row being from the same point in time:
Data Sample # | Acceleration X | Acceleration Y | Acceleration Z | Gyroscope X | Gyroscope Y | Gyroscope Z | Magnetometer X | Magnetometer Y | Magnetometer Z |
---|---|---|---|---|---|---|---|---|---|
1 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | mx1 mx2 | my1 my2 | mz1 mz2 |
2 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | |||
3 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | |||
4 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | |||
5 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | |||
6 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | |||
7 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | |||
8 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | |||
9 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | |||
10 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | |||
11 | ax1 ax2 | ay1 ay2 | az1 az2 | gx1 gx2 | gy1 gy2 | gz1 gz2 | mx1 mx2 | my1 my2 | mz1 mz2 |
... | ... | ... | ... | ... | ... | ... |
Because the magnetometer has 1/10th the sample rate of the accelerometer and gyroscope (when the latter is 240 Hz or greater), it only has data for every 10th point on the time axis.
The test parameters associated with a test's data is stored in a .CSVP file as a series of integers separated by newlines. This .CSVP file is a custom file extension and technically is not a "comma separated" file, but this design decision is an artifact carried on from the original DataOrganizer
codebase.
The .CSVP file is always 32 lines long, being padded with 0s and newlines after the test parameters to reach this length.
The format of List<Integer> testParameters
is shown below:
- Number of Tests (
0-8
) - Timer0 Tick Threshold (default
3848
) - Delay After Start (milliseconds) - default
0
- Battery Timeout Length (seconds) - default
300
- Timed Test Flag (
0
/1
) - default0
- limits maximum test length to "Test Duration" field - Trigger on Release Flag (
0
/1
) - default1
- allows test to start when the remote button is released vs when pressed - Test Duration (seconds) - default
30
- only applicable if "Timed Test Flag" is set to 1 - Accel/Gyro Sample Rate (
60
/120
/240
/480
/500
/960
Hz) - default960
- Mag Sample Rate (Hz) - default
96
- If "Accel/Gyro sample rate" ≥ 240, mag is 1/10 of it; otherwise, sample rate is equal - Accel Sensitivity (
2
/4
/8
/16
Gs) - default4
- Gyro Sensitivity (
250
/500
/1000
/2000
deg/s) - default1000
- Accel Filter (
5
/10
/20
/41
/92
/184
/460
/1130 (OFF)
Hz) - default92
- Gyro Filter (
10
/20
/41
/92
/184
/250
/3600
/8800 (OFF)
Hz) - default92
- Accel X Offset Min
- Accel X Offset Max
- Accel Y Offset Min
- Accel Y Offset Max
- Accel Z Offset Min
- Accel Z Offset Max
The Java representation of the test parameters, List<Integer> testParameters
, only has elements 0-12, with 13-18, the acceleration offsets, being appended on when writing the data to a .CSVP file.
Indices 13-18 of testParameters
collectively make up the Inertial Measurement Unit (IMU) calibration offsets. These values are signed raw data samples of the accelerometer on each axis when the module is at rest, laying on a flat surface. The maximum and minimum values correspond to the two orientations the axis can be (facing up vs facing down). Averaging these two offsets for an axis and subtracting the result from each acceleration data sample "normalizes" the value to zero, counteracts any deviation in IMU measurements.
Note: this is NOT the same as data normalization, which is used for gravity compensation when the module is moving on only one axis.
In the codebase, these offsets are in the form of an int[]
of length 9, referred to as mpuOffsets
, or accelOffsets
. Each element in the list is the general offset for an acceleration axis. The length of 9 was chosen in order to match the 9 sensor axes on the module, in the event that calibration offsets were ever needed for the gyroscope and magnetometer (only 3 elements are actually populated).
SerialComm
returns these offsets in the form of an int[][]
named MPUMinMax
, reading data directly from the module instead of from the testParameters
CSVP file. This is the precursor to mpuOffsets
and is structured as follows:
- (0) Acceleration X
- (0) Minimum Offset
- (1) Maximum Offset
- (1) Acceleration Y
- (0) Minimum Offset
- (1) Maximum Offset
- (2) Acceleration Z
- (0) Minimum Offset
- (1) Maximum Offset
This section describes various formats used in DataOrganizer
, GenericTest
, and AxisDataSeries
for converting raw data samples to physical quantities.
Once data is read from the module via SerialComm
converting byte format into 16-bit unsigned integer format, the raw data samples are stored in a List<List<Double>>
named dataSamples
. Each inner list represents a single sensor axis (accelerometer, gyroscope, and magnetometer along with either X, Y, or Z). The order of these lists is shown below:
- Time
- Acceleration X
- Acceleration Y
- Acceleration Z
- Gyroscope (Angular Velocity) X
- Gyroscope (Angular Velocity) Y
- Gyroscope (Angular Velocity) Z
- Magnetometer X
- Magnetometer Y
- Magnetometer Z
The BioForce Graph further processes dataSamples
into individual AxisDataSeries
objects. Each AxisDataSeries
represents a single axis's data (eg. Acceleration X or Angular Velocity Y). The key difference from dataSamples
is that AxisDataSeries
supports "virtual" axes -- generating integrated/differentiated data sets, such as velocity or angular acceleration, from native sensor data sets. In addition, AxisDataSeries
encapsulates all methods related to data conversion, calculation, and manipulations in a single class. This improves readability and changes without affecting other portions of the codebase.
GenericTest
, which represents all test data associated with a single module, contains the list of axes in its field AxisDataSeries[] axes
, the format of which is described below:
- Acceleration X
- Acceleration Y
- Acceleration Z
- Acceleration Magnitude
- Velocity X
- Velocity Y
- Velocity Z
- Velocity Magnitude
- Displacement X
- Displacement Y
- Displacement Z
- Displacement Magnitude
- Angular Acceleration X
- Angular Acceleration Y
- Angular Acceleration Z
- Angular Acceleration Magnitude
- Angular Velocity X
- Angular Velocity Y
- Angular Velocity Z
- Angular Velocity Magnitude
- Angular Displacement X
- Angular Displacement Y
- Angular Displacement Z
- Angular Displacement Magnitude
- Magnetometer X
- Magnetometer Y
- Magnetometer Z
- Magnetometer Magnitude
Within an AxisDataSeries
, raw data is processed in the following steps:
- Sign data samples,
- apply sensor sensitivity,
- normalize the data sets,
- and smooth the graphs.
As mentioned previously, data samples are originally recorded as 16-bit unsigned integers (meaning a number between 0 and 65535). However, since all physical quantities have magnitude, the Dashboard must "sign" the data, converting the all-positive integer range (0-65535) to both positive and negative integers.
To do this, the top half of the range (32768-65535) is mapped to negative numbers, while the bottom half (0-32767) maps to positive numbers. In doing so, the number of possible values in the range would remain the same (a total of 65536 values), but it would simply be shifted to encompass a "signed" range (-32767,32767).
You may notice that the number of values in the range above, (-32767,32767), is only 65535, instead of 65536. This is because the signed number 0 is actually mapped to two unsigned numbers, 0 and 65535. This is intentional as you'll see later.
Specifically, the "signing" algorithm to convert unsigned samples to signed samples boils down to the following:
if sample > 32767, subtract 65535;
else, leave sample unchanged
This obeys two's complement representation, resulting in both the minimum and maximum unsigned values (0 and 65535) being mapped to the signed value 0.
Example: the raw sample
42439
becomes the signed sample-23096
.
Once data samples are signed, they must be translated into their physical quantities using the sensor's given sensitivity. This can be thought of as how "wide" the range of data can be. For example, a sensitivity of 16G would allow the module to measure data samples anywhere from -16Gs of motion to +16Gs of motion, while a sensitivity of 8G would measure data between -8Gs and +8Gs.
However, since raw data samples are fixed to only 65535 unique values, changing a sensor's sensitivity directly affects its resolution. This can be thought of as how "precise" its measurements are.
For example, with a sensitivity of 16G, each of the 65535 possible values would be more "spread out" to cover the entire range (to be specific, each sample would measure in increments of 16G / 32768 = 4.88*10^-4 Gs
). However, with a sensitivity of 8G, the 65535 values would be much closer together since the range is smaller. This allows data samples to have finer increments of 8G / 32768 = 2.44*10^-4 Gs
.
The figure below illustrates the core concept described earlier:
With all this, we now have all the information needed to convert our raw data. To find where a given signed data sample "falls" on the scale of 65535 possible values (see above), we can divide by the maximum magnitude of the range, 32768. Using the same logic, we then multiply by the sensitivity, which represents the maximum magnitude of the range recorded by the sensor. In other words, these two operations convert any sample from the "raw" range of (-32767,32767) to the "physical" range of (-sensitivity
,+sensitivity
).
Therefore, the final equation to convert signed samples to physical quantities is:
physical quantity = (signed data sample) * (sensitivity) / 32768
Example: the signed sample
-23096
recorded at16G
sensitivity is-23096 * 16 / 32768 = -11.27G
, or-110.56 m/s^2
.