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, ServiceId,
18};
19use crate::simulation::Simulation;
20use crate::structure::{Joint, JointKind, Structure};
21
22/// One service this robot runs.
23///
24/// Official and user services alike: presence in [`Robot::services`] is what
25/// declares the service, and the config is the only thing that distinguishes
26/// one entry from another. The config stays an opaque JSON value because its
27/// shape belongs to the service binary, which validates it against the schema it
28/// embeds; the model carries it without an opinion.
29#[derive(phoxal_macros::DescribeWire, Debug, Clone, serde::Serialize, serde::Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct Service {
32    config: Option<serde_json::Value>,
33}
34
35impl Service {
36    pub(crate) const fn new(config: Option<serde_json::Value>) -> Self {
37        Self { config }
38    }
39
40    /// The user-owned configuration this service is launched with.
41    #[must_use]
42    pub const fn config(&self) -> Option<&serde_json::Value> {
43        self.config.as_ref()
44    }
45}
46
47/// One mounted component instance in the canonical robot.
48///
49/// The instance is keyed by its own id in [`Robot::components`], so it carries
50/// no copy of that id: the map is the identity.
51#[derive(phoxal_macros::DescribeWire, Debug, Clone, serde::Serialize)]
52#[serde(deny_unknown_fields)]
53pub struct ComponentInstance {
54    #[serde(rename = "type")]
55    component_type: ComponentTypeId,
56    mount_link: LinkId,
57    /// The hardware connection block, present exactly when this instance is
58    /// driven by a component driver. It is the driver participant's own
59    /// configuration, opaque here for the same reason a service config is.
60    driver: Option<serde_json::Value>,
61    direction_signs: BTreeMap<CapabilityId, i8>,
62    /// Authored purpose(s) for each capability. An absent key means that the
63    /// capability was not selected for any role and creates no obligation.
64    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
65    roles: BTreeMap<CapabilityId, BTreeSet<CapabilityRole>>,
66}
67
68impl<'de> serde::Deserialize<'de> for ComponentInstance {
69    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
70        #[derive(serde::Deserialize)]
71        #[serde(deny_unknown_fields)]
72        struct Wire {
73            #[serde(rename = "type")]
74            component_type: ComponentTypeId,
75            mount_link: LinkId,
76            driver: Option<serde_json::Value>,
77            direction_signs: BTreeMap<CapabilityId, i8>,
78            #[serde(default)]
79            roles: BTreeMap<CapabilityId, Vec<CapabilityRole>>,
80        }
81
82        let wire = Wire::deserialize(deserializer)?;
83        let mut roles = BTreeMap::new();
84        for (capability_id, authored) in wire.roles {
85            if authored.is_empty() {
86                return Err(serde::de::Error::custom(ModelError::EmptyCapabilityRoles {
87                    capability_id,
88                }));
89            }
90            let mut canonical = BTreeSet::new();
91            for role in authored {
92                if !canonical.insert(role) {
93                    return Err(serde::de::Error::custom(
94                        ModelError::DuplicateCapabilityRole {
95                            capability_id,
96                            role,
97                        },
98                    ));
99                }
100            }
101            roles.insert(capability_id, canonical);
102        }
103        Ok(Self::new(
104            wire.component_type,
105            wire.mount_link,
106            wire.direction_signs,
107            roles,
108            wire.driver,
109        ))
110    }
111}
112
113/// Canonical motion facts.
114#[derive(Debug, Clone)]
115pub struct MotionModel {
116    kinematic: KinematicConfig,
117    limits: MotionLimits,
118}
119
120/// The outer envelope every motion command is clamped to.
121#[derive(
122    phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq,
123)]
124#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
125#[serde(deny_unknown_fields)]
126pub struct MotionLimits {
127    pub max_linear_speed_mps: f64,
128    pub max_angular_speed_radps: f64,
129}
130
131/// The drive geometry, and the capabilities that realize it.
132#[derive(
133    phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq,
134)]
135#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
136#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
137pub enum KinematicConfig {
138    Differential {
139        left_actuators: Vec<CapabilityRef>,
140        right_actuators: Vec<CapabilityRef>,
141        left_encoders: Vec<CapabilityRef>,
142        right_encoders: Vec<CapabilityRef>,
143        wheel_radius_m: f64,
144        wheel_base_m: f64,
145    },
146    Mecanum {
147        front_left_actuator: CapabilityRef,
148        front_right_actuator: CapabilityRef,
149        rear_left_actuator: CapabilityRef,
150        rear_right_actuator: CapabilityRef,
151        wheel_radius_m: f64,
152        wheel_base_m: f64,
153        track_m: f64,
154    },
155    Ackermann {
156        steering_actuator: CapabilityRef,
157        drive_actuator: CapabilityRef,
158        steering_encoder: Option<CapabilityRef>,
159        drive_encoder: Option<CapabilityRef>,
160        wheel_base_m: f64,
161        track_m: f64,
162        max_steering_angle_rad: f64,
163    },
164    Omnidirectional {
165        actuators: Vec<CapabilityRef>,
166        encoders: Vec<CapabilityRef>,
167    },
168}
169
170impl KinematicConfig {
171    /// The drive geometry this config describes, with its scalars validated.
172    ///
173    /// This is the one place the authored kinematic fields are turned into
174    /// geometry, so every consumer that derives motion from the robot works from
175    /// the same reading of the document.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`ModelError::KinematicScalar`] when a declared scalar is not
180    /// finite and positive.
181    pub fn drive_kinematics(&self) -> Result<DriveKinematics, ModelError> {
182        Ok(match self {
183            Self::Differential {
184                wheel_radius_m,
185                wheel_base_m,
186                ..
187            } => DriveKinematics::Differential(
188                DifferentialDrive::new(*wheel_radius_m, *wheel_base_m).validate()?,
189            ),
190            Self::Mecanum {
191                wheel_radius_m,
192                wheel_base_m,
193                track_m,
194                ..
195            } => DriveKinematics::Mecanum(
196                MecanumDrive::new(*wheel_radius_m, *wheel_base_m, *track_m).validate()?,
197            ),
198            Self::Ackermann {
199                wheel_base_m,
200                track_m,
201                max_steering_angle_rad,
202                ..
203            } => DriveKinematics::Ackermann(
204                AckermannDrive::new(*wheel_base_m, *track_m, *max_steering_angle_rad).validate()?,
205            ),
206            Self::Omnidirectional { .. } => DriveKinematics::Omnidirectional,
207        })
208    }
209}
210
211/// A planar body twist in the robot's base frame.
212///
213/// `linear_y_mps` is only meaningful for a holonomic geometry. A differential or
214/// Ackermann robot cannot translate sideways at all, so those geometries ignore
215/// it rather than approximating it: silently turning a commanded sideways
216/// velocity into yaw would move the robot somewhere its caller did not ask for.
217#[derive(Debug, Clone, Copy, Default, PartialEq)]
218pub struct BodyTwist {
219    /// Forward velocity, in metres per second.
220    pub linear_x_mps: f64,
221    /// Leftward velocity, in metres per second. Zero for a non-holonomic drive.
222    pub linear_y_mps: f64,
223    /// Yaw rate, in radians per second, positive counter-clockwise.
224    pub angular_z_radps: f64,
225}
226
227impl BodyTwist {
228    /// A twist a non-holonomic drive can realize: forward and yaw only.
229    #[must_use]
230    pub const fn planar(linear_x_mps: f64, angular_z_radps: f64) -> Self {
231        Self {
232            linear_x_mps,
233            linear_y_mps: 0.0,
234            angular_z_radps,
235        }
236    }
237
238    /// A full holonomic twist.
239    #[must_use]
240    pub const fn new(linear_x_mps: f64, linear_y_mps: f64, angular_z_radps: f64) -> Self {
241        Self {
242            linear_x_mps,
243            linear_y_mps,
244            angular_z_radps,
245        }
246    }
247
248    /// Whether every component is finite.
249    #[must_use]
250    pub fn is_finite(&self) -> bool {
251        self.linear_x_mps.is_finite()
252            && self.linear_y_mps.is_finite()
253            && self.angular_z_radps.is_finite()
254    }
255}
256
257/// The wheel angular speeds of a differential drive, in radians per second.
258#[derive(Debug, Clone, Copy, PartialEq)]
259pub struct DifferentialWheelSpeeds {
260    pub left_radps: f64,
261    pub right_radps: f64,
262}
263
264/// The wheel angular speeds of a mecanum drive, in radians per second.
265#[derive(Debug, Clone, Copy, PartialEq)]
266pub struct MecanumWheelSpeeds {
267    pub front_left_radps: f64,
268    pub front_right_radps: f64,
269    pub rear_left_radps: f64,
270    pub rear_right_radps: f64,
271}
272
273/// What an Ackermann drive is commanded with.
274///
275/// This is a linear speed rather than a wheel angular speed because
276/// [`KinematicConfig::Ackermann`] authors no wheel radius: the document has no
277/// value that could convert one to the other, and inventing one here would put a
278/// number on the wire that nobody authored.
279#[derive(Debug, Clone, Copy, PartialEq)]
280pub struct AckermannCommand {
281    /// Speed of the driven axle, in metres per second.
282    pub drive_speed_mps: f64,
283    /// Steering angle, in radians, positive counter-clockwise.
284    pub steering_angle_rad: f64,
285}
286
287/// The drive geometry of a robot, in the form its kinematics need.
288///
289/// This is the single dispatch point over every geometry [`KinematicConfig`]
290/// can declare. Each variant carries its own geometry type with its own
291/// statically typed wheel commands, because the four do not share a command
292/// shape: a differential drive is commanded with two wheel speeds, a mecanum
293/// with four, and an Ackermann with a speed and a steering angle. Collapsing
294/// them behind one signature would mean either an erased command vector or a
295/// lowest-common-denominator twist, and both lose exactly the information the
296/// caller needs.
297///
298/// Obtained from [`KinematicConfig::drive_kinematics`], which validates the
299/// scalars first, so a value of this type always has usable geometry.
300#[derive(Debug, Clone, Copy, PartialEq)]
301pub enum DriveKinematics {
302    Differential(DifferentialDrive),
303    Mecanum(MecanumDrive),
304    Ackermann(AckermannDrive),
305    /// An omnidirectional drive, whose kinematics are not derivable from the
306    /// authored document.
307    ///
308    /// [`KinematicConfig::Omnidirectional`] carries actuator and encoder lists
309    /// and no geometry at all - no wheel radius, no wheel mounting angles, no
310    /// distance from the rotation centre - and every one of those is required to
311    /// relate wheel speeds to a body twist. The variant is carried here so the
312    /// enum covers every geometry the model can declare, and so a consumer
313    /// matching on it is told the geometry is unavailable rather than silently
314    /// falling through to another drive's math.
315    Omnidirectional,
316}
317
318/// The differential-drive geometry, separated from the capabilities realizing it.
319///
320/// [`KinematicConfig::Differential`] carries the wheel geometry alongside the
321/// actuator and encoder lists, but the two directions of the wheel/twist
322/// relation depend only on the geometry. They live together here because they
323/// are one relation read two ways: a robot whose commanded twist and whose
324/// measured twist disagreed about wheel radius would drive one distance and
325/// report another, and nothing downstream could detect it. Keeping the pair on
326/// one type is what makes them impossible to change independently.
327///
328/// This is a derived value, not part of the canonical document, so it carries no
329/// serde representation.
330#[derive(Debug, Clone, Copy, PartialEq)]
331pub struct DifferentialDrive {
332    /// Driven wheel radius, in metres.
333    pub wheel_radius_m: f64,
334    /// Distance between the driven wheels, in metres.
335    pub wheel_base_m: f64,
336}
337
338impl DifferentialDrive {
339    /// The geometry with the given wheel radius and track width, both in metres.
340    #[must_use]
341    pub const fn new(wheel_radius_m: f64, wheel_base_m: f64) -> Self {
342        Self {
343            wheel_radius_m,
344            wheel_base_m,
345        }
346    }
347
348    /// Check the geometry is usable.
349    ///
350    /// Both scalars divide in [`Self::wheel_speeds`] and [`Self::body_twist`],
351    /// so a zero or non-finite value does not fail loudly - it yields an
352    /// infinite or `NaN` wheel command, which is why every consumer must run
353    /// this before deriving anything from the geometry.
354    ///
355    /// # Errors
356    ///
357    /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and
358    /// positive.
359    pub fn validate(self) -> Result<Self, ModelError> {
360        for (value, field) in [
361            (self.wheel_radius_m, KinematicScalarField::WheelRadiusM),
362            (self.wheel_base_m, KinematicScalarField::WheelBaseM),
363        ] {
364            if !(value.is_finite() && value > 0.0) {
365                return Err(ModelError::KinematicScalar {
366                    kinematics: KinematicKind::Differential,
367                    field,
368                });
369            }
370        }
371        Ok(self)
372    }
373
374    /// The wheel speeds that produce `twist`.
375    ///
376    /// This is the inverse of [`Self::body_twist`]. `twist.linear_y_mps` is
377    /// ignored: a differential drive cannot translate sideways.
378    ///
379    /// It does not reject a non-finite result: what a caller must do about a
380    /// geometry that turns a finite twist into an uncommandable speed depends on
381    /// what it is about to do with it, so that judgment stays with the caller.
382    #[must_use]
383    pub fn wheel_speeds(self, twist: BodyTwist) -> DifferentialWheelSpeeds {
384        let half_track = self.wheel_base_m / 2.0;
385        let left = twist.linear_x_mps - twist.angular_z_radps * half_track;
386        let right = twist.linear_x_mps + twist.angular_z_radps * half_track;
387        DifferentialWheelSpeeds {
388            left_radps: left / self.wheel_radius_m,
389            right_radps: right / self.wheel_radius_m,
390        }
391    }
392
393    /// The body twist a pair of wheel angular speeds implies.
394    ///
395    /// The inverse of [`Self::wheel_speeds`]. `linear_y_mps` is always zero.
396    #[must_use]
397    pub fn body_twist(self, speeds: DifferentialWheelSpeeds) -> BodyTwist {
398        let left = speeds.left_radps * self.wheel_radius_m;
399        let right = speeds.right_radps * self.wheel_radius_m;
400        BodyTwist::planar((left + right) / 2.0, (right - left) / self.wheel_base_m)
401    }
402}
403
404/// The mecanum-drive geometry: four independently driven wheels with 45-degree
405/// rollers, in the standard X configuration.
406///
407/// Unlike a differential drive this geometry is holonomic, so it realizes
408/// `linear_y_mps` directly rather than ignoring it.
409#[derive(Debug, Clone, Copy, PartialEq)]
410pub struct MecanumDrive {
411    /// Driven wheel radius, in metres.
412    pub wheel_radius_m: f64,
413    /// Front-to-rear axle separation, in metres.
414    pub wheel_base_m: f64,
415    /// Left-to-right wheel separation, in metres.
416    pub track_m: f64,
417}
418
419impl MecanumDrive {
420    /// The geometry with the given wheel radius, wheel base and track, in metres.
421    #[must_use]
422    pub const fn new(wheel_radius_m: f64, wheel_base_m: f64, track_m: f64) -> Self {
423        Self {
424            wheel_radius_m,
425            wheel_base_m,
426            track_m,
427        }
428    }
429
430    /// Half the wheel base plus half the track: the lever arm that converts yaw
431    /// rate into the differential wheel speed a mecanum uses to rotate.
432    const fn yaw_lever_m(self) -> f64 {
433        (self.wheel_base_m + self.track_m) / 2.0
434    }
435
436    /// Check the geometry is usable.
437    ///
438    /// # Errors
439    ///
440    /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and
441    /// positive.
442    pub fn validate(self) -> Result<Self, ModelError> {
443        for (value, field) in [
444            (self.wheel_radius_m, KinematicScalarField::WheelRadiusM),
445            (self.wheel_base_m, KinematicScalarField::WheelBaseM),
446            (self.track_m, KinematicScalarField::TrackM),
447        ] {
448            if !(value.is_finite() && value > 0.0) {
449                return Err(ModelError::KinematicScalar {
450                    kinematics: KinematicKind::Mecanum,
451                    field,
452                });
453            }
454        }
455        Ok(self)
456    }
457
458    /// The four wheel speeds that produce `twist`.
459    ///
460    /// The inverse of [`Self::body_twist`].
461    #[must_use]
462    pub fn wheel_speeds(self, twist: BodyTwist) -> MecanumWheelSpeeds {
463        let yaw = twist.angular_z_radps * self.yaw_lever_m();
464        let scale = 1.0 / self.wheel_radius_m;
465        MecanumWheelSpeeds {
466            front_left_radps: scale * (twist.linear_x_mps - twist.linear_y_mps - yaw),
467            front_right_radps: scale * (twist.linear_x_mps + twist.linear_y_mps + yaw),
468            rear_left_radps: scale * (twist.linear_x_mps + twist.linear_y_mps - yaw),
469            rear_right_radps: scale * (twist.linear_x_mps - twist.linear_y_mps + yaw),
470        }
471    }
472
473    /// The body twist four wheel angular speeds imply.
474    ///
475    /// The inverse of [`Self::wheel_speeds`]. Four wheel speeds over-determine a
476    /// three-component twist, so this is the least-squares solution: a set of
477    /// speeds that no rigid twist can produce (the wheels fighting each other)
478    /// yields the twist closest to what they describe rather than an error.
479    #[must_use]
480    pub fn body_twist(self, speeds: MecanumWheelSpeeds) -> BodyTwist {
481        let MecanumWheelSpeeds {
482            front_left_radps: fl,
483            front_right_radps: fr,
484            rear_left_radps: rl,
485            rear_right_radps: rr,
486        } = speeds;
487        BodyTwist::new(
488            (fl + fr + rl + rr) * self.wheel_radius_m / 4.0,
489            (-fl + fr + rl - rr) * self.wheel_radius_m / 4.0,
490            (-fl + fr - rl + rr) * self.wheel_radius_m / (4.0 * self.yaw_lever_m()),
491        )
492    }
493}
494
495/// The Ackermann-steering geometry: one steered axle and one driven axle.
496///
497/// The relation used is the bicycle model taken at the centre of the driven
498/// axle, which is what a single steering actuator can express. `track_m` is
499/// carried because the authored document declares it, but a true per-wheel
500/// Ackermann split needs two independently steered wheels, which this config
501/// does not describe.
502#[derive(Debug, Clone, Copy, PartialEq)]
503pub struct AckermannDrive {
504    /// Front-to-rear axle separation, in metres.
505    pub wheel_base_m: f64,
506    /// Left-to-right wheel separation, in metres.
507    pub track_m: f64,
508    /// The largest steering angle the mechanism reaches, in radians.
509    pub max_steering_angle_rad: f64,
510}
511
512impl AckermannDrive {
513    /// The geometry with the given wheel base, track and steering limit.
514    #[must_use]
515    pub const fn new(wheel_base_m: f64, track_m: f64, max_steering_angle_rad: f64) -> Self {
516        Self {
517            wheel_base_m,
518            track_m,
519            max_steering_angle_rad,
520        }
521    }
522
523    /// Check the geometry is usable.
524    ///
525    /// # Errors
526    ///
527    /// Returns [`ModelError::KinematicScalar`] when a scalar is not finite and
528    /// positive.
529    pub fn validate(self) -> Result<Self, ModelError> {
530        for (value, field) in [
531            (self.wheel_base_m, KinematicScalarField::WheelBaseM),
532            (self.track_m, KinematicScalarField::TrackM),
533            (
534                self.max_steering_angle_rad,
535                KinematicScalarField::MaxSteeringAngleRad,
536            ),
537        ] {
538            if !(value.is_finite() && value > 0.0) {
539                return Err(ModelError::KinematicScalar {
540                    kinematics: KinematicKind::Ackermann,
541                    field,
542                });
543            }
544        }
545        Ok(self)
546    }
547
548    /// The drive speed and steering angle that produce `twist`.
549    ///
550    /// The inverse of [`Self::body_twist`]. `twist.linear_y_mps` is ignored: a
551    /// steered drive cannot translate sideways.
552    ///
553    /// A stationary robot has no steering angle that produces yaw, so a zero
554    /// forward speed yields a zero steering angle. The returned angle is **not**
555    /// clamped to [`Self::max_steering_angle_rad`]: a caller that must refuse an
556    /// unreachable request needs to see that it was unreachable, which
557    /// [`Self::steering_is_reachable`] answers.
558    #[must_use]
559    pub fn command(self, twist: BodyTwist) -> AckermannCommand {
560        let steering_angle_rad = if twist.linear_x_mps == 0.0 {
561            0.0
562        } else {
563            (twist.angular_z_radps * self.wheel_base_m / twist.linear_x_mps).atan()
564        };
565        AckermannCommand {
566            drive_speed_mps: twist.linear_x_mps,
567            steering_angle_rad,
568        }
569    }
570
571    /// The body twist a drive speed and steering angle imply.
572    ///
573    /// The inverse of [`Self::command`]. `linear_y_mps` is always zero.
574    #[must_use]
575    pub fn body_twist(self, command: AckermannCommand) -> BodyTwist {
576        BodyTwist::planar(
577            command.drive_speed_mps,
578            command.drive_speed_mps * command.steering_angle_rad.tan() / self.wheel_base_m,
579        )
580    }
581
582    /// Whether the mechanism can actually reach `steering_angle_rad`.
583    #[must_use]
584    pub fn steering_is_reachable(self, steering_angle_rad: f64) -> bool {
585        steering_angle_rad.abs() <= self.max_steering_angle_rad
586    }
587}
588
589/// Which drive geometry a [`KinematicConfig`] describes.
590#[derive(Clone, Copy, Debug, PartialEq, Eq)]
591pub enum KinematicKind {
592    Differential,
593    Mecanum,
594    Ackermann,
595    Omnidirectional,
596}
597
598/// Fully normalized runtime-facing robot model.
599///
600/// This is the whole of what `manifest.json` carries: the robot's identity and
601/// structure, the motion it may make, the services it runs, and the components
602/// it mounts together with the types behind them. Everything a launched
603/// participant needs to know about the robot - including its own configuration -
604/// is read from here, so there is no second persisted document to agree with.
605#[derive(Debug, Clone)]
606pub struct Robot {
607    id: RobotId,
608    motion: MotionModel,
609    services: BTreeMap<ServiceId, Service>,
610    components: BTreeMap<ComponentInstanceId, ComponentInstance>,
611    component_types: BTreeMap<ComponentTypeId, Component>,
612    structure: Structure,
613    /// Compiler-derived stock-safety facts. `None` is explicitly persisted
614    /// when the authored robot has no collision geometry.
615    footprint: Option<FootprintEnvelope>,
616}
617
618/// The canonical robot wire shape used by the persisted manifest.
619///
620/// This is intentionally private to the model crate: bundle layout belongs to
621/// `phoxal-bundle`, while the model owns the exact fields and the validation
622/// that turns them into a `Robot`. Keeping the wire helper here also means
623/// deserialization can never construct an invalid robot by bypassing
624/// [`Robot::new`].
625#[derive(phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize)]
626#[serde(deny_unknown_fields)]
627struct RobotWire {
628    id: RobotId,
629    kinematic: KinematicConfig,
630    motion_limits: MotionLimits,
631    services: BTreeMap<ServiceId, Service>,
632    components: BTreeMap<ComponentInstanceId, ComponentInstance>,
633    component_types: BTreeMap<ComponentTypeId, Component>,
634    structure: Structure,
635    footprint: PersistedFootprint,
636}
637
638/// A required wire field whose value may be `null`.
639///
640/// `Option<T>` is normally permissive in a derived serde struct: both a
641/// missing key and `null` become `None`. The manifest needs to distinguish
642/// them so every persisted robot says explicitly whether a footprint exists.
643#[derive(phoxal_macros::DescribeWire, serde::Serialize, serde::Deserialize)]
644struct PersistedFootprint(Option<FootprintEnvelope>);
645
646impl serde::Serialize for Robot {
647    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
648        RobotWire {
649            id: self.id.clone(),
650            kinematic: self.motion.kinematic.clone(),
651            motion_limits: self.motion.limits,
652            services: self.services.clone(),
653            components: self.components.clone(),
654            component_types: self.component_types.clone(),
655            structure: self.structure.clone(),
656            footprint: PersistedFootprint(self.footprint),
657        }
658        .serialize(serializer)
659    }
660}
661
662impl phoxal_runtime_contract::wire_schema::DescribeWire for Robot {
663    // Invariant: the `Serialize` above builds a `RobotWire` and writes that, so
664    // the wire helper's shape is the whole of what a persisted robot is.
665    fn wire_schema() -> phoxal_runtime_contract::wire_schema::WireSchema {
666        phoxal_runtime_contract::wire_schema::WireSchema::opaque(
667            "Robot",
668            <RobotWire as phoxal_runtime_contract::wire_schema::DescribeWire>::wire_schema(),
669        )
670    }
671}
672
673impl<'de> serde::Deserialize<'de> for Robot {
674    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
675        let wire = RobotWire::deserialize(deserializer)?;
676        Self::new(
677            RobotParts {
678                id: wire.id,
679                kinematic: wire.kinematic,
680                motion_limits: wire.motion_limits,
681                services: wire.services,
682                components: wire.components,
683                component_types: wire.component_types,
684                structure: wire.structure,
685            },
686            wire.footprint.0,
687        )
688        .map_err(serde::de::Error::custom)
689    }
690}
691
692/// One mounted component, read as the single fact it is: the instance, the type
693/// behind it, and how a simulated world models that type.
694///
695/// This exists so no consumer joins [`Robot::components`] and
696/// [`Robot::component_types`] by hand. Joining them is the one lookup every
697/// participant, the supervisor and the simulator all need, and doing it in three
698/// places is three chances to disagree about what an absent type means.
699#[derive(Clone, Copy, Debug)]
700pub struct ComponentView<'a> {
701    id: &'a ComponentInstanceId,
702    instance: &'a ComponentInstance,
703    component_type: &'a Component,
704}
705
706impl<'a> ComponentView<'a> {
707    /// The mounted instance's identity.
708    #[must_use]
709    pub const fn id(&self) -> &'a ComponentInstanceId {
710        self.id
711    }
712
713    /// The mounted instance: its type, mount, driver block and per-capability
714    /// parameters.
715    #[must_use]
716    pub const fn instance(&self) -> &'a ComponentInstance {
717        self.instance
718    }
719
720    /// The component type this instance mounts.
721    #[must_use]
722    pub const fn component_type(&self) -> &'a Component {
723        self.component_type
724    }
725
726    /// How a simulated world models this component, when a document authored a
727    /// simulation for its type.
728    #[must_use]
729    pub const fn simulation(&self) -> Option<&'a Simulation> {
730        self.component_type.simulation()
731    }
732}
733
734impl ComponentInstance {
735    pub(crate) const fn new(
736        component_type: ComponentTypeId,
737        mount_link: LinkId,
738        direction_signs: BTreeMap<CapabilityId, i8>,
739        roles: BTreeMap<CapabilityId, BTreeSet<CapabilityRole>>,
740        driver: Option<serde_json::Value>,
741    ) -> Self {
742        Self {
743            component_type,
744            mount_link,
745            driver,
746            direction_signs,
747            roles,
748        }
749    }
750
751    #[must_use]
752    pub const fn component_type(&self) -> &ComponentTypeId {
753        &self.component_type
754    }
755
756    /// The robot link this instance is rigidly mounted on.
757    #[must_use]
758    pub const fn mount_link(&self) -> &LinkId {
759        &self.mount_link
760    }
761
762    /// The hardware connection block, present exactly when a component driver
763    /// runs for this instance. It is that driver's own configuration, and the
764    /// driver's participant id is this instance's id.
765    #[must_use]
766    pub const fn driver(&self) -> Option<&serde_json::Value> {
767        self.driver.as_ref()
768    }
769
770    /// The authored direction sign for each capability this instance overrides.
771    /// An absent capability turns forward.
772    #[must_use]
773    pub const fn direction_signs(&self) -> &BTreeMap<CapabilityId, i8> {
774        &self.direction_signs
775    }
776
777    /// Authored role assignments, ordered by capability and role.
778    #[must_use]
779    pub const fn roles(&self) -> &BTreeMap<CapabilityId, BTreeSet<CapabilityRole>> {
780        &self.roles
781    }
782
783    /// Whether this instance assigns `role` to the named capability.
784    #[must_use]
785    pub fn has_role(&self, capability: &CapabilityId, role: CapabilityRole) -> bool {
786        self.roles
787            .get(capability)
788            .is_some_and(|roles| roles.contains(&role))
789    }
790}
791
792impl MotionModel {
793    #[must_use]
794    pub const fn kinematic(&self) -> &KinematicConfig {
795        &self.kinematic
796    }
797
798    #[must_use]
799    pub const fn limits(&self) -> MotionLimits {
800        self.limits
801    }
802}
803
804impl MotionLimits {
805    /// Check the envelope is usable.
806    ///
807    /// # Errors
808    ///
809    /// Returns [`ModelError::MotionLimit`] when a limit is not finite,
810    /// positive, and representable as `f32`.
811    pub fn validate(self) -> Result<Self, ModelError> {
812        for (value, field) in [
813            (
814                self.max_linear_speed_mps,
815                MotionLimitField::MaxLinearSpeedMps,
816            ),
817            (
818                self.max_angular_speed_radps,
819                MotionLimitField::MaxAngularSpeedRadps,
820            ),
821        ] {
822            if !(value.is_finite() && value > 0.0 && value <= f64::from(f32::MAX)) {
823                return Err(ModelError::MotionLimit { field });
824            }
825        }
826        Ok(self)
827    }
828}
829
830impl KinematicConfig {
831    /// Which drive geometry this configuration describes.
832    #[must_use]
833    pub const fn kind(&self) -> KinematicKind {
834        match self {
835            Self::Differential { .. } => KinematicKind::Differential,
836            Self::Mecanum { .. } => KinematicKind::Mecanum,
837            Self::Ackermann { .. } => KinematicKind::Ackermann,
838            Self::Omnidirectional { .. } => KinematicKind::Omnidirectional,
839        }
840    }
841}
842
843impl fmt::Display for KinematicKind {
844    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
845        formatter.write_str(match self {
846            Self::Differential => "differential",
847            Self::Mecanum => "mecanum",
848            Self::Ackermann => "ackermann",
849            Self::Omnidirectional => "omnidirectional",
850        })
851    }
852}
853
854impl Robot {
855    pub(crate) fn new(
856        parts: RobotParts,
857        footprint: Option<FootprintEnvelope>,
858    ) -> Result<Self, ModelError> {
859        let robot = Self {
860            id: parts.id,
861            motion: MotionModel::new(parts.kinematic, parts.motion_limits),
862            services: parts.services,
863            components: parts.components,
864            component_types: parts.component_types,
865            structure: parts.structure,
866            footprint,
867        };
868        robot.validate()?;
869        Ok(robot)
870    }
871
872    #[must_use]
873    pub const fn id(&self) -> &RobotId {
874        &self.id
875    }
876
877    #[must_use]
878    pub const fn motion(&self) -> &MotionModel {
879        &self.motion
880    }
881
882    /// Every service this robot runs, ordered by service id.
883    pub fn services(&self) -> impl ExactSizeIterator<Item = (&ServiceId, &Service)> {
884        self.services.iter()
885    }
886
887    /// The named service, if this robot runs one.
888    #[must_use]
889    pub fn service(&self, id: &str) -> Option<&Service> {
890        self.services.get(id)
891    }
892
893    /// The configuration the named service is launched with.
894    ///
895    /// `None` covers both "this robot does not run that service" and "it runs
896    /// with no configuration", which is the same answer to the one question a
897    /// service asks about itself at startup.
898    #[must_use]
899    pub fn service_config(&self, id: &str) -> Option<&serde_json::Value> {
900        self.service(id)?.config()
901    }
902
903    /// Every mounted component, ordered by instance id.
904    pub fn components(&self) -> impl Iterator<Item = ComponentView<'_>> {
905        self.components
906            .iter()
907            .filter_map(|(id, instance)| self.view(id, instance))
908    }
909
910    /// Every mounted instance's identity, ordered.
911    pub fn component_ids(&self) -> impl ExactSizeIterator<Item = &ComponentInstanceId> {
912        self.components.keys()
913    }
914
915    /// The named component: its instance, its type, and its simulation.
916    #[must_use]
917    pub fn component(&self, id: &str) -> Option<ComponentView<'_>> {
918        let (id, instance) = self.components.get_key_value(id)?;
919        self.view(id, instance)
920    }
921
922    /// Every declared component type, ordered by type id.
923    pub fn component_types(&self) -> impl ExactSizeIterator<Item = (&ComponentTypeId, &Component)> {
924        self.component_types.iter()
925    }
926
927    /// Join one instance with the type behind it.
928    ///
929    /// A validated robot never mounts an instance of a type it did not load, so
930    /// the `None` arm is unreachable rather than a component being dropped. It
931    /// stays a `filter_map`/`?` rather than a panic so the impossible case
932    /// degrades into a smaller answer instead of taking the process down.
933    fn view<'a>(
934        &'a self,
935        id: &'a ComponentInstanceId,
936        instance: &'a ComponentInstance,
937    ) -> Option<ComponentView<'a>> {
938        Some(ComponentView {
939            id,
940            instance,
941            component_type: self.component_types.get(instance.component_type())?,
942        })
943    }
944
945    /// The robot's own structure, in flattened runtime identities.
946    #[must_use]
947    pub const fn structure(&self) -> &Structure {
948        &self.structure
949    }
950
951    /// The persisted compiler-derived stock-safety envelope, if available.
952    #[must_use]
953    pub const fn footprint_envelope(&self) -> Option<FootprintEnvelope> {
954        self.footprint
955    }
956
957    /// The referenced capability, if the robot declares it.
958    #[must_use]
959    pub fn capability(&self, reference: &CapabilityRef) -> Option<&Capability> {
960        self.resolve(reference).map(|(_, capability)| capability)
961    }
962
963    /// Every declared capability matching `selects`.
964    ///
965    /// The result is ordered by `(component id, capability id)`. That order is
966    /// an invariant, not an accident of iteration: participants derive bus
967    /// identities and arbitration priorities from these positions, so two runs
968    /// over the same robot must produce the same sequence.
969    ///
970    /// This is infallible because a `Robot` value cannot exist with an instance
971    /// whose component type is absent: validation rejects that with
972    /// [`ModelError::UnknownComponentType`] before the value is constructed. The
973    /// unresolved arm below is therefore unreachable rather than a capability
974    /// being quietly dropped, and it stays a skip rather than a panic so that
975    /// the impossible case degrades into a smaller result set instead of taking
976    /// the process down.
977    pub fn capability_refs(&self, selects: impl Fn(&Capability) -> bool) -> Vec<CapabilityRef> {
978        let mut references = self
979            .components()
980            .flat_map(|component| {
981                component
982                    .component_type()
983                    .capabilities()
984                    .filter(|(_, capability)| selects(capability))
985                    .map(move |(capability_id, _)| {
986                        CapabilityRef::new(component.id().clone(), capability_id.clone())
987                    })
988            })
989            .collect::<Vec<_>>();
990        references.sort();
991        references
992    }
993
994    /// Every capability assigned the given authored role, ordered by
995    /// `(component id, capability id)`.
996    #[must_use]
997    pub fn capabilities_with_role(&self, role: CapabilityRole) -> Vec<CapabilityRef> {
998        self.components()
999            .flat_map(|component| {
1000                component
1001                    .instance()
1002                    .roles()
1003                    .iter()
1004                    .filter(move |(capability_id, roles)| {
1005                        roles.contains(&role)
1006                            && component
1007                                .component_type()
1008                                .capability(capability_id.as_str())
1009                                .is_some()
1010                    })
1011                    .map(move |(capability_id, _)| {
1012                        CapabilityRef::new(component.id().clone(), capability_id.clone())
1013                    })
1014            })
1015            .collect()
1016    }
1017
1018    /// The referenced motor and the direction sign to apply to it.
1019    ///
1020    /// # Errors
1021    ///
1022    /// Returns [`ModelError::UnknownCapability`] when the robot does not
1023    /// declare the capability, and [`ModelError::CapabilityKindMismatch`] when
1024    /// it declares something other than a motor.
1025    pub fn require_motor(&self, reference: &CapabilityRef) -> Result<(&Motor, i8), ModelError> {
1026        let capability = self.require_capability(reference)?;
1027        let Capability::Motor(motor) = capability else {
1028            return Err(ModelError::CapabilityKindMismatch {
1029                reference: reference.clone(),
1030                expected: CapabilityKind::Motor,
1031                actual: capability.kind(),
1032            });
1033        };
1034        Ok((motor, self.direction_sign(reference)))
1035    }
1036
1037    /// The referenced encoder and the direction sign to apply to it.
1038    ///
1039    /// # Errors
1040    ///
1041    /// Returns [`ModelError::UnknownCapability`] when the robot does not
1042    /// declare the capability, and [`ModelError::CapabilityKindMismatch`] when
1043    /// it declares something other than an encoder.
1044    pub fn require_encoder(&self, reference: &CapabilityRef) -> Result<(&Encoder, i8), ModelError> {
1045        let capability = self.require_capability(reference)?;
1046        let Capability::Encoder(encoder) = capability else {
1047            return Err(ModelError::CapabilityKindMismatch {
1048                reference: reference.clone(),
1049                expected: CapabilityKind::Encoder,
1050                actual: capability.kind(),
1051            });
1052        };
1053        Ok((encoder, self.direction_sign(reference)))
1054    }
1055
1056    /// The namespaced runtime frame for a capability's component-local link.
1057    ///
1058    /// # Errors
1059    ///
1060    /// Returns [`ModelError::CapabilityTargetKind`] when the capability is
1061    /// attached to a joint rather than a link, and
1062    /// [`ModelError::UnknownBoundTarget`] when its component type has no such
1063    /// link.
1064    pub fn link_target_frame(&self, reference: &CapabilityRef) -> Result<LinkId, ModelError> {
1065        let (component, capability) =
1066            self.resolve(reference)
1067                .ok_or_else(|| ModelError::UnknownCapability {
1068                    reference: reference.clone(),
1069                })?;
1070        let StructuralTarget::Link { id } = capability.target() else {
1071            return Err(ModelError::CapabilityTargetKind {
1072                reference: reference.clone(),
1073                expected: StructuralKind::Link,
1074            });
1075        };
1076        if component.structure().link(id.as_str()).is_none() {
1077            return Err(ModelError::UnknownBoundTarget {
1078                reference: reference.clone(),
1079                kind: StructuralKind::Link,
1080                id: id.as_str().to_string(),
1081            });
1082        }
1083        Ok(id.namespaced(&reference.component_id))
1084    }
1085
1086    fn resolve(&self, reference: &CapabilityRef) -> Option<(&Component, &Capability)> {
1087        let component = self
1088            .component(reference.component_id.as_str())?
1089            .component_type;
1090        let capability = component.capability(reference.capability_id.as_str())?;
1091        Some((component, capability))
1092    }
1093
1094    fn require_capability(&self, reference: &CapabilityRef) -> Result<&Capability, ModelError> {
1095        self.capability(reference)
1096            .ok_or_else(|| ModelError::UnknownCapability {
1097                reference: reference.clone(),
1098            })
1099    }
1100
1101    /// The authored direction sign, defaulting to `1` when none was authored.
1102    fn direction_sign(&self, reference: &CapabilityRef) -> i8 {
1103        self.components
1104            .get(reference.component_id.as_str())
1105            .and_then(|instance| {
1106                instance
1107                    .direction_signs
1108                    .get(reference.capability_id.as_str())
1109            })
1110            .copied()
1111            .unwrap_or(1)
1112    }
1113
1114    fn validate(&self) -> Result<(), ModelError> {
1115        self.motion.limits.validate()?;
1116        self.validate_robot_structure()?;
1117        self.validate_component_types()?;
1118        self.validate_components()?;
1119        self.validate_kinematic()?;
1120        self.validate_footprint()
1121    }
1122
1123    /// The robot's own structure carries flattened identities, so no authored
1124    /// robot link or joint may already contain the namespacing separator.
1125    fn validate_robot_structure(&self) -> Result<(), ModelError> {
1126        for link in self.structure.links() {
1127            Self::reject_reserved_separator(IdentifierKind::RobotLink, link.name().as_str())?;
1128        }
1129        for joint in self.structure.joints() {
1130            Self::reject_reserved_separator(IdentifierKind::RobotJoint, joint.name().as_str())?;
1131            Self::validate_runtime_joint_kind(joint, &JointOwner::Robot)?;
1132        }
1133        Ok(self.structure.validate_robot_frames()?)
1134    }
1135
1136    fn validate_component_types(&self) -> Result<(), ModelError> {
1137        for (component_type, component) in &self.component_types {
1138            for joint in component.structure().joints() {
1139                Self::validate_runtime_joint_kind(
1140                    joint,
1141                    &JointOwner::ComponentType(component_type.clone()),
1142                )?;
1143            }
1144            for (capability_id, capability) in component.capabilities() {
1145                let target = capability.target();
1146                let present = match target {
1147                    StructuralTarget::Link { id } => {
1148                        component.structure().link(id.as_str()).is_some()
1149                    }
1150                    StructuralTarget::Joint { id } => {
1151                        component.structure().joint(id.as_str()).is_some()
1152                    }
1153                };
1154                if !present {
1155                    let id = match target {
1156                        StructuralTarget::Link { id } => id.as_str().to_string(),
1157                        StructuralTarget::Joint { id } => id.as_str().to_string(),
1158                    };
1159                    return Err(ModelError::UnknownDeclaredTarget {
1160                        component_type: component_type.clone(),
1161                        capability_id: capability_id.clone(),
1162                        kind: target.kind(),
1163                        id,
1164                    });
1165                }
1166            }
1167            Self::validate_simulation(component_type, component)?;
1168        }
1169        Ok(())
1170    }
1171
1172    /// A simulation never introduces a capability of its own: every entry
1173    /// models one the type already declares, of the same kind.
1174    fn validate_simulation(
1175        component_type: &ComponentTypeId,
1176        component: &Component,
1177    ) -> Result<(), ModelError> {
1178        let Some(simulation) = component.simulation() else {
1179            return Ok(());
1180        };
1181        for (capability_id, simulated) in simulation.capabilities() {
1182            let capability = component
1183                .capability(capability_id.as_str())
1184                .ok_or_else(|| ModelError::SimulationWithoutCapability {
1185                    component_type: component_type.clone(),
1186                    capability_id: capability_id.clone(),
1187                })?;
1188            if simulated.kind() != capability.kind() {
1189                return Err(ModelError::SimulationCapabilityKindMismatch {
1190                    component_type: component_type.clone(),
1191                    capability_id: capability_id.clone(),
1192                    simulated: simulated.kind(),
1193                    declared: capability.kind(),
1194                });
1195            }
1196        }
1197        Ok(())
1198    }
1199
1200    /// Validate only the envelope's universal scalar invariant.
1201    ///
1202    /// Collision geometry is authored source and is deliberately unavailable
1203    /// to the persisted manifest. Its conservative envelope is derived once by
1204    /// the source compiler, then persisted as a value or explicit `null`.
1205    fn validate_footprint(&self) -> Result<(), ModelError> {
1206        if let Some(footprint) = self.footprint {
1207            FootprintEnvelope::new(footprint.radius_m)?;
1208        }
1209        Ok(())
1210    }
1211
1212    fn validate_components(&self) -> Result<(), ModelError> {
1213        for (id, instance) in &self.components {
1214            Self::reject_reserved_separator(IdentifierKind::ComponentInstance, id.as_str())?;
1215            let component = self
1216                .component_types
1217                .get(instance.component_type())
1218                .ok_or_else(|| ModelError::UnknownComponentType {
1219                    instance: id.clone(),
1220                    component_type: instance.component_type().clone(),
1221                })?;
1222            if self
1223                .structure
1224                .link(instance.mount_link().as_str())
1225                .is_none()
1226            {
1227                return Err(ModelError::UnknownMountLink {
1228                    instance: id.clone(),
1229                    link: instance.mount_link().clone(),
1230                });
1231            }
1232            for (capability_id, sign) in &instance.direction_signs {
1233                if !matches!(sign, -1 | 1) {
1234                    return Err(ModelError::DirectionSign {
1235                        instance: id.clone(),
1236                        capability_id: capability_id.clone(),
1237                        value: *sign,
1238                    });
1239                }
1240                if component.capability(capability_id.as_str()).is_none() {
1241                    return Err(ModelError::UnknownDirectionSignCapability {
1242                        instance: id.clone(),
1243                        capability_id: capability_id.clone(),
1244                    });
1245                }
1246            }
1247            for capability_id in instance.roles.keys() {
1248                if component.capability(capability_id.as_str()).is_none() {
1249                    return Err(ModelError::UnknownRoleCapability {
1250                        instance: id.clone(),
1251                        capability_id: capability_id.clone(),
1252                    });
1253                }
1254            }
1255        }
1256        Ok(())
1257    }
1258
1259    fn validate_kinematic(&self) -> Result<(), ModelError> {
1260        // The geometry scalars are checked by the one reader that turns them
1261        // into geometry, so this cannot drift from what consumers actually get.
1262        self.motion.kinematic().drive_kinematics()?;
1263        match self.motion.kinematic() {
1264            KinematicConfig::Differential {
1265                left_actuators,
1266                right_actuators,
1267                left_encoders,
1268                right_encoders,
1269                ..
1270            } => {
1271                for reference in left_actuators.iter().chain(right_actuators) {
1272                    self.require_motor(reference)?;
1273                }
1274                for reference in left_encoders.iter().chain(right_encoders) {
1275                    self.require_encoder(reference)?;
1276                }
1277            }
1278            KinematicConfig::Mecanum {
1279                front_left_actuator,
1280                front_right_actuator,
1281                rear_left_actuator,
1282                rear_right_actuator,
1283                ..
1284            } => {
1285                for reference in [
1286                    front_left_actuator,
1287                    front_right_actuator,
1288                    rear_left_actuator,
1289                    rear_right_actuator,
1290                ] {
1291                    self.require_motor(reference)?;
1292                }
1293            }
1294            KinematicConfig::Ackermann {
1295                steering_actuator,
1296                drive_actuator,
1297                steering_encoder,
1298                drive_encoder,
1299                ..
1300            } => {
1301                self.require_motor(steering_actuator)?;
1302                self.require_motor(drive_actuator)?;
1303                for reference in steering_encoder.iter().chain(drive_encoder) {
1304                    self.require_encoder(reference)?;
1305                }
1306            }
1307            KinematicConfig::Omnidirectional {
1308                actuators,
1309                encoders,
1310            } => {
1311                for reference in actuators {
1312                    self.require_motor(reference)?;
1313                }
1314                for reference in encoders {
1315                    self.require_encoder(reference)?;
1316                }
1317            }
1318        }
1319        Ok(())
1320    }
1321
1322    /// Reject an identifier that already carries the namespacing separator,
1323    /// which would make the flattened runtime identity ambiguous.
1324    fn reject_reserved_separator(kind: IdentifierKind, value: &str) -> Result<(), ModelError> {
1325        if value.contains(MODULE_INSTANCE_SEPARATOR) {
1326            return Err(ModelError::ReservedSeparator {
1327                kind,
1328                value: value.to_string(),
1329            });
1330        }
1331        Ok(())
1332    }
1333
1334    /// Reject a joint whose kind the runtime has no controller for.
1335    fn validate_runtime_joint_kind(joint: &Joint, owner: &JointOwner) -> Result<(), ModelError> {
1336        if matches!(
1337            joint.kind(),
1338            JointKind::Fixed | JointKind::Revolute | JointKind::Continuous | JointKind::Prismatic
1339        ) {
1340            Ok(())
1341        } else {
1342            Err(ModelError::UnsupportedJointKind {
1343                owner: owner.clone(),
1344                joint: joint.name().clone(),
1345                kind: joint.kind(),
1346            })
1347        }
1348    }
1349}
1350
1351impl MotionModel {
1352    pub(crate) const fn new(kinematic: KinematicConfig, limits: MotionLimits) -> Self {
1353        Self { kinematic, limits }
1354    }
1355}
1356
1357#[cfg(test)]
1358mod kinematics_tests {
1359    use super::{
1360        AckermannDrive, BodyTwist, DifferentialDrive, DriveKinematics, KinematicConfig,
1361        KinematicScalarField, MecanumDrive, ModelError,
1362    };
1363    use crate::identity::CapabilityRef;
1364
1365    const DIFFERENTIAL: DifferentialDrive = DifferentialDrive::new(0.1, 0.5);
1366    const MECANUM: MecanumDrive = MecanumDrive::new(0.1, 0.4, 0.6);
1367    const ACKERMANN: AckermannDrive = AckermannDrive::new(2.5, 1.5, 0.6);
1368
1369    fn close(left: f64, right: f64, what: &str) {
1370        assert!((left - right).abs() < 1e-9, "{what}: {left} vs {right}");
1371    }
1372
1373    /// Forward and inverse are one relation read two ways. A twist that survives
1374    /// the round trip is the property that matters: if the two ever disagreed, a
1375    /// robot would drive one distance and report another, and nothing downstream
1376    /// could detect it.
1377    #[test]
1378    fn a_differential_twist_survives_the_round_trip() {
1379        for twist in [
1380            BodyTwist::planar(0.0, 0.0),
1381            BodyTwist::planar(1.0, 0.0),
1382            BodyTwist::planar(0.0, 2.0),
1383            BodyTwist::planar(0.75, -1.25),
1384        ] {
1385            let back = DIFFERENTIAL.body_twist(DIFFERENTIAL.wheel_speeds(twist));
1386            close(back.linear_x_mps, twist.linear_x_mps, "linear x");
1387            close(back.angular_z_radps, twist.angular_z_radps, "angular z");
1388            assert_eq!(back.linear_y_mps, 0.0, "a differential drive has no sway");
1389        }
1390    }
1391
1392    #[test]
1393    fn a_mecanum_twist_survives_the_round_trip_including_sideways() {
1394        for twist in [
1395            BodyTwist::new(0.0, 0.0, 0.0),
1396            BodyTwist::new(1.0, 0.0, 0.0),
1397            BodyTwist::new(0.0, 1.0, 0.0),
1398            BodyTwist::new(0.0, 0.0, 1.5),
1399            BodyTwist::new(0.4, -0.7, 0.9),
1400        ] {
1401            let back = MECANUM.body_twist(MECANUM.wheel_speeds(twist));
1402            close(back.linear_x_mps, twist.linear_x_mps, "linear x");
1403            close(back.linear_y_mps, twist.linear_y_mps, "linear y");
1404            close(back.angular_z_radps, twist.angular_z_radps, "angular z");
1405        }
1406    }
1407
1408    #[test]
1409    fn an_ackermann_twist_survives_the_round_trip() {
1410        for twist in [
1411            BodyTwist::planar(1.0, 0.0),
1412            BodyTwist::planar(2.0, 0.4),
1413            BodyTwist::planar(-1.5, -0.3),
1414        ] {
1415            let back = ACKERMANN.body_twist(ACKERMANN.command(twist));
1416            close(back.linear_x_mps, twist.linear_x_mps, "linear x");
1417            close(back.angular_z_radps, twist.angular_z_radps, "angular z");
1418        }
1419    }
1420
1421    #[test]
1422    fn driving_straight_turns_both_differential_wheels_at_the_same_speed() {
1423        let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(1.0, 0.0));
1424        assert_eq!(speeds.left_radps, speeds.right_radps);
1425        assert_eq!(speeds.left_radps, 1.0 / DIFFERENTIAL.wheel_radius_m);
1426    }
1427
1428    #[test]
1429    fn turning_in_place_turns_the_differential_wheels_in_opposite_directions() {
1430        let speeds = DIFFERENTIAL.wheel_speeds(BodyTwist::planar(0.0, 1.0));
1431        assert_eq!(speeds.left_radps, -speeds.right_radps);
1432        assert!(
1433            speeds.right_radps > 0.0,
1434            "a positive yaw rate drives the right wheel forward"
1435        );
1436    }
1437
1438    /// Strafing left is the motion a differential drive cannot make, so it is
1439    /// the one that proves the mecanum roller signs are right: the diagonal
1440    /// pairs must counter-rotate.
1441    #[test]
1442    fn strafing_counter_rotates_the_mecanum_diagonals() {
1443        let speeds = MECANUM.wheel_speeds(BodyTwist::new(0.0, 1.0, 0.0));
1444        assert_eq!(speeds.front_left_radps, -speeds.front_right_radps);
1445        assert_eq!(speeds.rear_left_radps, -speeds.rear_right_radps);
1446        assert_eq!(speeds.front_left_radps, speeds.rear_right_radps);
1447        assert!(
1448            speeds.front_right_radps > 0.0,
1449            "left sway drives FR forward"
1450        );
1451    }
1452
1453    /// A non-holonomic geometry ignores sway rather than approximating it, so a
1454    /// sideways request must not leak into the wheels.
1455    #[test]
1456    fn non_holonomic_geometries_ignore_a_sideways_request() {
1457        let straight = BodyTwist::planar(1.0, 0.0);
1458        let swaying = BodyTwist::new(1.0, 5.0, 0.0);
1459        assert_eq!(
1460            DIFFERENTIAL.wheel_speeds(straight),
1461            DIFFERENTIAL.wheel_speeds(swaying)
1462        );
1463        assert_eq!(ACKERMANN.command(straight), ACKERMANN.command(swaying));
1464    }
1465
1466    /// A stationary robot has no steering angle that produces yaw, so asking for
1467    /// one must not divide by zero into a `NaN` the caller would then command.
1468    #[test]
1469    fn a_stationary_ackermann_has_a_defined_steering_angle() {
1470        let command = ACKERMANN.command(BodyTwist::planar(0.0, 1.0));
1471        assert_eq!(command.drive_speed_mps, 0.0);
1472        assert_eq!(command.steering_angle_rad, 0.0);
1473    }
1474
1475    #[test]
1476    fn the_steering_limit_is_reported_rather_than_silently_clamped() {
1477        let command = ACKERMANN.command(BodyTwist::planar(0.5, 2.0));
1478        assert!(
1479            command.steering_angle_rad.abs() > ACKERMANN.max_steering_angle_rad,
1480            "this request should exceed the mechanism"
1481        );
1482        assert!(!ACKERMANN.steering_is_reachable(command.steering_angle_rad));
1483        assert!(ACKERMANN.steering_is_reachable(0.0));
1484    }
1485
1486    fn reference() -> CapabilityRef {
1487        "base.motor".parse().expect("a well formed capability ref")
1488    }
1489
1490    #[test]
1491    fn every_authored_geometry_resolves_to_its_kinematics() {
1492        let differential = KinematicConfig::Differential {
1493            left_actuators: vec![reference()],
1494            right_actuators: vec![reference()],
1495            left_encoders: Vec::new(),
1496            right_encoders: Vec::new(),
1497            wheel_radius_m: 0.1,
1498            wheel_base_m: 0.5,
1499        };
1500        assert_eq!(
1501            differential.drive_kinematics().expect("valid geometry"),
1502            DriveKinematics::Differential(DIFFERENTIAL)
1503        );
1504
1505        let mecanum = KinematicConfig::Mecanum {
1506            front_left_actuator: reference(),
1507            front_right_actuator: reference(),
1508            rear_left_actuator: reference(),
1509            rear_right_actuator: reference(),
1510            wheel_radius_m: 0.1,
1511            wheel_base_m: 0.4,
1512            track_m: 0.6,
1513        };
1514        assert_eq!(
1515            mecanum.drive_kinematics().expect("valid geometry"),
1516            DriveKinematics::Mecanum(MECANUM)
1517        );
1518
1519        let ackermann = KinematicConfig::Ackermann {
1520            steering_actuator: reference(),
1521            drive_actuator: reference(),
1522            steering_encoder: None,
1523            drive_encoder: None,
1524            wheel_base_m: 2.5,
1525            track_m: 1.5,
1526            max_steering_angle_rad: 0.6,
1527        };
1528        assert_eq!(
1529            ackermann.drive_kinematics().expect("valid geometry"),
1530            DriveKinematics::Ackermann(ACKERMANN)
1531        );
1532
1533        // An omnidirectional document authors actuators and encoders but no
1534        // geometry, so there is nothing to resolve and the variant says so
1535        // rather than borrowing another drive's math.
1536        let omnidirectional = KinematicConfig::Omnidirectional {
1537            actuators: vec![reference()],
1538            encoders: Vec::new(),
1539        };
1540        assert_eq!(
1541            omnidirectional
1542                .drive_kinematics()
1543                .expect("carries no scalars to reject"),
1544            DriveKinematics::Omnidirectional
1545        );
1546    }
1547
1548    #[test]
1549    fn a_non_positive_scalar_is_refused_by_the_geometry_it_belongs_to() {
1550        assert!(matches!(
1551            DifferentialDrive::new(0.0, 0.5).validate(),
1552            Err(ModelError::KinematicScalar {
1553                field: KinematicScalarField::WheelRadiusM,
1554                ..
1555            })
1556        ));
1557        assert!(matches!(
1558            MecanumDrive::new(0.1, 0.4, f64::NAN).validate(),
1559            Err(ModelError::KinematicScalar {
1560                field: KinematicScalarField::TrackM,
1561                ..
1562            })
1563        ));
1564        assert!(matches!(
1565            AckermannDrive::new(2.5, 1.5, -0.1).validate(),
1566            Err(ModelError::KinematicScalar {
1567                field: KinematicScalarField::MaxSteeringAngleRad,
1568                ..
1569            })
1570        ));
1571    }
1572}
1573
1574#[cfg(test)]
1575mod tests {
1576    use super::*;
1577    use crate::compiler::{self, RobotParts};
1578    use serde_json::{Value, json};
1579
1580    const INERTIAL: &str = r#"{
1581        "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1582        "mass_kg": 1.0,
1583        "inertia": { "ixx": 1.0, "ixy": 0.0, "ixz": 0.0, "iyy": 1.0, "iyz": 0.0, "izz": 1.0 }
1584    }"#;
1585
1586    fn inertial() -> Value {
1587        serde_json::from_str(INERTIAL).expect("a well-formed inertial fixture")
1588    }
1589
1590    fn link(name: &str) -> Value {
1591        json!({ "name": name, "inertial": inertial(), "visuals": [], "collisions": [] })
1592    }
1593
1594    fn robot_structure() -> Structure {
1595        compiler::structure(json!({
1596            "name": "rover",
1597            "links": [link("base_footprint"), link("base_link")],
1598            "joints": [{
1599                "name": "base_joint",
1600                "kind": "fixed",
1601                "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1602                "parent": "base_footprint",
1603                "child": "base_link",
1604                "axis": [0.0, 0.0, 1.0],
1605                "limit": { "lower": 0.0, "upper": 0.0, "effort": 0.0, "velocity": 0.0 }
1606            }],
1607            "materials": []
1608        }))
1609        .expect("a well-formed robot structure fixture")
1610    }
1611
1612    fn robot_structure_with_collision() -> Structure {
1613        compiler::structure(json!({
1614            "name": "rover",
1615            "links": [
1616                {
1617                    "name": "base_footprint",
1618                    "inertial": inertial(),
1619                    "visuals": [],
1620                    "collisions": [{
1621                        "name": "hull",
1622                        "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1623                        "geometry": { "kind": "sphere", "radius": 0.5 }
1624                    }]
1625                },
1626                link("base_link")
1627            ],
1628            "joints": [{
1629                "name": "base_joint",
1630                "kind": "fixed",
1631                "origin": { "xyz": [0.0, 0.0, 0.0], "rpy": [0.0, 0.0, 0.0] },
1632                "parent": "base_footprint",
1633                "child": "base_link",
1634                "axis": [0.0, 0.0, 1.0],
1635                "limit": { "lower": 0.0, "upper": 0.0, "effort": 0.0, "velocity": 0.0 }
1636            }],
1637            "materials": []
1638        }))
1639        .expect("a well-formed colliding robot structure fixture")
1640    }
1641
1642    fn component_structure() -> Structure {
1643        compiler::structure(json!({
1644            "name": "drive",
1645            "links": [link("body")],
1646            "joints": [],
1647            "materials": []
1648        }))
1649        .expect("a well-formed component structure fixture")
1650    }
1651
1652    /// A component type declaring one motor and one camera, both on `body`.
1653    fn drive_component() -> Component {
1654        let capabilities = serde_json::from_value(json!({
1655            "spin": {
1656                "kind": "motor",
1657                "target": { "kind": "link", "id": "body" },
1658                "command": "velocity",
1659                "gear_ratio": 1.0
1660            },
1661            "eye": {
1662                "kind": "camera",
1663                "target": { "kind": "link", "id": "body" },
1664                "mode": "rgb",
1665                "publish_rate_hz": 30.0,
1666                "width_px": 640,
1667                "height_px": 480
1668            }
1669        }))
1670        .expect("a well-formed capability fixture");
1671        compiler::component(capabilities, component_structure(), None)
1672    }
1673
1674    fn instance() -> ComponentInstance {
1675        compiler::component_instance(
1676            ComponentTypeId::new("drive").expect("a normalized type id"),
1677            LinkId::new("base_link"),
1678            BTreeMap::new(),
1679            BTreeMap::new(),
1680            None,
1681        )
1682    }
1683
1684    fn robot_with_structure(structure: Structure, instance_ids: &[&str]) -> Robot {
1685        compiler::robot(RobotParts {
1686            id: RobotId::new("rover").expect("a normalized robot id"),
1687            kinematic: KinematicConfig::Omnidirectional {
1688                actuators: Vec::new(),
1689                encoders: Vec::new(),
1690            },
1691            motion_limits: MotionLimits {
1692                max_linear_speed_mps: 1.0,
1693                max_angular_speed_radps: 1.0,
1694            },
1695            services: BTreeMap::new(),
1696            components: instance_ids
1697                .iter()
1698                .map(|id| {
1699                    (
1700                        ComponentInstanceId::new(*id).expect("a normalized instance id"),
1701                        instance(),
1702                    )
1703                })
1704                .collect(),
1705            component_types: [(
1706                ComponentTypeId::new("drive").expect("a normalized type id"),
1707                drive_component(),
1708            )]
1709            .into_iter()
1710            .collect(),
1711            structure,
1712        })
1713        .expect("a valid canonical robot")
1714    }
1715
1716    fn robot_with(instance_ids: &[&str]) -> Robot {
1717        robot_with_structure(robot_structure(), instance_ids)
1718    }
1719
1720    /// `Robot` and `Structure` both write through a private wire helper their
1721    /// own declarations do not predict, so the declared shape is checked
1722    /// against a real serialized robot rather than asserted. This is the whole
1723    /// canonical model - components, capabilities, structure, footprint - so a
1724    /// type anywhere below it whose shape drifted fails here.
1725    #[test]
1726    fn the_declared_robot_shape_is_the_shape_serde_writes() {
1727        use phoxal_runtime_contract::wire_schema::DescribeWire;
1728
1729        for robot in [
1730            robot_with(&["left"]),
1731            robot_with_structure(robot_structure_with_collision(), &[]),
1732        ] {
1733            let json = serde_json::to_value(&robot).expect("a canonical robot serializes");
1734            assert_eq!(Robot::wire_schema().conforms(&json), Ok(()));
1735        }
1736    }
1737
1738    #[test]
1739    fn robot_wire_requires_an_explicit_footprint_value_or_null() {
1740        let robot = robot_with(&[]);
1741        let mut value = serde_json::to_value(&robot).expect("robot serializes");
1742        assert!(value["footprint"].is_null());
1743        value
1744            .as_object_mut()
1745            .expect("robot wire is an object")
1746            .remove("footprint");
1747        assert!(serde_json::from_value::<Robot>(value).is_err());
1748    }
1749
1750    #[test]
1751    fn runtime_deserialize_checks_envelope_invariants_without_rederiving_geometry() {
1752        let robot = robot_with_structure(robot_structure_with_collision(), &[]);
1753        assert_eq!(robot.footprint_envelope().unwrap().radius_m, 0.5);
1754        let mut value = serde_json::to_value(&robot).expect("robot serializes");
1755        value["footprint"]["radius_m"] = json!(0.1);
1756        let decoded: Robot = serde_json::from_value(value).expect("finite stored radius is valid");
1757        assert_eq!(decoded.footprint_envelope().unwrap().radius_m, 0.1);
1758    }
1759
1760    #[test]
1761    fn runtime_role_lists_reject_empty_and_duplicate_assignments() {
1762        let robot = robot_with(&["front"]);
1763        let value = serde_json::to_value(&robot).expect("robot serializes");
1764
1765        let mut empty = value.clone();
1766        empty["components"]["front"]["roles"] = json!({"eye": []});
1767        assert!(serde_json::from_value::<Robot>(empty).is_err());
1768
1769        let mut duplicate = value;
1770        duplicate["components"]["front"]["roles"] = json!({"eye": ["perception", "perception"]});
1771        assert!(serde_json::from_value::<Robot>(duplicate).is_err());
1772    }
1773
1774    fn reference(component: &str, capability: &str) -> CapabilityRef {
1775        CapabilityRef::new(
1776            ComponentInstanceId::new(component).expect("a normalized instance id"),
1777            CapabilityId::new(capability).expect("a normalized capability id"),
1778        )
1779    }
1780
1781    #[test]
1782    fn selecting_no_capability_yields_nothing() {
1783        let robot = robot_with(&["front", "rear"]);
1784        assert!(
1785            robot
1786                .capability_refs(|capability| matches!(capability, Capability::Lidar(_)))
1787                .is_empty()
1788        );
1789    }
1790
1791    #[test]
1792    fn selection_spans_every_instance_that_declares_the_capability() {
1793        let robot = robot_with(&["front", "rear"]);
1794        let cameras =
1795            robot.capability_refs(|capability| matches!(capability, Capability::Camera(_)));
1796        assert_eq!(
1797            cameras.iter().map(ToString::to_string).collect::<Vec<_>>(),
1798            ["front.eye", "rear.eye"]
1799        );
1800    }
1801
1802    #[test]
1803    fn selection_is_ordered_by_component_then_capability() {
1804        // Instances are supplied in reverse order on purpose: the ordering is
1805        // an invariant of the result, not of the input.
1806        let robot = robot_with(&["rear", "front"]);
1807        let all = robot.capability_refs(|_| true);
1808        assert_eq!(
1809            all.iter().map(ToString::to_string).collect::<Vec<_>>(),
1810            ["front.eye", "front.spin", "rear.eye", "rear.spin"]
1811        );
1812        let mut sorted = all.clone();
1813        sorted.sort();
1814        assert_eq!(all, sorted);
1815    }
1816
1817    #[test]
1818    fn a_routine_lookup_miss_is_absence_not_failure() {
1819        let robot = robot_with(&["front"]);
1820        let front = robot.component("front").expect("the instance is mounted");
1821        assert_eq!(front.id().as_str(), "front");
1822        assert_eq!(front.instance().mount_link(), &LinkId::new("base_link"));
1823        assert!(front.simulation().is_none());
1824        assert!(robot.component("nope").is_none());
1825        assert!(robot.capability(&reference("front", "spin")).is_some());
1826        assert!(robot.capability(&reference("front", "nope")).is_none());
1827        assert!(robot.capability(&reference("nope", "spin")).is_none());
1828    }
1829
1830    #[test]
1831    fn requiring_the_wrong_kind_names_both_kinds() {
1832        let robot = robot_with(&["front"]);
1833        let error = robot
1834            .require_motor(&reference("front", "eye"))
1835            .expect_err("a camera is not a motor");
1836        assert!(matches!(
1837            error,
1838            ModelError::CapabilityKindMismatch {
1839                expected: CapabilityKind::Motor,
1840                actual: CapabilityKind::Camera,
1841                ..
1842            }
1843        ));
1844        assert_eq!(
1845            error.to_string(),
1846            "capability 'front.eye' must reference a motor, found camera"
1847        );
1848
1849        let error = robot
1850            .require_encoder(&reference("front", "nope"))
1851            .expect_err("an undeclared capability cannot be required");
1852        assert!(matches!(error, ModelError::UnknownCapability { .. }));
1853    }
1854
1855    #[test]
1856    fn a_link_target_resolves_to_the_namespaced_runtime_frame() {
1857        let robot = robot_with(&["front"]);
1858        assert_eq!(
1859            robot
1860                .link_target_frame(&reference("front", "eye"))
1861                .expect("the camera targets a link"),
1862            LinkId::new("front__body")
1863        );
1864    }
1865
1866    #[test]
1867    fn an_unauthored_direction_sign_defaults_to_forward() {
1868        let robot = robot_with(&["front"]);
1869        let (_, sign) = robot
1870            .require_motor(&reference("front", "spin"))
1871            .expect("the motor resolves");
1872        assert_eq!(sign, 1);
1873    }
1874
1875    #[test]
1876    fn a_motion_limit_must_survive_the_narrowing_to_f32() {
1877        for limits in [
1878            MotionLimits {
1879                max_linear_speed_mps: 0.0,
1880                max_angular_speed_radps: 1.0,
1881            },
1882            MotionLimits {
1883                max_linear_speed_mps: 1.0,
1884                max_angular_speed_radps: f64::MAX,
1885            },
1886            MotionLimits {
1887                max_linear_speed_mps: f64::NAN,
1888                max_angular_speed_radps: 1.0,
1889            },
1890        ] {
1891            assert!(matches!(
1892                limits.validate(),
1893                Err(ModelError::MotionLimit { .. })
1894            ));
1895        }
1896        assert!(
1897            MotionLimits {
1898                max_linear_speed_mps: 1.5,
1899                max_angular_speed_radps: 2.5,
1900            }
1901            .validate()
1902            .is_ok()
1903        );
1904    }
1905}