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