-
Notifications
You must be signed in to change notification settings - Fork 127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add preliminary support for parameters #332
Add preliminary support for parameters #332
Conversation
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Co-authored-by: jhdcs <48914066+jhdcs@users.noreply.github.com>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not the most familiar person with ROS 2 parameters, but I really like the ergonomics of this impl.
Thanks for the great work!
My understanding is that in |
/// Describes the parameter's type. Similar to `ParameterValue` but also includes a `Dynamic` | ||
/// variant for dynamic parameters. | ||
#[derive(Clone, Debug, PartialEq)] | ||
pub enum ParameterKind { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The need for this comes (1) from the case where the parameter is an DeclaredValue::Optional
, because it can be derived from the ParameterValue
itself in that case, and (2) from it being the source of truth for identifying dynamic parameters, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's correct. Unset optional parameters and dynamic parameters are two cases where the type constraint cannot be inferred from just looking at what variant is active in the ParameterValue
enum.
/// | ||
/// Returns a `Parameter` struct that can be used to get and set all parameters. | ||
pub fn use_undeclared_parameters(&self) -> Parameters { | ||
self._parameter.allow_undeclared(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where does this get reset? In fact, is it used at all?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the current API, once undeclared parameters are allowed, they're allowed for the rest of the node's lifespan. It's hard for me to imagine a use case for undoing that allowance or even what the behavior should be to undo it.
In rclcpp this decision is set in stone at initialization, so I think it's reasonable for us to not offer a way to reverse it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But I don't see the allow_undeclared
atomic being read anywhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It has been added in anticipation of parameter services. When parameter services are implemented on top of this in the future, this flag will determine whether to reject parameter service requests on undeclared parameters.
Until then it's true that it doesn't have any effect, simply because we've designed the rclrs API in a way that makes it unnecessary to check this for rclrs API calls.
rclrs/src/parameter.rs
Outdated
#[derive(Debug)] | ||
enum ParameterStorage { | ||
Declared(DeclaredStorage), | ||
Undeclared(Arc<RwLock<ParameterValue>>), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a really different usage of the words "declared" and "undeclared" than rclcpp
uses imo. In rclcpp
, there is no internal distinction between a declared and an undeclared parameter - some parameter either exists or it doesn't. The allow_undeclared_parameters
flag just causes all parameter overrides to create/declare parameters automatically, instead of acting only as defaults for declare_parameter()
. By contrast, you have a user-facing distinction between the two and do not implement allow_undeclared_parameters
, right?
The PR in rclcpp that introduced the "declaration" concept was ros2/rclcpp#495.
To reply to @mxgrey's comment on the rationale for the distinction:
The gist is that declared parameters carry a lot more information with them. When a parameter is declared it will have some type (we say
kind
in the implementation to avoid keyword collision) and may have a range, description, or other properties. An undeclared parameter does not have any of that.I imagine in rclcpp they probably just have "sensible default" values for all that extra information to store declared and undeclared parameters the same way. That makes sense in C++ where tagged unions are ... not pleasant (
std::variant
has terrible ergonomics). In this implementation we're taking an approach of "don't do/say more than what is explicitly needed". An undeclared parameter cannot have ranges, constraints, or descriptions because the user never provided any of those, so we should not pretend to have any of them in our implementation.
You could also have one parameter map, with parameter's fields for user-provided metadata being optionals, right?
rclrs/src/parameter/value.rs
Outdated
impl ParameterVariant for ParameterValue { | ||
type Range = ParameterRanges; | ||
fn maybe_from(value: ParameterValue) -> Option<Self> { | ||
Some(value) | ||
} | ||
|
||
fn kind() -> ParameterKind { | ||
ParameterKind::Dynamic | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are there good use cases for dynamic parameters? This functionality always seemed like an antipattern to me, so I left it out from my prototype to keep complexity smaller, at least initially.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I... Honestly don't know of any real use case but I thought I would add the functionality since it is present in the other libraries.
It is also part of ParameterDescriptor and I thought we should have an implementation for all the fields in the message so when we implement services and users call /describe_parameters
we have an implementation for all the features in the message fields.
The nice aspect of the proposed implementation is that it is just treated as a different enum value and not as a boolean flag that we need to check all over the place so it doesn't really clutter the code or introduce potential errors too much. If there is consensus to remove it I'm happy to do so though
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think dynamic types should be kept. I can imagine dynamic types being used to represent variants, especially if a future PR introduces the "parameter struct" concept that was discussed in the last WG meeting. Dynamic parameters would theoretically allow us to support enum
parameter structs. That allows json/yaml structures with schemas to be converted into ROS parameters more completely.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel that if the other client libraries are including dynamic parameters, we probably should as well. At least initially. Especially since this doesn't seem to add too many issues at first glance.
In future, we can probably gather some more data to see whether or not this is a useful feature for the client libraries in general.
But I will agree that dynamic parameters feel a tad spooky to me as well. And if it weren't for the fact that the other libraries have them, I'd leave them out as well. But, for now, I think we should leave this in.
rclrs/src/parameter/value.rs
Outdated
type Range: Into<ParameterRanges> + Default + Clone; | ||
/// Attempts to convert `value` into the requested type. | ||
/// Returns `Some(Self)` if the conversion was successful, `None` otherwise. | ||
// TODO(luca) should we use try_from? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we should
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I gave it a try but I struggled a fair bit with the error type and it ended up being a lot more verbose without really adding much (I could only get it to work by converting the errors to options through ok()
) 25cd2a8. The main part I struggled with is the fact that the trait TryFrom
can have any type as its Error
, so all the code that uses try_from(_)
needs to be able to deal with this unless we constrain the type through a quite verbose bound.
Happy to refactor / revert it, or add the trait bounds, I find them a bit tough to get right.
It's true that there are many valid ways to structure the internals of the parameter map. The question really boils down to what reads most clearly to the maintainers (the perspective of users doesn't even matter since this is just implementation detail that they can't touch). Our feeling was that a declared/undeclared split is the most clear because it allows a clear path to upgrade an undeclared parameter into a declared parameter if the user asks for it. There are two important differences between declared and undeclared parameters that make this relevant:
If the implementation does not have a clear distinction between which parameters are declared or undeclared then implementing those behaviors becomes less obvious (but certainly not impossible). We are also able to do a slight optimization for undeclared parameters because they do not need to be shared or modified from outside of the parameter map, but I don't think that minor optimization is a strong argument for sticking to the current implementation if another arrangement can improve the clarity of the implementation. |
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
I suspect the field you are referring to is There are different types of validation happening in rclcpp and other libraries. I agree that in general having a single place for constraints and unifying both ranged and callbacks into a single callback would be cleaner, but that would make it impossible for the |
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Michael X. Grey <grey@openrobotics.org>
I just pushed some substantial changes, some of which address feedback we've received and others which tie up some loose ends. Here's a summary of the changes. One entry point for building parametersWe previously had Now setting a default value is always optional, but Initial value discriminatorFor any declaration there are up to three possible values the parameter could be initialized to:
Initially we thought we would provide a variety of flags to the builder to determine which value to use as the initial value in any given declaration. For example, Instead we have settled on allowing users to provide a custom discriminator function. The discriminator would have a signature
There is a default discriminator whose behavior is to prefer We could provide a variety of out-of-the-box discriminators for whatever uses cases we expect will be relatively common if anyone has opinions on what those should look like. We still provide
|
Signed-off-by: Michael X. Grey <grey@openrobotics.org>
…-vedova/ros2_rust into luca/parameter_services
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
Signed-off-by: Luca Della Vedova <lucadv@intrinsic.ai>
@luca-della-vedova thanks for addressing my feedback. I'll have another look, but I'm leaning towards just merging this PR and iterate. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have no further comments.
@luca-della-vedova @mxgrey thank you so much for all the hard work and @jhdcs @maspe36 @nnmm thank you for the thorough reviews |
Description
This PR brings preliminary support for parameters to
rclrs
. It's fairly complex and some choices have been made for ergonomics and safety that don't necessarily match what other client libraries do. At this stage feedback on whether the behavior and those deviations are OK is probably more productive that going too deep into code review.There are extensive unit tests implemented in parameter.rs. These can be a good example of what the experience of using the library would be like.
Note: This PR is currently built on top of #325 since it refactors some of its usage of parameters, depending on the preferred order we can either look at this after or I can rebase this on top of main and look at the time source PR later, but they are still somewhat interconnected.What's implemented
What's not implemented
API Overview
The main API that users are expected to interact with is
node.declare_parameter(...)
which returns a parameter builder object that can be used to set its properties before finalizing it and declaring the parameter.The lifetime of the parameter is tied to the object returned by the declaration. A parameter can only be declared once and once the returned object is destroyed it will be automatically undeclared.
The returned object depends on the type of parameter and offers different APIs.
Three types of parameters have been implemented:
Mandatory
, built throughnode.declare_parameter(name, default_value).[options].mandatory()
. This is a parameter that must always have its value set. Its setter must provide a value and its getter will always return a value.Optional
, built through.optional()
builder function. Differs from the mandatory since it accepts anOption<T>
as a default value and can be set toNone
. The getter as a consequence will return anOption<T>
as well.ReadOnly
, built through.read_only()
builder function. Cannot be changed and only offers aget
API, making it actually impossible to edit.Main features
Parameter typing and interaction
The main way users are expected to do operations on parameters is through the object returned by the builder and its associated getters and setters. This allows better compile time control over the type and less error handling to be done by the user, for example:
This is opposed to the parameter API in the other libraries that leaves the user to make sure the type returned by the getter is the expected type.
Of course there must be support for dynamic typing, but this still works with the proposed API where now users declare a parameter of enum type, like so:
The dynamic nature of the parameter is implied from the user setting its value as an enum rather than a matching primitive type. This is in contrast to other client libraries that use a boolean flag for this purpose.
Parameter lifetime and declaration
As mentioned before parameters will be declared and undeclared automatically based on the lifetime of the object returned by the declaration.
Undeclared parameter API
Users can still access all parameters, declared or undeclared, through their name and value. However this is hidden behind a
node.use_undeclared_parameters()
that returns an interface that has the more familiar.set(name, value)
and.get(name)
.The idea is that users must explicitly express their intent to allow setting undeclared parameters in their code. When services are implemented they will behave accordingly when trying to set a non existing parameter.
No ParameterDescriptor argument
Other libraries such as
rclcpp
, use aParameterDescriptor
object as an input to the declaration function. However this is non ideal in several ways:declare_parameter
function signature so they are unnecessary and silently ignored.read_only
is a boolean parameter which is saved as a parameter property. Every time a parameter is set the code needs to check it and return an error if it is read only. This is not allowed with the proposed API that doesn't offer aset
function in the first place.int_range
and afloat_range
field. This is unnecessary information and also a bit confusing if for example we are declaring a statically typed string parameter.For such reason a builder type API has been implemented. The
read_only
anddynamic
properties of the descriptor don't exist anymore and are embedded in the type as described in the previous paragraphs. The others are arguments of the builder. For example:Depending on the parameter type only a certain type of range can be set:
Integer
param: therange(..)
function will take an integer range as an argument.Double
param: therange(..)
function will take a double range.ParameterRanges
struct that contains both a float and an integer range.More control over ranges
As shown partially above, ranges now are only specified when the correct parameter type applies. In addition, a few changes are present that might differ from how they work in other libraries.
There is an
InvalidRange
error that will be thrown if the user specifies an invalid range, like lower > upper or step <= 0. These are all allowed inrclcpp
where:Option
for this).In my opinion these cases, especially the first one, are really not very intuitive and for this reason the proposal is to introduce an error that flags them out.
Furthermore, in the ROS interface, if a range is specified its upper and lower bounds must both be specified, while the step is optional (where step = 0 means no step specified).
The proposal here is to also allow users to specify only one of lower or upper bound, these can be trivially translated to the interface messages by using the float infinity or integer max value.
Easily clonable ParameterValue
The
ParameterValue
variants that were not wrapping primitive types and needed heap allocations have been wrapped by anArc<[]>
type. This allows both less expensiveparameter.get()
calls, since they return a cloned value, and guarantee immutability outside of theparameter.set(value)
functions.Extra care has been put to make the parameter declaration behavior as ergonomic as possible and avoid forcing users to add
Arc::from(type)
or.into()
in all their calls.Extensive and partially configurable error reporting
Parameter declaration is a complex subject and each library has some discretion over what is acceptable and what is not. In this implementation the following always fail:
In addition, we use an explicitly different storage for declared and undeclared parameters, upon declaration if there was already an undeclared value the following could happen:
This API defaults to ignoring the value set through the undeclared interface if any of these happens. The idea is that at the declaration level the user knows exactly what their parameter should contain. Furthermore if declaration failed it could be tricky for users to reset the parameter.
Still, there is an API to make these cases an explicit error rather than a silently drop behavior, as such: