Schema reference
Every schema type with examples, in one page. See the schema system overview for how schemas fit together. A schema describes how one type becomes bytes. FateWeaver writes the schema into the log ahead of the data, so a reader can decode the file without knowing anything about your code.
What are schemas?
Every logged path has exactly one schema, and that schema fixes the path's layout until the program
exits. It gives a viewer the field names and types it needs. The first log call to a path picks the
schema, and a later value of a different type is skipped with a one-time warning instead of
producing a plot that looks wrong three matches later.
Most of the time you never name a schema. Fate.log("Battery/Voltage", volts) infers DoubleSchema
from the value. This page covers what inference produces and how to pick a schema yourself.
In Kotlin, import the log extension next to Fate. The examples below assume both imports:
To use a schema you built, pass it as a third argument. Fate.log(name, value, schema) and
table.log(name, value, schema) use that schema and skip inference. The first log to a path fixes
its schema, so pass the same schema on every call.
Schema types overview
FateWeaver has three categories of schemas:
graph TD
A[FateSchema] --> B[Primitive Schemas]
A --> C[Collection Schemas]
A --> D[Class Schemas]
B --> B1[IntSchema]
B --> B2[LongSchema]
B --> B3[DoubleSchema]
B --> B4[StringSchema]
B --> B5[BooleanSchema]
C --> C1[ArraySchema]
C --> C2[EnumSchema]
C --> C3[MapSchema]
C --> C4[OptionalSchema]
D --> D1[ReflectedClassSchema]
D --> D2[TypedClassSchema]
D --> D3[CustomStructSchema]
D --> D4[TranslatedSchema]
Primitive schemas
Five singletons cover the types that need no configuration. Each has a fixed size, so nothing has
to be measured before writing. You rarely pass these yourself, because log infers them from an
Int, Long, Double, String or Boolean.
IntSchema
Serializes 32-bit signed integers.
LongSchema
Serializes 64-bit signed integers. Use it for counters and other whole numbers that outgrow an Int.
You don't log timestamps yourself, because every message in a version 3 log already carries one, in
nanoseconds.
DoubleSchema
Serializes 64-bit floating-point numbers with full precision. Float fields and FloatArray use
this too, widened on the way out, since the format has no float type. The conversion is exact, but
the encoded value takes 8 bytes instead of 4.
StringSchema
Serializes UTF-8 strings, each with a length prefix.
BooleanSchema
Serializes booleans as single bytes.
Collection schemas
ArraySchema
Serializes arrays of any supported type, with a length prefix. Pass the schema explicitly when you
want the boxed form. The Kotlin example logs an Array<Double>, and the Java one logs a Double[].
Primitive arrays have their own schemas
DoubleArray, IntArray, LongArray and BooleanArray get DoubleArraySchema and the
matching ones for the other types. They write the same bytes as the boxed form and don't
allocate. Inference picks them for you, so Fate.log("Powers", doubleArrayOf(0.8, 0.6)) needs
no schema argument.
MapSchema
Serializes maps with an entry count prefix. Keys don't have to be strings. Any supported type works.
In Kotlin, log keeps the type arguments, so a Map<String, Double> is inferred without a schema.
Java's two-argument log only sees the runtime class and can't log a map, so Java passes a schema.
Homogeneous values only
The format has a fixed shape, so every value in a map must share one type. Map<String, Any>
can't be logged. FateWeaver rejects it when it builds the schema and warns once. Entry order
carries no meaning in the log. A nullable value type such as Map<String, Double?> is fine and
infers an OptionalSchema for the value.
EnumSchema
Serializes enums as their ordinal.
Class schemas
You have four ways to turn one of your own classes into bytes, from no effort to full control.
ReflectedClassSchema
Built by reflecting over the class's public properties. This is what you get when you log an instance of your own class and name no schema.
public class RobotConfig {
public final double maxSpeed;
public final double wheelDiameter;
public final double trackWidth;
public final double gearRatio;
public RobotConfig(double maxSpeed, double wheelDiameter,
double trackWidth, double gearRatio) {
this.maxSpeed = maxSpeed;
this.wheelDiameter = wheelDiameter;
this.trackWidth = trackWidth;
this.gearRatio = gearRatio;
}
}
// FateWeaver infers a ReflectedClassSchema from the class
Fate.log("Robot/Config", new RobotConfig(1.0, 4.0, 14.5, 1.5));
How reflection works
ReflectedClassSchema walks the class's public fields, which are public properties in Kotlin, and
builds a schema for each field's type. It does this recursively, so nested classes work. It reads
the declared type of arrays, List, Set and Map fields to infer their element, key and value
schemas.
Limitations
- Private fields are skipped, with or without getters. Make them public or use a
CustomStructSchema. - Every write reads each field through reflection. A
CustomStructSchemaavoids that cost. - Generic fields need concrete type arguments.
Map<String, Any>andList<*>are rejected when the schema is built. Nullable arguments likeList<String?>are fine and infer as optional elements. - A field must be declared nullable to hold null. A
Foo?property infers anOptionalSchema. A non-null property that turns out to be null fails the write, and the warning names the field.
TypedClassSchema
A version of ReflectedClassSchema that also writes a type name into the log. You turn it on with
an AS_TYPE static field in Java, or a companion object property in Kotlin.
public class SensorReading {
public static final String AS_TYPE = "SensorReading"; // Triggers TypedClassSchema
public final long timestamp;
public final double value;
public final String sensorId;
public SensorReading(long timestamp, double value, String sensorId) {
this.timestamp = timestamp;
this.value = value;
this.sensorId = sensorId;
}
}
// Uses TypedClassSchema because of AS_TYPE
Fate.log("Sensors/Reading", new SensorReading(System.nanoTime(), 3.2, "left"));
Why use AS_TYPE
The name goes into the log next to the fields. A viewer or an analysis script can then tell a
Pose2d from any other three-double struct without matching on the path.
CustomStructSchema
You name the components and supply a function that pulls them out of the object. Nothing is reflected, and nothing is written that you didn't ask for. Pass the schema on every log call.
data class Pose2d(val x: Double, val y: Double, val heading: Double)
val customPoseSchema = CustomStructSchema<Pose2d>(
type = "Pose2d",
componentNames = listOf("x", "y", "heading"),
componentSchemas = listOf(DoubleSchema, DoubleSchema, DoubleSchema),
encoder = { pose ->
listOf(pose.x, pose.y, pose.heading)
}
)
Fate.log("Robot/Pose", pose, customPoseSchema)
public class Pose2d {
public final double x, y, heading;
public Pose2d(double x, double y, double heading) {
this.x = x; this.y = y; this.heading = heading;
}
}
CustomStructSchema<Pose2d> customPoseSchema = new CustomStructSchema<>(
"Pose2d",
Arrays.asList("x", "y", "heading"),
Arrays.asList(DoubleSchema.INSTANCE, DoubleSchema.INSTANCE, DoubleSchema.INSTANCE),
pose -> Arrays.asList(pose.x, pose.y, pose.heading)
);
Fate.log("Robot/Pose", pose, customPoseSchema);
Advanced custom schema examples
Data transformation
// Convert units during serialization
val distanceSchema = CustomStructSchema<Double>(
type = "Distance",
componentNames = listOf("meters"),
componentSchemas = listOf(DoubleSchema),
encoder = { inches -> listOf(inches * 0.0254) } // Convert inches to meters
)
Fate.log("Drive/Distance", distanceInches, distanceSchema)
// Convert units during serialization
CustomStructSchema<Double> distanceSchema = new CustomStructSchema<>(
"Distance",
Arrays.asList("meters"),
Arrays.asList(DoubleSchema.INSTANCE),
inches -> Arrays.asList(inches * 0.0254) // Convert inches to meters
);
Fate.log("Drive/Distance", distanceInches, distanceSchema);
Selective field logging
data class ComplexObject(
val importantData: Double,
val timestamp: Long,
val debugInfo: String, // Skip this
val internalState: Any // Skip this
)
val selectiveSchema = CustomStructSchema<ComplexObject>(
type = "ImportantData",
componentNames = listOf("data", "time"),
componentSchemas = listOf(DoubleSchema, LongSchema),
encoder = { obj ->
listOf(obj.importantData, obj.timestamp) // Only log important fields
}
)
Fate.log("Robot/Important", complexObject, selectiveSchema)
CustomStructSchema<ComplexObject> selectiveSchema = new CustomStructSchema<>(
"ImportantData",
Arrays.asList("data", "time"),
Arrays.asList(DoubleSchema.INSTANCE, LongSchema.INSTANCE),
obj -> Arrays.asList(obj.importantData, obj.timestamp) // Only log important fields
);
Fate.log("Robot/Important", complexObject, selectiveSchema);
TranslatedSchema
Converts a value to another type on the way out, then encodes that. You give it a function from
T to U and a schema for U.
Use it for a class you can't modify, from RoadRunner or any other library, and for wrappers you
would rather see unwrapped in the log. A Rotation2d becomes a plain double, so the plot shows a
heading in radians instead of a one-field struct.
Basic usage
// You have a wrapper class but want to log the inner value
data class Voltage(val value: Double)
val voltageSchema = TranslatedSchema<Voltage, Double>(
baseSchema = DoubleSchema,
toBase = { voltage -> voltage.value }
)
Fate.log("Battery/Voltage", Voltage(12.6), voltageSchema) // Logs as 12.6 (double)
// You have a wrapper class but want to log the inner value
public class Voltage {
public final double value;
public Voltage(double value) { this.value = value; }
}
TranslatedSchema<Voltage, Double> voltageSchema = new TranslatedSchema<>(
DoubleSchema.INSTANCE,
voltage -> voltage.value
);
Fate.log("Battery/Voltage", new Voltage(12.6), voltageSchema); // Logs as 12.6 (double)
Alternative constructor
You can also pass the target class and let FateWeaver look up its schema:
Advanced examples
Converting library types
// RoadRunner's Rotation2d to Double
import com.acmerobotics.roadrunner.Rotation2d
val rotationSchema = TranslatedSchema<Rotation2d, Double>(
baseClass = Double::class.java,
toBase = { rotation -> rotation.toDouble() }
)
Fate.log("Robot/Heading", Rotation2d.exp(Math.PI / 2), rotationSchema) // Logs as 1.5707... (double)
// RoadRunner's Rotation2d to Double
import com.acmerobotics.roadrunner.Rotation2d;
TranslatedSchema<Rotation2d, Double> rotationSchema = new TranslatedSchema<>(
Double.class,
rotation -> rotation.toDouble()
);
Fate.log("Robot/Heading", Rotation2d.exp(Math.PI / 2), rotationSchema); // Logs as 1.5707... (double)
Converting to complex types
// Convert a custom type to a data class with a registered schema
data class MotorState(val power: Double, val position: Int, val velocity: Double)
class Motor(/* ... */) {
fun getPower(): Double = /* ... */
fun getPosition(): Int = /* ... */
fun getVelocity(): Double = /* ... */
}
val motorSchema = TranslatedSchema<Motor, MotorState>(
baseClass = MotorState::class.java,
toBase = { motor ->
MotorState(motor.getPower(), motor.getPosition(), motor.getVelocity())
}
)
Fate.log("Motors/FrontLeft", frontLeftMotor, motorSchema) // Converted to MotorState, then serialized
// Convert a custom type to a data class with a registered schema
public class MotorState {
public final double power;
public final int position;
public final double velocity;
public MotorState(double power, int position, double velocity) {
this.power = power;
this.position = position;
this.velocity = velocity;
}
}
public class Motor {
public double getPower() { /* ... */ }
public int getPosition() { /* ... */ }
public double getVelocity() { /* ... */ }
}
TranslatedSchema<Motor, MotorState> motorSchema = new TranslatedSchema<>(
MotorState.class,
motor -> new MotorState(motor.getPower(), motor.getPosition(), motor.getVelocity())
);
Fate.log("Motors/FrontLeft", frontLeftMotor, motorSchema); // Converted to MotorState, then serialized
Enum to string conversion
When to use TranslatedSchema
| Situation | Use TranslatedSchema? | Alternative |
|---|---|---|
| Wrapper class with one value | Yes | CustomStructSchema |
| Library type you can't modify | Yes | CustomStructSchema |
| Converting to an existing type | Yes | CustomStructSchema for a custom shape |
| Multi-field conversion | Maybe | CustomStructSchema |
| Unwrapping a simple type | Yes | Read the field directly |
Performance considerations
The translation function runs on every logged value, twice. Once sizes the output and once encodes it. The base schema is built once and reused, so nothing is reflected per write. Keep the function to field reads and arithmetic. If you log the value every loop, don't allocate a temporary object in it.
Combining with the schema registry
You can register a TranslatedSchema globally so plain log calls use it:
Schema registry
The registry maps a class to the schema used for it everywhere. See the registry page for more.
Automatic registration
When you log a value of a class that has no schema yet, FateWeaver does four things:
- Checks whether a schema is registered for that class.
- If not, builds one by reflection, a
TypedClassSchemaif the class hasAS_TYPEand aReflectedClassSchemaotherwise. - Registers that schema for later use.
- Creates the path with the registered schema.
Manual registration
Register your own schema to replace the default. After that, plain log(name, value) uses it, so
you don't pass the schema on each call.
// Define once
val optimizedPoseSchema = CustomStructSchema<Pose2d>(
type = "Pose2d",
componentNames = listOf("x", "y", "theta"),
componentSchemas = listOf(DoubleSchema, DoubleSchema, DoubleSchema),
encoder = { pose -> listOf(pose.position.x, pose.position.y, pose.heading.toDouble()) }
)
// Register globally
FateSchema.registerSchema(Pose2d::class.java, optimizedPoseSchema)
// Every later log of a Pose2d uses your schema
Fate.log("Robot/Pose", pose)
Fate.log("Target/Pose", targetPose)
// Define once
CustomStructSchema<Pose2d> optimizedPoseSchema = new CustomStructSchema<>(
"Pose2d",
Arrays.asList("x", "y", "theta"),
Arrays.asList(DoubleSchema.INSTANCE, DoubleSchema.INSTANCE, DoubleSchema.INSTANCE),
pose -> Arrays.asList(pose.position.x, pose.position.y, pose.heading.toDouble())
);
// Register globally
FateSchema.registerSchema(Pose2d.class, optimizedPoseSchema);
// Every later log of a Pose2d uses your schema
Fate.log("Robot/Pose", pose);
Fate.log("Target/Pose", targetPose);
Where to register
Register in your OpMode's init(), before anything logs a value of that class. A path keeps the
schema it got on its first log, so a schema registered afterward does nothing for paths that
already exist.
Keep every registration in one place. Registering the same class twice overwrites the first registration without a warning, and the calls are easy to compare when they sit next to each other.
abstract class BaseOpMode : OpMode() {
override fun init() {
setupSchemas()
initHardware()
}
private fun setupSchemas() {
// Register all custom schemas here
FateSchema.registerSchema(Pose2d::class.java, OPTIMIZED_POSE_SCHEMA)
FateSchema.registerSchema(PoseVelocity2d::class.java, VELOCITY_SCHEMA)
FateSchema.registerSchema(RobotState::class.java, STATE_SCHEMA)
}
companion object {
private val OPTIMIZED_POSE_SCHEMA = CustomStructSchema<Pose2d>(/* ... */)
private val VELOCITY_SCHEMA = CustomStructSchema<PoseVelocity2d>(/* ... */)
private val STATE_SCHEMA = CustomStructSchema<RobotState>(/* ... */)
}
}
public abstract class BaseOpMode extends OpMode {
@Override
public final void init() {
setupSchemas();
initHardware();
}
private void setupSchemas() {
// Register all custom schemas here
FateSchema.registerSchema(Pose2d.class, OPTIMIZED_POSE_SCHEMA);
FateSchema.registerSchema(PoseVelocity2d.class, VELOCITY_SCHEMA);
FateSchema.registerSchema(RobotState.class, STATE_SCHEMA);
}
protected abstract void initHardware();
private static final CustomStructSchema<Pose2d> OPTIMIZED_POSE_SCHEMA =
new CustomStructSchema<>(/* ... */);
private static final CustomStructSchema<PoseVelocity2d> VELOCITY_SCHEMA =
new CustomStructSchema<>(/* ... */);
private static final CustomStructSchema<RobotState> STATE_SCHEMA =
new CustomStructSchema<>(/* ... */);
}
Schema selection guide
Pick the schema from what you are logging:
| What you log | Schema | Why |
|---|---|---|
| Simple values (int, double, string) | Primitive schemas | Inferred, fixed size |
| Arrays | ArraySchema | Inferred, length prefix |
| Lists and sets | ArraySchema (inferred) | Same bytes as an array |
| Maps with one value type | MapSchema | Inferred in Kotlin, explicit in Java |
| Enums | EnumSchema | Stored as a 4-byte ordinal |
| Simple data classes | ReflectedClassSchema | Inferred, no setup |
| Classes a viewer should recognize | TypedClassSchema (AS_TYPE) | Writes the type name into the log |
| Hot paths | CustomStructSchema | No reflection on each write |
| Unit conversion | CustomStructSchema | Converts in the encoder |
| Logging only some fields | CustomStructSchema | You choose which fields to write |
Troubleshooting schemas
FateWeaver never throws into your robot code. When it can't build a schema or log a value, it warns once and skips the value. These are the usual causes.
Common issues
"Could not log ...: no schema for ..."
// Problem: the class has no public fields
data class PrivateFields(private val x: Int)
// Solution: use CustomStructSchema or make the fields public
data class PublicFields(val x: Int)
Unsupported field type
// Problem: functions have no schema
data class BadClass(val lambda: () -> Unit)
// Solution: use supported types or a CustomStructSchema
data class GoodClass(val value: Double)
A value is skipped with "A path keeps one type"
// Problem: the first log fixed "Arm/Target" as a Double
Fate.log("Arm/Target", 0.5)
Fate.log("Arm/Target", "extended") // skipped, warns once
// Solution: use one type per path, or a different name
Fate.log("Arm/State", "extended")
Paths using the wrong schema
// Problem: registering twice. The second call overwrites the first without
// a warning, and paths that already logged keep the old schema.
FateSchema.registerSchema(MyClass::class.java, schemaV1)
// Later...
FateSchema.registerSchema(MyClass::class.java, schemaV2)
// Solution: register each schema once, before anything logs that class