Skip to content

Commit 9b93579

Browse files
Fix nested mapped parameters map (#306)
1 parent f101d5e commit 9b93579

13 files changed

Lines changed: 219 additions & 22 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,28 @@ params.gain.joints_map.at("joint1").interfaces_map.at("position").value
384384
params.gain.get_entry("joint1").get_entry("position").value
385385
```
386386

387+
#### Key array scope resolution
388+
389+
The `key` used by a `__map_<key>` segment does not need to be defined at the root namespace level. It can also be a **sibling** within the same struct, or defined anywhere in a parent scope.
390+
This allows you to co-locate the key array alongside the map it controls:
391+
392+
```yaml
393+
cpp_name_space:
394+
# key array defined as a sibling of the map that uses it
395+
nested_map:
396+
entries:
397+
type: string_array
398+
default_value: ["entry1", "entry2"]
399+
description: "Keys for the nested map"
400+
__map_entries: # resolved to nested_map.entries (sibling scope)
401+
value:
402+
type: double
403+
default_value: 1.0
404+
description: "A value keyed by entries"
405+
```
406+
407+
> **Note:** Scope resolution searches the current struct first, then walks up to parent scopes. If the key array is not found in any scope, the bare name is used as a fallback.
408+
387409
### Use generated struct in Cpp
388410
The generated header file is named based on the target library name you passed as the first argument to the cmake function.
389411
If you specified it to be `turtlesim_parameters` you can then include the generated code with `#include <turtlesim/turtlesim_parameters.hpp>`.

example/config/implementation.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,9 @@ admittance_controller:
115115
# general settings
116116
enable_parameter_update_without_reactivation: true
117117
use_feedforward_commanded_input: true
118+
119+
nested_map:
120+
entry1:
121+
value: 3.14
122+
entry2:
123+
value: 2.71

example/src/minimal_publisher.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ MinimalPublisher::MinimalPublisher(const rclcpp::NodeOptions& options)
5656
for (auto d : fixed_array) {
5757
RCLCPP_INFO(get_logger(), "value: '%s'", std::to_string(d).c_str());
5858
}
59+
60+
RCLCPP_INFO(get_logger(), "self.params.nested_map.entry1.value = '%f'",
61+
params_.nested_map.entries_map["entry1"].value);
62+
RCLCPP_INFO(get_logger(), "self.params.nested_map.entry2.value = '%f'",
63+
params_.nested_map.entries_map["entry2"].value);
5964
}
6065

6166
void MinimalPublisher::timer_callback() {

example/src/parameters.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ admittance_controller:
7676
default_value: 2.0
7777
description: "general gain"
7878

79+
nested_map:
80+
entries:
81+
type: string_array
82+
default_value: ["entry1", "entry2"]
83+
description: "Keys for nested mapped parameters"
84+
__map_entries:
85+
value:
86+
type: double
87+
default_value: 1.0
88+
description: "A value in the nested map with sibling keys"
89+
7990
fixed_string:
8091
type: string_fixed_25
8192
default_value: "string_value"

example_python/config/implementation.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,9 @@ admittance_controller:
115115
# general settings
116116
enable_parameter_update_without_reactivation: true
117117
use_feedforward_commanded_input: true
118+
119+
nested_map:
120+
entry1:
121+
value: 3.14
122+
entry2:
123+
value: 2.71

example_python/generate_parameter_module_example/minimal_publisher.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ def __init__(self):
5353
for d in self.params.fixed_array:
5454
self.get_logger().info("value: '%s'" % str(d))
5555

56+
self.get_logger().info(
57+
"self.params.nested_map.entry1.value = '%s'"
58+
% self.params.nested_map.get_entry('entry1').value
59+
)
60+
self.get_logger().info(
61+
"self.params.nested_map.entry2.value = '%s'"
62+
% self.params.nested_map.get_entry('entry2').value
63+
)
64+
5665
def timer_callback(self):
5766
if self.param_listener.is_old(self.params):
5867
self.param_listener.refresh_dynamic_parameters()

example_python/generate_parameter_module_example/parameters.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ admittance_controller:
8080
default_value: 2.0
8181
description: "general gain"
8282

83+
nested_map:
84+
entries:
85+
type: string_array
86+
default_value: ["entry1", "entry2"]
87+
description: "Keys for nested mapped parameters"
88+
__map_entries:
89+
value:
90+
type: double
91+
default_value: 1.0
92+
description: "A value in the nested map with sibling keys"
93+
8394
fixed_string:
8495
type: string_fixed_25
8596
default_value: "string_value"

generate_parameter_library_py/generate_parameter_library_py/parse_yaml.py

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,37 @@ def get_dynamic_parameter_field(yaml_parameter_name: str):
105105
return '.'.join(field)
106106

107107

108-
def get_dynamic_mapped_parameter(yaml_parameter_name: str):
109-
tmp = yaml_parameter_name.split('.')
110-
mapped_params = [
111-
val.replace('__map_', '') for val in tmp[:-1] if is_mapped_parameter(val)
108+
def get_dynamic_mapped_parameter(
109+
yaml_parameter_name: str, declared_names: set = None
110+
) -> list:
111+
"""Get the resolved paths to the key arrays for each __map_ segment."""
112+
keys = get_dynamic_mapped_parameter_keys(yaml_parameter_name)
113+
if not declared_names:
114+
return keys
115+
116+
struct_name = get_dynamic_struct_name(yaml_parameter_name)
117+
return [_resolve_key_path(k, struct_name, declared_names) for k in keys]
118+
119+
120+
def _resolve_key_path(key_name: str, struct_name: str, declared_names: set) -> str:
121+
"""Resolve the full path to a mapped parameter key array by searching scopes."""
122+
prefix = struct_name
123+
while prefix:
124+
candidate = f'{prefix}.{key_name}'
125+
if candidate in declared_names:
126+
return candidate
127+
# Move up one level (e.g., "a.b.c" -> "a.b")
128+
prefix, _, _ = prefix.rpartition('.')
129+
# Fallback to the bare key name if not found in any scope
130+
return key_name
131+
132+
133+
def get_dynamic_mapped_parameter_keys(yaml_parameter_name: str) -> list:
134+
"""Get the base key names (without __map_ prefix) for all mapped segments."""
135+
segments = yaml_parameter_name.split('.')
136+
return [
137+
seg.replace('__map_', '') for seg in segments[:-1] if is_mapped_parameter(seg)
112138
]
113-
return mapped_params
114139

115140

116141
def get_dynamic_struct_name(yaml_parameter_name: str):
@@ -129,7 +154,7 @@ def get_dynamic_parameter_name(yaml_parameter_name: str):
129154

130155

131156
def get_dynamic_parameter_map(yaml_parameter_name: str):
132-
mapped_params = get_dynamic_mapped_parameter(yaml_parameter_name)
157+
mapped_params = get_dynamic_mapped_parameter_keys(yaml_parameter_name)
133158
parameter_map = [val + '_map' for val in mapped_params]
134159
parameter_map = '.'.join(parameter_map)
135160
return parameter_map
@@ -411,9 +436,18 @@ def __str__(self):
411436

412437

413438
class UpdateRuntimeParameter(UpdateParameterBase):
439+
def __init__(
440+
self,
441+
parameter_name: str,
442+
code_gen_variable: CodeGenVariableBase,
443+
mapped_params: list = None,
444+
):
445+
super().__init__(parameter_name, code_gen_variable)
446+
self.mapped_params = mapped_params or []
447+
414448
def __str__(self):
415449
parameter_validations_str = ''.join(str(x) for x in self.parameter_validations)
416-
mapped_params = get_dynamic_mapped_parameter(self.parameter_name)
450+
mapped_params = self.mapped_params
417451
parameter_map = get_dynamic_parameter_map(self.parameter_name)
418452
parameter_map = parameter_map.split('.')
419453
struct_name = get_dynamic_struct_name(self.parameter_name)
@@ -542,6 +576,7 @@ def __init__(
542576
parameter_read_only: bool,
543577
parameter_validations: list,
544578
parameter_additional_constraints: str,
579+
mapped_params: list = None,
545580
):
546581
super().__init__(
547582
code_gen_variable,
@@ -550,6 +585,7 @@ def __init__(
550585
parameter_validations,
551586
parameter_additional_constraints,
552587
)
588+
self.mapped_params = mapped_params or []
553589
self.set_runtime_parameter = None
554590
self.param_struct_instance = 'updated_params'
555591

@@ -570,7 +606,7 @@ def __str__(self):
570606

571607
bool_to_str = self.code_gen_variable.conversion.bool_to_str
572608
parameter_field = get_dynamic_parameter_field(self.parameter_name)
573-
mapped_params = get_dynamic_mapped_parameter(self.parameter_name)
609+
mapped_params = self.mapped_params
574610
parameter_map = get_dynamic_parameter_map(self.parameter_name)
575611
struct_name = get_dynamic_struct_name(self.parameter_name)
576612
parameter_map = parameter_map.split('.')
@@ -619,9 +655,7 @@ def __str__(self):
619655
parameter_field = get_dynamic_parameter_field(
620656
self.dynamic_declare_parameter.parameter_name
621657
)
622-
mapped_params = get_dynamic_mapped_parameter(
623-
self.dynamic_declare_parameter.parameter_name
624-
)
658+
mapped_params = self.dynamic_declare_parameter.mapped_params
625659

626660
data = {
627661
'parameter_map': parameter_map,
@@ -744,6 +778,7 @@ def __init__(self, language: str):
744778
'Invalid language, only cpp, markdown, rst, and python are currently supported.'
745779
)
746780
GenerateCode.templates = get_all_templates(language)
781+
self.declared_param_names = set()
747782
self.language = language
748783
self.namespace = ''
749784
self.struct_tree = DeclareStruct('Params', [])
@@ -808,6 +843,15 @@ def parse_params(self, name, value, nested_name_list):
808843
# check if runtime parameter
809844
is_runtime_parameter = is_mapped_parameter(param_name)
810845

846+
# Resolve mapped paths for dynamic params; track paths for standard params
847+
mapped_params = []
848+
if is_runtime_parameter:
849+
mapped_params = get_dynamic_mapped_parameter(
850+
param_name, self.declared_param_names
851+
)
852+
else:
853+
self.declared_param_names.add(param_name)
854+
811855
if is_runtime_parameter:
812856
declare_parameter_set = SetRuntimeParameter(param_name, code_gen_variable)
813857
declare_parameter = DeclareRuntimeParameter(
@@ -816,9 +860,12 @@ def parse_params(self, name, value, nested_name_list):
816860
read_only,
817861
validations,
818862
additional_constraints,
863+
mapped_params,
819864
)
820865
declare_parameter.add_set_runtime_parameter(declare_parameter_set)
821-
update_parameter = UpdateRuntimeParameter(param_name, code_gen_variable)
866+
update_parameter = UpdateRuntimeParameter(
867+
param_name, code_gen_variable, mapped_params
868+
)
822869
else:
823870
declare_parameter = DeclareParameter(
824871
code_gen_variable,

generate_parameter_library_py/generate_parameter_library_py/test/YAML_parse_error_test.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,17 @@ def test_expected(test_input, expected):
8484
print(e.value)
8585

8686

87-
def test_parse_valid_parameter_file():
88-
try:
89-
yaml_test_file = 'valid_parameters.yaml'
90-
set_up(yaml_test_file)
91-
except Exception as e:
92-
assert False, f'failed to parse valid file, reason:{e}'
93-
94-
95-
def test_parse_valid_parameter_file_including_none_type():
87+
@pytest.mark.parametrize(
88+
'yaml_test_file',
89+
[
90+
('valid_parameters.yaml'),
91+
('valid_parameters_with_none_type.yaml'),
92+
('nested_map_test.yaml'),
93+
('nested_map_keys.yaml'),
94+
],
95+
)
96+
def test_parse_valid_parameter_files(yaml_test_file):
9697
try:
97-
yaml_test_file = 'valid_parameters_with_none_type.yaml'
9898
set_up(yaml_test_file)
9999
except Exception as e:
100100
assert False, f'failed to parse valid file, reason:{e}'
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Copyright 2026 Visionick
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
17+
from generate_parameter_library_py.parse_yaml import get_dynamic_mapped_parameter
18+
19+
20+
@pytest.mark.parametrize(
21+
'param_name,declared_params,expected',
22+
[
23+
# entries is inside nested -> resolve to nested.entries
24+
('nested.__map_entries.value', {'nested.entries'}, ['nested.entries']),
25+
# items is inside level1.level2 -> resolve to level1.level2.items
26+
(
27+
'level1.level2.__map_items.param',
28+
{'level1.level2.items'},
29+
['level1.level2.items'],
30+
),
31+
# keys is at root -> resolve to bare name
32+
('__map_keys.value', {'keys'}, ['keys']),
33+
# multi-level maps with keys at root -> all bare names
34+
(
35+
'__map_level1.__map_level2.__map_level3.value',
36+
{'level1', 'level2', 'level3'},
37+
['level1', 'level2', 'level3'],
38+
),
39+
# keys at root but __map_ inside a struct (like pid.__map_joints)
40+
('pid.__map_joints.p', {'joints'}, ['joints']),
41+
# nested struct with keys at root (like nested_dynamic.__map_joints)
42+
(
43+
'nested_dynamic.__map_joints.__map_dof_names.nested',
44+
{'joints', 'dof_names'},
45+
['joints', 'dof_names'],
46+
),
47+
],
48+
)
49+
def test_get_dynamic_mapped_parameter_nested(param_name, declared_params, expected):
50+
"""Test that get_dynamic_mapped_parameter returns correct paths for nested maps."""
51+
result = get_dynamic_mapped_parameter(param_name, declared_params)
52+
assert result == expected

0 commit comments

Comments
 (0)