Skip to main content

phoxal_model/component/
capability.rs

1//! Canonical component capabilities normalized from versioned source documents.
2
3use std::fmt;
4
5use crate::identity::{ComponentInstanceId, JointId, LinkId};
6
7/// The canonical purpose assigned to a component capability by an authored
8/// robot manifest.
9///
10/// Roles are source/runtime contract facts rather than service names. Keeping
11/// the closed vocabulary in the canonical model means every reader of a
12/// finalized runtime document agrees on the same spelling and set of values.
13#[derive(
14    serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash,
15)]
16#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
17#[serde(rename_all = "snake_case")]
18pub enum CapabilityRole {
19    Localization,
20    Mapping,
21    Traversability,
22    Odometry,
23    Perception,
24    Safety,
25}
26
27impl CapabilityRole {
28    #[must_use]
29    pub const fn as_str(self) -> &'static str {
30        match self {
31            Self::Localization => "localization",
32            Self::Mapping => "mapping",
33            Self::Traversability => "traversability",
34            Self::Odometry => "odometry",
35            Self::Perception => "perception",
36            Self::Safety => "safety",
37        }
38    }
39}
40
41impl fmt::Display for CapabilityRole {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        formatter.write_str(self.as_str())
44    }
45}
46
47#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
48#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
49pub enum Capability {
50    Motor(Motor),
51    Encoder(Encoder),
52    Accelerometer(Accelerometer),
53    Gyroscope(Gyroscope),
54    Magnetometer(Magnetometer),
55    Imu(Imu),
56    Gnss(Gnss),
57    Camera(Camera),
58    Depth(Depth),
59    EmergencyStop(EmergencyStop),
60    Range(Range),
61    Lidar(Lidar),
62    Mmwave(Mmwave),
63    Microphone(Microphone),
64    Speaker(Speaker),
65    Battery(Battery),
66    Led(Led),
67}
68
69#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
70#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
71#[serde(rename_all = "snake_case")]
72pub enum EncoderType {
73    Incremental,
74    Absolute,
75}
76
77#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
78#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
79#[serde(rename_all = "snake_case")]
80pub enum MotorCommand {
81    Position,
82    Velocity,
83    Torque,
84}
85
86/// The component-local structural item a capability is attached to.
87#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
88#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
89pub enum StructuralTarget {
90    Joint { id: JointId },
91    Link { id: LinkId },
92}
93
94/// Which kind of structural item a target names.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub enum StructuralKind {
97    Link,
98    Joint,
99}
100
101impl fmt::Display for StructuralKind {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        formatter.write_str(match self {
104            Self::Link => "link",
105            Self::Joint => "joint",
106        })
107    }
108}
109
110impl StructuralTarget {
111    /// Which kind of structural item this target names.
112    #[must_use]
113    pub const fn kind(&self) -> StructuralKind {
114        match self {
115            Self::Joint { .. } => StructuralKind::Joint,
116            Self::Link { .. } => StructuralKind::Link,
117        }
118    }
119
120    /// This target as it appears in the robot's flattened structure, under the
121    /// instance that mounts the component.
122    #[must_use]
123    pub fn namespaced(&self, component_id: &ComponentInstanceId) -> Self {
124        match self {
125            Self::Joint { id } => Self::Joint {
126                id: id.namespaced(component_id),
127            },
128            Self::Link { id } => Self::Link {
129                id: id.namespaced(component_id),
130            },
131        }
132    }
133}
134
135#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
136#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
137#[serde(rename_all = "snake_case")]
138pub enum LidarOutput {
139    Ranges,
140    Points,
141}
142
143#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
144#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
145#[serde(rename_all = "snake_case")]
146pub enum CameraMode {
147    Mono,
148    Rgb,
149}
150
151#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
152#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
153#[serde(rename_all = "snake_case")]
154pub enum GnssCoordinateSystem {
155    #[default]
156    Local,
157    Wgs84,
158}
159
160/// The device kind a capability describes.
161///
162/// A component declares the kind and a simulation models it; the two must
163/// agree, which is why the kind is one shared type rather than two parallel
164/// vocabularies compared as strings.
165#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
166pub enum CapabilityKind {
167    Motor,
168    Encoder,
169    Accelerometer,
170    Gyroscope,
171    Magnetometer,
172    Imu,
173    Gnss,
174    Camera,
175    Depth,
176    EmergencyStop,
177    Range,
178    Lidar,
179    Mmwave,
180    Microphone,
181    Speaker,
182    Battery,
183    Led,
184}
185
186impl CapabilityKind {
187    /// The canonical snake_case name, as it appears in authored documents.
188    #[must_use]
189    pub const fn as_str(self) -> &'static str {
190        match self {
191            Self::Motor => "motor",
192            Self::Encoder => "encoder",
193            Self::Accelerometer => "accelerometer",
194            Self::Gyroscope => "gyroscope",
195            Self::Magnetometer => "magnetometer",
196            Self::Imu => "imu",
197            Self::Gnss => "gnss",
198            Self::Camera => "camera",
199            Self::Depth => "depth",
200            Self::EmergencyStop => "emergency_stop",
201            Self::Range => "range",
202            Self::Lidar => "lidar",
203            Self::Mmwave => "mmwave",
204            Self::Microphone => "microphone",
205            Self::Speaker => "speaker",
206            Self::Battery => "battery",
207            Self::Led => "led",
208        }
209    }
210}
211
212impl fmt::Display for CapabilityKind {
213    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
214        formatter.write_str(self.as_str())
215    }
216}
217
218impl Capability {
219    /// The device kind this capability describes.
220    #[must_use]
221    pub const fn kind(&self) -> CapabilityKind {
222        match self {
223            Self::Motor(_) => CapabilityKind::Motor,
224            Self::Encoder(_) => CapabilityKind::Encoder,
225            Self::Accelerometer(_) => CapabilityKind::Accelerometer,
226            Self::Gyroscope(_) => CapabilityKind::Gyroscope,
227            Self::Magnetometer(_) => CapabilityKind::Magnetometer,
228            Self::Imu(_) => CapabilityKind::Imu,
229            Self::Gnss(_) => CapabilityKind::Gnss,
230            Self::Camera(_) => CapabilityKind::Camera,
231            Self::Depth(_) => CapabilityKind::Depth,
232            Self::EmergencyStop(_) => CapabilityKind::EmergencyStop,
233            Self::Range(_) => CapabilityKind::Range,
234            Self::Lidar(_) => CapabilityKind::Lidar,
235            Self::Mmwave(_) => CapabilityKind::Mmwave,
236            Self::Microphone(_) => CapabilityKind::Microphone,
237            Self::Speaker(_) => CapabilityKind::Speaker,
238            Self::Battery(_) => CapabilityKind::Battery,
239            Self::Led(_) => CapabilityKind::Led,
240        }
241    }
242
243    /// The structural item this capability is attached to.
244    #[must_use]
245    pub const fn target(&self) -> &StructuralTarget {
246        match self {
247            Self::Motor(value) => &value.target,
248            Self::Encoder(value) => &value.target,
249            Self::Accelerometer(value) => &value.target,
250            Self::Gyroscope(value) => &value.target,
251            Self::Magnetometer(value) => &value.target,
252            Self::Imu(value) => &value.target,
253            Self::Gnss(value) => &value.target,
254            Self::Camera(value) => &value.target,
255            Self::Depth(value) => &value.target,
256            Self::EmergencyStop(value) => &value.target,
257            Self::Range(value) => &value.target,
258            Self::Lidar(value) => &value.target,
259            Self::Mmwave(value) => &value.target,
260            Self::Microphone(value) => &value.target,
261            Self::Speaker(value) => &value.target,
262            Self::Battery(value) => &value.target,
263            Self::Led(value) => &value.target,
264        }
265    }
266}
267
268#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
269#[serde(deny_unknown_fields)]
270pub struct Motor {
271    pub target: StructuralTarget,
272    pub command: MotorCommand,
273    pub gear_ratio: f64,
274    pub max_torque_nm: Option<f64>,
275    pub max_velocity_radps: Option<f64>,
276}
277
278#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
279#[serde(deny_unknown_fields)]
280pub struct Encoder {
281    pub target: StructuralTarget,
282    pub publish_rate_hz: f64,
283    pub gear_ratio: f64,
284    pub encoder_type: EncoderType,
285    pub counts_per_revolution: u32,
286}
287
288#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
289#[serde(deny_unknown_fields)]
290pub struct Accelerometer {
291    pub target: StructuralTarget,
292    pub publish_rate_hz: f64,
293    pub axes: Option<[bool; 3]>,
294}
295
296#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
297#[serde(deny_unknown_fields)]
298pub struct Gyroscope {
299    pub target: StructuralTarget,
300    pub publish_rate_hz: f64,
301    pub axes: Option<[bool; 3]>,
302}
303
304#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
305#[serde(deny_unknown_fields)]
306pub struct Magnetometer {
307    pub target: StructuralTarget,
308    pub publish_rate_hz: f64,
309    pub axes: Option<[bool; 3]>,
310}
311
312#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
313#[serde(deny_unknown_fields)]
314pub struct Imu {
315    pub target: StructuralTarget,
316    pub publish_rate_hz: f64,
317    pub axes: Option<[bool; 3]>,
318}
319
320#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
321#[serde(deny_unknown_fields)]
322pub struct Gnss {
323    pub target: StructuralTarget,
324    pub publish_rate_hz: f64,
325    pub coordinate_system: GnssCoordinateSystem,
326}
327
328#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
329#[serde(deny_unknown_fields)]
330pub struct Camera {
331    pub target: StructuralTarget,
332    pub mode: CameraMode,
333    pub publish_rate_hz: f64,
334    pub width_px: u32,
335    pub height_px: u32,
336    pub field_of_view_rad: Option<f64>,
337}
338
339#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
340#[serde(deny_unknown_fields)]
341pub struct Depth {
342    pub target: StructuralTarget,
343    pub publish_rate_hz: f64,
344    pub width_px: u32,
345    pub height_px: u32,
346    pub field_of_view_rad: Option<f64>,
347    pub min_range_m: Option<f64>,
348    pub max_range_m: Option<f64>,
349}
350
351#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
352#[serde(deny_unknown_fields)]
353pub struct Range {
354    pub target: StructuralTarget,
355    pub publish_rate_hz: f64,
356    pub min_range_m: f64,
357    pub max_range_m: f64,
358    pub field_of_view_rad: f64,
359}
360
361#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
362#[serde(deny_unknown_fields)]
363pub struct EmergencyStop {
364    pub target: StructuralTarget,
365}
366
367#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
368#[serde(deny_unknown_fields)]
369pub struct Lidar {
370    pub target: StructuralTarget,
371    pub publish_rate_hz: f64,
372    pub output: LidarOutput,
373    pub min_range_m: Option<f64>,
374    pub max_range_m: Option<f64>,
375    pub horizontal_fov_rad: Option<f64>,
376    pub horizontal_resolution_rad: Option<f64>,
377    pub vertical_fov_rad: Option<f64>,
378    pub vertical_resolution_rad: Option<f64>,
379}
380
381#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
382#[serde(deny_unknown_fields)]
383pub struct Mmwave {
384    pub target: StructuralTarget,
385    pub publish_rate_hz: f64,
386}
387
388#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
389#[serde(deny_unknown_fields)]
390pub struct Microphone {
391    pub target: StructuralTarget,
392    pub publish_rate_hz: f64,
393}
394
395#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
396#[serde(deny_unknown_fields)]
397pub struct Speaker {
398    pub target: StructuralTarget,
399}
400
401#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
402#[serde(deny_unknown_fields)]
403pub struct Battery {
404    pub target: StructuralTarget,
405    pub publish_rate_hz: f64,
406    pub voltage_v: f64,
407    pub capacity_ah: f64,
408}
409
410#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)]
411#[serde(deny_unknown_fields)]
412pub struct Led {
413    pub target: StructuralTarget,
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::identity::ComponentInstanceId;
420
421    #[test]
422    fn a_structural_target_is_a_tagged_id_on_the_wire() {
423        // Typing the id must not change the serialized shape: it is still a
424        // `kind` tag beside a bare string.
425        for (json, target) in [
426            (
427                r#"{"kind":"joint","id":"axle"}"#,
428                StructuralTarget::Joint {
429                    id: JointId::new("axle"),
430                },
431            ),
432            (
433                r#"{"kind":"link","id":"body"}"#,
434                StructuralTarget::Link {
435                    id: LinkId::new("body"),
436                },
437            ),
438        ] {
439            assert_eq!(serde_json::to_string(&target).unwrap(), json);
440            assert_eq!(
441                serde_json::from_str::<StructuralTarget>(json).unwrap(),
442                target
443            );
444        }
445    }
446
447    #[test]
448    fn namespacing_a_target_keeps_its_kind() {
449        let instance = ComponentInstanceId::new("left_drive").unwrap();
450        let target = StructuralTarget::Joint {
451            id: JointId::new("axle"),
452        };
453        assert_eq!(
454            target.namespaced(&instance),
455            StructuralTarget::Joint {
456                id: JointId::new("left_drive__axle"),
457            }
458        );
459        assert_eq!(target.namespaced(&instance).kind(), StructuralKind::Joint);
460    }
461
462    #[test]
463    fn a_capability_reports_the_kind_it_is() {
464        let motor = Capability::Motor(Motor {
465            target: StructuralTarget::Link {
466                id: LinkId::new("body"),
467            },
468            command: MotorCommand::Velocity,
469            gear_ratio: 1.0,
470            max_torque_nm: None,
471            max_velocity_radps: None,
472        });
473        assert_eq!(motor.kind(), CapabilityKind::Motor);
474        assert_eq!(motor.kind().as_str(), "motor");
475    }
476}