1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5
6use crate::compiler::RobotParts;
7use crate::component::Component;
8use crate::component::capability::{
9 Capability, CapabilityKind, CapabilityRole, Encoder, Motor, StructuralKind, StructuralTarget,
10};
11use crate::error::{
12 IdentifierKind, JointOwner, KinematicScalarField, ModelError, MotionLimitField,
13};
14use crate::footprint::FootprintEnvelope;
15use crate::identity::{
16 CapabilityId, CapabilityRef, ComponentInstanceId, ComponentTypeId, LinkId,
17 MODULE_INSTANCE_SEPARATOR, RobotId, ServiceId,
18};
19use crate::simulation::Simulation;
20use crate::structure::{Joint, JointKind, Structure};
21
22#[derive(phoxal_macros::DescribeWire, Debug, Clone, serde::Serialize, serde::Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct Service {
32 config: Option<serde_json::Value>,
33}
34
35impl Service {
36 pub(crate) const fn new(config: Option<serde_json::Value>) -> Self {
37 Self { config }
38 }
39
40 #[must_use]
42 pub const fn config(&self) -> Option<&serde_json::Value> {
43 self.config.as_ref()
44 }
45}
46
47#[derive(phoxal_macros::DescribeWire, Debug, Clone, serde::Serialize)]
52#[serde(deny_unknown_fields)]
53pub struct ComponentInstance {
54 #[serde(rename = "type")]
55 component_type: ComponentTypeId,
56 mount_link: LinkId,
57 driver: Option<serde_json::Value>,
61 direction_signs: BTreeMap<CapabilityId, i8>,
62 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
65 roles: BTreeMap<CapabilityId, BTreeSet<CapabilityRole>>,
66}
67
68impl<'de> serde::Deserialize<'de> for ComponentInstance {
69 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
70 #[derive(serde::Deserialize)]
71 #[serde(deny_unknown_fields)]
72 struct Wire {
73 #[serde(rename = "type")]
74 component_type: ComponentTypeId,
75 mount_link: LinkId,
76 driver: Option<serde_json::Value>,
77 direction_signs: BTreeMap<CapabilityId, i8>,
78 #[serde(default)]
79 roles: BTreeMap<CapabilityId, Vec<CapabilityRole>>,
80 }
81
82 let wire = Wire::deserialize(deserializer)?;
83 let mut roles = BTreeMap::new();
84 for (capability_id, authored) in wire.roles {
85 if authored.is_empty() {
86 return Err(serde::de::Error::custom(ModelError::EmptyCapabilityRoles {
87 capability_id,
88 }));
89 }
90 let mut canonical = BTreeSet::new();
91 for role in authored {
92 if !canonical.insert(role) {
93 return Err(serde::de::Error::custom(
94 ModelError::DuplicateCapabilityRole {
95 capability_id,
96 role,
97 },
98 ));
99 }
100 }
101 roles.insert(capability_id, canonical);
102 }
103 Ok(Self::new(
104 wire.component_type,
105 wire.mount_link,
106 wire.direction_signs,
107 roles,
108 wire.driver,
109 ))
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct MotionModel {
116 kinematic: KinematicConfig,
117 limits: MotionLimits,
118}
119
120#[derive(
122 phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq,
123)]
124#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
125#[serde(deny_unknown_fields)]
126pub struct MotionLimits {
127 pub max_linear_speed_mps: f64,
128 pub max_angular_speed_radps: f64,
129}
130
131#[derive(
133 phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq,
134)]
135#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
136#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
137pub enum KinematicConfig {
138 Differential {
139 left_actuators: Vec<CapabilityRef>,
140 right_actuators: Vec<CapabilityRef>,
141 left_encoders: Vec<CapabilityRef>,
142 right_encoders: Vec<CapabilityRef>,
143 wheel_radius_m: f64,
144 wheel_base_m: f64,
145 },
146 Mecanum {
147 front_left_actuator: CapabilityRef,
148 front_right_actuator: CapabilityRef,
149 rear_left_actuator: CapabilityRef,
150 rear_right_actuator: CapabilityRef,
151 wheel_radius_m: f64,
152 wheel_base_m: f64,
153 track_m: f64,
154 },
155 Ackermann {
156 steering_actuator: CapabilityRef,
157 drive_actuator: CapabilityRef,
158 steering_encoder: Option<CapabilityRef>,
159 drive_encoder: Option<CapabilityRef>,
160 wheel_base_m: f64,
161 track_m: f64,
162 max_steering_angle_rad: f64,
163 },
164 Omnidirectional {
165 actuators: Vec<CapabilityRef>,
166 encoders: Vec<CapabilityRef>,
167 },
168}
169
170impl KinematicConfig {
171 pub fn drive_kinematics(&self) -> Result<DriveKinematics, ModelError> {
182 Ok(match self {
183 Self::Differential {
184 wheel_radius_m,
185 wheel_base_m,
186 ..
187 } => DriveKinematics::Differential(
188 DifferentialDrive::new(*wheel_radius_m, *wheel_base_m).validate()?,
189 ),
190 Self::Mecanum {
191 wheel_radius_m,
192 wheel_base_m,
193 track_m,
194 ..
195 } => DriveKinematics::Mecanum(
196 MecanumDrive::new(*wheel_radius_m, *wheel_base_m, *track_m).validate()?,
197 ),
198 Self::Ackermann {
199 wheel_base_m,
200 track_m,
201 max_steering_angle_rad,
202 ..
203 } => DriveKinematics::Ackermann(
204 AckermannDrive::new(*wheel_base_m, *track_m, *max_steering_angle_rad).validate()?,
205 ),
206 Self::Omnidirectional { .. } => DriveKinematics::Omnidirectional,
207 })
208 }
209}
210
211#[derive(Debug, Clone, Copy, Default, PartialEq)]
218pub struct BodyTwist {
219 pub linear_x_mps: f64,
221 pub linear_y_mps: f64,
223 pub angular_z_radps: f64,
225}
226
227impl BodyTwist {
228 #[must_use]
230 pub const fn planar(linear_x_mps: f64, angular_z_radps: f64) -> Self {
231 Self {
232 linear_x_mps,
233 linear_y_mps: 0.0,
234 angular_z_radps,
235 }
236 }
237
238 #[must_use]
240 pub const fn new(linear_x_mps: f64, linear_y_mps: f64, angular_z_radps: f64) -> Self {
241 Self {
242 linear_x_mps,
243 linear_y_mps,
244 angular_z_radps,
245 }
246 }
247
248 #[must_use]
250 pub fn is_finite(&self) -> bool {
251 self.linear_x_mps.is_finite()
252 && self.linear_y_mps.is_finite()
253 && self.angular_z_radps.is_finite()
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq)]
259pub struct DifferentialWheelSpeeds {
260 pub left_radps: f64,
261 pub right_radps: f64,
262}
263
264#[derive(Debug, Clone, Copy, PartialEq)]
266pub struct MecanumWheelSpeeds {
267 pub front_left_radps: f64,
268 pub front_right_radps: f64,
269 pub rear_left_radps: f64,
270 pub rear_right_radps: f64,
271}
272
273#[derive(Debug, Clone, Copy, PartialEq)]
280pub struct AckermannCommand {
281 pub drive_speed_mps: f64,
283 pub steering_angle_rad: f64,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq)]
301pub enum DriveKinematics {
302 Differential(DifferentialDrive),
303 Mecanum(MecanumDrive),
304 Ackermann(AckermannDrive),
305 Omnidirectional,
316}
317
318#[derive(Debug, Clone, Copy, PartialEq)]
331pub struct DifferentialDrive {
332 pub wheel_radius_m: f64,
334 pub wheel_base_m: f64,
336}
337
338impl DifferentialDrive {
339 #[must_use]
341 pub const fn new(wheel_radius_m: f64, wheel_base_m: f64) -> Self {
342 Self {
343 wheel_radius_m,
344 wheel_base_m,
345 }
346 }
347
348 pub fn validate(self) -> Result<Self, ModelError> {
360 for (value, field) in [
361 (self.wheel_radius_m, KinematicScalarField::WheelRadiusM),
362 (self.wheel_base_m, KinematicScalarField::WheelBaseM),
363 ] {
364 if !(value.is_finite() && value > 0.0) {
365 return Err(ModelError::KinematicScalar {
366 kinematics: KinematicKind::Differential,
367 field,
368 });
369 }
370 }
371 Ok(self)
372 }
373
374 #[must_use]
383 pub fn wheel_speeds(self, twist: BodyTwist) -> DifferentialWheelSpeeds {
384 let half_track = self.wheel_base_m / 2.0;
385 let left = twist.linear_x_mps - twist.angular_z_radps * half_track;
386 let right = twist.linear_x_mps + twist.angular_z_radps * half_track;
387 DifferentialWheelSpeeds {
388 left_radps: left / self.wheel_radius_m,
389 right_radps: right / self.wheel_radius_m,
390 }
391 }
392
393 #[must_use]
397 pub fn body_twist(self, speeds: DifferentialWheelSpeeds) -> BodyTwist {
398 let left = speeds.left_radps * self.wheel_radius_m;
399 let right = speeds.right_radps * self.wheel_radius_m;
400 BodyTwist::planar((left + right) / 2.0, (right - left) / self.wheel_base_m)
401 }
402}
403
404#[derive(Debug, Clone, Copy, PartialEq)]
410pub struct MecanumDrive {
411 pub wheel_radius_m: f64,
413 pub wheel_base_m: f64,
415 pub track_m: f64,
417}
418
419impl MecanumDrive {
420 #[must_use]
422 pub const fn new(wheel_radius_m: f64, wheel_base_m: f64, track_m: f64) -> Self {
423 Self {
424 wheel_radius_m,
425 wheel_base_m,
426 track_m,
427 }
428 }
429
430 const fn yaw_lever_m(self) -> f64 {
433 (self.wheel_base_m + self.track_m) / 2.0
434 }
435
436 pub fn validate(self) -> Result<Self, ModelError> {
443 for (value, field) in [
444 (self.wheel_radius_m, KinematicScalarField::WheelRadiusM),
445 (self.wheel_base_m, KinematicScalarField::WheelBaseM),
446 (self.track_m, KinematicScalarField::TrackM),
447 ] {
448 if !(value.is_finite() && value > 0.0) {
449 return Err(ModelError::KinematicScalar {
450 kinematics: KinematicKind::Mecanum,
451 field,
452 });
453 }
454 }
455 Ok(self)
456 }
457
458 #[must_use]
462 pub fn wheel_speeds(self, twist: BodyTwist) -> MecanumWheelSpeeds {
463 let yaw = twist.angular_z_radps * self.yaw_lever_m();
464 let scale = 1.0 / self.wheel_radius_m;
465 MecanumWheelSpeeds {
466 front_left_radps: scale * (twist.linear_x_mps - twist.linear_y_mps - yaw),
467 front_right_radps: scale * (twist.linear_x_mps + twist.linear_y_mps + yaw),
468 rear_left_radps: scale * (twist.linear_x_mps + twist.linear_y_mps - yaw),
469 rear_right_radps: scale * (twist.linear_x_mps - twist.linear_y_mps + yaw),
470 }
471 }
472
473 #[must_use]
480 pub fn body_twist(self, speeds: MecanumWheelSpeeds) -> BodyTwist {
481 let MecanumWheelSpeeds {
482 front_left_radps: fl,
483 front_right_radps: fr,
484 rear_left_radps: rl,
485 rear_right_radps: rr,
486 } = speeds;
487 BodyTwist::new(
488 (fl + fr + rl + rr) * self.wheel_radius_m / 4.0,
489 (-fl + fr + rl - rr) * self.wheel_radius_m / 4.0,
490 (-fl + fr - rl + rr) * self.wheel_radius_m / (4.0 * self.yaw_lever_m()),
491 )
492 }
493}
494
495#[derive(Debug, Clone, Copy, PartialEq)]
503pub struct AckermannDrive {
504 pub wheel_base_m: f64,
506 pub track_m: f64,
508 pub max_steering_angle_rad: f64,
510}
511
512impl AckermannDrive {
513 #[must_use]
515 pub const fn new(wheel_base_m: f64, track_m: f64, max_steering_angle_rad: f64) -> Self {
516 Self {
517 wheel_base_m,
518 track_m,
519 max_steering_angle_rad,
520 }
521 }
522
523 pub fn validate(self) -> Result<Self, ModelError> {
530 for (value, field) in [
531 (self.wheel_base_m, KinematicScalarField::WheelBaseM),
532 (self.track_m, KinematicScalarField::TrackM),
533 (
534 self.max_steering_angle_rad,
535 KinematicScalarField::MaxSteeringAngleRad,
536 ),
537 ] {
538 if !(value.is_finite() && value > 0.0) {
539 return Err(ModelError::KinematicScalar {
540 kinematics: KinematicKind::Ackermann,
541 field,
542 });
543 }
544 }
545 Ok(self)
546 }
547
548 #[must_use]
559 pub fn command(self, twist: BodyTwist) -> AckermannCommand {
560 let steering_angle_rad = if twist.linear_x_mps == 0.0 {
561 0.0
562 } else {
563 (twist.angular_z_radps * self.wheel_base_m / twist.linear_x_mps).atan()
564 };
565 AckermannCommand {
566 drive_speed_mps: twist.linear_x_mps,
567 steering_angle_rad,
568 }
569 }
570
571 #[must_use]
575 pub fn body_twist(self, command: AckermannCommand) -> BodyTwist {
576 BodyTwist::planar(
577 command.drive_speed_mps,
578 command.drive_speed_mps * command.steering_angle_rad.tan() / self.wheel_base_m,
579 )
580 }
581
582 #[must_use]
584 pub fn steering_is_reachable(self, steering_angle_rad: f64) -> bool {
585 steering_angle_rad.abs() <= self.max_steering_angle_rad
586 }
587}
588
589#[derive(Clone, Copy, Debug, PartialEq, Eq)]
591pub enum KinematicKind {
592 Differential,
593 Mecanum,
594 Ackermann,
595 Omnidirectional,
596}
597
598#[derive(Debug, Clone)]
606pub struct Robot {
607 id: RobotId,
608 motion: MotionModel,
609 services: BTreeMap<ServiceId, Service>,
610 components: BTreeMap<ComponentInstanceId, ComponentInstance>,
611 component_types: BTreeMap<ComponentTypeId, Component>,
612 structure: Structure,
613 footprint: Option<FootprintEnvelope>,
616}
617
618#[derive(phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize)]
626#[serde(deny_unknown_fields)]
627struct RobotWire {
628 id: RobotId,
629 kinematic: KinematicConfig,
630 motion_limits: MotionLimits,
631 services: BTreeMap<ServiceId, Service>,
632 components: BTreeMap<ComponentInstanceId, ComponentInstance>,
633 component_types: BTreeMap<ComponentTypeId, Component>,
634 structure: Structure,
635 footprint: PersistedFootprint,
636}
637
638#[derive(phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize)]
644struct PersistedFootprint(Option<FootprintEnvelope>);
645
646impl serde::Serialize for Robot {
647 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
648 RobotWire {
649 id: self.id.clone(),
650 kinematic: self.motion.kinematic.clone(),
651 motion_limits: self.motion.limits,
652 services: self.services.clone(),
653 components: self.components.clone(),
654 component_types: self.component_types.clone(),
655 structure: self.structure.clone(),
656 footprint: PersistedFootprint(self.footprint),
657 }
658 .serialize(serializer)
659 }
660}
661
662impl phoxal_runtime_contract::wire_schema::DescribeWire for Robot {
663 fn wire_schema() -> phoxal_runtime_contract::wire_schema::WireSchema {
666 phoxal_runtime_contract::wire_schema::WireSchema::opaque(
667 "Robot",
668 <RobotWire as phoxal_runtime_contract::wire_schema::DescribeWire>::wire_schema(),
669 )
670 }
671}
672
673impl<'de> serde::Deserialize<'de> for Robot {
674 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
675 let wire = RobotWire::deserialize(deserializer)?;
676 Self::new(
677 RobotParts {
678 id: wire.id,
679 kinematic: wire.kinematic,
680 motion_limits: wire.motion_limits,
681 services: wire.services,
682 components: wire.components,
683 component_types: wire.component_types,
684 structure: wire.structure,
685 },
686 wire.footprint.0,
687 )
688 .map_err(serde::de::Error::custom)
689 }
690}
691
692#[derive(Clone, Copy, Debug)]
700pub struct ComponentView<'a> {
701 id: &'a ComponentInstanceId,
702 instance: &'a ComponentInstance,
703 component_type: &'a Component,
704}
705
706impl<'a> ComponentView<'a> {
707 #[must_use]
709 pub const fn id(&self) -> &'a ComponentInstanceId {
710 self.id
711 }
712
713 #[must_use]
716 pub const fn instance(&self) -> &'a ComponentInstance {
717 self.instance
718 }
719
720 #[must_use]
722 pub const fn component_type(&self) -> &'a Component {
723 self.component_type
724 }
725
726 #[must_use]
729 pub const fn simulation(&self) -> Option<&'a Simulation> {
730 self.component_type.simulation()
731 }
732}
733
734impl ComponentInstance {
735 pub(crate) const fn new(
736 component_type: ComponentTypeId,
737 mount_link: LinkId,
738 direction_signs: BTreeMap<CapabilityId, i8>,
739 roles: BTreeMap<CapabilityId, BTreeSet<CapabilityRole>>,
740 driver: Option<serde_json::Value>,
741 ) -> Self {
742 Self {
743 component_type,
744 mount_link,
745 driver,
746 direction_signs,
747 roles,
748 }
749 }
750
751 #[must_use]
752 pub const fn component_type(&self) -> &ComponentTypeId {
753 &self.component_type
754 }
755
756 #[must_use]
758 pub const fn mount_link(&self) -> &LinkId {
759 &self.mount_link
760 }
761
762 #[must_use]
766 pub const fn driver(&self) -> Option<&serde_json::Value> {
767 self.driver.as_ref()
768 }
769
770 #[must_use]
773 pub const fn direction_signs(&self) -> &BTreeMap<CapabilityId, i8> {
774 &self.direction_signs
775 }
776
777 #[must_use]
779 pub const fn roles(&self) -> &BTreeMap<CapabilityId, BTreeSet<CapabilityRole>> {
780 &self.roles
781 }
782
783 #[must_use]
785 pub fn has_role(&self, capability: &CapabilityId, role: CapabilityRole) -> bool {
786 self.roles
787 .get(capability)
788 .is_some_and(|roles| roles.contains(&role))
789 }
790}
791
792impl MotionModel {
793 #[must_use]
794 pub const fn kinematic(&self) -> &KinematicConfig {
795 &self.kinematic
796 }
797
798 #[must_use]
799 pub const fn limits(&self) -> MotionLimits {
800 self.limits
801 }
802}
803
804impl MotionLimits {
805 pub fn validate(self) -> Result<Self, ModelError> {
812 for (value, field) in [
813 (
814 self.max_linear_speed_mps,
815 MotionLimitField::MaxLinearSpeedMps,
816 ),
817 (
818 self.max_angular_speed_radps,
819 MotionLimitField::MaxAngularSpeedRadps,
820 ),
821 ] {
822 if !(value.is_finite() && value > 0.0 && value <= f64::from(f32::MAX)) {
823 return Err(ModelError::MotionLimit { field });
824 }
825 }
826 Ok(self)
827 }
828}
829
830impl KinematicConfig {
831 #[must_use]
833 pub const fn kind(&self) -> KinematicKind {
834 match self {
835 Self::Differential { .. } => KinematicKind::Differential,
836 Self::Mecanum { .. } => KinematicKind::Mecanum,
837 Self::Ackermann { .. } => KinematicKind::Ackermann,
838 Self::Omnidirectional { .. } => KinematicKind::Omnidirectional,
839 }
840 }
841}
842
843impl fmt::Display for KinematicKind {
844 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
845 formatter.write_str(match self {
846 Self::Differential => "differential",
847 Self::Mecanum => "mecanum",
848 Self::Ackermann => "ackermann",
849 Self::Omnidirectional => "omnidirectional",
850 })
851 }
852}
853
854impl Robot {
855 pub(crate) fn new(
856 parts: RobotParts,
857 footprint: Option<FootprintEnvelope>,
858 ) -> Result<Self, ModelError> {
859 let robot = Self {
860 id: parts.id,
861 motion: MotionModel::new(parts.kinematic, parts.motion_limits),
862 services: parts.services,
863 components: parts.components,
864 component_types: parts.component_types,
865 structure: parts.structure,
866 footprint,
867 };
868 robot.validate()?;
869 Ok(robot)
870 }
871
872 #[must_use]
873 pub const fn id(&self) -> &RobotId {
874 &self.id
875 }
876
877 #[must_use]
878 pub const fn motion(&self) -> &MotionModel {
879 &self.motion
880 }
881
882 pub fn services(&self) -> impl ExactSizeIterator<Item = (&ServiceId, &Service)> {
884 self.services.iter()
885 }
886
887 #[must_use]
889 pub fn service(&self, id: &str) -> Option<&Service> {
890 self.services.get(id)
891 }
892
893 #[must_use]
899 pub fn service_config(&self, id: &str) -> Option<&serde_json::Value> {
900 self.service(id)?.config()
901 }
902
903 pub fn components(&self) -> impl Iterator<Item = ComponentView<'_>> {
905 self.components
906 .iter()
907 .filter_map(|(id, instance)| self.view(id, instance))
908 }
909
910 pub fn component_ids(&self) -> impl ExactSizeIterator<Item = &ComponentInstanceId> {
912 self.components.keys()
913 }
914
915 #[must_use]
917 pub fn component(&self, id: &str) -> Option<ComponentView<'_>> {
918 let (id, instance) = self.components.get_key_value(id)?;
919 self.view(id, instance)
920 }
921
922 pub fn component_types(&self) -> impl ExactSizeIterator<Item = (&ComponentTypeId, &Component)> {
924 self.component_types.iter()
925 }
926
927 fn view<'a>(
934 &'a self,
935 id: &'a ComponentInstanceId,
936 instance: &'a ComponentInstance,
937 ) -> Option<ComponentView<'a>> {
938 Some(ComponentView {
939 id,
940 instance,
941 component_type: self.component_types.get(instance.component_type())?,
942 })
943 }
944
945 #[must_use]
947 pub const fn structure(&self) -> &Structure {
948 &self.structure
949 }
950
951 #[must_use]
953 pub const fn footprint_envelope(&self) -> Option<FootprintEnvelope> {
954 self.footprint
955 }
956
957 #[must_use]
959 pub fn capability(&self, reference: &CapabilityRef) -> Option<&Capability> {
960 self.resolve(reference).map(|(_, capability)| capability)
961 }
962
963 pub fn capability_refs(&self, selects: impl Fn(&Capability) -> bool) -> Vec<CapabilityRef> {
978 let mut references = self
979 .components()
980 .flat_map(|component| {
981 component
982 .component_type()
983 .capabilities()
984 .filter(|(_, capability)| selects(capability))
985 .map(move |(capability_id, _)| {
986 CapabilityRef::new(component.id().clone(), capability_id.clone())
987 })
988 })
989 .collect::<Vec<_>>();
990 references.sort();
991 references
992 }
993
994 #[must_use]
997 pub fn capabilities_with_role(&self, role: CapabilityRole) -> Vec<CapabilityRef> {
998 self.components()
999 .flat_map(|component| {
1000 component
1001 .instance()
1002 .roles()
1003 .iter()
1004 .filter(move |(capability_id, roles)| {
1005 roles.contains(&role)
1006 && component
1007 .component_type()
1008 .capability(capability_id.as_str())
1009 .is_some()
1010 })
1011 .map(move |(capability_id, _)| {
1012 CapabilityRef::new(component.id().clone(), capability_id.clone())
1013 })
1014 })
1015 .collect()
1016 }
1017
1018 pub fn require_motor(&self, reference: &CapabilityRef) -> Result<(&Motor, i8), ModelError> {
1026 let capability = self.require_capability(reference)?;
1027 let Capability::Motor(motor) = capability else {
1028 return Err(ModelError::CapabilityKindMismatch {
1029 reference: reference.clone(),
1030 expected: CapabilityKind::Motor,
1031 actual: capability.kind(),
1032 });
1033 };
1034 Ok((motor, self.direction_sign(reference)))
1035 }
1036
1037 pub fn require_encoder(&self, reference: &CapabilityRef) -> Result<(&Encoder, i8), ModelError> {
1045 let capability = self.require_capability(reference)?;
1046 let Capability::Encoder(encoder) = capability else {
1047 return Err(ModelError::CapabilityKindMismatch {
1048 reference: reference.clone(),
1049 expected: CapabilityKind::Encoder,
1050 actual: capability.kind(),
1051 });
1052 };
1053 Ok((encoder, self.direction_sign(reference)))
1054 }
1055
1056 pub fn link_target_frame(&self, reference: &CapabilityRef) -> Result<LinkId, ModelError> {
1065 let (component, capability) =
1066 self.resolve(reference)
1067 .ok_or_else(|| ModelError::UnknownCapability {
1068 reference: reference.clone(),
1069 })?;
1070 let StructuralTarget::Link { id } = capability.target() else {
1071 return Err(ModelError::CapabilityTargetKind {
1072 reference: reference.clone(),
1073 expected: StructuralKind::Link,
1074 });
1075 };
1076 if component.structure().link(id.as_str()).is_none() {
1077 return Err(ModelError::UnknownBoundTarget {
1078 reference: reference.clone(),
1079 kind: StructuralKind::Link,
1080 id: id.as_str().to_string(),
1081 });
1082 }
1083 Ok(id.namespaced(&reference.component_id))
1084 }
1085
1086 fn resolve(&self, reference: &CapabilityRef) -> Option<(&Component, &Capability)> {
1087 let component = self
1088 .component(reference.component_id.as_str())?
1089 .component_type;
1090 let capability = component.capability(reference.capability_id.as_str())?;
1091 Some((component, capability))
1092 }
1093
1094 fn require_capability(&self, reference: &CapabilityRef) -> Result<&Capability, ModelError> {
1095 self.capability(reference)
1096 .ok_or_else(|| ModelError::UnknownCapability {
1097 reference: reference.clone(),
1098 })
1099 }
1100
1101 fn direction_sign(&self, reference: &CapabilityRef) -> i8 {
1103 self.components
1104 .get(reference.component_id.as_str())
1105 .and_then(|instance| {
1106 instance
1107 .direction_signs
1108 .get(reference.capability_id.as_str())
1109 })
1110 .copied()
1111 .unwrap_or(1)
1112 }
1113
1114 fn validate(&self) -> Result<(), ModelError> {
1115 self.motion.limits.validate()?;
1116 self.validate_robot_structure()?;
1117 self.validate_component_types()?;
1118 self.validate_components()?;
1119 self.validate_kinematic()?;
1120 self.validate_footprint()
1121 }
1122
1123 fn validate_robot_structure(&self) -> Result<(), ModelError> {
1126 for link in self.structure.links() {
1127 Self::reject_reserved_separator(IdentifierKind::RobotLink, link.name().as_str())?;
1128 }
1129 for joint in self.structure.joints() {
1130 Self::reject_reserved_separator(IdentifierKind::RobotJoint, joint.name().as_str())?;
1131 Self::validate_runtime_joint_kind(joint, &JointOwner::Robot)?;
1132 }
1133 Ok(self.structure.validate_robot_frames()?)
1134 }
1135
1136 fn validate_component_types(&self) -> Result<(), ModelError> {
1137 for (component_type, component) in &self.component_types {
1138 for joint in component.structure().joints() {
1139 Self::validate_runtime_joint_kind(
1140 joint,
1141 &JointOwner::ComponentType(component_type.clone()),
1142 )?;
1143 }
1144 for (capability_id, capability) in component.capabilities() {
1145 let target = capability.target();
1146 let present = match target {
1147 StructuralTarget::Link { id } => {
1148 component.structure().link(id.as_str()).is_some()
1149 }
1150 StructuralTarget::Joint { id } => {
1151 component.structure().joint(id.as_str()).is_some()
1152 }
1153 };
1154 if !present {
1155 let id = match target {
1156 StructuralTarget::Link { id } => id.as_str().to_string(),
1157 StructuralTarget::Joint { id } => id.as_str().to_string(),
1158 };
1159 return Err(ModelError::UnknownDeclaredTarget {
1160 component_type: component_type.clone(),
1161 capability_id: capability_id.clone(),
1162 kind: target.kind(),
1163 id,
1164 });
1165 }
1166 }
1167 Self::validate_simulation(component_type, component)?;
1168 }
1169 Ok(())
1170 }
1171
1172 fn validate_simulation(
1175 component_type: &ComponentTypeId,
1176 component: &Component,
1177 ) -> Result<(), ModelError> {
1178 let Some(simulation) = component.simulation() else {
1179 return Ok(());
1180 };
1181 for (capability_id, simulated) in simulation.capabilities() {
1182 let capability = component
1183 .capability(capability_id.as_str())
1184 .ok_or_else(|| ModelError::SimulationWithoutCapability {
1185 component_type: component_type.clone(),
1186 capability_id: capability_id.clone(),
1187 })?;
1188 if simulated.kind() != capability.kind() {
1189 return Err(ModelError::SimulationCapabilityKindMismatch {
1190 component_type: component_type.clone(),
1191 capability_id: capability_id.clone(),
1192 simulated: simulated.kind(),
1193 declared: capability.kind(),
1194 });
1195 }
1196 }
1197 Ok(())
1198 }
1199
1200 fn validate_footprint(&self) -> Result<(), ModelError> {
1206 if let Some(footprint) = self.footprint {
1207 FootprintEnvelope::new(footprint.radius_m)?;
1208 }
1209 Ok(())
1210 }
1211
1212 fn validate_components(&self) -> Result<(), ModelError> {
1213 for (id, instance) in &self.components {
1214 Self::reject_reserved_separator(IdentifierKind::ComponentInstance, id.as_str())?;
1215 let component = self
1216 .component_types
1217 .get(instance.component_type())
1218 .ok_or_else(|| ModelError::UnknownComponentType {
1219 instance: id.clone(),
1220 component_type: instance.component_type().clone(),
1221 })?;
1222 if self
1223 .structure
1224 .link(instance.mount_link().as_str())
1225 .is_none()
1226 {
1227 return Err(ModelError::UnknownMountLink {
1228 instance: id.clone(),
1229 link: instance.mount_link().clone(),
1230 });
1231 }
1232 for (capability_id, sign) in &instance.direction_signs {
1233 if !matches!(sign, -1 | 1) {
1234 return Err(ModelError::DirectionSign {
1235 instance: id.clone(),
1236 capability_id: capability_id.clone(),
1237 value: *sign,
1238 });
1239 }
1240 if component.capability(capability_id.as_str()).is_none() {
1241 return Err(ModelError::UnknownDirectionSignCapability {
1242 instance: id.clone(),
1243 capability_id: capability_id.clone(),
1244 });
1245 }
1246 }
1247 for capability_id in instance.roles.keys() {
1248 if component.capability(capability_id.as_str()).is_none() {
1249 return Err(ModelError::UnknownRoleCapability {
1250 instance: id.clone(),
1251 capability_id: capability_id.clone(),
1252 });
1253 }
1254 }
1255 }
1256 Ok(())
1257 }
1258
1259 fn validate_kinematic(&self) -> Result<(), ModelError> {
1260 self.motion.kinematic().drive_kinematics()?;
1263 match self.motion.kinematic() {
1264 KinematicConfig::Differential {
1265 left_actuators,
1266 right_actuators,
1267 left_encoders,
1268 right_encoders,
1269 ..
1270 } => {
1271 for reference in left_actuators.iter().chain(right_actuators) {
1272 self.require_motor(reference)?;
1273 }
1274 for reference in left_encoders.iter().chain(right_encoders) {
1275 self.require_encoder(reference)?;
1276 }
1277 }
1278 KinematicConfig::Mecanum {
1279 front_left_actuator,
1280 front_right_actuator,
1281 rear_left_actuator,
1282 rear_right_actuator,
1283 ..
1284 } => {
1285 for reference in [
1286 front_left_actuator,
1287 front_right_actuator,
1288 rear_left_actuator,
1289 rear_right_actuator,
1290 ] {
1291 self.require_motor(reference)?;
1292 }
1293 }
1294 KinematicConfig::Ackermann {
1295 steering_actuator,
1296 drive_actuator,
1297 steering_encoder,
1298 drive_encoder,
1299 ..
1300 } => {
1301 self.require_motor(steering_actuator)?;
1302 self.require_motor(drive_actuator)?;
1303 for reference in steering_encoder.iter().chain(drive_encoder) {
1304 self.require_encoder(reference)?;
1305 }
1306 }
1307 KinematicConfig::Omnidirectional {
1308 actuators,
1309 encoders,
1310 } => {
1311 for reference in actuators {
1312 self.require_motor(reference)?;
1313 }
1314 for reference in encoders {
1315 self.require_encoder(reference)?;
1316 }
1317 }
1318 }
1319 Ok(())
1320 }
1321
1322 fn reject_reserved_separator(kind: IdentifierKind, value: &str) -> Result<(), ModelError> {
1325 if value.contains(MODULE_INSTANCE_SEPARATOR) {
1326 return Err(ModelError::ReservedSeparator {
1327 kind,
1328 value: value.to_string(),
1329 });
1330 }
1331 Ok(())
1332 }
1333
1334 fn validate_runtime_joint_kind(joint: &Joint, owner: &JointOwner) -> Result<(), ModelError> {
1336 if matches!(
1337 joint.kind(),
1338 JointKind::Fixed | JointKind::Revolute | JointKind::Continuous | JointKind::Prismatic
1339 ) {
1340 Ok(())
1341 } else {
1342 Err(ModelError::UnsupportedJointKind {
1343 owner: owner.clone(),
1344 joint: joint.name().clone(),
1345 kind: joint.kind(),
1346 })
1347 }
1348 }
1349}
1350
1351impl MotionModel {
1352 pub(crate) const fn new(kinematic: KinematicConfig, limits: MotionLimits) -> Self {
1353 Self { kinematic, limits }
1354 }
1355}
1356
1357#[cfg(test)]
1358mod kinematics_tests {
1359 use super::{
1360 AckermannDrive, BodyTwist, DifferentialDrive, DriveKinematics, KinematicConfig,
1361 KinematicScalarField, MecanumDrive, ModelError,
1362 };
1363 use crate::identity::CapabilityRef;
1364
1365 const DIFFERENTIAL: DifferentialDrive = DifferentialDrive::new(0.1, 0.5);
1366 const MECANUM: MecanumDrive = MecanumDrive::new(0.1, 0.4, 0.6);
1367 const ACKERMANN: AckermannDrive = AckermannDrive::new(2.5, 1.5, 0.6);
1368
1369 fn close(left: f64, right: f64, what: &str) {
1370 assert!((left - right).abs() < 1e-9, "{what}: {left} vs {right}");
1371 }
1372
1373 #[test]
1378 fn a_differential_twist_survives_the_round_trip() {
1379 for twist in [
1380 BodyTwist::planar(0.0, 0.0),
1381 BodyTwist::planar(1.0, 0.0),
1382 BodyTwist::planar(0.0, 2.0),
1383 BodyTwist::planar(0.75, -1.25),
1384 ] {
1385 let back = DIFFERENTIAL.body_twist(DIFFERENTIAL.wheel_speeds(twist));
1386 close(back.linear_x_mps, twist.linear_x_mps, "linear x");
1387 close(back.angular_z_radps, twist.angular_z_radps, "angular z");
1388 assert_eq!(back.linear_y_mps, 0.0, "a differential drive has no sway");
1389 }
1390 }
1391
1392 #[test]
1393 fn a_mecanum_twist_survives_the_round_trip_including_sideways() {
1394 for twist in [
1395 BodyTwist::new(0.0, 0.0, 0.0),
1396 BodyTwist::new(1.0, 0.0, 0.0),
1397 BodyTwist::new(0.0, 1.0, 0.0),
1398 BodyTwist::new(0.0, 0.0, 1.5),
1399 BodyTwist::new(0.4, -0.7, 0.9),
1400 ] {
1401 let back = MECANUM.body_twist(MECANUM.wheel_speeds(twist));
1402 close(back.linear_x_mps, twist.linear_x_mps, "linear x");
1403 close(back.linear_y_mps, twist.linear_y_mps, "linear y");
1404 close(back.angular_z_radps, twist.angular_z_radps, "angular z");
1405 }
1406 }
1407
1408 #[test]
1409 fn an_ackermann_twist_survives_the_round_trip() {
1410 for twist in [
1411 BodyTwist::planar(1.0, 0.0),
1412 BodyTwist::planar(2.0, 0.4),
1413 BodyTwist::planar(-1.5, -0.3),
1414 ] {
1415 let back = ACKERMANN.body_twist(ACKERMANN.command(twist));
1416 close(back.linear_x_mps, twist.linear_x_mps, "linear x");
1417 close(back.angular_z_radps, twist.angular_z_radps, "angular z");
1418 }
1419 }
1420
1421 #[test]
1422 fn driving_straight_turns_both_differential_wheels_at_the_same_speed() {
1423 let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(1.0, 0.0));
1424 assert_eq!(speeds.left_radps, speeds.right_radps);
1425 assert_eq!(speeds.left_radps, 1.0 / DIFFERENTIAL.wheel_radius_m);
1426 }
1427
1428 #[test]
1429 fn turning_in_place_turns_the_differential_wheels_in_opposite_directions() {
1430 let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(0.0, 1.0));
1431 assert_eq!(speeds.left_radps, -speeds.right_radps);
1432 assert!(
1433 speeds.right_radps > 0.0,
1434 "a positive yaw rate drives the right wheel forward"
1435 );
1436 }
1437
1438 #[test]
1442 fn strafing_counter_rotates_the_mecanum_diagonals() {
1443 let speeds = MECANUM.wheel_speeds(BodyTwist::new(0.0, 1.0, 0.0));
1444 assert_eq!(speeds.front_left_radps, -speeds.front_right_radps);
1445 assert_eq!(speeds.rear_left_radps, -speeds.rear_right_radps);
1446 assert_eq!(speeds.front_left_radps, speeds.rear_right_radps);
1447 assert!(
1448 speeds.front_right_radps > 0.0,
1449 "left sway drives FR forward"
1450 );
1451 }
1452
1453 #[test]
1456 fn non_holonomic_geometries_ignore_a_sideways_request() {
1457 let straight = BodyTwist::planar(1.0, 0.0);
1458 let swaying = BodyTwist::new(1.0, 5.0, 0.0);
1459 assert_eq!(
1460 DIFFERENTIAL.wheel_speeds(straight),
1461 DIFFERENTIAL.wheel_speeds(swaying)
1462 );
1463 assert_eq!(ACKERMANN.command(straight), ACKERMANN.command(swaying));
1464 }
1465
1466 #[test]
1469 fn a_stationary_ackermann_has_a_defined_steering_angle() {
1470 let command = ACKERMANN.command(BodyTwist::planar(0.0, 1.0));
1471 assert_eq!(command.drive_speed_mps, 0.0);
1472 assert_eq!(command.steering_angle_rad, 0.0);
1473 }
1474
1475 #[test]
1476 fn the_steering_limit_is_reported_rather_than_silently_clamped() {
1477 let command = ACKERMANN.command(BodyTwist::planar(0.5, 2.0));
1478 assert!(
1479 command.steering_angle_rad.abs() > ACKERMANN.max_steering_angle_rad,
1480 "this request should exceed the mechanism"
1481 );
1482 assert!(!ACKERMANN.steering_is_reachable(command.steering_angle_rad));
1483 assert!(ACKERMANN.steering_is_reachable(0.0));
1484 }
1485
1486 fn reference() -> CapabilityRef {
1487 "base.motor".parse().expect("a well formed capability ref")
1488 }
1489
1490 #[test]
1491 fn every_authored_geometry_resolves_to_its_kinematics() {
1492 let differential = KinematicConfig::Differential {
1493 left_actuators: vec![reference()],
1494 right_actuators: vec![reference()],
1495 left_encoders: Vec::new(),
1496 right_encoders: Vec::new(),
1497 wheel_radius_m: 0.1,
1498 wheel_base_m: 0.5,
1499 };
1500 assert_eq!(
1501 differential.drive_kinematics().expect("valid geometry"),
1502 DriveKinematics::Differential(DIFFERENTIAL)
1503 );
1504
1505 let mecanum = KinematicConfig::Mecanum {
1506 front_left_actuator: reference(),
1507 front_right_actuator: reference(),
1508 rear_left_actuator: reference(),
1509 rear_right_actuator: reference(),
1510 wheel_radius_m: 0.1,
1511 wheel_base_m: 0.4,
1512 track_m: 0.6,
1513 };
1514 assert_eq!(
1515 mecanum.drive_kinematics().expect("valid geometry"),
1516 DriveKinematics::Mecanum(MECANUM)
1517 );
1518
1519 let ackermann = KinematicConfig::Ackermann {
1520 steering_actuator: reference(),
1521 drive_actuator: reference(),
1522 steering_encoder: None,
1523 drive_encoder: None,
1524 wheel_base_m: 2.5,
1525 track_m: 1.5,
1526 max_steering_angle_rad: 0.6,
1527 };
1528 assert_eq!(
1529 ackermann.drive_kinematics().expect("valid geometry"),
1530 DriveKinematics::Ackermann(ACKERMANN)
1531 );
1532
1533 let omnidirectional = KinematicConfig::Omnidirectional {
1537 actuators: vec![reference()],
1538 encoders: Vec::new(),
1539 };
1540 assert_eq!(
1541 omnidirectional
1542 .drive_kinematics()
1543 .expect("carries no scalars to reject"),
1544 DriveKinematics::Omnidirectional
1545 );
1546 }
1547
1548 #[test]
1549 fn a_non_positive_scalar_is_refused_by_the_geometry_it_belongs_to() {
1550 assert!(matches!(
1551 DifferentialDrive::new(0.0, 0.5).validate(),
1552 Err(ModelError::KinematicScalar {
1553 field: KinematicScalarField::WheelRadiusM,
1554 ..
1555 })
1556 ));
1557 assert!(matches!(
1558 MecanumDrive::new(0.1, 0.4, f64::NAN).validate(),
1559 Err(ModelError::KinematicScalar {
1560 field: KinematicScalarField::TrackM,
1561 ..
1562 })
1563 ));
1564 assert!(matches!(
1565 AckermannDrive::new(2.5, 1.5, -0.1).validate(),
1566 Err(ModelError::KinematicScalar {
1567 field: KinematicScalarField::MaxSteeringAngleRad,
1568 ..
1569 })
1570 ));
1571 }
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576 use super::*;
1577 use crate::compiler::{self, RobotParts};
1578 use serde_json::{Value, json};
1579
1580 const INERTIAL: &str = r#"{
1581 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1582 "mass_kg": 1.0,
1583 "inertia": { "ixx": 1.0, "ixy": 0.0, "ixz": 0.0, "iyy": 1.0, "iyz": 0.0, "izz": 1.0 }
1584 }"#;
1585
1586 fn inertial() -> Value {
1587 serde_json::from_str(INERTIAL).expect("a well-formed inertial fixture")
1588 }
1589
1590 fn link(name: &str) -> Value {
1591 json!({ "name": name, "inertial": inertial(), "visuals": [], "collisions": [] })
1592 }
1593
1594 fn robot_structure() -> Structure {
1595 compiler::structure(json!({
1596 "name": "rover",
1597 "links": [link("base_footprint"), link("base_link")],
1598 "joints": [{
1599 "name": "base_joint",
1600 "kind": "fixed",
1601 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1602 "parent": "base_footprint",
1603 "child": "base_link",
1604 "axis": [0.0, 0.0, 1.0],
1605 "limit": { "lower": 0.0, "upper": 0.0, "effort": 0.0, "velocity": 0.0 }
1606 }],
1607 "materials": []
1608 }))
1609 .expect("a well-formed robot structure fixture")
1610 }
1611
1612 fn robot_structure_with_collision() -> Structure {
1613 compiler::structure(json!({
1614 "name": "rover",
1615 "links": [
1616 {
1617 "name": "base_footprint",
1618 "inertial": inertial(),
1619 "visuals": [],
1620 "collisions": [{
1621 "name": "hull",
1622 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1623 "geometry": { "kind": "sphere", "radius": 0.5 }
1624 }]
1625 },
1626 link("base_link")
1627 ],
1628 "joints": [{
1629 "name": "base_joint",
1630 "kind": "fixed",
1631 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1632 "parent": "base_footprint",
1633 "child": "base_link",
1634 "axis": [0.0, 0.0, 1.0],
1635 "limit": { "lower": 0.0, "upper": 0.0, "effort": 0.0, "velocity": 0.0 }
1636 }],
1637 "materials": []
1638 }))
1639 .expect("a well-formed colliding robot structure fixture")
1640 }
1641
1642 fn component_structure() -> Structure {
1643 compiler::structure(json!({
1644 "name": "drive",
1645 "links": [link("body")],
1646 "joints": [],
1647 "materials": []
1648 }))
1649 .expect("a well-formed component structure fixture")
1650 }
1651
1652 fn drive_component() -> Component {
1654 let capabilities = serde_json::from_value(json!({
1655 "spin": {
1656 "kind": "motor",
1657 "target": { "kind": "link", "id": "body" },
1658 "command": "velocity",
1659 "gear_ratio": 1.0
1660 },
1661 "eye": {
1662 "kind": "camera",
1663 "target": { "kind": "link", "id": "body" },
1664 "mode": "rgb",
1665 "publish_rate_hz": 30.0,
1666 "width_px": 640,
1667 "height_px": 480
1668 }
1669 }))
1670 .expect("a well-formed capability fixture");
1671 compiler::component(capabilities, component_structure(), None)
1672 }
1673
1674 fn instance() -> ComponentInstance {
1675 compiler::component_instance(
1676 ComponentTypeId::new("drive").expect("a normalized type id"),
1677 LinkId::new("base_link"),
1678 BTreeMap::new(),
1679 BTreeMap::new(),
1680 None,
1681 )
1682 }
1683
1684 fn robot_with_structure(structure: Structure, instance_ids: &[&str]) -> Robot {
1685 compiler::robot(RobotParts {
1686 id: RobotId::new("rover").expect("a normalized robot id"),
1687 kinematic: KinematicConfig::Omnidirectional {
1688 actuators: Vec::new(),
1689 encoders: Vec::new(),
1690 },
1691 motion_limits: MotionLimits {
1692 max_linear_speed_mps: 1.0,
1693 max_angular_speed_radps: 1.0,
1694 },
1695 services: BTreeMap::new(),
1696 components: instance_ids
1697 .iter()
1698 .map(|id| {
1699 (
1700 ComponentInstanceId::new(*id).expect("a normalized instance id"),
1701 instance(),
1702 )
1703 })
1704 .collect(),
1705 component_types: [(
1706 ComponentTypeId::new("drive").expect("a normalized type id"),
1707 drive_component(),
1708 )]
1709 .into_iter()
1710 .collect(),
1711 structure,
1712 })
1713 .expect("a valid canonical robot")
1714 }
1715
1716 fn robot_with(instance_ids: &[&str]) -> Robot {
1717 robot_with_structure(robot_structure(), instance_ids)
1718 }
1719
1720 #[test]
1726 fn the_declared_robot_shape_is_the_shape_serde_writes() {
1727 use phoxal_runtime_contract::wire_schema::DescribeWire;
1728
1729 for robot in [
1730 robot_with(&["left"]),
1731 robot_with_structure(robot_structure_with_collision(), &[]),
1732 ] {
1733 let json = serde_json::to_value(&robot).expect("a canonical robot serializes");
1734 assert_eq!(Robot::wire_schema().conforms(&json), Ok(()));
1735 }
1736 }
1737
1738 #[test]
1739 fn robot_wire_requires_an_explicit_footprint_value_or_null() {
1740 let robot = robot_with(&[]);
1741 let mut value = serde_json::to_value(&robot).expect("robot serializes");
1742 assert!(value["footprint"].is_null());
1743 value
1744 .as_object_mut()
1745 .expect("robot wire is an object")
1746 .remove("footprint");
1747 assert!(serde_json::from_value::<Robot>(value).is_err());
1748 }
1749
1750 #[test]
1751 fn runtime_deserialize_checks_envelope_invariants_without_rederiving_geometry() {
1752 let robot = robot_with_structure(robot_structure_with_collision(), &[]);
1753 assert_eq!(robot.footprint_envelope().unwrap().radius_m, 0.5);
1754 let mut value = serde_json::to_value(&robot).expect("robot serializes");
1755 value["footprint"]["radius_m"] = json!(0.1);
1756 let decoded: Robot = serde_json::from_value(value).expect("finite stored radius is valid");
1757 assert_eq!(decoded.footprint_envelope().unwrap().radius_m, 0.1);
1758 }
1759
1760 #[test]
1761 fn runtime_role_lists_reject_empty_and_duplicate_assignments() {
1762 let robot = robot_with(&["front"]);
1763 let value = serde_json::to_value(&robot).expect("robot serializes");
1764
1765 let mut empty = value.clone();
1766 empty["components"]["front"]["roles"] = json!({"eye": []});
1767 assert!(serde_json::from_value::<Robot>(empty).is_err());
1768
1769 let mut duplicate = value;
1770 duplicate["components"]["front"]["roles"] = json!({"eye": ["perception", "perception"]});
1771 assert!(serde_json::from_value::<Robot>(duplicate).is_err());
1772 }
1773
1774 fn reference(component: &str, capability: &str) -> CapabilityRef {
1775 CapabilityRef::new(
1776 ComponentInstanceId::new(component).expect("a normalized instance id"),
1777 CapabilityId::new(capability).expect("a normalized capability id"),
1778 )
1779 }
1780
1781 #[test]
1782 fn selecting_no_capability_yields_nothing() {
1783 let robot = robot_with(&["front", "rear"]);
1784 assert!(
1785 robot
1786 .capability_refs(|capability| matches!(capability, Capability::Lidar(_)))
1787 .is_empty()
1788 );
1789 }
1790
1791 #[test]
1792 fn selection_spans_every_instance_that_declares_the_capability() {
1793 let robot = robot_with(&["front", "rear"]);
1794 let cameras =
1795 robot.capability_refs(|capability| matches!(capability, Capability::Camera(_)));
1796 assert_eq!(
1797 cameras.iter().map(ToString::to_string).collect::<Vec<_>>(),
1798 ["front.eye", "rear.eye"]
1799 );
1800 }
1801
1802 #[test]
1803 fn selection_is_ordered_by_component_then_capability() {
1804 let robot = robot_with(&["rear", "front"]);
1807 let all = robot.capability_refs(|_| true);
1808 assert_eq!(
1809 all.iter().map(ToString::to_string).collect::<Vec<_>>(),
1810 ["front.eye", "front.spin", "rear.eye", "rear.spin"]
1811 );
1812 let mut sorted = all.clone();
1813 sorted.sort();
1814 assert_eq!(all, sorted);
1815 }
1816
1817 #[test]
1818 fn a_routine_lookup_miss_is_absence_not_failure() {
1819 let robot = robot_with(&["front"]);
1820 let front = robot.component("front").expect("the instance is mounted");
1821 assert_eq!(front.id().as_str(), "front");
1822 assert_eq!(front.instance().mount_link(), &LinkId::new("base_link"));
1823 assert!(front.simulation().is_none());
1824 assert!(robot.component("nope").is_none());
1825 assert!(robot.capability(&reference("front", "spin")).is_some());
1826 assert!(robot.capability(&reference("front", "nope")).is_none());
1827 assert!(robot.capability(&reference("nope", "spin")).is_none());
1828 }
1829
1830 #[test]
1831 fn requiring_the_wrong_kind_names_both_kinds() {
1832 let robot = robot_with(&["front"]);
1833 let error = robot
1834 .require_motor(&reference("front", "eye"))
1835 .expect_err("a camera is not a motor");
1836 assert!(matches!(
1837 error,
1838 ModelError::CapabilityKindMismatch {
1839 expected: CapabilityKind::Motor,
1840 actual: CapabilityKind::Camera,
1841 ..
1842 }
1843 ));
1844 assert_eq!(
1845 error.to_string(),
1846 "capability 'front.eye' must reference a motor, found camera"
1847 );
1848
1849 let error = robot
1850 .require_encoder(&reference("front", "nope"))
1851 .expect_err("an undeclared capability cannot be required");
1852 assert!(matches!(error, ModelError::UnknownCapability { .. }));
1853 }
1854
1855 #[test]
1856 fn a_link_target_resolves_to_the_namespaced_runtime_frame() {
1857 let robot = robot_with(&["front"]);
1858 assert_eq!(
1859 robot
1860 .link_target_frame(&reference("front", "eye"))
1861 .expect("the camera targets a link"),
1862 LinkId::new("front__body")
1863 );
1864 }
1865
1866 #[test]
1867 fn an_unauthored_direction_sign_defaults_to_forward() {
1868 let robot = robot_with(&["front"]);
1869 let (_, sign) = robot
1870 .require_motor(&reference("front", "spin"))
1871 .expect("the motor resolves");
1872 assert_eq!(sign, 1);
1873 }
1874
1875 #[test]
1876 fn a_motion_limit_must_survive_the_narrowing_to_f32() {
1877 for limits in [
1878 MotionLimits {
1879 max_linear_speed_mps: 0.0,
1880 max_angular_speed_radps: 1.0,
1881 },
1882 MotionLimits {
1883 max_linear_speed_mps: 1.0,
1884 max_angular_speed_radps: f64::MAX,
1885 },
1886 MotionLimits {
1887 max_linear_speed_mps: f64::NAN,
1888 max_angular_speed_radps: 1.0,
1889 },
1890 ] {
1891 assert!(matches!(
1892 limits.validate(),
1893 Err(ModelError::MotionLimit { .. })
1894 ));
1895 }
1896 assert!(
1897 MotionLimits {
1898 max_linear_speed_mps: 1.5,
1899 max_angular_speed_radps: 2.5,
1900 }
1901 .validate()
1902 .is_ok()
1903 );
1904 }
1905}