Skip to main content

phoxal_model/
error.rs

1//! The canonical model's failure vocabulary.
2//!
3//! Every variant carries the typed values that were rejected, so a caller can
4//! match on the condition instead of parsing a message. The `Display` text is
5//! what operators see in a compile failure, so it stays close to the domain
6//! wording of the rule that was broken.
7
8use std::fmt;
9
10use crate::component::capability::{CapabilityKind, CapabilityRole, StructuralKind};
11use crate::identity::{
12    CapabilityId, CapabilityRef, ComponentInstanceId, ComponentTypeId, JointId, LinkId,
13    MODULE_INSTANCE_SEPARATOR,
14};
15use crate::robot::KinematicKind;
16use crate::structure::JointKind;
17
18/// A canonical robot model that violates the runtime model's invariants.
19#[derive(Debug, thiserror::Error)]
20pub enum ModelError {
21    /// An identifier is not a normalized token.
22    #[error("{kind} '{value}' is not normalized")]
23    NotNormalized { kind: IdentifierKind, value: String },
24
25    /// An identifier contains the separator reserved for flattening a
26    /// component's structure into the robot's.
27    #[error("{kind} '{value}' must not contain reserved separator '{MODULE_INSTANCE_SEPARATOR}'")]
28    ReservedSeparator { kind: IdentifierKind, value: String },
29
30    /// A capability reference is not written as `component.capability`.
31    #[error("capability reference '{value}' must use component.capability")]
32    MalformedCapabilityReference { value: String },
33
34    /// A logical asset id is not a normalized relative forward-slash path.
35    #[error("invalid logical asset id '{value}'")]
36    MalformedAssetId { value: String },
37
38    /// A reference names a capability the robot does not declare.
39    #[error("unknown capability '{reference}'")]
40    UnknownCapability { reference: CapabilityRef },
41
42    /// A capability is not of the kind the caller requires.
43    #[error("capability '{reference}' must reference a {expected}, found {actual}")]
44    CapabilityKindMismatch {
45        reference: CapabilityRef,
46        expected: CapabilityKind,
47        actual: CapabilityKind,
48    },
49
50    /// A capability does not target the kind of structural item the caller
51    /// requires.
52    #[error("capability '{reference}' must target a {expected}")]
53    CapabilityTargetKind {
54        reference: CapabilityRef,
55        expected: StructuralKind,
56    },
57
58    /// A bound capability targets a structural item its component type does
59    /// not define.
60    #[error("{kind} target '{id}' for capability '{reference}' not found")]
61    UnknownBoundTarget {
62        reference: CapabilityRef,
63        kind: StructuralKind,
64        id: String,
65    },
66
67    /// A component type's capability targets a structural item that type does
68    /// not define.
69    #[error(
70        "component type '{component_type}' capability '{capability_id}' references unknown {kind} '{id}'"
71    )]
72    UnknownDeclaredTarget {
73        component_type: ComponentTypeId,
74        capability_id: CapabilityId,
75        kind: StructuralKind,
76        id: String,
77    },
78
79    /// A component instance names a component type the model did not load.
80    #[error("component '{instance}' references unknown component type '{component_type}'")]
81    UnknownComponentType {
82        instance: ComponentInstanceId,
83        component_type: ComponentTypeId,
84    },
85
86    /// A component instance mounts on a link the robot structure lacks.
87    #[error("component '{instance}' references unknown mount link '{link}'")]
88    UnknownMountLink {
89        instance: ComponentInstanceId,
90        link: LinkId,
91    },
92
93    /// A direction sign is neither `-1` nor `1`.
94    #[error("component '{instance}' capability '{capability_id}' direction sign must be -1 or 1")]
95    DirectionSign {
96        instance: ComponentInstanceId,
97        capability_id: CapabilityId,
98        value: i8,
99    },
100
101    /// A direction sign names a capability its component type does not declare.
102    #[error(
103        "component '{instance}' direction sign references unknown capability '{capability_id}'"
104    )]
105    UnknownDirectionSignCapability {
106        instance: ComponentInstanceId,
107        capability_id: CapabilityId,
108    },
109
110    /// A capability role assignment names a capability its component type does
111    /// not declare.
112    #[error(
113        "component '{instance}' role assignment references unknown capability '{capability_id}'"
114    )]
115    UnknownRoleCapability {
116        instance: ComponentInstanceId,
117        capability_id: CapabilityId,
118    },
119
120    /// A persisted role key must name at least one role.
121    ///
122    /// The component instance is not named: this is raised while the instance is
123    /// being decoded, before the map key that identifies it is in scope. serde
124    /// prefixes the field path it was decoding, which names the instance.
125    #[error("capability '{capability_id}' has an empty role assignment")]
126    EmptyCapabilityRoles { capability_id: CapabilityId },
127
128    /// A persisted role list may not repeat a role and rely on set coercion.
129    #[error("capability '{capability_id}' repeats role '{role}'")]
130    DuplicateCapabilityRole {
131        capability_id: CapabilityId,
132        role: CapabilityRole,
133    },
134
135    /// A simulated capability has no counterpart on the component type.
136    #[error("simulation capability '{component_type}.{capability_id}' has no component capability")]
137    SimulationWithoutCapability {
138        component_type: ComponentTypeId,
139        capability_id: CapabilityId,
140    },
141
142    /// A simulated capability models a different kind than the component
143    /// declares, so the simulator would drive the wrong device.
144    #[error(
145        "simulation capability '{component_type}.{capability_id}' kind does not match component"
146    )]
147    SimulationCapabilityKindMismatch {
148        component_type: ComponentTypeId,
149        capability_id: CapabilityId,
150        simulated: CapabilityKind,
151        declared: CapabilityKind,
152    },
153
154    /// A joint uses a kind the runtime cannot drive.
155    #[error("{owner} joint '{joint}' uses unsupported runtime kind '{kind:?}'")]
156    UnsupportedJointKind {
157        owner: JointOwner,
158        joint: JointId,
159        kind: JointKind,
160    },
161
162    /// A motion limit is not finite, positive, and representable as `f32`.
163    ///
164    /// The `f32` bound is not cosmetic: motion commands cross the bus as
165    /// `f32`, so a limit that does not survive the narrowing is not a limit.
166    #[error("motion {field} must be finite, positive, and fit in f32")]
167    MotionLimit { field: MotionLimitField },
168
169    /// A kinematic scalar is not finite and positive.
170    #[error("{kinematics} {field} must be finite and positive")]
171    KinematicScalar {
172        kinematics: KinematicKind,
173        field: KinematicScalarField,
174    },
175
176    /// Stock safety cannot conservatively account for a collision shape that
177    /// is attached below a movable joint.
178    #[error("stock safety footprint cannot include movable joint '{joint}'")]
179    FootprintMovableJoint { joint: JointId },
180
181    /// Mesh dimensions depend on asset contents and therefore cannot be
182    /// compiled into the source-free stock safety envelope.
183    #[error("stock safety footprint does not support mesh collision on link '{link}'")]
184    FootprintMesh { link: LinkId },
185
186    /// A footprint scalar must remain finite after all fixed-joint transforms.
187    #[error("stock safety footprint contains a non-finite value")]
188    FootprintNonFinite,
189
190    /// A compiled footprint radius is invalid, which must never enter a
191    /// bundle manifest or authorize motion.
192    #[error("stock safety footprint radius must be finite and positive")]
193    FootprintRadius,
194
195    /// The canonical structure itself is invalid.
196    #[error(transparent)]
197    Structure(#[from] StructureError),
198}
199
200impl From<phoxal_runtime_contract::identity::TopologyIdError> for ModelError {
201    fn from(error: phoxal_runtime_contract::identity::TopologyIdError) -> Self {
202        use phoxal_runtime_contract::identity::TopologyIdError;
203        match error {
204            TopologyIdError::Robot(value) => Self::NotNormalized {
205                kind: IdentifierKind::RobotId,
206                value,
207            },
208            TopologyIdError::ComponentInstance(value) => Self::NotNormalized {
209                kind: IdentifierKind::ComponentInstance,
210                value,
211            },
212            TopologyIdError::Service(value) => Self::NotNormalized {
213                kind: IdentifierKind::Service,
214                value,
215            },
216        }
217    }
218}
219
220/// A canonical structure that violates the structural invariants.
221#[derive(Debug, thiserror::Error)]
222pub enum StructureError {
223    /// Two structural items share one identity.
224    #[error("duplicate {kind} identity '{name}'")]
225    DuplicateIdentity { kind: StructuralKind, name: String },
226
227    /// A joint attaches to a link the structure does not define.
228    #[error("joint '{joint}' references unknown {role} link '{link}'")]
229    UnknownJointLink {
230        joint: JointId,
231        role: LinkRole,
232        link: LinkId,
233    },
234
235    /// A link has more than one parent joint, so it has no single pose.
236    #[error("link '{link}' is the child of multiple joints")]
237    MultipleParentJoints { link: LinkId },
238
239    /// A joint attaches a link to itself.
240    #[error("joint '{joint}' cannot use '{link}' as both parent and child")]
241    SelfReferentialJoint { joint: JointId, link: LinkId },
242
243    /// The link graph is not a single tree.
244    #[error("structure must have exactly one root link, found {found}")]
245    RootLinkCount { found: usize },
246
247    /// The link graph contains a cycle, so no link has a defined world pose.
248    #[error("structure contains a joint cycle involving '{link}'")]
249    JointCycle { link: LinkId },
250
251    /// A pose has a non-finite component.
252    #[error("{owner} pose must be finite")]
253    Pose { owner: PoseOwner },
254
255    /// A link mass is not finite and non-negative.
256    #[error("link '{link}' mass must be finite and non-negative")]
257    Mass { link: LinkId },
258
259    /// A link inertia tensor is not finite and positive semidefinite, so it
260    /// does not describe a physically realizable body.
261    #[error("link '{link}' inertia must be finite and positive semidefinite")]
262    Inertia { link: LinkId },
263
264    /// A geometry dimension is not finite and positive.
265    #[error("link '{link}' geometry dimensions must be finite and positive")]
266    Geometry { link: LinkId },
267
268    /// A joint axis has a non-finite component.
269    #[error("joint '{joint}' axis must be finite")]
270    AxisNotFinite { joint: JointId },
271
272    /// A movable joint has a zero-length axis, so its motion is undefined.
273    #[error("joint '{joint}' axis must be non-zero")]
274    AxisNotOriented { joint: JointId },
275
276    /// A joint limit is not finite, or its bounds are inverted.
277    #[error("joint '{joint}' limits must be finite with lower <= upper")]
278    JointLimits { joint: JointId },
279
280    /// Joint damping or friction is not finite and non-negative.
281    #[error("joint '{joint}' dynamics must be finite and non-negative")]
282    JointDynamics { joint: JointId },
283
284    /// A joint mimics a joint the structure does not define.
285    #[error("joint '{joint}' mimics unknown joint '{mimicked}'")]
286    UnknownMimicJoint { joint: JointId, mimicked: JointId },
287
288    /// A joint's software safety limits are not finite, or inverted.
289    #[error("joint '{joint}' safety limits must be finite with lower <= upper")]
290    JointSafety { joint: JointId },
291
292    /// The robot's root link is not the conventional ground-projection frame.
293    #[error("structure root link must be '{expected}', found '{found}'")]
294    RootLinkName { expected: LinkId, found: LinkId },
295
296    /// The robot structure has no `base_link` at all.
297    #[error("structure must attach 'base_link' under 'base_footprint' with a fixed joint")]
298    MissingBaseLink,
299
300    /// The robot structure has a `base_link` that is not rigidly attached
301    /// directly beneath the root.
302    #[error("structure must attach 'base_link' directly under 'base_footprint' with a fixed joint")]
303    MisattachedBaseLink,
304
305    /// The canonical structure document does not match the canonical schema.
306    #[error("canonical structure document is invalid: {0}")]
307    Document(#[from] serde_json::Error),
308}
309
310/// What a rejected identifier was being used to name.
311#[derive(Clone, Copy, Debug, PartialEq, Eq)]
312pub enum IdentifierKind {
313    RobotId,
314    RobotLink,
315    RobotJoint,
316    ComponentType,
317    ComponentInstance,
318    Capability,
319    Service,
320}
321
322impl fmt::Display for IdentifierKind {
323    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
324        formatter.write_str(match self {
325            Self::RobotId => "robot id",
326            Self::RobotLink => "robot link",
327            Self::RobotJoint => "robot joint",
328            Self::ComponentType => "component type",
329            Self::ComponentInstance => "component instance id",
330            Self::Capability => "capability id",
331            Self::Service => "service id",
332        })
333    }
334}
335
336/// Which structure a rejected joint belongs to.
337#[derive(Clone, Debug, PartialEq, Eq)]
338pub enum JointOwner {
339    /// The robot's own structure.
340    Robot,
341    /// One component type's structure.
342    ComponentType(ComponentTypeId),
343}
344
345impl fmt::Display for JointOwner {
346    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
347        match self {
348            Self::Robot => formatter.write_str("robot"),
349            Self::ComponentType(component_type) => write!(formatter, "{component_type}"),
350        }
351    }
352}
353
354/// Which end of a joint named a missing link.
355#[derive(Clone, Copy, Debug, PartialEq, Eq)]
356pub enum LinkRole {
357    Parent,
358    Child,
359}
360
361impl fmt::Display for LinkRole {
362    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
363        formatter.write_str(match self {
364            Self::Parent => "parent",
365            Self::Child => "child",
366        })
367    }
368}
369
370/// Which pose in the structure was rejected.
371#[derive(Clone, Debug, PartialEq, Eq)]
372pub enum PoseOwner {
373    LinkInertial(LinkId),
374    LinkVisual(LinkId),
375    LinkCollision(LinkId),
376    Joint(JointId),
377}
378
379impl fmt::Display for PoseOwner {
380    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
381        match self {
382            Self::LinkInertial(link) => write!(formatter, "link '{link}' inertial"),
383            Self::LinkVisual(link) => write!(formatter, "link '{link}' visual"),
384            Self::LinkCollision(link) => write!(formatter, "link '{link}' collision"),
385            Self::Joint(joint) => write!(formatter, "joint '{joint}'"),
386        }
387    }
388}
389
390/// Which motion limit was rejected.
391#[derive(Clone, Copy, Debug, PartialEq, Eq)]
392pub enum MotionLimitField {
393    MaxLinearSpeedMps,
394    MaxAngularSpeedRadps,
395}
396
397impl fmt::Display for MotionLimitField {
398    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
399        formatter.write_str(match self {
400            Self::MaxLinearSpeedMps => "max_linear_speed_mps",
401            Self::MaxAngularSpeedRadps => "max_angular_speed_radps",
402        })
403    }
404}
405
406/// Which kinematic dimension was rejected.
407#[derive(Clone, Copy, Debug, PartialEq, Eq)]
408pub enum KinematicScalarField {
409    WheelRadiusM,
410    WheelBaseM,
411    TrackM,
412    MaxSteeringAngleRad,
413}
414
415impl fmt::Display for KinematicScalarField {
416    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
417        formatter.write_str(match self {
418            Self::WheelRadiusM => "wheel_radius_m",
419            Self::WheelBaseM => "wheel_base_m",
420            Self::TrackM => "track_m",
421            Self::MaxSteeringAngleRad => "max_steering_angle_rad",
422        })
423    }
424}