Skip to main content

phoxal_model/robot/
mod.rs

1//! Canonical immutable robot model.
2
3mod motion;
4
5pub use motion::{KinematicConfig, MotionLimits};
6
7use std::collections::BTreeMap;
8
9use crate::component::capability::{
10    Capability, Encoder, Motor, StructuralTarget, namespaced_structure_id,
11};
12use crate::component::{CapabilityRef, Component};
13use crate::simulation::Simulation;
14use crate::structure::{JointKind, Structure};
15use crate::{DecodeError, EncodeError, ModelError, strict_json};
16
17/// Exact compiled-model wire schema. There is no legacy fallback.
18pub const ROBOT_SCHEMA: &str = "phoxal/robot/v0";
19
20/// Stable canonical robot identity.
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct RobotIdentity {
24    id: String,
25    namespace: String,
26}
27
28/// One resolved component instance in the canonical robot.
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ComponentInstance {
32    id: String,
33    component_type: String,
34    mount_link: String,
35    direction_signs: BTreeMap<String, i8>,
36}
37
38/// Canonical motion facts.
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct MotionModel {
42    kinematic: KinematicConfig,
43    limits: MotionLimits,
44}
45
46/// Compiler-only construction seam used by `phoxal-manifest`.
47#[doc(hidden)]
48pub struct RobotParts {
49    pub id: String,
50    pub namespace: String,
51    pub kinematic: KinematicConfig,
52    pub motion_limits: MotionLimits,
53    pub component_instances: BTreeMap<String, ComponentInstance>,
54    pub component_types: BTreeMap<String, Component>,
55    pub simulation_types: BTreeMap<String, Simulation>,
56    pub structure: Structure,
57}
58
59/// Fully normalized runtime-facing robot model.
60#[derive(Debug, Clone)]
61pub struct Robot {
62    identity: RobotIdentity,
63    motion: MotionModel,
64    component_instances: BTreeMap<String, ComponentInstance>,
65    component_types: BTreeMap<String, Component>,
66    simulation_types: BTreeMap<String, Simulation>,
67    structure: Structure,
68}
69
70impl RobotIdentity {
71    #[must_use]
72    pub fn id(&self) -> &str {
73        &self.id
74    }
75
76    #[must_use]
77    pub fn namespace(&self) -> &str {
78        &self.namespace
79    }
80}
81
82impl ComponentInstance {
83    /// Compiler-only constructor after source validation and normalization.
84    #[doc(hidden)]
85    pub fn __new(
86        id: String,
87        component_type: String,
88        mount_link: String,
89        direction_signs: BTreeMap<String, i8>,
90    ) -> Self {
91        Self {
92            id,
93            component_type,
94            mount_link,
95            direction_signs,
96        }
97    }
98
99    #[must_use]
100    pub fn id(&self) -> &str {
101        &self.id
102    }
103
104    #[must_use]
105    pub fn component_type(&self) -> &str {
106        &self.component_type
107    }
108
109    #[must_use]
110    pub fn mount_link(&self) -> &str {
111        &self.mount_link
112    }
113}
114
115impl MotionModel {
116    #[must_use]
117    pub fn kinematic(&self) -> &KinematicConfig {
118        &self.kinematic
119    }
120
121    #[must_use]
122    pub const fn limits(&self) -> MotionLimits {
123        self.limits
124    }
125}
126
127impl Robot {
128    /// Build a canonical model from compiler-normalized parts.
129    #[doc(hidden)]
130    pub fn __from_compiler(parts: RobotParts) -> Result<Self, ModelError> {
131        let robot = Self {
132            identity: RobotIdentity {
133                id: parts.id,
134                namespace: parts.namespace,
135            },
136            motion: MotionModel {
137                kinematic: parts.kinematic,
138                limits: parts.motion_limits,
139            },
140            component_instances: parts.component_instances,
141            component_types: parts.component_types,
142            simulation_types: parts.simulation_types,
143            structure: parts.structure,
144        };
145        robot.validate()?;
146        Ok(robot)
147    }
148
149    /// Encode the canonical runtime wire document deterministically.
150    pub fn encode(&self) -> Result<Vec<u8>, EncodeError> {
151        serde_json::to_vec_pretty(&RobotWire {
152            schema: ROBOT_SCHEMA,
153            robot: RobotPayloadRef {
154                identity: &self.identity,
155                motion: &self.motion,
156                component_instances: &self.component_instances,
157                component_types: &self.component_types,
158                simulation_types: &self.simulation_types,
159                structure: &self.structure,
160            },
161        })
162        .map_err(EncodeError::from)
163    }
164
165    /// Strictly decode and validate the canonical runtime wire document.
166    pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
167        let value = strict_json::parse(bytes)?;
168        let schema = value
169            .get("schema")
170            .and_then(serde_json::Value::as_str)
171            .ok_or(DecodeError::MissingSchema)?;
172        if schema != ROBOT_SCHEMA {
173            return Err(DecodeError::UnsupportedSchema(schema.to_string()));
174        }
175        let wire: RobotWireOwned = serde_json::from_value(value)?;
176        Self::__from_compiler(RobotParts {
177            id: wire.robot.identity.id,
178            namespace: wire.robot.identity.namespace,
179            kinematic: wire.robot.motion.kinematic,
180            motion_limits: wire.robot.motion.limits,
181            component_instances: wire.robot.component_instances,
182            component_types: wire.robot.component_types,
183            simulation_types: wire.robot.simulation_types,
184            structure: wire.robot.structure,
185        })
186        .map_err(DecodeError::Model)
187    }
188
189    #[must_use]
190    pub fn identity(&self) -> &RobotIdentity {
191        &self.identity
192    }
193
194    #[must_use]
195    pub fn robot_id(&self) -> &str {
196        self.identity.id()
197    }
198
199    #[must_use]
200    pub fn namespace(&self) -> &str {
201        self.identity.namespace()
202    }
203
204    #[must_use]
205    pub fn motion(&self) -> &MotionModel {
206        &self.motion
207    }
208
209    #[must_use]
210    pub fn kinematic(&self) -> &KinematicConfig {
211        self.motion.kinematic()
212    }
213
214    #[must_use]
215    pub const fn motion_limits(&self) -> MotionLimits {
216        self.motion.limits()
217    }
218
219    pub fn components(&self) -> impl ExactSizeIterator<Item = &ComponentInstance> {
220        self.component_instances.values()
221    }
222
223    pub fn component_ids(&self) -> impl ExactSizeIterator<Item = &str> {
224        self.component_instances.keys().map(String::as_str)
225    }
226
227    #[must_use]
228    pub fn component(&self, id: &str) -> Option<&ComponentInstance> {
229        self.component_instances.get(id)
230    }
231
232    pub fn component_instance(&self, id: &str) -> Result<&ComponentInstance, ModelError> {
233        self.component(id)
234            .ok_or_else(|| ModelError::Invalid(format!("unknown component instance '{id}'")))
235    }
236
237    pub fn component_for_instance(&self, id: &str) -> Result<&Component, ModelError> {
238        let instance = self.component_instance(id)?;
239        self.component_types
240            .get(instance.component_type())
241            .ok_or_else(|| {
242                ModelError::Invalid(format!(
243                    "component type '{}' for instance '{id}' is not loaded",
244                    instance.component_type()
245                ))
246            })
247    }
248
249    #[must_use]
250    pub fn simulation_for_component_type(&self, component_type: &str) -> Option<&Simulation> {
251        self.simulation_types.get(component_type)
252    }
253
254    pub fn simulation_for_instance(
255        &self,
256        component_id: &str,
257    ) -> Result<Option<&Simulation>, ModelError> {
258        let instance = self.component_instance(component_id)?;
259        Ok(self.simulation_for_component_type(instance.component_type()))
260    }
261
262    #[must_use]
263    pub fn structure(&self) -> &Structure {
264        &self.structure
265    }
266
267    pub fn capability(&self, reference: &CapabilityRef) -> Result<&Capability, ModelError> {
268        self.component_for_instance(&reference.component_id)?
269            .capability(&reference.capability_id)
270            .ok_or_else(|| ModelError::Invalid(format!("unknown capability '{reference}'")))
271    }
272
273    pub fn camera_capabilities(&self) -> Result<Vec<CapabilityRef>, ModelError> {
274        let mut capabilities = Vec::new();
275        for component_id in self.component_ids() {
276            let component = self.component_for_instance(component_id)?;
277            capabilities.extend(
278                component
279                    .capabilities()
280                    .filter(|(_, capability)| matches!(capability, Capability::Camera(_)))
281                    .map(|(capability_id, _)| CapabilityRef::new(component_id, capability_id)),
282            );
283        }
284        capabilities.sort();
285        Ok(capabilities)
286    }
287
288    pub fn require_motor(&self, reference: &CapabilityRef) -> Result<(&Motor, i8), ModelError> {
289        let capability = self.capability(reference)?;
290        let Capability::Motor(motor) = capability else {
291            return Err(ModelError::Invalid(format!(
292                "capability '{reference}' must reference a motor, found {}",
293                capability.kind_name()
294            )));
295        };
296        Ok((motor, self.direction_sign(reference)?))
297    }
298
299    pub fn require_encoder(&self, reference: &CapabilityRef) -> Result<(&Encoder, i8), ModelError> {
300        let capability = self.capability(reference)?;
301        let Capability::Encoder(encoder) = capability else {
302            return Err(ModelError::Invalid(format!(
303                "capability '{reference}' must reference an encoder, found {}",
304                capability.kind_name()
305            )));
306        };
307        Ok((encoder, self.direction_sign(reference)?))
308    }
309
310    /// Validate and return the namespaced runtime frame for a capability's
311    /// component-local link target.
312    pub fn link_target_frame(&self, reference: &CapabilityRef) -> Result<String, ModelError> {
313        let StructuralTarget::Link { id } = self.capability(reference)?.target() else {
314            return Err(ModelError::Invalid(format!(
315                "capability '{reference}' must target a link"
316            )));
317        };
318        if self
319            .component_for_instance(&reference.component_id)?
320            .structure()
321            .link(id)
322            .is_none()
323        {
324            return Err(ModelError::Invalid(format!(
325                "link target '{id}' for capability '{reference}' not found"
326            )));
327        }
328        Ok(namespaced_structure_id(&reference.component_id, id))
329    }
330
331    fn direction_sign(&self, reference: &CapabilityRef) -> Result<i8, ModelError> {
332        Ok(self
333            .component_instance(&reference.component_id)?
334            .direction_signs
335            .get(&reference.capability_id)
336            .copied()
337            .unwrap_or(1))
338    }
339
340    fn validate(&self) -> Result<(), ModelError> {
341        validate_token(self.identity.id(), "robot id")?;
342        validate_token(self.identity.namespace(), "robot namespace")?;
343        self.motion.limits.validate()?;
344        for link in self.structure.links() {
345            if link.name().contains("__") {
346                return Err(ModelError::Invalid(format!(
347                    "robot link '{}' must not contain reserved separator '__'",
348                    link.name()
349                )));
350            }
351        }
352        for joint in self.structure.joints() {
353            if joint.name().contains("__") {
354                return Err(ModelError::Invalid(format!(
355                    "robot joint '{}' must not contain reserved separator '__'",
356                    joint.name()
357                )));
358            }
359            validate_runtime_joint_kind(joint.kind(), joint.name(), "robot")?;
360        }
361        self.structure.validate_robot_frames()?;
362        for (component_type, component) in &self.component_types {
363            validate_token(component_type, "component type")?;
364            for joint in component.structure().joints() {
365                validate_runtime_joint_kind(joint.kind(), joint.name(), component_type)?;
366            }
367            for (capability_id, capability) in component.capabilities() {
368                validate_token(capability_id, "capability id")?;
369                match capability.target() {
370                    StructuralTarget::Link { id } => {
371                        if component.structure().link(id).is_none() {
372                            return Err(ModelError::Invalid(format!(
373                                "component type '{component_type}' capability '{capability_id}' references unknown link '{id}'"
374                            )));
375                        }
376                    }
377                    StructuralTarget::Joint { id } => {
378                        if component.structure().joint(id).is_none() {
379                            return Err(ModelError::Invalid(format!(
380                                "component type '{component_type}' capability '{capability_id}' references unknown joint '{id}'"
381                            )));
382                        }
383                    }
384                }
385            }
386        }
387        for (id, instance) in &self.component_instances {
388            validate_token(id, "component instance id")?;
389            if id.contains("__") {
390                return Err(ModelError::Invalid(format!(
391                    "component instance id '{id}' must not contain reserved separator '__'"
392                )));
393            }
394            if id != instance.id() {
395                return Err(ModelError::Invalid(format!(
396                    "component map identity '{id}' does not match embedded id '{}'",
397                    instance.id()
398                )));
399            }
400            if !self.component_types.contains_key(instance.component_type()) {
401                return Err(ModelError::Invalid(format!(
402                    "component '{id}' references unknown component type '{}'",
403                    instance.component_type()
404                )));
405            }
406            if self.structure.link(instance.mount_link()).is_none() {
407                return Err(ModelError::Invalid(format!(
408                    "component '{id}' references unknown mount link '{}'",
409                    instance.mount_link()
410                )));
411            }
412            let component = self
413                .component_types
414                .get(instance.component_type())
415                .ok_or_else(|| {
416                    ModelError::Invalid(format!(
417                        "component '{id}' references unknown component type '{}'",
418                        instance.component_type()
419                    ))
420                })?;
421            for (capability_id, sign) in &instance.direction_signs {
422                validate_token(capability_id, "direction-sign capability id")?;
423                if !matches!(sign, -1 | 1) {
424                    return Err(ModelError::Invalid(format!(
425                        "component '{id}' capability '{capability_id}' direction sign must be -1 or 1"
426                    )));
427                }
428                if component.capability(capability_id).is_none() {
429                    return Err(ModelError::Invalid(format!(
430                        "component '{id}' direction sign references unknown capability '{capability_id}'"
431                    )));
432                }
433            }
434        }
435        for (component_type, simulation) in &self.simulation_types {
436            let component = self.component_types.get(component_type).ok_or_else(|| {
437                ModelError::Invalid(format!(
438                    "simulation type '{component_type}' has no matching component type"
439                ))
440            })?;
441            for (id, simulated) in simulation.capabilities() {
442                let capability = component.capability(id).ok_or_else(|| {
443                    ModelError::Invalid(format!(
444                        "simulation capability '{component_type}.{id}' has no component capability"
445                    ))
446                })?;
447                if simulated.kind_name() != capability.kind_name() {
448                    return Err(ModelError::Invalid(format!(
449                        "simulation capability '{component_type}.{id}' kind does not match component"
450                    )));
451                }
452            }
453        }
454        match self.kinematic() {
455            KinematicConfig::Differential {
456                left_actuators,
457                right_actuators,
458                left_encoders,
459                right_encoders,
460                wheel_radius_m,
461                wheel_base_m,
462            } => {
463                validate_positive(*wheel_radius_m, "differential wheel_radius_m")?;
464                validate_positive(*wheel_base_m, "differential wheel_base_m")?;
465                for reference in left_actuators.iter().chain(right_actuators) {
466                    self.require_motor(reference)?;
467                }
468                for reference in left_encoders.iter().chain(right_encoders) {
469                    self.require_encoder(reference)?;
470                }
471            }
472            KinematicConfig::Mecanum {
473                front_left_actuator,
474                front_right_actuator,
475                rear_left_actuator,
476                rear_right_actuator,
477                wheel_radius_m,
478                wheel_base_m,
479                track_m,
480            } => {
481                validate_positive(*wheel_radius_m, "mecanum wheel_radius_m")?;
482                validate_positive(*wheel_base_m, "mecanum wheel_base_m")?;
483                validate_positive(*track_m, "mecanum track_m")?;
484                for reference in [
485                    front_left_actuator,
486                    front_right_actuator,
487                    rear_left_actuator,
488                    rear_right_actuator,
489                ] {
490                    self.require_motor(reference)?;
491                }
492            }
493            KinematicConfig::Ackermann {
494                steering_actuator,
495                drive_actuator,
496                steering_encoder,
497                drive_encoder,
498                wheel_base_m,
499                track_m,
500                max_steering_angle_rad,
501            } => {
502                validate_positive(*wheel_base_m, "ackermann wheel_base_m")?;
503                validate_positive(*track_m, "ackermann track_m")?;
504                validate_positive(*max_steering_angle_rad, "ackermann max_steering_angle_rad")?;
505                self.require_motor(steering_actuator)?;
506                self.require_motor(drive_actuator)?;
507                if let Some(reference) = steering_encoder {
508                    self.require_encoder(reference)?;
509                }
510                if let Some(reference) = drive_encoder {
511                    self.require_encoder(reference)?;
512                }
513            }
514            KinematicConfig::Omnidirectional {
515                actuators,
516                encoders,
517            } => {
518                for reference in actuators {
519                    self.require_motor(reference)?;
520                }
521                for reference in encoders {
522                    self.require_encoder(reference)?;
523                }
524            }
525        }
526        Ok(())
527    }
528}
529
530fn validate_token(value: &str, label: &str) -> Result<(), ModelError> {
531    if value.is_empty()
532        || !value
533            .chars()
534            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '_' | '-'))
535    {
536        return Err(ModelError::Invalid(format!(
537            "{label} '{value}' is not normalized"
538        )));
539    }
540    Ok(())
541}
542
543fn validate_positive(value: f64, label: &str) -> Result<(), ModelError> {
544    if !value.is_finite() || value <= 0.0 {
545        return Err(ModelError::Invalid(format!(
546            "{label} must be finite and positive"
547        )));
548    }
549    Ok(())
550}
551
552fn validate_runtime_joint_kind(
553    kind: JointKind,
554    joint_id: &str,
555    owner: &str,
556) -> Result<(), ModelError> {
557    if matches!(
558        kind,
559        JointKind::Fixed | JointKind::Revolute | JointKind::Continuous | JointKind::Prismatic
560    ) {
561        Ok(())
562    } else {
563        Err(ModelError::Invalid(format!(
564            "{owner} joint '{joint_id}' uses unsupported runtime kind '{kind:?}'"
565        )))
566    }
567}
568
569#[derive(serde::Serialize)]
570struct RobotWire<'a> {
571    schema: &'static str,
572    robot: RobotPayloadRef<'a>,
573}
574
575#[derive(serde::Serialize)]
576struct RobotPayloadRef<'a> {
577    identity: &'a RobotIdentity,
578    motion: &'a MotionModel,
579    component_instances: &'a BTreeMap<String, ComponentInstance>,
580    component_types: &'a BTreeMap<String, Component>,
581    simulation_types: &'a BTreeMap<String, Simulation>,
582    structure: &'a Structure,
583}
584
585#[derive(serde::Deserialize)]
586#[serde(deny_unknown_fields)]
587struct RobotWireOwned {
588    #[serde(rename = "schema")]
589    _schema: String,
590    robot: RobotPayloadOwned,
591}
592
593#[derive(serde::Deserialize)]
594#[serde(deny_unknown_fields)]
595struct RobotPayloadOwned {
596    identity: RobotIdentity,
597    motion: MotionModel,
598    component_instances: BTreeMap<String, ComponentInstance>,
599    component_types: BTreeMap<String, Component>,
600    simulation_types: BTreeMap<String, Simulation>,
601    structure: Structure,
602}