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:
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 thegetValue
function. These keys must never be used in a JSON input file.
#root
: stored the contents of the rootnlohmann::json
object.#keypath
: stored the (absolute) key path from which anlohmann::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 rootnlohmann::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
.