Enhanced value access

Once that the main.json file has been deserialized into the mainJson object using the getDeserializedJSON function, its keys can be accessed using the default value-access operators and functions, e.g.:

double initialEpoch = mainJson[ "initialEpoch" ];                       // 0.0
std::string integratorType = mainJson.at( "integrator" ).at( "type" );  // "rungeKutta4"

The main problem of this approach is that, in case that the user didn’t define the key type for the integrator object, the thrown value-access error will not provide sufficient information to uniquely identify the source of the problem:

libc++abi.dylib: terminating with uncaught exception of type nlohmann::detail::out_of_range:
[json.exception.out_of_range.403] key 'type' not found

As it can be seen, for a simple input file such as main.json the key type is defined for six objects. If we forget to define it for any of them, the message key 'type' not found will be useless as we cannot know for which object it is missing. The only way to find out is to determine which objects require a mandatory type key and to manually check the input file for any of those objects with this key missing. For more complex simulations, in which the JSON input file is hundreds of lines long, or splitted into several modular files, identification of the source of the problem can be difficult. A message that is informative would be something like key 'integrator.type' not found. In the JSON Interface library, integrator.type is known as a key path.

Key paths

A key path is a list of keys of a nlohmann::json that are accessed sequentialy one after the other. The class KeyPath is declared in the file Tudat/JsonInterface/Support/keys.h. This class derives from std::vector< std::string > and has some additional features, such as the capability to be initialised directly from a single std::string or being outputted as text:

KeyPath simpleKeyPath = "integrator";                   // { "integrator" }
std::cout << simpleKeyPath << std::endl;                // integrator

KeyPath compoundKeyPath = { "integrator", "type" };     // { "integrator", "type" }
std::cout << compoundKeyPath << std::endl;              // integrator.type

KeyPath compoundKeyPath2 = "integrator.type";           // { "integrator", "type" }
std::cout << compoundKeyPath2 << std::endl;             // integrator.type

Additionally, the operator / is overloaded for KeyPath and std::string:

KeyPath simpleKeyPath = "integrator";                  // { "integrator" }
KeyPath compoundKeyPath = simpleKeyPath / "type";      // { "integrator", "type" }

Now, image that the we want to access the values of the files to which to export the results. In our main.json example, using basic value access methods, this would be done by writing:

std::string file0 = mainJson.at( "export" ).at( 0 ).at( "file" );   // "epochs.txt"
std::string file1 = mainJson.at( "export" ).at( 1 ).at( "file" );   // "states.txt"

However, a key path has been defined as a list of strings. If we want to define the key paths for those objects, we need to convert 0 and 1 to an unambiguous string representation. The special keys @0, @1, etc. are used, which means that the character @ should not be used for the keys of the objects in the input files to avoid conflicts. Thus, we can write:

KeyPath keyPathFile0 = "export" / 0 / "file";          // { "export", "@0", "file" }
std::cout << keyPathFile0 << std::endl;                // export[0].file
KeyPath keyPathFile1 = "export[1].file";               // { "export", "@1", "file" }
std::cout << keyPathFile1 << std::endl;                // export[1].file

Note that the / operator is also overloaded for combinations of unsigned int with std::string or KeyPath (but not for two pairs of unsigned int), so 0 and 1 are implicitly converted to strings.

Error handling

The templated function ValueType getValue( const nlohmann::json& jsonObject, const KeyPath& keyPath ) declared in Tudat/JsonInterface/Support/valueAccess.h returns the value of jsonObject defined at keyPath as a ValueType. For instance:

getValue< std::string >( mainJson, "integrator" / "type" );   // "rungeKutta4"
getValue< std::string >( mainJson, "export[1].file" );        // "states.txt"

In addition to recursively accessing the keys contained in keyPath and eventually transforming the last retrieved nlohmann::json object to ValueType, this function adds support for comprehensive value-access and value-conversion errors. For instance:

getValue< double >( mainJson, "integrator" / "errorTolerance" );

throws an UndefinedKeyError:

libc++abi.dylib: terminating with uncaught exception of type tudat::json_interface::UndefinedKeyError:
Undefined key: integrator.errorTolerance

And the following:

getValue< double >( mainJson, "export" / 1 / "file" );

throws an IllegalValueError:

libc++abi.dylib: terminating with uncaught exception of type tudat::json_interface::IllegalValueError:
Could not convert value to expected type double
Illegal value for key: export[1].file

since we are requesting to convert a nlohmann::json object of value type string to double.

Now, image that we have an Integrator class and we define its from_json function so that it can be created from nlohmann::json objects:

integrator.h
class Integrator
{
public:
    std::string type;
    double stepSize;

    Integrator( const std::string& type = "", const double stepSize = 0.0 ) :
      type( type ), stepSize( stepSize ) { }
};

inline void from_json( const nlohmann::json& jsonIntegrator, Integrator& integrator )
{
    integrator.type = getValue< std::string >( jsonIntegrator, "type" );
    integrator.stepSize = getValue< double >( jsonIntegrator, "stepSize" );
}

If, somewhere else, we write:

Integrator integrator = mainJson.at( "integrator" );

the default value access functions will be used, leading to error messages in case of missing keys or illegal values that are difficult to debug. Thus, the following should be used instead:

Integrator integrator = getValue< Integrator >( mainJson, "integrator" );

Note that, in both cases, a nlohmann::json object containing only the integrator object is passed to the from_json function. However, this object is not the same in both cases. When using the default basic value access, the following object is passed:

{
  "type": "rungeKutta4",
  "stepSize": 0
}

When using the getValue function, the following nlohmann::json object is passed:

{
  "type": "rungeKutta4",
  "stepSize": 10,
  "#keypath": ["~", "integrator"],
  "#root": {
    "initialEpoch": 0,
    "finalEpoch": 3600,
    "spice": {
      "useStandardKernels": true
    },
    "bodies": {
      "Earth": {
        "useDefaultSettings": true,
        "ephemeris": {
          "type": "constant",
          "constantState": [0, 0, 0, 0, 0, 0]
        }
      },
      "asterix": {
        "initialState": {
          "type": "keplerian",
          "semiMajorAxis": 7.5E+6,
          "eccentricity": 0.1,
          "inclination": 1.4888
        }
      }
    },
    "propagators": [
      {
        "integratedStateType": "translational",
        "bodiesToPropagate": ["asterix"],
        "centralBodies": ["Earth"],
        "accelerations": {
          "asterix": {
            "Earth": [
              {
                "type": "pointMassGravity"
              }
            ]
          }
        }
      }
    ],
    "integrator": {
      "type": "rungeKutta4",
      "stepSize": 10
    },
    "export": [
      {
        "file": "@path(epochs.txt)",
        "variables": [
          {
            "type": "independent"
          }
        ],
        "epochsInFirstColumn": false
      },
      {
        "file": "@path(states.txt)",
        "variables": [
          {
            "type": "state"
          }
        ],
        "epochsInFirstColumn": false
      }
    ],
    "options": {
      "defaultValueUsedForMissingKey": "continueSilently",
      "unusedKey": "printWarning"
    }
  }
}

I.e. the original mainObject and the key path from which the integrator can be retrieved are also passed. Although the first two keys are not necessary (they contain redundant information that could be retrieved from #root at #keypath), they are also included in the returned object so that it can be used with the default basic value access (i.e. the [] operator and the at method).

Special keys

In order to make possible this advanced error handling in which the full key path is printed, a set of special keys are defined in the JSON Interface library. These special keys are subdivided into two categories:

  • Object-related. These special keys are assigned to nlohmann::json objects by the getValue function. These keys must never be used in a JSON input file.
    • #root: stored the contents of the root nlohmann::json object.
    • #keypath: stored the (absolute) key path from which a nlohmann::json object is retrieved.
  • Path-related. These special keys are used only in the elements of KeyPath objects.
    • ~: known as root key. Used to denote that a key path is absolute (i.e. relative to the root nlohmann::json object). Relative paths start with a key other than ~.
    • <-: known as up key. Used to navigate up one level in the key tree.

For instance, imagine that our Integrator has an initialTime property. If we want this property to be retrieved from the initialEpoch key of the mainJson object in case it is not defined for the integrator, we can update its from_json to look like this:

inline void from_json( const nlohmann::json& jsonIntegrator, Integrator& integrator )
{
    ...
    try
    {
        integrator.initialTime = getValue< double >( jsonIntegrator, "initialTime" );
    }
    catch ( UndefinedKeyError& e )
    {
        integrator.initialTime = getValue< double >( jsonIntegrator, "<-" / "initialEpoch" );
    }
}

In this case, we navigate one level up in the key by using the special key <-, so we end up in the mainJson object, and then we can access the key initialEpoch. However, in case it is possible for integrator objects to be defined at different levels in the key tree (i.e. not always defined in one of the keys immediately under the mainJson), it is better to use an absolute key path:

integrator.initialTime = getValue< double >( jsonIntegrator, "~" / "initialEpoch" );

Note

When printing a KeyPath, either during the generation of an error or for debugging, its canonical representation is used. Canonical key paths are always absolute (i.e. relative to the mainJson), so there is no need to print the initial root key (~) as there is no possible ambiguity. Additionally, the up keys (<-) are removed, popping back the previous key. For instance:

std::cout << "integrator" / "type" << std::endl;                   // integrator.type
std::cout << "~" / "integrator" / "type" << std::endl;             // integrator.type
std::cout << "integrator" / "<-" / "initialEpoch" << std::endl;    // initialEpoch

Note

The from_json function of std::map and std::unordered_map have been overridden in Tudat/JsonInterface/Support/valueConversions.h, so that the special keys #root and #keypath are not assigned to the converted map.

Multi-source properties

As illustrated in the previous section, some properties (referred to as multi-source properties), such as the integrator’s initial time, can be retrieved from different key paths (initialEpoch or integrator.initialTime) This is so because the value at initialEpoch can also be used by other parts of the simulation (such as by Spice, to load the ephemeris from an initial time until a final time). In order to avoid repeating information, this value can be omitted for the individual Spice and integrator objects and retrieved from the mainJson object instead. However, if Spice is not being used, it makes more sense to define it inside the integrator object. Catching the UndefinedKeyError, as shown above, allows to use any of these values. Since this is done frequently in many parts of the json_interface, the getValue function has been overloaded to allow an std::vector< KeyPath > as second argument. The value will be retrieved from the first defined key path in that list. If none of the key paths in the list are defined, an UndefinedKeyError will be thrown printing the last key path in the vector. Thus, the try-catch block shown above can be replaced by:

integrator.initialTime = getValue< double >(
  jsonIntegrator, { "initialTime", "~" / "initialEpoch" } );

Defaultable properties

Imagine that the type property of our Integrator class can take several values, e.g. "euler" and "rungeKutta4". If we want the Runge-Kutta 4 integrator to be used when the key type is not defined by the user, we could modify the from_json method:

inline void from_json( const nlohmann::json& jsonIntegrator, Integrator& integrator )
{
    try
    {
        integrator.type = getValue< std::string >( jsonIntegrator, "type" );
    }
    catch ( UndefinedKeyError& e )
    {
        integrator.type = "rungeKutta4";
    }
    ...
}

This is also done frequently throughout the JSON Interface library, so an overload for the getValue function with a third argument (the default value) is provided. Since the template type is inferred by the compiler from the third argument’s type, in most cases we can safely remove the template arguments (e.g. < std::string >) from the function call. Thus, the previous try-catch block becomes:

integrator.type = getValue( jsonIntegrator, "type", "rungeKutta4" );

Note that, if the user does provide a value for the integrator’s type, but it is not of the expected type (i.e. not a string), the default value will not be used, and an IllegalValueError will be thrown.

Other value-access functions

In this subsection, a few functions widely used in the json_interface, all defined in Tudat/JsonInterface/Support/valueAccess.h, are mentioned together with an example. For more information, see the Doxygen documentation.

  • bool isDefined( const nlohmann::json& jsonObject, const KeyPath& keyPath )

    isDefined( mainJson, "integrator" / "initialTime" );   // false
    
  • ValueType getAs( const nlohmann::json& jsonObject )

    Integrator integrator = getAs< Integrator >( jsonIntegrator );
    
  • nlohmann::json getRootObject( const nlohmann::json& jsonObject )

    getRootObject( jsonIntegrator );   // mainJson
    
  • KeyPath getKeyPath( const nlohmann::json& jsonObject )

    getKeyPath( jsonIntegrator );   // { "~", "integrator" }
    
  • std::string getParentKey( const nlohmann::json& jsonObject )

    getParentKey( jsonIntegrator );   // "integrator"
    

Enhanced value access for arrays

Imagine that our propagation requires the use of three different integrators for different periods of time. The user provides settings for the different integrators by defining the integrator key of mainJson to be an array with three elements:

[
  {
    "type": "rungeKutta4",
    "stepSize": 10,
  },
  {
    "type": "rungeKutta4",
    "stepSize": 20,
  },
  {
    "type": "rungeKutta4"
  }
]

Thus, when we try to access:

nlohmann::json integrators = mainJson.at( "integrator" );
double thirdIntegratorStepSize = integrators.at( 2 ).at( "stepSize" );

we get the following error:

libc++abi.dylib: terminating with uncaught exception of type nlohmann::detail::out_of_range:
[json.exception.out_of_range.403] key 'stepSize' not found

which is not very informative when trying to identify the source of the problem.

If we use enhanced value access:

double thirdIntegratorStepSize = getValue< nlohmann::json >( mainJson, "integrator[2].stepSize" );

the used does get a comprehensible error message:

libc++abi.dylib: terminating with uncaught exception of type tudat::json_interface::UndefinedKeyError:
Undefined key: integrator[2].stepSize

Although the functionality is identical for nlohmann::json objects of value type object and array, the internal implementation is different and can have consequences for a developer extending the JSON interface. As explained before, the comprehensible error messages are generated by defining the special key #keypath of the nlohmann::json objects retrieved by using the getValue function. If the retrieved object is of value type object, defining this key is trivial. However, if the retrieved object is of value type array, the key cannot be defined directly, as string keys cannot be defined for nlohmann::json arrays.

To overcome this problem, nlohmann::json objects of value type array containing structured objects are converted first to nlohmann::json objects of value type object. Only then, it is possible to set the special keys #keypath and #root. This means that, when calling getValue< nlohmann::json >( ... ), the returned nlohmann::json may be of value type object even though the original objects was of value type array. For primitive types and arrays containing only (arrays of) primitive types, there is no need to convert them to object and define the special keys.

The process of converting nlohmann::json objects from value type array to value type object is done inside the getValue function automatically. For instance:

nlohmann::json integrators = getValue< nlohmann::json >( mainJson, "integrator" );

generate the following nlohmann::json object:

{
  "@0": {
    "type": "rungeKutta4",
    "stepSize": 10
  },
  "@1": {
    "type": "rungeKutta4",
    "stepSize": 20
  },
  "@2": {
    "type": "rungeKutta4"
  },
  "#keypath": ["~", "integrator"],
  "#root": {}
}

where the root object, i.e. mainJson, has been omitted in this code-block. If one wants to check whether the returned object actually represents an array, instead of using the built-in is_array method, one has to use the function bool isConvertibleToArray( const nlohmann::json& j ) defined in Tudat/JsonInterface/Support/valueAccess.h. This function returns true if j is of value type object and all its non-special keys match the expression @i, with i convertible to int, or if j is already of value type array. After this check, it is safe to call the function nlohmann::json getAsArray( const nlohmann::json& jsonObject ) to convert the object back to array type. During this process, the information stored in the special keys is lost, so this is rarely done. Instead, the from_json function of std::vector has been overridden so that it is possible to write:

nlohmann::json jsonObject = getValue< nlohmann::json >( mainJson, "integrator" );
jsonObject.is_array( );                    // false
isConvertibleToArray( jsonObject );        // true
std::vector< Integrator > integrators = getAs< std::vector< Integrator > >( jsonObject );

Warning

No custom implementation of the from_json function for std::set is provided by json_interface, since this type is not used by Tudat (as of now). In the future, if one wants to use the getValue function with std::set as template argument, the default from_json function for std::set will have to be overridden to allow conversion of nlohmann::json objects of value type object to std::set, in a similar way as has been done for std::vector in Tudat/JsonInterface/Support/valueConversions.h.