Skip to main content

phoxal_model/
robot.rs

1//! Canonical immutable robot model.
2
3use 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/// One resolved component instance in the canonical robot.
24#[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    /// Authored purpose(s) for each capability. An absent key means that the
32    /// capability was not selected for any role and creates no obligation.
33    #[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/// Canonical motion facts.
84#[derive(Debug, Clone)]
85pub struct MotionModel {
86    kinematic: KinematicConfig,
87    limits: MotionLimits,
88}
89
90/// The outer envelope every motion command is clamped to.
91#[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/// The drive geometry, and the capabilities that realize it.
102#[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    /// The drive geometry this config describes, with its scalars validated.
142    ///
143    /// This is the one place the authored kinematic fields are turned into
144    /// geometry, so every consumer that derives motion from the robot works from
145    /// the same reading of the document.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`ModelError::KinematicScalar`] when a declared scalar is not
150    /// finite and positive.
151    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/// A planar body twist in the robot's base frame.
182///
183/// `linear_y_mps` is only meaningful for a holonomic geometry. A differential or
184/// Ackermann robot cannot translate sideways at all, so those geometries ignore
185/// it rather than approximating it: silently turning a commanded sideways
186/// velocity into yaw would move the robot somewhere its caller did not ask for.
187#[derive(Debug, Clone, Copy, Default, PartialEq)]
188pub struct BodyTwist {
189    /// Forward velocity, in metres per second.
190    pub linear_x_mps: f64,
191    /// Leftward velocity, in metres per second. Zero for a non-holonomic drive.
192    pub linear_y_mps: f64,
193    /// Yaw rate, in radians per second, positive counter-clockwise.
194    pub angular_z_radps: f64,
195}
196
197impl BodyTwist {
198    /// A twist a non-holonomic drive can realize: forward and yaw only.
199    #[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    /// A full holonomic twist.
209    #[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    /// Whether every component is finite.
219    #[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/// The wheel angular speeds of a differential drive, in radians per second.
228#[derive(Debug, Clone, Copy, PartialEq)]
229pub struct DifferentialWheelSpeeds {
230    pub left_radps: f64,
231    pub right_radps: f64,
232}
233
234/// The wheel angular speeds of a mecanum drive, in radians per second.
235#[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/// What an Ackermann drive is commanded with.
244///
245/// This is a linear speed rather than a wheel angular speed because
246/// [`KinematicConfig::Ackermann`] authors no wheel radius: the document has no
247/// value that could convert one to the other, and inventing one here would put a
248/// number on the wire that nobody authored.
249#[derive(Debug, Clone, Copy, PartialEq)]
250pub struct AckermannCommand {
251    /// Speed of the driven axle, in metres per second.
252    pub drive_speed_mps: f64,
253    /// Steering angle, in radians, positive counter-clockwise.
254    pub steering_angle_rad: f64,
255}
256
257/// The drive geometry of a robot, in the form its kinematics need.
258///
259/// This is the single dispatch point over every geometry [`KinematicConfig`]
260/// can declare. Each variant carries its own geometry type with its own
261/// statically typed wheel commands, because the four do not share a command
262/// shape: a differential drive is commanded with two wheel speeds, a mecanum
263/// with four, and an Ackermann with a speed and a steering angle. Collapsing
264/// them behind one signature would mean either an erased command vector or a
265/// lowest-common-denominator twist, and both lose exactly the information the
266/// caller needs.
267///
268/// Obtained from [`KinematicConfig::drive_kinematics`], which validates the
269/// scalars first, so a value of this type always has usable geometry.
270#[derive(Debug, Clone, Copy, PartialEq)]
271pub enum DriveKinematics {
272    Differential(DifferentialDrive),
273    Mecanum(MecanumDrive),
274    Ackermann(AckermannDrive),
275    /// An omnidirectional drive, whose kinematics are not derivable from the
276    /// authored document.
277    ///
278    /// [`KinematicConfig::Omnidirectional`] carries actuator and encoder lists
279    /// and no geometry at all - no wheel radius, no wheel mounting angles, no
280    /// distance from the rotation centre - and every one of those is required to
281    /// relate wheel speeds to a body twist. The variant is carried here so the
282    /// enum covers every geometry the model can declare, and so a consumer
283    /// matching on it is told the geometry is unavailable rather than silently
284    /// falling through to another drive's math.
285    Omnidirectional,
286}
287
288/// The differential-drive geometry, separated from the capabilities realizing it.
289///
290/// [`KinematicConfig::Differential`] carries the wheel geometry alongside the
291/// actuator and encoder lists, but the two directions of the wheel/twist
292/// relation depend only on the geometry. They live together here because they
293/// are one relation read two ways: a robot whose commanded twist and whose
294/// measured twist disagreed about wheel radius would drive one distance and
295/// report another, and nothing downstream could detect it. Keeping the pair on
296/// one type is what makes them impossible to change independently.
297///
298/// This is a derived value, not part of the canonical document, so it carries no
299/// serde representation.
300#[derive(Debug, Clone, Copy, PartialEq)]
301pub struct DifferentialDrive {
302    /// Driven wheel radius, in metres.
303    pub wheel_radius_m: f64,
304    /// Distance between the driven wheels, in metres.
305    pub wheel_base_m: f64,
306}
307
308impl DifferentialDrive {
309    /// The geometry with the given wheel radius and track width, both in metres.
310    #[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    /// Check the geometry is usable.
319    ///
320    /// Both scalars divide in [`Self::wheel_speeds`] and [`Self::body_twist`],
321    /// so a zero or non-finite value does not fail loudly - it yields an
322    /// infinite or `NaN` wheel command, which is why every consumer must run
323    /// this before deriving anything from the geometry.
324    ///
325    /// # Errors
326    ///
327    /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and
328    /// positive.
329    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    /// The wheel speeds that produce `twist`.
345    ///
346    /// This is the inverse of [`Self::body_twist`]. `twist.linear_y_mps` is
347    /// ignored: a differential drive cannot translate sideways.
348    ///
349    /// It does not reject a non-finite result: what a caller must do about a
350    /// geometry that turns a finite twist into an uncommandable speed depends on
351    /// what it is about to do with it, so that judgment stays with the caller.
352    #[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    /// The body twist a pair of wheel angular speeds implies.
364    ///
365    /// The inverse of [`Self::wheel_speeds`]. `linear_y_mps` is always zero.
366    #[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/// The mecanum-drive geometry: four independently driven wheels with 45-degree
375/// rollers, in the standard X configuration.
376///
377/// Unlike a differential drive this geometry is holonomic, so it realizes
378/// `linear_y_mps` directly rather than ignoring it.
379#[derive(Debug, Clone, Copy, PartialEq)]
380pub struct MecanumDrive {
381    /// Driven wheel radius, in metres.
382    pub wheel_radius_m: f64,
383    /// Front-to-rear axle separation, in metres.
384    pub wheel_base_m: f64,
385    /// Left-to-right wheel separation, in metres.
386    pub track_m: f64,
387}
388
389impl MecanumDrive {
390    /// The geometry with the given wheel radius, wheel base and track, in metres.
391    #[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    /// Half the wheel base plus half the track: the lever arm that converts yaw
401    /// rate into the differential wheel speed a mecanum uses to rotate.
402    const fn yaw_lever_m(self) -> f64 {
403        (self.wheel_base_m + self.track_m) / 2.0
404    }
405
406    /// Check the geometry is usable.
407    ///
408    /// # Errors
409    ///
410    /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and
411    /// positive.
412    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    /// The four wheel speeds that produce `twist`.
429    ///
430    /// The inverse of [`Self::body_twist`].
431    #[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    /// The body twist four wheel angular speeds imply.
444    ///
445    /// The inverse of [`Self::wheel_speeds`]. Four wheel speeds over-determine a
446    /// three-component twist, so this is the least-squares solution: a set of
447    /// speeds that no rigid twist can produce (the wheels fighting each other)
448    /// yields the twist closest to what they describe rather than an error.
449    #[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/// The Ackermann-steering geometry: one steered axle and one driven axle.
466///
467/// The relation used is the bicycle model taken at the centre of the driven
468/// axle, which is what a single steering actuator can express. `track_m` is
469/// carried because the authored document declares it, but a true per-wheel
470/// Ackermann split needs two independently steered wheels, which this config
471/// does not describe.
472#[derive(Debug, Clone, Copy, PartialEq)]
473pub struct AckermannDrive {
474    /// Front-to-rear axle separation, in metres.
475    pub wheel_base_m: f64,
476    /// Left-to-right wheel separation, in metres.
477    pub track_m: f64,
478    /// The largest steering angle the mechanism reaches, in radians.
479    pub max_steering_angle_rad: f64,
480}
481
482impl AckermannDrive {
483    /// The geometry with the given wheel base, track and steering limit.
484    #[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    /// Check the geometry is usable.
494    ///
495    /// # Errors
496    ///
497    /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and
498    /// positive.
499    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    /// The drive speed and steering angle that produce `twist`.
519    ///
520    /// The inverse of [`Self::body_twist`]. `twist.linear_y_mps` is ignored: a
521    /// steered drive cannot translate sideways.
522    ///
523    /// A stationary robot has no steering angle that produces yaw, so a zero
524    /// forward speed yields a zero steering angle. The returned angle is **not**
525    /// clamped to [`Self::max_steering_angle_rad`]: a caller that must refuse an
526    /// unreachable request needs to see that it was unreachable, which
527    /// [`Self::steering_is_reachable`] answers.
528    #[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    /// The body twist a drive speed and steering angle imply.
542    ///
543    /// The inverse of [`Self::command`]. `linear_y_mps` is always zero.
544    #[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    /// Whether the mechanism can actually reach `steering_angle_rad`.
553    #[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/// Which drive geometry a [`KinematicConfig`] describes.
560#[derive(Clone, Copy, Debug, PartialEq, Eq)]
561pub enum KinematicKind {
562    Differential,
563    Mecanum,
564    Ackermann,
565    Omnidirectional,
566}
567
568/// Fully normalized runtime-facing robot model.
569#[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    /// Compiler-derived stock-safety facts. `None` is explicitly persisted
579    /// when the authored robot has no collision geometry.
580    footprint: Option<FootprintEnvelope>,
581}
582
583/// The canonical robot wire shape used by a persisted runtime document.
584///
585/// This is intentionally private to the model crate: bundle layout and the
586/// schema tag belong to `phoxal-bundle`, while the model owns the exact fields
587/// and the validation that turns them into a `Robot`. Keeping the wire helper
588/// here also means deserialization can never construct an invalid robot by
589/// bypassing [`Robot::new`].
590#[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/// A required wire field whose value may be `null`.
605///
606/// `Option<T>` is normally permissive in a derived serde struct: both a
607/// missing key and `null` become `None`. Runtime documents need to distinguish
608/// them so every persisted robot says explicitly whether a footprint exists.
609#[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    // Invariant: the `Serialize` above builds a `RobotWire` and writes that, so
631    // the wire helper's shape is the whole of what a persisted robot is.
632    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    /// The robot link this instance is rigidly mounted on.
688    #[must_use]
689    pub const fn mount_link(&self) -> &LinkId {
690        &self.mount_link
691    }
692
693    /// Authored role assignments, ordered by capability and role.
694    #[must_use]
695    pub const fn roles(&self) -> &BTreeMap<CapabilityId, BTreeSet<CapabilityRole>> {
696        &self.roles
697    }
698
699    /// Whether this instance assigns `role` to the named capability.
700    #[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    /// Check the envelope is usable.
722    ///
723    /// # Errors
724    ///
725    /// Returns [`ModelError::MotionLimit`] when a limit is not finite,
726    /// positive, and representable as `f32`.
727    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    /// Which drive geometry this configuration describes.
748    #[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    /// The time domain this robot runs on.
795    #[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    /// Every mounted component instance, ordered by instance id.
806    pub fn components(&self) -> impl ExactSizeIterator<Item = &ComponentInstance> {
807        self.component_instances.values()
808    }
809
810    /// Every mounted instance's identity, ordered.
811    pub fn component_ids(&self) -> impl ExactSizeIterator<Item = &ComponentInstanceId> {
812        self.component_instances.keys()
813    }
814
815    /// The named instance, if the robot mounts one.
816    #[must_use]
817    pub fn component_instance(&self, id: &str) -> Option<&ComponentInstance> {
818        self.component_instances.get(id)
819    }
820
821    /// The component type behind the named instance.
822    ///
823    /// Absent exactly when the instance is: a validated robot never mounts an
824    /// instance of a type it did not load.
825    #[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    /// The simulation for a component *type*, when one was authored.
832    #[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    /// The simulation behind the named instance, when one was authored.
838    #[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    /// The robot's own structure, in flattened runtime identities.
845    #[must_use]
846    pub const fn structure(&self) -> &Structure {
847        &self.structure
848    }
849
850    /// The persisted compiler-derived stock-safety envelope, if available.
851    #[must_use]
852    pub const fn footprint_envelope(&self) -> Option<FootprintEnvelope> {
853        self.footprint
854    }
855
856    /// The referenced capability, if the robot declares it.
857    #[must_use]
858    pub fn capability(&self, reference: &CapabilityRef) -> Option<&Capability> {
859        self.resolve(reference).map(|(_, capability)| capability)
860    }
861
862    /// Every declared capability matching `selects`.
863    ///
864    /// The result is ordered by `(component id, capability id)`. That order is
865    /// an invariant, not an accident of iteration: participants derive bus
866    /// identities and arbitration priorities from these positions, so two runs
867    /// over the same robot must produce the same sequence.
868    ///
869    /// This is infallible because a `Robot` value cannot exist with an instance
870    /// whose component type is absent: validation rejects that with
871    /// [`ModelError::UnknownComponentType`] before the value is constructed. The
872    /// unresolved arm below is therefore unreachable rather than a capability
873    /// being quietly dropped, and it stays a skip rather than a panic so that
874    /// the impossible case degrades into a smaller result set instead of taking
875    /// the process down.
876    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    /// Every capability assigned the given authored role, ordered by
900    /// `(component id, capability id)`.
901    #[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    /// The referenced motor and the direction sign to apply to it.
926    ///
927    /// # Errors
928    ///
929    /// Returns [`ModelError::UnknownCapability`] when the robot does not
930    /// declare the capability, and [`ModelError::CapabilityKindMismatch`] when
931    /// it declares something other than a motor.
932    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    /// The referenced encoder and the direction sign to apply to it.
945    ///
946    /// # Errors
947    ///
948    /// Returns [`ModelError::UnknownCapability`] when the robot does not
949    /// declare the capability, and [`ModelError::CapabilityKindMismatch`] when
950    /// it declares something other than an encoder.
951    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    /// The namespaced runtime frame for a capability's component-local link.
964    ///
965    /// # Errors
966    ///
967    /// Returns [`ModelError::CapabilityTargetKind`] when the capability is
968    /// attached to a joint rather than a link, and
969    /// [`ModelError::UnknownBoundTarget`] when its component type has no such
970    /// link.
971    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    /// The authored direction sign, defaulting to `1` when none was authored.
1007    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    /// The robot's own structure carries flattened identities, so no authored
1029    /// robot link or joint may already contain the namespacing separator.
1030    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    /// Validate only the envelope's universal scalar invariant.
1077    ///
1078    /// Collision geometry is authored source and is deliberately unavailable
1079    /// to a runtime document. Its conservative envelope is derived once by the
1080    /// source compiler, then persisted as a value or explicit `null`.
1081    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        // The geometry scalars are checked by the one reader that turns them
1170        // into geometry, so this cannot drift from what consumers actually get.
1171        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    /// Reject an identifier that already carries the namespacing separator,
1232    /// which would make the flattened runtime identity ambiguous.
1233    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    /// Reject a joint whose kind the runtime has no controller for.
1244    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    /// Forward and inverse are one relation read two ways. A twist that survives
1283    /// the round trip is the property that matters: if the two ever disagreed, a
1284    /// robot would drive one distance and report another, and nothing downstream
1285    /// could detect it.
1286    #[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    /// Strafing left is the motion a differential drive cannot make, so it is
1348    /// the one that proves the mecanum roller signs are right: the diagonal
1349    /// pairs must counter-rotate.
1350    #[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    /// A non-holonomic geometry ignores sway rather than approximating it, so a
1363    /// sideways request must not leak into the wheels.
1364    #[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    /// A stationary robot has no steering angle that produces yaw, so asking for
1376    /// one must not divide by zero into a `NaN` the caller would then command.
1377    #[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        // An omnidirectional document authors actuators and encoders but no
1443        // geometry, so there is nothing to resolve and the variant says so
1444        // rather than borrowing another drive's math.
1445        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    /// A component type declaring one motor and one camera, both on `body`.
1562    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    /// `Robot` and `Structure` both write through a private wire helper their
1630    /// own declarations do not predict, so the declared shape is checked
1631    /// against a real serialized robot rather than asserted. This is the whole
1632    /// canonical model - components, capabilities, structure, footprint - so a
1633    /// type anywhere below it whose shape drifted fails here.
1634    #[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        // Instances are supplied in reverse order on purpose: the ordering is
1715        // an invariant of the result, not of the input.
1716        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}