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