Skip to content

[Draft/Discussion] Recursive parameter interface#17

Open
henningkayser wants to merge 4 commits intoPickNikRobotics:ros2from
henningkayser:draft/param_iface
Open

[Draft/Discussion] Recursive parameter interface#17
henningkayser wants to merge 4 commits intoPickNikRobotics:ros2from
henningkayser:draft/param_iface

Conversation

@henningkayser
Copy link
Contributor

@henningkayser henningkayser commented Apr 29, 2021

@tylerjw here is a draft interface that could combine the parameter tree as implemented in ros2_params with the new NodeParameters interface. To me the only hurdle would be how callbacks would actually be handled in the parameter tree, or if we would only allow callbacks for top-level parameters and for the leafs (= node parameters).

@henningkayser henningkayser requested a review from tylerjw April 29, 2021 10:47
ParameterT& operator=(const ParameterT& value);
ParameterT operator() const; // or: 'operator ParameterT() const;'

// Validity Callbacks (could also use rcl_interfaces::msg::SetParametersResult like in node_parameters.h).
Copy link
Contributor Author

@henningkayser henningkayser Apr 29, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There could also be something like bool testValueChange(const ParameterT& new_value) which triggers the same (chained) validity callbacks only without actually changing the value. Incoming parameter changes from other nodes can be rejected if they are not valid.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems useful. It could also mean that subsystems inside the same node could internally "test a config change" although I'm not sure that's a pattern we want to encourage. There are better ways for a node to communicate internally.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd argue that a node should probably not change its own parameters internally, except for setting defaults maybe. I mean, the use case of dynamic parameters is that other nodes (e.g. user interfaces) can change the modality of the nodes subsystem.

: NodeConfig(node)
{
declare(double_param)
.describe("This is a very important double parameter", "should be greater than 0 and not 7")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm... this would only really work if the node parameters are actually declared after all these calls...

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node parameters are loaded by the launch system after execution of the node starts once it provides the set_parameter interface which is started when you spin your ros node. Is that what you are talking about?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean that the declare() call will declare the node parameters which will already trigger all validity callbacks which have not been initialized at this point. Ideally, the builder functions describe(), warnIf(), etc are called before the parameter is actually declared. That means, either we need to simply run these before calling declare() which doesn't look as pretty or the actual declare calls of the node parameters is deferred to a NodeConfig::declareAll() function that is called after registering all the parameters.

Comment on lines 177 to 186
declare(double_param)
.describe("This is a very important double parameter", "should be greater than 0 and not 7")
.warnIf([](const double& val, std::string& message) {
message = "Value is 7, are you sure this is a good value?";
return val == 7;
})
.errorIf([](const double& val, std::string& message) {
message = "Value must not be negative!";
return val < 0;
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like this builder syntax, the only thing I think it is missing is a default value. Maybe there would be a default(ParameterT ) method? There should also be support for allowing parameters to be unset (no default) but waiting for some timeout for them to be set. This makes sense because the way nodes are launched the parameters are sent to the node after they are started so they are always unset and it is possible to declare a parameter before it is set and then the parameter service interface will set it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default value can be set with the Parameter constructor: Parameter<double> double_param {"double param", 2.0 };.
For non-trivial types this becomes a little more difficult. Having the parameter name and default value part of that builder syntax could improve readability. How about initializing all parameters with default constructors and just setting the name and possible default with declare(). Undeclared parameters would simply throw an exception if they are used.

Suggested change
declare(double_param)
.describe("This is a very important double parameter", "should be greater than 0 and not 7")
.warnIf([](const double& val, std::string& message) {
message = "Value is 7, are you sure this is a good value?";
return val == 7;
})
.errorIf([](const double& val, std::string& message) {
message = "Value must not be negative!";
return val < 0;
});
declare("double_param", double_param, 2.0 /* default */)
.describe("This is a very important double parameter", "should be greater than 0 and not 7")
.warnIf([](const double& val, std::string& message) {
message = "Value is 7, are you sure this is a good value?";
return val == 7;
})
.errorIf([](const double& val, std::string& message) {
message = "Value must not be negative!";
return val < 0;
});

Comment on lines 208 to 213
Parameter<double> double_param{ "double_param" };
Parameter<std::vector<std::string>> string_param{ "string_param" };
Parameter<std::map<std::string, double>> double_map_param{ "double_map_param" };
Parameter<int> int1_param{ "int1_param" };
Parameter<int> int2_param{ "int2_param" };
Parameter<int> int3_param{ "int3_param" };
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I take it these are the keys of the parameters. Would it make sense to have the keys supplied as a parameter to the declare function instead of here? I'm just trying to get all the meta-data about each parameter declared in one place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that would make sense. We just need to make sure that it's not possible to change the name and to declare the parameter a second time.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really worth going for the explicit parameter members instead of any datatypes collected in a map?
It's somewhat cumbersome due to the repeated naming (unless you add pre-processor magic to repeat it for you) and you can't define the datatype, name, and additional data in the same command.
I'd prefer something like

declare<int>("parameter")
   .describe("my parameter")
   .errorIf("no prime number", [](auto x){ for(int i= 2; i < x; ++i) if(x%i == 0) return true; return false; });

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this as well, but to me the explicit members make the config API much more usable since it doesn't require type casting and accessing the map using (const?) keys which might also fail. Actually, the current API doesn't even prevent you from using any and a member map as long as you specify the corresponding parameter template.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's clearly two alternatives with their separate pros and cons.
There might be a middle-way using variadic templates, but even if there is, it would add a lot of complexity and expertise to get it right.

Type-casting is somewhat annoying indeed, but so is having the type declared separate from the actual declaration of everything.

One idea to move all information into one place with your current approach could be a best-practice using uniform initialization. Something like

Parameter<int> int1_param{
		name("int1_param").
		.describe("This is a very important double parameter", "should be greater than 0 and not 7")
		.warnIf([](const double& val, std::string& message) {
			message = "Value is 7, are you sure this is a good value?";
			return val == 7;
		})
		.errorIf([](const double& val, std::string& message) {
			message = "Value must not be negative!";
			return val < 0;
		}) };

That should be possible if you allow Parameter construction only from the builder-types.

const ExampleConfig config(node);

// access copy of value
double value = config.double_param();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nice interface for getting the data. I'm assuming this will lock a mutex to make sure when you are reading it isn't also being updated. Maybe this should throw an exception if the value has not been initialized (no default and value wasn't set). Maybe there should be a read(timeout) method for the parameters with some default value set for the timeout so you can specify the timeout if you want to per-parameter.

That might not be a good idea, we need to keep the interface simple and make it easy to follow a good pattern with it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the read is locked by a shared mutex. I don't know if a timeout is really needed at all. The only scenario I see is if that some thread locks write access and doesn't release the mutex for a long time, which would be a user error that definitely shouldn't happen. I guess that if we really wanted to protect for that, we could use an internal timeout that throws an exception if the value has been locked for too long.

Comment on lines +234 to +243
auto robot_state = /* get robot state copy from somewhere else */;
auto update_robot_state = [this, robot_state](const std::map<std::string, double> values) {
for (const auto& [name, val] : values)
robot_state->setVariablePosition(name, val);

// other thread-safe member function somewhere
this->updateRobotState(robot_state);
};
config.double_map_param.apply(update_robot_state);
config.double_map_param.onChanged(update_robot_state);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like what this could enable. ROS2 is supposed to be more event driven than ros1 and this will enable more event driven behaviors. I also like that the values are passed in by value here so you don't have to worry about the issues around references and them being updated somewhere else at the same time.

Copy link
Contributor Author

@henningkayser henningkayser Apr 30, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right? ;) ... huh I guess I missed the reference, the apply() and onChange() calls would be read-locked so using const-ref should be fine. I think it can work both ways, copy or const-ref.

@tylerjw
Copy link

tylerjw commented Apr 30, 2021

@henningkayser I made some small edits here: henningkayser#1

Copy link
Contributor

@v4hn v4hn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tylerjw talked to me about this effort and I had a look. I love the ideas!
I can't claim I understand enough for a thorough review, but I added a number of comments.

declare(string_param)
.describe("meh")
.warnIf([](const std::string& val, std::string& message) {
message = "string is much too long";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't that string only be set when the method returns true?

It can have advantages to define a predicate in such a way that it can return a message, but for many cases a single associated string (as I proposed in other comments) would suffice.
If you deem it necessary to have these dynamic strings, e.g., to say "<your_specified_joint> does not exist" in the message, maybe it makes sense to allow for two interfaces, one with the additional message and one without.
I implemented something similar in MTC a while ago

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good point! I'd say we should offer both interfaces, one with static message (like in #17 (comment)) and one for dynamic strings.

// "double_map_param", validate keys and values, reject if not valid
declare(double_map_param).describe("6-dof joint state").errorIf([](const auto& values, std::string& message) {
message = "Invalid joint state map values, expected 6-dof";
return values.size() != 6 || checkJointNamesInRobotModel(values); // from somewhere
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting the details right for checks like that might be a problem because usually robot models (or other core datastructures) are not loaded yet when you declare your parameters, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

depends... Of course this example is not meant to make any actual sense, but technically parameters are already set to their overrides when you call declare(). That means, you could first create the RobotModel from your srdf parameter and then declare the map with proper validity checks.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though usually you would want to declare all parameters in the same code block (as in the NodeConfig class you want to provide), so entwined parsing and parameter interpretation will yield clumsy code again.
I don't have a proposal on how to improve that though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True! But would there always be only one config per node? Plugins might have their own configs with defaults already initialized. Even if this example (joint names) is really not perfect, I definitely see the use case of declaring parameters (plugin configs) based on other parameters (plugin class name).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I guess plugins should always be forced to have their parameters in dedicated namespace then, to avoid potential clashes because of redeclared parameters?

// Read/write-locked data access using std::shared_mutex
// See: https://www.youtube.com/watch?v=ozOgzlxIsdg
ReadLockedPtr<ParameterT> operator->const;
WriteLockedPtr<ParameterT> operator->;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure you actually need a shared_mutex here instead of a simple one?
The current API design only locks readers while it copies the values of parameters for the method return value, right?
The concept of a shared mutex really only offers better performance with long locking times for readers and rare write updates.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure you actually need a shared_mutex here instead of a simple one?

I think it has many advantages, but a simple one might work as well.

The current API design only locks readers while it copies the values of parameters for the method return value, right?

What do you mean with "only locks readers"? The ReadLockedPtr is a scoped lock that prevents write access. The read lock is shared so that multiple callers can access the value (const ref) at the same time. The WriteLockedPtr is also a scoped lock, but it's exclusive and allows write access (non-const ref).
Then there are two extra convenience operators which allow copy-get (operator()), copy-set (operator=) which would use read/write locks internally.

The concept of a shared mutex really only offers better performance with long locking times for readers and rare write updates.

I would expect that we have many many read calls and only some (declare/update callbacks) write calls.
The shared_mutex would allow many readers at the same time which would make it more efficient than a simple one as multiple threads don't need to be synchronized for reading.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect that we have many many read calls

True. That is quite a difference to ROS, where we fetch parameters only rarely via API.
Assuming you want to keep parallel access to the dynamic datastructures possible, the shared read-lock makes sense.


const std::string name_;
std::string description_;
std::string additional_constraints_;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dislike the term additional_ because there is no simple contraints yet.
Usually this kind of information is just added to the description, but if you will support turing-complete validation predicates in the API, it makes a lot of sense to let authors explain them in text as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOL, I don't like it either, it's just the field name of the description message. Since that one only applies to basic parameter types, we could also just rename this field to type_specification, unit, or simply comment (or just leave it). The reason I added this is because it would be nice to add this as inline-comment when generating yaml dumps.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name makes more sense in the message though because the other modeled constraints are listed below (ranges, etc.).
I don't think you directly model these other aspects here. I guess they are modeled in the underlying rclcpp API?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct! This field wasn't really thought through. Basically, you should be able to specify these for supported rclcpp parameters but in the context of more generic types this doesn't make a lot of sense. I'm not even sure if we really want to model the rclcpp constraints at all if they basically act the same way as the callbacks in the end.

Comment on lines 208 to 213
Parameter<double> double_param{ "double_param" };
Parameter<std::vector<std::string>> string_param{ "string_param" };
Parameter<std::map<std::string, double>> double_map_param{ "double_map_param" };
Parameter<int> int1_param{ "int1_param" };
Parameter<int> int2_param{ "int2_param" };
Parameter<int> int3_param{ "int3_param" };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really worth going for the explicit parameter members instead of any datatypes collected in a map?
It's somewhat cumbersome due to the repeated naming (unless you add pre-processor magic to repeat it for you) and you can't define the datatype, name, and additional data in the same command.
I'd prefer something like

declare<int>("parameter")
   .describe("my parameter")
   .errorIf("no prime number", [](auto x){ for(int i= 2; i < x; ++i) if(x%i == 0) return true; return false; });

// Alternatively, return Parameter<ParameterT>& for method chaining
using ParameterValidityCallback = std::function<bool(const ParameterT& value, std::string& message)>;
Parameter<ParameterT>& warnIf(ParameterValidityCallback warn_if_callback);
Parameter<ParameterT>& errorIf(ParameterValidityCallback error_if_callback); // or rejectIf()/failIf()..
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love the idea to support these predicates in the API.

I would consider to add the textual description together with each predicate here, instead of describing them independently in describe. See my example below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So.. describe() doesn't really describe the predicates, it describes the parameter itself. I agree that we should extend the API to allow setting the predicate descriptions like you suggested below.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, the additional_constraints field is currently set through describe() and should pretty much describe the predicates on these parameters. That's what I meant. :-)

// Store callbacks, use alternative data structurs for ordered hashes
std::map<std::string, ParameterValidityCallback> warn_if_callbacks_;
std::map<std::string, ParameterValidityCallback> error_if_callbacks_;
std::map<std::string, ParameterChangedCallback> on_changed_callbacks_;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do all these key strings contain? Shouldn't these all just be lists?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hashes so that we can identify and remove them again? At least for the on_changed_callbacks_ this would be desirable. But this definitely doesn't have to be a feature for the MVP.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A much simpler approach would just use std::list then and return the iterator as a handle.
The overhead of maps for this feels unjustified.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

of course!

...
};
Parameter<ParameterT>& warnIf(ParameterCondition condition);
Parameter<ParameterT>& errorIf(ParameterCondition condition);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you convinced it is actually useful to have a warnIf interface at all? I would argue that requests to set parameters either have to be valid or they are invalid. Allowing warnings here might support cases where the user knows better and sets parameters that trigger warnings (and they work). The result is a useless warning in an otherwise running system.

The other way around, if the warning actually indicates a failure, it should actually be an error anyway...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably up to the user. To me it's useful to warn that some feature is disabled or if some parameter doesn't have an override even though the default is valid. Sometimes warnings are just error cases that can be handled. In the end this API would allow making warnings errors and vice-versa without having to refactor custom error handling somewhere else in the code.

@codecov

This comment has been minimized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants