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,
18};
19use crate::simulation::Simulation;
20use crate::structure::{Joint, JointKind, Structure};
21pub use phoxal_runtime_contract::clock::Clock;
22
23#[derive(phoxal_macros::DescribeWire, Debug, Clone, serde::Serialize)]
25#[serde(deny_unknown_fields)]
26pub struct ComponentInstance {
27 id: ComponentInstanceId,
28 component_type: ComponentTypeId,
29 mount_link: LinkId,
30 direction_signs: BTreeMap<CapabilityId, i8>,
31 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
34 roles: BTreeMap<CapabilityId, BTreeSet<CapabilityRole>>,
35}
36
37impl<'de> serde::Deserialize<'de> for ComponentInstance {
38 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
39 #[derive(serde::Deserialize)]
40 #[serde(deny_unknown_fields)]
41 struct Wire {
42 id: ComponentInstanceId,
43 component_type: ComponentTypeId,
44 mount_link: LinkId,
45 direction_signs: BTreeMap<CapabilityId, i8>,
46 #[serde(default)]
47 roles: BTreeMap<CapabilityId, Vec<CapabilityRole>>,
48 }
49
50 let wire = Wire::deserialize(deserializer)?;
51 let mut roles = BTreeMap::new();
52 for (capability_id, authored) in wire.roles {
53 if authored.is_empty() {
54 return Err(serde::de::Error::custom(ModelError::EmptyCapabilityRoles {
55 instance: wire.id.clone(),
56 capability_id,
57 }));
58 }
59 let mut canonical = BTreeSet::new();
60 for role in authored {
61 if !canonical.insert(role) {
62 return Err(serde::de::Error::custom(
63 ModelError::DuplicateCapabilityRole {
64 instance: wire.id.clone(),
65 capability_id,
66 role,
67 },
68 ));
69 }
70 }
71 roles.insert(capability_id, canonical);
72 }
73 Ok(Self::new(
74 wire.id,
75 wire.component_type,
76 wire.mount_link,
77 wire.direction_signs,
78 roles,
79 ))
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct MotionModel {
86 kinematic: KinematicConfig,
87 limits: MotionLimits,
88}
89
90#[derive(
92 phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq,
93)]
94#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
95#[serde(deny_unknown_fields)]
96pub struct MotionLimits {
97 pub max_linear_speed_mps: f64,
98 pub max_angular_speed_radps: f64,
99}
100
101#[derive(
103 phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq,
104)]
105#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
106#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
107pub enum KinematicConfig {
108 Differential {
109 left_actuators: Vec<CapabilityRef>,
110 right_actuators: Vec<CapabilityRef>,
111 left_encoders: Vec<CapabilityRef>,
112 right_encoders: Vec<CapabilityRef>,
113 wheel_radius_m: f64,
114 wheel_base_m: f64,
115 },
116 Mecanum {
117 front_left_actuator: CapabilityRef,
118 front_right_actuator: CapabilityRef,
119 rear_left_actuator: CapabilityRef,
120 rear_right_actuator: CapabilityRef,
121 wheel_radius_m: f64,
122 wheel_base_m: f64,
123 track_m: f64,
124 },
125 Ackermann {
126 steering_actuator: CapabilityRef,
127 drive_actuator: CapabilityRef,
128 steering_encoder: Option<CapabilityRef>,
129 drive_encoder: Option<CapabilityRef>,
130 wheel_base_m: f64,
131 track_m: f64,
132 max_steering_angle_rad: f64,
133 },
134 Omnidirectional {
135 actuators: Vec<CapabilityRef>,
136 encoders: Vec<CapabilityRef>,
137 },
138}
139
140impl KinematicConfig {
141 pub fn drive_kinematics(&self) -> Result<DriveKinematics, ModelError> {
152 Ok(match self {
153 Self::Differential {
154 wheel_radius_m,
155 wheel_base_m,
156 ..
157 } => DriveKinematics::Differential(
158 DifferentialDrive::new(*wheel_radius_m, *wheel_base_m).validate()?,
159 ),
160 Self::Mecanum {
161 wheel_radius_m,
162 wheel_base_m,
163 track_m,
164 ..
165 } => DriveKinematics::Mecanum(
166 MecanumDrive::new(*wheel_radius_m, *wheel_base_m, *track_m).validate()?,
167 ),
168 Self::Ackermann {
169 wheel_base_m,
170 track_m,
171 max_steering_angle_rad,
172 ..
173 } => DriveKinematics::Ackermann(
174 AckermannDrive::new(*wheel_base_m, *track_m, *max_steering_angle_rad).validate()?,
175 ),
176 Self::Omnidirectional { .. } => DriveKinematics::Omnidirectional,
177 })
178 }
179}
180
181#[derive(Debug, Clone, Copy, Default, PartialEq)]
188pub struct BodyTwist {
189 pub linear_x_mps: f64,
191 pub linear_y_mps: f64,
193 pub angular_z_radps: f64,
195}
196
197impl BodyTwist {
198 #[must_use]
200 pub const fn planar(linear_x_mps: f64, angular_z_radps: f64) -> Self {
201 Self {
202 linear_x_mps,
203 linear_y_mps: 0.0,
204 angular_z_radps,
205 }
206 }
207
208 #[must_use]
210 pub const fn new(linear_x_mps: f64, linear_y_mps: f64, angular_z_radps: f64) -> Self {
211 Self {
212 linear_x_mps,
213 linear_y_mps,
214 angular_z_radps,
215 }
216 }
217
218 #[must_use]
220 pub fn is_finite(&self) -> bool {
221 self.linear_x_mps.is_finite()
222 && self.linear_y_mps.is_finite()
223 && self.angular_z_radps.is_finite()
224 }
225}
226
227#[derive(Debug, Clone, Copy, PartialEq)]
229pub struct DifferentialWheelSpeeds {
230 pub left_radps: f64,
231 pub right_radps: f64,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq)]
236pub struct MecanumWheelSpeeds {
237 pub front_left_radps: f64,
238 pub front_right_radps: f64,
239 pub rear_left_radps: f64,
240 pub rear_right_radps: f64,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq)]
250pub struct AckermannCommand {
251 pub drive_speed_mps: f64,
253 pub steering_angle_rad: f64,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq)]
271pub enum DriveKinematics {
272 Differential(DifferentialDrive),
273 Mecanum(MecanumDrive),
274 Ackermann(AckermannDrive),
275 Omnidirectional,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq)]
301pub struct DifferentialDrive {
302 pub wheel_radius_m: f64,
304 pub wheel_base_m: f64,
306}
307
308impl DifferentialDrive {
309 #[must_use]
311 pub const fn new(wheel_radius_m: f64, wheel_base_m: f64) -> Self {
312 Self {
313 wheel_radius_m,
314 wheel_base_m,
315 }
316 }
317
318 pub fn validate(self) -> Result<Self, ModelError> {
330 for (value, field) in [
331 (self.wheel_radius_m, KinematicScalarField::WheelRadiusM),
332 (self.wheel_base_m, KinematicScalarField::WheelBaseM),
333 ] {
334 if !(value.is_finite() && value > 0.0) {
335 return Err(ModelError::KinematicScalar {
336 kinematics: KinematicKind::Differential,
337 field,
338 });
339 }
340 }
341 Ok(self)
342 }
343
344 #[must_use]
353 pub fn wheel_speeds(self, twist: BodyTwist) -> DifferentialWheelSpeeds {
354 let half_track = self.wheel_base_m / 2.0;
355 let left = twist.linear_x_mps - twist.angular_z_radps * half_track;
356 let right = twist.linear_x_mps + twist.angular_z_radps * half_track;
357 DifferentialWheelSpeeds {
358 left_radps: left / self.wheel_radius_m,
359 right_radps: right / self.wheel_radius_m,
360 }
361 }
362
363 #[must_use]
367 pub fn body_twist(self, speeds: DifferentialWheelSpeeds) -> BodyTwist {
368 let left = speeds.left_radps * self.wheel_radius_m;
369 let right = speeds.right_radps * self.wheel_radius_m;
370 BodyTwist::planar((left + right) / 2.0, (right - left) / self.wheel_base_m)
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq)]
380pub struct MecanumDrive {
381 pub wheel_radius_m: f64,
383 pub wheel_base_m: f64,
385 pub track_m: f64,
387}
388
389impl MecanumDrive {
390 #[must_use]
392 pub const fn new(wheel_radius_m: f64, wheel_base_m: f64, track_m: f64) -> Self {
393 Self {
394 wheel_radius_m,
395 wheel_base_m,
396 track_m,
397 }
398 }
399
400 const fn yaw_lever_m(self) -> f64 {
403 (self.wheel_base_m + self.track_m) / 2.0
404 }
405
406 pub fn validate(self) -> Result<Self, ModelError> {
413 for (value, field) in [
414 (self.wheel_radius_m, KinematicScalarField::WheelRadiusM),
415 (self.wheel_base_m, KinematicScalarField::WheelBaseM),
416 (self.track_m, KinematicScalarField::TrackM),
417 ] {
418 if !(value.is_finite() && value > 0.0) {
419 return Err(ModelError::KinematicScalar {
420 kinematics: KinematicKind::Mecanum,
421 field,
422 });
423 }
424 }
425 Ok(self)
426 }
427
428 #[must_use]
432 pub fn wheel_speeds(self, twist: BodyTwist) -> MecanumWheelSpeeds {
433 let yaw = twist.angular_z_radps * self.yaw_lever_m();
434 let scale = 1.0 / self.wheel_radius_m;
435 MecanumWheelSpeeds {
436 front_left_radps: scale * (twist.linear_x_mps - twist.linear_y_mps - yaw),
437 front_right_radps: scale * (twist.linear_x_mps + twist.linear_y_mps + yaw),
438 rear_left_radps: scale * (twist.linear_x_mps + twist.linear_y_mps - yaw),
439 rear_right_radps: scale * (twist.linear_x_mps - twist.linear_y_mps + yaw),
440 }
441 }
442
443 #[must_use]
450 pub fn body_twist(self, speeds: MecanumWheelSpeeds) -> BodyTwist {
451 let MecanumWheelSpeeds {
452 front_left_radps: fl,
453 front_right_radps: fr,
454 rear_left_radps: rl,
455 rear_right_radps: rr,
456 } = speeds;
457 BodyTwist::new(
458 (fl + fr + rl + rr) * self.wheel_radius_m / 4.0,
459 (-fl + fr + rl - rr) * self.wheel_radius_m / 4.0,
460 (-fl + fr - rl + rr) * self.wheel_radius_m / (4.0 * self.yaw_lever_m()),
461 )
462 }
463}
464
465#[derive(Debug, Clone, Copy, PartialEq)]
473pub struct AckermannDrive {
474 pub wheel_base_m: f64,
476 pub track_m: f64,
478 pub max_steering_angle_rad: f64,
480}
481
482impl AckermannDrive {
483 #[must_use]
485 pub const fn new(wheel_base_m: f64, track_m: f64, max_steering_angle_rad: f64) -> Self {
486 Self {
487 wheel_base_m,
488 track_m,
489 max_steering_angle_rad,
490 }
491 }
492
493 pub fn validate(self) -> Result<Self, ModelError> {
500 for (value, field) in [
501 (self.wheel_base_m, KinematicScalarField::WheelBaseM),
502 (self.track_m, KinematicScalarField::TrackM),
503 (
504 self.max_steering_angle_rad,
505 KinematicScalarField::MaxSteeringAngleRad,
506 ),
507 ] {
508 if !(value.is_finite() && value > 0.0) {
509 return Err(ModelError::KinematicScalar {
510 kinematics: KinematicKind::Ackermann,
511 field,
512 });
513 }
514 }
515 Ok(self)
516 }
517
518 #[must_use]
529 pub fn command(self, twist: BodyTwist) -> AckermannCommand {
530 let steering_angle_rad = if twist.linear_x_mps == 0.0 {
531 0.0
532 } else {
533 (twist.angular_z_radps * self.wheel_base_m / twist.linear_x_mps).atan()
534 };
535 AckermannCommand {
536 drive_speed_mps: twist.linear_x_mps,
537 steering_angle_rad,
538 }
539 }
540
541 #[must_use]
545 pub fn body_twist(self, command: AckermannCommand) -> BodyTwist {
546 BodyTwist::planar(
547 command.drive_speed_mps,
548 command.drive_speed_mps * command.steering_angle_rad.tan() / self.wheel_base_m,
549 )
550 }
551
552 #[must_use]
554 pub fn steering_is_reachable(self, steering_angle_rad: f64) -> bool {
555 steering_angle_rad.abs() <= self.max_steering_angle_rad
556 }
557}
558
559#[derive(Clone, Copy, Debug, PartialEq, Eq)]
561pub enum KinematicKind {
562 Differential,
563 Mecanum,
564 Ackermann,
565 Omnidirectional,
566}
567
568#[derive(Debug, Clone)]
570pub struct Robot {
571 id: RobotId,
572 clock: Clock,
573 motion: MotionModel,
574 component_instances: BTreeMap<ComponentInstanceId, ComponentInstance>,
575 component_types: BTreeMap<ComponentTypeId, Component>,
576 simulation_types: BTreeMap<ComponentTypeId, Simulation>,
577 structure: Structure,
578 footprint: Option<FootprintEnvelope>,
581}
582
583#[derive(phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize)]
591#[serde(deny_unknown_fields)]
592struct RobotWire {
593 id: RobotId,
594 clock: Clock,
595 kinematic: KinematicConfig,
596 motion_limits: MotionLimits,
597 component_instances: BTreeMap<ComponentInstanceId, ComponentInstance>,
598 component_types: BTreeMap<ComponentTypeId, Component>,
599 simulation_types: BTreeMap<ComponentTypeId, Simulation>,
600 structure: Structure,
601 footprint: PersistedFootprint,
602}
603
604#[derive(phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize)]
610struct PersistedFootprint(Option<FootprintEnvelope>);
611
612impl serde::Serialize for Robot {
613 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
614 RobotWire {
615 id: self.id.clone(),
616 clock: self.clock,
617 kinematic: self.motion.kinematic.clone(),
618 motion_limits: self.motion.limits,
619 component_instances: self.component_instances.clone(),
620 component_types: self.component_types.clone(),
621 simulation_types: self.simulation_types.clone(),
622 structure: self.structure.clone(),
623 footprint: PersistedFootprint(self.footprint),
624 }
625 .serialize(serializer)
626 }
627}
628
629impl phoxal_runtime_contract::wire_schema::DescribeWire for Robot {
630 fn wire_schema() -> phoxal_runtime_contract::wire_schema::WireSchema {
633 phoxal_runtime_contract::wire_schema::WireSchema::opaque(
634 "Robot",
635 <RobotWire as phoxal_runtime_contract::wire_schema::DescribeWire>::wire_schema(),
636 )
637 }
638}
639
640impl<'de> serde::Deserialize<'de> for Robot {
641 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
642 let wire = RobotWire::deserialize(deserializer)?;
643 Self::new(
644 RobotParts {
645 id: wire.id,
646 clock: wire.clock,
647 kinematic: wire.kinematic,
648 motion_limits: wire.motion_limits,
649 component_instances: wire.component_instances,
650 component_types: wire.component_types,
651 simulation_types: wire.simulation_types,
652 structure: wire.structure,
653 },
654 wire.footprint.0,
655 )
656 .map_err(serde::de::Error::custom)
657 }
658}
659
660impl ComponentInstance {
661 pub(crate) const fn new(
662 id: ComponentInstanceId,
663 component_type: ComponentTypeId,
664 mount_link: LinkId,
665 direction_signs: BTreeMap<CapabilityId, i8>,
666 roles: BTreeMap<CapabilityId, BTreeSet<CapabilityRole>>,
667 ) -> Self {
668 Self {
669 id,
670 component_type,
671 mount_link,
672 direction_signs,
673 roles,
674 }
675 }
676
677 #[must_use]
678 pub const fn id(&self) -> &ComponentInstanceId {
679 &self.id
680 }
681
682 #[must_use]
683 pub const fn component_type(&self) -> &ComponentTypeId {
684 &self.component_type
685 }
686
687 #[must_use]
689 pub const fn mount_link(&self) -> &LinkId {
690 &self.mount_link
691 }
692
693 #[must_use]
695 pub const fn roles(&self) -> &BTreeMap<CapabilityId, BTreeSet<CapabilityRole>> {
696 &self.roles
697 }
698
699 #[must_use]
701 pub fn has_role(&self, capability: &CapabilityId, role: CapabilityRole) -> bool {
702 self.roles
703 .get(capability)
704 .is_some_and(|roles| roles.contains(&role))
705 }
706}
707
708impl MotionModel {
709 #[must_use]
710 pub const fn kinematic(&self) -> &KinematicConfig {
711 &self.kinematic
712 }
713
714 #[must_use]
715 pub const fn limits(&self) -> MotionLimits {
716 self.limits
717 }
718}
719
720impl MotionLimits {
721 pub fn validate(self) -> Result<Self, ModelError> {
728 for (value, field) in [
729 (
730 self.max_linear_speed_mps,
731 MotionLimitField::MaxLinearSpeedMps,
732 ),
733 (
734 self.max_angular_speed_radps,
735 MotionLimitField::MaxAngularSpeedRadps,
736 ),
737 ] {
738 if !(value.is_finite() && value > 0.0 && value <= f64::from(f32::MAX)) {
739 return Err(ModelError::MotionLimit { field });
740 }
741 }
742 Ok(self)
743 }
744}
745
746impl KinematicConfig {
747 #[must_use]
749 pub const fn kind(&self) -> KinematicKind {
750 match self {
751 Self::Differential { .. } => KinematicKind::Differential,
752 Self::Mecanum { .. } => KinematicKind::Mecanum,
753 Self::Ackermann { .. } => KinematicKind::Ackermann,
754 Self::Omnidirectional { .. } => KinematicKind::Omnidirectional,
755 }
756 }
757}
758
759impl fmt::Display for KinematicKind {
760 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
761 formatter.write_str(match self {
762 Self::Differential => "differential",
763 Self::Mecanum => "mecanum",
764 Self::Ackermann => "ackermann",
765 Self::Omnidirectional => "omnidirectional",
766 })
767 }
768}
769
770impl Robot {
771 pub(crate) fn new(
772 parts: RobotParts,
773 footprint: Option<FootprintEnvelope>,
774 ) -> Result<Self, ModelError> {
775 let robot = Self {
776 id: parts.id,
777 clock: parts.clock,
778 motion: MotionModel::new(parts.kinematic, parts.motion_limits),
779 component_instances: parts.component_instances,
780 component_types: parts.component_types,
781 simulation_types: parts.simulation_types,
782 structure: parts.structure,
783 footprint,
784 };
785 robot.validate()?;
786 Ok(robot)
787 }
788
789 #[must_use]
790 pub const fn id(&self) -> &RobotId {
791 &self.id
792 }
793
794 #[must_use]
796 pub const fn clock(&self) -> Clock {
797 self.clock
798 }
799
800 #[must_use]
801 pub const fn motion(&self) -> &MotionModel {
802 &self.motion
803 }
804
805 pub fn components(&self) -> impl ExactSizeIterator<Item = &ComponentInstance> {
807 self.component_instances.values()
808 }
809
810 pub fn component_ids(&self) -> impl ExactSizeIterator<Item = &ComponentInstanceId> {
812 self.component_instances.keys()
813 }
814
815 #[must_use]
817 pub fn component_instance(&self, id: &str) -> Option<&ComponentInstance> {
818 self.component_instances.get(id)
819 }
820
821 #[must_use]
826 pub fn component_for_instance(&self, id: &str) -> Option<&Component> {
827 self.component_types
828 .get(self.component_instance(id)?.component_type())
829 }
830
831 #[must_use]
833 pub fn simulation_for_component_type(&self, component_type: &str) -> Option<&Simulation> {
834 self.simulation_types.get(component_type)
835 }
836
837 #[must_use]
839 pub fn simulation_for_instance(&self, component_id: &str) -> Option<&Simulation> {
840 let instance = self.component_instance(component_id)?;
841 self.simulation_for_component_type(instance.component_type().as_str())
842 }
843
844 #[must_use]
846 pub const fn structure(&self) -> &Structure {
847 &self.structure
848 }
849
850 #[must_use]
852 pub const fn footprint_envelope(&self) -> Option<FootprintEnvelope> {
853 self.footprint
854 }
855
856 #[must_use]
858 pub fn capability(&self, reference: &CapabilityRef) -> Option<&Capability> {
859 self.resolve(reference).map(|(_, capability)| capability)
860 }
861
862 pub fn capability_refs(&self, selects: impl Fn(&Capability) -> bool) -> Vec<CapabilityRef> {
877 let mut references = self
878 .component_instances
879 .values()
880 .filter_map(|instance| {
881 Some((
882 instance.id(),
883 self.component_types.get(instance.component_type())?,
884 ))
885 })
886 .flat_map(|(component_id, component)| {
887 component
888 .capabilities()
889 .filter(|(_, capability)| selects(capability))
890 .map(move |(capability_id, _)| {
891 CapabilityRef::new(component_id.clone(), capability_id.clone())
892 })
893 })
894 .collect::<Vec<_>>();
895 references.sort();
896 references
897 }
898
899 #[must_use]
902 pub fn capabilities_with_role(&self, role: CapabilityRole) -> Vec<CapabilityRef> {
903 self.component_instances
904 .values()
905 .filter_map(|instance| {
906 self.component_types
907 .get(instance.component_type())
908 .map(|component| (instance, component))
909 })
910 .flat_map(|(instance, component)| {
911 instance
912 .roles()
913 .iter()
914 .filter(move |(capability_id, roles)| {
915 roles.contains(&role)
916 && component.capability(capability_id.as_str()).is_some()
917 })
918 .map(move |(capability_id, _)| {
919 CapabilityRef::new(instance.id().clone(), capability_id.clone())
920 })
921 })
922 .collect()
923 }
924
925 pub fn require_motor(&self, reference: &CapabilityRef) -> Result<(&Motor, i8), ModelError> {
933 let capability = self.require_capability(reference)?;
934 let Capability::Motor(motor) = capability else {
935 return Err(ModelError::CapabilityKindMismatch {
936 reference: reference.clone(),
937 expected: CapabilityKind::Motor,
938 actual: capability.kind(),
939 });
940 };
941 Ok((motor, self.direction_sign(reference)))
942 }
943
944 pub fn require_encoder(&self, reference: &CapabilityRef) -> Result<(&Encoder, i8), ModelError> {
952 let capability = self.require_capability(reference)?;
953 let Capability::Encoder(encoder) = capability else {
954 return Err(ModelError::CapabilityKindMismatch {
955 reference: reference.clone(),
956 expected: CapabilityKind::Encoder,
957 actual: capability.kind(),
958 });
959 };
960 Ok((encoder, self.direction_sign(reference)))
961 }
962
963 pub fn link_target_frame(&self, reference: &CapabilityRef) -> Result<LinkId, ModelError> {
972 let (component, capability) =
973 self.resolve(reference)
974 .ok_or_else(|| ModelError::UnknownCapability {
975 reference: reference.clone(),
976 })?;
977 let StructuralTarget::Link { id } = capability.target() else {
978 return Err(ModelError::CapabilityTargetKind {
979 reference: reference.clone(),
980 expected: StructuralKind::Link,
981 });
982 };
983 if component.structure().link(id.as_str()).is_none() {
984 return Err(ModelError::UnknownBoundTarget {
985 reference: reference.clone(),
986 kind: StructuralKind::Link,
987 id: id.as_str().to_string(),
988 });
989 }
990 Ok(id.namespaced(&reference.component_id))
991 }
992
993 fn resolve(&self, reference: &CapabilityRef) -> Option<(&Component, &Capability)> {
994 let component = self.component_for_instance(reference.component_id.as_str())?;
995 let capability = component.capability(reference.capability_id.as_str())?;
996 Some((component, capability))
997 }
998
999 fn require_capability(&self, reference: &CapabilityRef) -> Result<&Capability, ModelError> {
1000 self.capability(reference)
1001 .ok_or_else(|| ModelError::UnknownCapability {
1002 reference: reference.clone(),
1003 })
1004 }
1005
1006 fn direction_sign(&self, reference: &CapabilityRef) -> i8 {
1008 self.component_instance(reference.component_id.as_str())
1009 .and_then(|instance| {
1010 instance
1011 .direction_signs
1012 .get(reference.capability_id.as_str())
1013 })
1014 .copied()
1015 .unwrap_or(1)
1016 }
1017
1018 fn validate(&self) -> Result<(), ModelError> {
1019 self.motion.limits.validate()?;
1020 self.validate_robot_structure()?;
1021 self.validate_component_types()?;
1022 self.validate_component_instances()?;
1023 self.validate_simulation_types()?;
1024 self.validate_kinematic()?;
1025 self.validate_footprint()
1026 }
1027
1028 fn validate_robot_structure(&self) -> Result<(), ModelError> {
1031 for link in self.structure.links() {
1032 Self::reject_reserved_separator(IdentifierKind::RobotLink, link.name().as_str())?;
1033 }
1034 for joint in self.structure.joints() {
1035 Self::reject_reserved_separator(IdentifierKind::RobotJoint, joint.name().as_str())?;
1036 Self::validate_runtime_joint_kind(joint, &JointOwner::Robot)?;
1037 }
1038 Ok(self.structure.validate_robot_frames()?)
1039 }
1040
1041 fn validate_component_types(&self) -> Result<(), ModelError> {
1042 for (component_type, component) in &self.component_types {
1043 for joint in component.structure().joints() {
1044 Self::validate_runtime_joint_kind(
1045 joint,
1046 &JointOwner::ComponentType(component_type.clone()),
1047 )?;
1048 }
1049 for (capability_id, capability) in component.capabilities() {
1050 let target = capability.target();
1051 let present = match target {
1052 StructuralTarget::Link { id } => {
1053 component.structure().link(id.as_str()).is_some()
1054 }
1055 StructuralTarget::Joint { id } => {
1056 component.structure().joint(id.as_str()).is_some()
1057 }
1058 };
1059 if !present {
1060 let id = match target {
1061 StructuralTarget::Link { id } => id.as_str().to_string(),
1062 StructuralTarget::Joint { id } => id.as_str().to_string(),
1063 };
1064 return Err(ModelError::UnknownDeclaredTarget {
1065 component_type: component_type.clone(),
1066 capability_id: capability_id.clone(),
1067 kind: target.kind(),
1068 id,
1069 });
1070 }
1071 }
1072 }
1073 Ok(())
1074 }
1075
1076 fn validate_footprint(&self) -> Result<(), ModelError> {
1082 if let Some(footprint) = self.footprint {
1083 FootprintEnvelope::new(footprint.radius_m)?;
1084 }
1085 Ok(())
1086 }
1087
1088 fn validate_component_instances(&self) -> Result<(), ModelError> {
1089 for (id, instance) in &self.component_instances {
1090 Self::reject_reserved_separator(IdentifierKind::ComponentInstance, id.as_str())?;
1091 if id != instance.id() {
1092 return Err(ModelError::ComponentIdentityMismatch {
1093 key: id.clone(),
1094 embedded: instance.id().clone(),
1095 });
1096 }
1097 let component = self
1098 .component_types
1099 .get(instance.component_type())
1100 .ok_or_else(|| ModelError::UnknownComponentType {
1101 instance: id.clone(),
1102 component_type: instance.component_type().clone(),
1103 })?;
1104 if self
1105 .structure
1106 .link(instance.mount_link().as_str())
1107 .is_none()
1108 {
1109 return Err(ModelError::UnknownMountLink {
1110 instance: id.clone(),
1111 link: instance.mount_link().clone(),
1112 });
1113 }
1114 for (capability_id, sign) in &instance.direction_signs {
1115 if !matches!(sign, -1 | 1) {
1116 return Err(ModelError::DirectionSign {
1117 instance: id.clone(),
1118 capability_id: capability_id.clone(),
1119 value: *sign,
1120 });
1121 }
1122 if component.capability(capability_id.as_str()).is_none() {
1123 return Err(ModelError::UnknownDirectionSignCapability {
1124 instance: id.clone(),
1125 capability_id: capability_id.clone(),
1126 });
1127 }
1128 }
1129 for capability_id in instance.roles.keys() {
1130 if component.capability(capability_id.as_str()).is_none() {
1131 return Err(ModelError::UnknownRoleCapability {
1132 instance: id.clone(),
1133 capability_id: capability_id.clone(),
1134 });
1135 }
1136 }
1137 }
1138 Ok(())
1139 }
1140
1141 fn validate_simulation_types(&self) -> Result<(), ModelError> {
1142 for (component_type, simulation) in &self.simulation_types {
1143 let component = self.component_types.get(component_type).ok_or_else(|| {
1144 ModelError::SimulationWithoutComponentType {
1145 component_type: component_type.clone(),
1146 }
1147 })?;
1148 for (capability_id, simulated) in simulation.capabilities() {
1149 let capability = component
1150 .capability(capability_id.as_str())
1151 .ok_or_else(|| ModelError::SimulationWithoutCapability {
1152 component_type: component_type.clone(),
1153 capability_id: capability_id.clone(),
1154 })?;
1155 if simulated.kind() != capability.kind() {
1156 return Err(ModelError::SimulationCapabilityKindMismatch {
1157 component_type: component_type.clone(),
1158 capability_id: capability_id.clone(),
1159 simulated: simulated.kind(),
1160 declared: capability.kind(),
1161 });
1162 }
1163 }
1164 }
1165 Ok(())
1166 }
1167
1168 fn validate_kinematic(&self) -> Result<(), ModelError> {
1169 self.motion.kinematic().drive_kinematics()?;
1172 match self.motion.kinematic() {
1173 KinematicConfig::Differential {
1174 left_actuators,
1175 right_actuators,
1176 left_encoders,
1177 right_encoders,
1178 ..
1179 } => {
1180 for reference in left_actuators.iter().chain(right_actuators) {
1181 self.require_motor(reference)?;
1182 }
1183 for reference in left_encoders.iter().chain(right_encoders) {
1184 self.require_encoder(reference)?;
1185 }
1186 }
1187 KinematicConfig::Mecanum {
1188 front_left_actuator,
1189 front_right_actuator,
1190 rear_left_actuator,
1191 rear_right_actuator,
1192 ..
1193 } => {
1194 for reference in [
1195 front_left_actuator,
1196 front_right_actuator,
1197 rear_left_actuator,
1198 rear_right_actuator,
1199 ] {
1200 self.require_motor(reference)?;
1201 }
1202 }
1203 KinematicConfig::Ackermann {
1204 steering_actuator,
1205 drive_actuator,
1206 steering_encoder,
1207 drive_encoder,
1208 ..
1209 } => {
1210 self.require_motor(steering_actuator)?;
1211 self.require_motor(drive_actuator)?;
1212 for reference in steering_encoder.iter().chain(drive_encoder) {
1213 self.require_encoder(reference)?;
1214 }
1215 }
1216 KinematicConfig::Omnidirectional {
1217 actuators,
1218 encoders,
1219 } => {
1220 for reference in actuators {
1221 self.require_motor(reference)?;
1222 }
1223 for reference in encoders {
1224 self.require_encoder(reference)?;
1225 }
1226 }
1227 }
1228 Ok(())
1229 }
1230
1231 fn reject_reserved_separator(kind: IdentifierKind, value: &str) -> Result<(), ModelError> {
1234 if value.contains(MODULE_INSTANCE_SEPARATOR) {
1235 return Err(ModelError::ReservedSeparator {
1236 kind,
1237 value: value.to_string(),
1238 });
1239 }
1240 Ok(())
1241 }
1242
1243 fn validate_runtime_joint_kind(joint: &Joint, owner: &JointOwner) -> Result<(), ModelError> {
1245 if matches!(
1246 joint.kind(),
1247 JointKind::Fixed | JointKind::Revolute | JointKind::Continuous | JointKind::Prismatic
1248 ) {
1249 Ok(())
1250 } else {
1251 Err(ModelError::UnsupportedJointKind {
1252 owner: owner.clone(),
1253 joint: joint.name().clone(),
1254 kind: joint.kind(),
1255 })
1256 }
1257 }
1258}
1259
1260impl MotionModel {
1261 pub(crate) const fn new(kinematic: KinematicConfig, limits: MotionLimits) -> Self {
1262 Self { kinematic, limits }
1263 }
1264}
1265
1266#[cfg(test)]
1267mod kinematics_tests {
1268 use super::{
1269 AckermannDrive, BodyTwist, DifferentialDrive, DriveKinematics, KinematicConfig,
1270 KinematicScalarField, MecanumDrive, ModelError,
1271 };
1272 use crate::identity::CapabilityRef;
1273
1274 const DIFFERENTIAL: DifferentialDrive = DifferentialDrive::new(0.1, 0.5);
1275 const MECANUM: MecanumDrive = MecanumDrive::new(0.1, 0.4, 0.6);
1276 const ACKERMANN: AckermannDrive = AckermannDrive::new(2.5, 1.5, 0.6);
1277
1278 fn close(left: f64, right: f64, what: &str) {
1279 assert!((left - right).abs() < 1e-9, "{what}: {left} vs {right}");
1280 }
1281
1282 #[test]
1287 fn a_differential_twist_survives_the_round_trip() {
1288 for twist in [
1289 BodyTwist::planar(0.0, 0.0),
1290 BodyTwist::planar(1.0, 0.0),
1291 BodyTwist::planar(0.0, 2.0),
1292 BodyTwist::planar(0.75, -1.25),
1293 ] {
1294 let back = DIFFERENTIAL.body_twist(DIFFERENTIAL.wheel_speeds(twist));
1295 close(back.linear_x_mps, twist.linear_x_mps, "linear x");
1296 close(back.angular_z_radps, twist.angular_z_radps, "angular z");
1297 assert_eq!(back.linear_y_mps, 0.0, "a differential drive has no sway");
1298 }
1299 }
1300
1301 #[test]
1302 fn a_mecanum_twist_survives_the_round_trip_including_sideways() {
1303 for twist in [
1304 BodyTwist::new(0.0, 0.0, 0.0),
1305 BodyTwist::new(1.0, 0.0, 0.0),
1306 BodyTwist::new(0.0, 1.0, 0.0),
1307 BodyTwist::new(0.0, 0.0, 1.5),
1308 BodyTwist::new(0.4, -0.7, 0.9),
1309 ] {
1310 let back = MECANUM.body_twist(MECANUM.wheel_speeds(twist));
1311 close(back.linear_x_mps, twist.linear_x_mps, "linear x");
1312 close(back.linear_y_mps, twist.linear_y_mps, "linear y");
1313 close(back.angular_z_radps, twist.angular_z_radps, "angular z");
1314 }
1315 }
1316
1317 #[test]
1318 fn an_ackermann_twist_survives_the_round_trip() {
1319 for twist in [
1320 BodyTwist::planar(1.0, 0.0),
1321 BodyTwist::planar(2.0, 0.4),
1322 BodyTwist::planar(-1.5, -0.3),
1323 ] {
1324 let back = ACKERMANN.body_twist(ACKERMANN.command(twist));
1325 close(back.linear_x_mps, twist.linear_x_mps, "linear x");
1326 close(back.angular_z_radps, twist.angular_z_radps, "angular z");
1327 }
1328 }
1329
1330 #[test]
1331 fn driving_straight_turns_both_differential_wheels_at_the_same_speed() {
1332 let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(1.0, 0.0));
1333 assert_eq!(speeds.left_radps, speeds.right_radps);
1334 assert_eq!(speeds.left_radps, 1.0 / DIFFERENTIAL.wheel_radius_m);
1335 }
1336
1337 #[test]
1338 fn turning_in_place_turns_the_differential_wheels_in_opposite_directions() {
1339 let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(0.0, 1.0));
1340 assert_eq!(speeds.left_radps, -speeds.right_radps);
1341 assert!(
1342 speeds.right_radps > 0.0,
1343 "a positive yaw rate drives the right wheel forward"
1344 );
1345 }
1346
1347 #[test]
1351 fn strafing_counter_rotates_the_mecanum_diagonals() {
1352 let speeds = MECANUM.wheel_speeds(BodyTwist::new(0.0, 1.0, 0.0));
1353 assert_eq!(speeds.front_left_radps, -speeds.front_right_radps);
1354 assert_eq!(speeds.rear_left_radps, -speeds.rear_right_radps);
1355 assert_eq!(speeds.front_left_radps, speeds.rear_right_radps);
1356 assert!(
1357 speeds.front_right_radps > 0.0,
1358 "left sway drives FR forward"
1359 );
1360 }
1361
1362 #[test]
1365 fn non_holonomic_geometries_ignore_a_sideways_request() {
1366 let straight = BodyTwist::planar(1.0, 0.0);
1367 let swaying = BodyTwist::new(1.0, 5.0, 0.0);
1368 assert_eq!(
1369 DIFFERENTIAL.wheel_speeds(straight),
1370 DIFFERENTIAL.wheel_speeds(swaying)
1371 );
1372 assert_eq!(ACKERMANN.command(straight), ACKERMANN.command(swaying));
1373 }
1374
1375 #[test]
1378 fn a_stationary_ackermann_has_a_defined_steering_angle() {
1379 let command = ACKERMANN.command(BodyTwist::planar(0.0, 1.0));
1380 assert_eq!(command.drive_speed_mps, 0.0);
1381 assert_eq!(command.steering_angle_rad, 0.0);
1382 }
1383
1384 #[test]
1385 fn the_steering_limit_is_reported_rather_than_silently_clamped() {
1386 let command = ACKERMANN.command(BodyTwist::planar(0.5, 2.0));
1387 assert!(
1388 command.steering_angle_rad.abs() > ACKERMANN.max_steering_angle_rad,
1389 "this request should exceed the mechanism"
1390 );
1391 assert!(!ACKERMANN.steering_is_reachable(command.steering_angle_rad));
1392 assert!(ACKERMANN.steering_is_reachable(0.0));
1393 }
1394
1395 fn reference() -> CapabilityRef {
1396 "base.motor".parse().expect("a well formed capability ref")
1397 }
1398
1399 #[test]
1400 fn every_authored_geometry_resolves_to_its_kinematics() {
1401 let differential = KinematicConfig::Differential {
1402 left_actuators: vec![reference()],
1403 right_actuators: vec![reference()],
1404 left_encoders: Vec::new(),
1405 right_encoders: Vec::new(),
1406 wheel_radius_m: 0.1,
1407 wheel_base_m: 0.5,
1408 };
1409 assert_eq!(
1410 differential.drive_kinematics().expect("valid geometry"),
1411 DriveKinematics::Differential(DIFFERENTIAL)
1412 );
1413
1414 let mecanum = KinematicConfig::Mecanum {
1415 front_left_actuator: reference(),
1416 front_right_actuator: reference(),
1417 rear_left_actuator: reference(),
1418 rear_right_actuator: reference(),
1419 wheel_radius_m: 0.1,
1420 wheel_base_m: 0.4,
1421 track_m: 0.6,
1422 };
1423 assert_eq!(
1424 mecanum.drive_kinematics().expect("valid geometry"),
1425 DriveKinematics::Mecanum(MECANUM)
1426 );
1427
1428 let ackermann = KinematicConfig::Ackermann {
1429 steering_actuator: reference(),
1430 drive_actuator: reference(),
1431 steering_encoder: None,
1432 drive_encoder: None,
1433 wheel_base_m: 2.5,
1434 track_m: 1.5,
1435 max_steering_angle_rad: 0.6,
1436 };
1437 assert_eq!(
1438 ackermann.drive_kinematics().expect("valid geometry"),
1439 DriveKinematics::Ackermann(ACKERMANN)
1440 );
1441
1442 let omnidirectional = KinematicConfig::Omnidirectional {
1446 actuators: vec![reference()],
1447 encoders: Vec::new(),
1448 };
1449 assert_eq!(
1450 omnidirectional
1451 .drive_kinematics()
1452 .expect("carries no scalars to reject"),
1453 DriveKinematics::Omnidirectional
1454 );
1455 }
1456
1457 #[test]
1458 fn a_non_positive_scalar_is_refused_by_the_geometry_it_belongs_to() {
1459 assert!(matches!(
1460 DifferentialDrive::new(0.0, 0.5).validate(),
1461 Err(ModelError::KinematicScalar {
1462 field: KinematicScalarField::WheelRadiusM,
1463 ..
1464 })
1465 ));
1466 assert!(matches!(
1467 MecanumDrive::new(0.1, 0.4, f64::NAN).validate(),
1468 Err(ModelError::KinematicScalar {
1469 field: KinematicScalarField::TrackM,
1470 ..
1471 })
1472 ));
1473 assert!(matches!(
1474 AckermannDrive::new(2.5, 1.5, -0.1).validate(),
1475 Err(ModelError::KinematicScalar {
1476 field: KinematicScalarField::MaxSteeringAngleRad,
1477 ..
1478 })
1479 ));
1480 }
1481}
1482
1483#[cfg(test)]
1484mod tests {
1485 use super::*;
1486 use crate::compiler::{self, RobotParts};
1487 use serde_json::{Value, json};
1488
1489 const INERTIAL: &str = r#"{
1490 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1491 "mass_kg": 1.0,
1492 "inertia": { "ixx": 1.0, "ixy": 0.0, "ixz": 0.0, "iyy": 1.0, "iyz": 0.0, "izz": 1.0 }
1493 }"#;
1494
1495 fn inertial() -> Value {
1496 serde_json::from_str(INERTIAL).expect("a well-formed inertial fixture")
1497 }
1498
1499 fn link(name: &str) -> Value {
1500 json!({ "name": name, "inertial": inertial(), "visuals": [], "collisions": [] })
1501 }
1502
1503 fn robot_structure() -> Structure {
1504 compiler::structure(json!({
1505 "name": "rover",
1506 "links": [link("base_footprint"), link("base_link")],
1507 "joints": [{
1508 "name": "base_joint",
1509 "kind": "fixed",
1510 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1511 "parent": "base_footprint",
1512 "child": "base_link",
1513 "axis": [0.0, 0.0, 1.0],
1514 "limit": { "lower": 0.0, "upper": 0.0, "effort": 0.0, "velocity": 0.0 }
1515 }],
1516 "materials": []
1517 }))
1518 .expect("a well-formed robot structure fixture")
1519 }
1520
1521 fn robot_structure_with_collision() -> Structure {
1522 compiler::structure(json!({
1523 "name": "rover",
1524 "links": [
1525 {
1526 "name": "base_footprint",
1527 "inertial": inertial(),
1528 "visuals": [],
1529 "collisions": [{
1530 "name": "hull",
1531 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1532 "geometry": { "kind": "sphere", "radius": 0.5 }
1533 }]
1534 },
1535 link("base_link")
1536 ],
1537 "joints": [{
1538 "name": "base_joint",
1539 "kind": "fixed",
1540 "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1541 "parent": "base_footprint",
1542 "child": "base_link",
1543 "axis": [0.0, 0.0, 1.0],
1544 "limit": { "lower": 0.0, "upper": 0.0, "effort": 0.0, "velocity": 0.0 }
1545 }],
1546 "materials": []
1547 }))
1548 .expect("a well-formed colliding robot structure fixture")
1549 }
1550
1551 fn component_structure() -> Structure {
1552 compiler::structure(json!({
1553 "name": "drive",
1554 "links": [link("body")],
1555 "joints": [],
1556 "materials": []
1557 }))
1558 .expect("a well-formed component structure fixture")
1559 }
1560
1561 fn drive_component() -> Component {
1563 let capabilities = serde_json::from_value(json!({
1564 "spin": {
1565 "kind": "motor",
1566 "target": { "kind": "link", "id": "body" },
1567 "command": "velocity",
1568 "gear_ratio": 1.0
1569 },
1570 "eye": {
1571 "kind": "camera",
1572 "target": { "kind": "link", "id": "body" },
1573 "mode": "rgb",
1574 "publish_rate_hz": 30.0,
1575 "width_px": 640,
1576 "height_px": 480
1577 }
1578 }))
1579 .expect("a well-formed capability fixture");
1580 compiler::component(capabilities, component_structure())
1581 }
1582
1583 fn instance(id: &str) -> ComponentInstance {
1584 compiler::component_instance(
1585 ComponentInstanceId::new(id).expect("a normalized instance id"),
1586 ComponentTypeId::new("drive").expect("a normalized type id"),
1587 LinkId::new("base_link"),
1588 BTreeMap::new(),
1589 )
1590 }
1591
1592 fn robot_with_structure(structure: Structure, instance_ids: &[&str]) -> Robot {
1593 compiler::robot(RobotParts {
1594 id: RobotId::new("rover").expect("a normalized robot id"),
1595 clock: Clock::Real,
1596 kinematic: KinematicConfig::Omnidirectional {
1597 actuators: Vec::new(),
1598 encoders: Vec::new(),
1599 },
1600 motion_limits: MotionLimits {
1601 max_linear_speed_mps: 1.0,
1602 max_angular_speed_radps: 1.0,
1603 },
1604 component_instances: instance_ids
1605 .iter()
1606 .map(|id| {
1607 (
1608 ComponentInstanceId::new(*id).expect("a normalized instance id"),
1609 instance(id),
1610 )
1611 })
1612 .collect(),
1613 component_types: [(
1614 ComponentTypeId::new("drive").expect("a normalized type id"),
1615 drive_component(),
1616 )]
1617 .into_iter()
1618 .collect(),
1619 simulation_types: BTreeMap::new(),
1620 structure,
1621 })
1622 .expect("a valid canonical robot")
1623 }
1624
1625 fn robot_with(instance_ids: &[&str]) -> Robot {
1626 robot_with_structure(robot_structure(), instance_ids)
1627 }
1628
1629 #[test]
1635 fn the_declared_robot_shape_is_the_shape_serde_writes() {
1636 use phoxal_runtime_contract::wire_schema::DescribeWire;
1637
1638 for robot in [
1639 robot_with(&["left"]),
1640 robot_with_structure(robot_structure_with_collision(), &[]),
1641 ] {
1642 let json = serde_json::to_value(&robot).expect("a canonical robot serializes");
1643 assert_eq!(Robot::wire_schema().conforms(&json), Ok(()));
1644 }
1645 }
1646
1647 #[test]
1648 fn robot_wire_requires_an_explicit_footprint_value_or_null() {
1649 let robot = robot_with(&[]);
1650 let mut value = serde_json::to_value(&robot).expect("robot serializes");
1651 assert!(value["footprint"].is_null());
1652 value
1653 .as_object_mut()
1654 .expect("robot wire is an object")
1655 .remove("footprint");
1656 assert!(serde_json::from_value::<Robot>(value).is_err());
1657 }
1658
1659 #[test]
1660 fn runtime_deserialize_checks_envelope_invariants_without_rederiving_geometry() {
1661 let robot = robot_with_structure(robot_structure_with_collision(), &[]);
1662 assert_eq!(robot.footprint_envelope().unwrap().radius_m, 0.5);
1663 let mut value = serde_json::to_value(&robot).expect("robot serializes");
1664 value["footprint"]["radius_m"] = json!(0.1);
1665 let decoded: Robot = serde_json::from_value(value).expect("finite stored radius is valid");
1666 assert_eq!(decoded.footprint_envelope().unwrap().radius_m, 0.1);
1667 }
1668
1669 #[test]
1670 fn runtime_role_lists_reject_empty_and_duplicate_assignments() {
1671 let robot = robot_with(&["front"]);
1672 let value = serde_json::to_value(&robot).expect("robot serializes");
1673
1674 let mut empty = value.clone();
1675 empty["component_instances"]["front"]["roles"] = json!({"eye": []});
1676 assert!(serde_json::from_value::<Robot>(empty).is_err());
1677
1678 let mut duplicate = value;
1679 duplicate["component_instances"]["front"]["roles"] =
1680 json!({"eye": ["perception", "perception"]});
1681 assert!(serde_json::from_value::<Robot>(duplicate).is_err());
1682 }
1683
1684 fn reference(component: &str, capability: &str) -> CapabilityRef {
1685 CapabilityRef::new(
1686 ComponentInstanceId::new(component).expect("a normalized instance id"),
1687 CapabilityId::new(capability).expect("a normalized capability id"),
1688 )
1689 }
1690
1691 #[test]
1692 fn selecting_no_capability_yields_nothing() {
1693 let robot = robot_with(&["front", "rear"]);
1694 assert!(
1695 robot
1696 .capability_refs(|capability| matches!(capability, Capability::Lidar(_)))
1697 .is_empty()
1698 );
1699 }
1700
1701 #[test]
1702 fn selection_spans_every_instance_that_declares_the_capability() {
1703 let robot = robot_with(&["front", "rear"]);
1704 let cameras =
1705 robot.capability_refs(|capability| matches!(capability, Capability::Camera(_)));
1706 assert_eq!(
1707 cameras.iter().map(ToString::to_string).collect::<Vec<_>>(),
1708 ["front.eye", "rear.eye"]
1709 );
1710 }
1711
1712 #[test]
1713 fn selection_is_ordered_by_component_then_capability() {
1714 let robot = robot_with(&["rear", "front"]);
1717 let all = robot.capability_refs(|_| true);
1718 assert_eq!(
1719 all.iter().map(ToString::to_string).collect::<Vec<_>>(),
1720 ["front.eye", "front.spin", "rear.eye", "rear.spin"]
1721 );
1722 let mut sorted = all.clone();
1723 sorted.sort();
1724 assert_eq!(all, sorted);
1725 }
1726
1727 #[test]
1728 fn a_routine_lookup_miss_is_absence_not_failure() {
1729 let robot = robot_with(&["front"]);
1730 assert!(robot.component_instance("front").is_some());
1731 assert!(robot.component_instance("nope").is_none());
1732 assert!(robot.component_for_instance("front").is_some());
1733 assert!(robot.component_for_instance("nope").is_none());
1734 assert!(robot.simulation_for_instance("front").is_none());
1735 assert!(robot.capability(&reference("front", "spin")).is_some());
1736 assert!(robot.capability(&reference("front", "nope")).is_none());
1737 assert!(robot.capability(&reference("nope", "spin")).is_none());
1738 }
1739
1740 #[test]
1741 fn requiring_the_wrong_kind_names_both_kinds() {
1742 let robot = robot_with(&["front"]);
1743 let error = robot
1744 .require_motor(&reference("front", "eye"))
1745 .expect_err("a camera is not a motor");
1746 assert!(matches!(
1747 error,
1748 ModelError::CapabilityKindMismatch {
1749 expected: CapabilityKind::Motor,
1750 actual: CapabilityKind::Camera,
1751 ..
1752 }
1753 ));
1754 assert_eq!(
1755 error.to_string(),
1756 "capability 'front.eye' must reference a motor, found camera"
1757 );
1758
1759 let error = robot
1760 .require_encoder(&reference("front", "nope"))
1761 .expect_err("an undeclared capability cannot be required");
1762 assert!(matches!(error, ModelError::UnknownCapability { .. }));
1763 }
1764
1765 #[test]
1766 fn a_link_target_resolves_to_the_namespaced_runtime_frame() {
1767 let robot = robot_with(&["front"]);
1768 assert_eq!(
1769 robot
1770 .link_target_frame(&reference("front", "eye"))
1771 .expect("the camera targets a link"),
1772 LinkId::new("front__body")
1773 );
1774 }
1775
1776 #[test]
1777 fn an_unauthored_direction_sign_defaults_to_forward() {
1778 let robot = robot_with(&["front"]);
1779 let (_, sign) = robot
1780 .require_motor(&reference("front", "spin"))
1781 .expect("the motor resolves");
1782 assert_eq!(sign, 1);
1783 }
1784
1785 #[test]
1786 fn a_motion_limit_must_survive_the_narrowing_to_f32() {
1787 for limits in [
1788 MotionLimits {
1789 max_linear_speed_mps: 0.0,
1790 max_angular_speed_radps: 1.0,
1791 },
1792 MotionLimits {
1793 max_linear_speed_mps: 1.0,
1794 max_angular_speed_radps: f64::MAX,
1795 },
1796 MotionLimits {
1797 max_linear_speed_mps: f64::NAN,
1798 max_angular_speed_radps: 1.0,
1799 },
1800 ] {
1801 assert!(matches!(
1802 limits.validate(),
1803 Err(ModelError::MotionLimit { .. })
1804 ));
1805 }
1806 assert!(
1807 MotionLimits {
1808 max_linear_speed_mps: 1.5,
1809 max_angular_speed_radps: 2.5,
1810 }
1811 .validate()
1812 .is_ok()
1813 );
1814 }
1815}