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