Maps

From Team 449 Wiki

Maps are used to keep track of constants that might vary between runs, such as port numbers or P, I , and D values for closed-loop control. We put all of these numbers into one configuration file, rather than have them hardcoded into 20 different .java files, for ease of access and change. (Note: In this article, "maps" refers to the .java files, and "config file" refers to map.yml, the yaml that holds the values of the constants.)

Making a map

Our map system is based around the YAML file format, for which tutorials that you should definitely read can be found on their official website. Yaml is nothing more than a file format, and we read from using the incredibly powerful Jackson library (a library is a bunch of code written by someone else). If we put the proper annotations on our code, Jackson will be able to parse the yaml file, then call all of the correct constructors to turn it into java objects. Basically, this means we don't need to explicitly call any constructors in our code, we can just let Jackson do it for us. In order to do this, we need to annotate certain things. The simplest annotation is @JsonCreator, which we need to put before the constructor we want Jackson. We also need to annotate some of the parameters of the constructor with @JsonProperty(required = true), which indicates to Jackson that those parameters need to be there, and that Jackson should throw an error if they aren't. Anything annotated with @NotNull should definitely also have @JsonProperty(required = true). Anything not in the config file and not annotated with @JsonProperty(required = true) will default to null if it's an object, 0 if it's a number, or false if it's a boolean. This can be useful, because there are many numerical and boolean parameters that you'll want to default to 0 or false, and so you can just have those fields be an unnanotated int or boolean. If you want a number or boolean to default to something else, you can use the Java object wrappers (e.g. Double or Boolean) and check if they're null. Most classes will need to be given an id field, so that they can be referenced elsewhere in the config file (this lets two different objects in the config take the same object in the config file, which is necessary for, say, having both the "extend arm" and "retract arm" commands reference the same arm subsystem.) The annotation to do this is @JsonIdentityInfo(generator = ObjectIdGenerators.StringIdGenerator.class), and it goes before the class declaration. For classes that have subclasses where you'd want to specify that class in a constructor but provide a subclass in the config file, you have to annotate them with @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.WRAPPER_OBJECT, property = "@class") so that the class can be specified.

Config File Format

The config file contains information readable to the robot map, in the YAML format. It should be src/main/resources/something.yml, and start with the line --- Values are given in the form of

variableName: value

Classes and hierarchy are shown with indentation. Here's a sample with a class called superclass that takes 3 arguments: a boolean called someBoolean, a Subclass called subclass, and an integer called someInt. Subclass takes 1 argument, a double called someDouble.

---
superclass:
    someBoolean: true
    subclass:
        someDouble: 23.5
    someInt: 4

Classes with the @JsonIdentityInfo(generator = ObjectIdGenerators.StringIdGenerator.class) annotation have their id specified with

'@id': some_id

Ids must be unique across the entire file. IDs are referenced by simply putting their name in place of the class's info. Here's the example above, but now Subclass is annotated to have an id, and whatever class the entire map is takes a Superclass and a Subclass.

---
subclassOne:
    '@id': subclassID
    someDouble: 23.5
superclass:
    someBoolean: true
    subclass:
        subclassID
    someInt: 4

IDs are used for passing objects by reference. For example, in the above code, if superclass called subclass.setSomeDouble(5.4), it would also effect subclassOne, because they're just two different pointers to the same object. However, sometimes with the config file we want to just sort of copy/paste info without actually affecting the Java information flow, i.e. we want subclass and subclassOne to have the same value for someDouble, but we want them to be two separate objects. For this, we use anchors and merge syntax. An anchor is marked with &anchorName, and is "merged" with <<: *anchorName. Here's the previous example, but with anchors instead:

---
subclassOne:
    &subclassAnchor
    '@id': subclassOne
    someDouble: 23.5
superclass:
    someBoolean: true
    subclass:
        <<: *subclassAnchor
        '@id': subclass
    someInt: 4

Note that we defined '@id' in both of them- we have to do this to avoid duplicated ids, as the anchor copies all fields, including '@id'. After an anchor, any of the fields gotten from the anchor can be overridden, as we do with '@id' here.

For classes annotated with @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.WRAPPER_OBJECT, property = "@class"), we have to wrap the data in the class's full name and package, which is kinda obnoxious, but allows Jackson to handle polymorphism. Here, subclass is annotated with @JsonTypeInfo:

---
superclass:
    someBoolean: true
    subclass:
        org.usfirst.frc.team449.robot.Subclass:
            someDouble: 23.5
    someInt: 4

As of July 2017, this still applies when using ids, even though it doesn't need to:

---
subclassOne:
    org.usfirst.frc.team449.robot.Subclass:
        '@id': subclassID
        someDouble: 23.5
superclass:
    someBoolean: true
    subclass:
        org.usfirst.frc.team449.robot.Subclass:
            subclassID
    someInt: 4

For more examples, check out the github code.

Reading from Config File

The method for reading from config files is slightly complex, because anchors and merging are not supported by Jackson, but are supported by the underlying SnakeYaml parser. So we use SnakeYaml to parse anchors and merge syntax, then take the output as a string and pass it to Jackson. In addition, we use something fancy that Jackson can do with Java 8 where it gets the names of the parameters from the code (otherwise we'd have to give each parameter an @JsonProperty annotation to specify the parameter name). This does mean that we have to pass the java compiler an extra parameter, -parameters (meaning you'd call javac -paramters to compile). Gradle handles this in all the robotics code, but if you use this for your own project, make sure to use javac -paramters. Here's the code we use to parse the config files:

//Read map with SnakeYaml
Map<?, ?> normalized = (Map<?, ?>) yaml.load(new FileReader("filename.yml"));
YAMLMapper mapper = new YAMLMapper();
//Turn the Map read by SnakeYaml into a String so Jackson can read it.
String fixed = mapper.writeValueAsString(normalized);
//Use a parameter name module so we don't have to specify name for every field.
mapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES));
//Deserialize the map into an object.
robotMap = mapper.readValue(fixed, RobotMap.class);