Skip to main content

problemreductions/models/misc/
minimum_discrete_planar_inverse_kinematics.rs

1//! Minimum Discrete Planar Inverse Kinematics problem implementation.
2//!
3//! Given positive link lengths, a target point in `R^2`, a finite set of
4//! candidate absolute orientations per link, and admissible-pair sets between
5//! consecutive links, choose one orientation index per link so that all
6//! consecutive-pair constraints are satisfied and the squared distance from
7//! the end-effector to the target point is minimized.
8
9use crate::registry::{FieldInfo, ProblemSchemaEntry};
10use crate::traits::Problem;
11use crate::types::Min;
12use serde::{Deserialize, Serialize};
13
14inventory::submit! {
15    ProblemSchemaEntry {
16        name: "MinimumDiscretePlanarInverseKinematics",
17        display_name: "Minimum Discrete Planar Inverse Kinematics",
18        aliases: &[],
19        dimensions: &[],
20        category: crate::registry::ProblemCategory::Misc,
21        module_path: module_path!(),
22        description: "Pick one sampled absolute orientation per link, subject to consecutive-pair feasibility constraints, to minimize the squared distance from the end-effector to a target point",
23        fields: &[
24            FieldInfo {
25                name: "link_lengths",
26                type_name: "Vec<f64>",
27                description: "Positive link lengths l_1, ..., l_n",
28            },
29            FieldInfo {
30                name: "target_point",
31                type_name: "(f64, f64)",
32                description: "Target point g = (g_x, g_y) in R^2",
33            },
34            FieldInfo {
35                name: "orientation_samples",
36                type_name: "Vec<Vec<f64>>",
37                description: "Sampled absolute orientations Phi_j for each link j",
38            },
39            FieldInfo {
40                name: "allowed_pairs",
41                type_name: "Vec<Vec<(usize, usize)>>",
42                description: "Admissible (a_{j-1}, a_j) pair sets A_j for j = 2, ..., n",
43            },
44        ],
45    }
46}
47
48/// The Minimum Discrete Planar Inverse Kinematics problem.
49///
50/// Given positive link lengths `l_1, ..., l_n`, a target point `g` in
51/// `R^2`, sampled absolute orientations `Phi_j = {phi_{j,0}, ..., phi_{j,m_j-1}}`
52/// for each link, and admissible-pair sets
53/// `A_j ⊆ {0, ..., m_{j-1}-1} x {0, ..., m_j-1}` for `j = 2, ..., n`,
54/// choose indices `a_j ∈ {0, ..., m_j-1}` such that `(a_{j-1}, a_j) ∈ A_j`
55/// for every `j = 2, ..., n`, minimizing
56///
57/// `|| Σ_{j=1}^n l_j (cos(phi_{j,a_j}), sin(phi_{j,a_j})) - g ||_2^2`.
58#[derive(Debug, Clone, Serialize)]
59pub struct MinimumDiscretePlanarInverseKinematics {
60    link_lengths: Vec<f64>,
61    target_point: (f64, f64),
62    orientation_samples: Vec<Vec<f64>>,
63    allowed_pairs: Vec<Vec<(usize, usize)>>,
64}
65
66#[derive(Deserialize)]
67struct MinimumDiscretePlanarInverseKinematicsData {
68    link_lengths: Vec<f64>,
69    target_point: (f64, f64),
70    orientation_samples: Vec<Vec<f64>>,
71    allowed_pairs: Vec<Vec<(usize, usize)>>,
72}
73
74impl<'de> Deserialize<'de> for MinimumDiscretePlanarInverseKinematics {
75    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
76    where
77        D: serde::Deserializer<'de>,
78    {
79        let data = MinimumDiscretePlanarInverseKinematicsData::deserialize(deserializer)?;
80        Self::new(
81            data.link_lengths,
82            data.target_point,
83            data.orientation_samples,
84            data.allowed_pairs,
85        )
86        .map_err(serde::de::Error::custom)
87    }
88}
89
90impl MinimumDiscretePlanarInverseKinematics {
91    /// Construct a new instance.
92    ///
93    pub fn new(
94        link_lengths: Vec<f64>,
95        target_point: (f64, f64),
96        orientation_samples: Vec<Vec<f64>>,
97        allowed_pairs: Vec<Vec<(usize, usize)>>,
98    ) -> Result<Self, crate::registry::ConstructionError> {
99        let n = link_lengths.len();
100        if n == 0 {
101            return Err("MinimumDiscretePlanarInverseKinematics requires at least one link".into());
102        }
103        for (index, &length) in link_lengths.iter().enumerate() {
104            if !length.is_finite() || length <= 0.0 {
105                return Err(
106                    format!("link length at index {index} must be positive and finite").into(),
107                );
108            }
109        }
110        if !target_point.0.is_finite() || !target_point.1.is_finite() {
111            return Err("target point coordinates must be finite".into());
112        }
113        if orientation_samples.len() != n {
114            return Err("orientation_samples must have one entry per link".into());
115        }
116        for (link, samples) in orientation_samples.iter().enumerate() {
117            if samples.is_empty() {
118                return Err(
119                    format!("link {link} must have at least one candidate orientation").into(),
120                );
121            }
122            for (sample, &angle) in samples.iter().enumerate() {
123                if !angle.is_finite() {
124                    return Err(format!(
125                        "orientation sample {sample} for link {link} must be finite"
126                    )
127                    .into());
128                }
129            }
130        }
131        if allowed_pairs.len() != n - 1 {
132            return Err("allowed_pairs must have one entry per junction".into());
133        }
134        for (j_minus_1, pairs) in allowed_pairs.iter().enumerate() {
135            let m_prev = orientation_samples[j_minus_1].len();
136            let m_curr = orientation_samples[j_minus_1 + 1].len();
137            for &(a_prev, a_curr) in pairs {
138                if a_prev >= m_prev || a_curr >= m_curr {
139                    return Err(format!(
140                        "allowed pair ({a_prev}, {a_curr}) at junction {j_minus_1} is out of range"
141                    )
142                    .into());
143                }
144            }
145        }
146        Ok(Self {
147            link_lengths,
148            target_point,
149            orientation_samples,
150            allowed_pairs,
151        })
152    }
153
154    /// Get the link lengths.
155    pub fn link_lengths(&self) -> &[f64] {
156        &self.link_lengths
157    }
158
159    /// Get the target point.
160    pub fn target_point(&self) -> (f64, f64) {
161        self.target_point
162    }
163
164    /// Get the per-link orientation samples.
165    pub fn orientation_samples(&self) -> &[Vec<f64>] {
166        &self.orientation_samples
167    }
168
169    /// Get the admissible-pair sets for consecutive junctions.
170    pub fn allowed_pairs(&self) -> &[Vec<(usize, usize)>] {
171        &self.allowed_pairs
172    }
173
174    /// Number of links `n`.
175    pub fn num_links(&self) -> usize {
176        self.link_lengths.len()
177    }
178
179    /// Total number of sampled orientations across all links:
180    /// `sum_{j=1}^n m_j`. This is the QUBO variable count for the one-hot
181    /// encoding used by the QUBO reduction.
182    pub fn num_orientation_samples(&self) -> usize {
183        self.orientation_samples.iter().map(Vec::len).sum()
184    }
185
186    /// Check if a configuration is feasible (one index per link, in range,
187    /// and every consecutive pair lies in the corresponding admissible set).
188    pub fn is_feasible(&self, config: &[usize]) -> bool {
189        let n = self.num_links();
190        if config.len() != n {
191            return false;
192        }
193        for (j, &a) in config.iter().enumerate() {
194            if a >= self.orientation_samples[j].len() {
195                return false;
196            }
197        }
198        for j in 1..n {
199            let pair = (config[j - 1], config[j]);
200            if !self.allowed_pairs[j - 1].contains(&pair) {
201                return false;
202            }
203        }
204        true
205    }
206
207    /// Compute the end-effector position for a configuration.
208    /// Returns `None` if the configuration is infeasible.
209    pub fn end_effector(
210        &self,
211        config: &[usize],
212    ) -> Result<Option<(f64, f64)>, crate::traits::EvaluationError> {
213        if !self.is_feasible(config) {
214            return Ok(None);
215        }
216        let mut x = 0.0_f64;
217        let mut y = 0.0_f64;
218        for (j, &a) in config.iter().enumerate() {
219            let phi = self.orientation_samples[j][a];
220            let next_x = x + self.link_lengths[j] * phi.cos();
221            let next_y = y + self.link_lengths[j] * phi.sin();
222            if !next_x.is_finite() || !next_y.is_finite() {
223                return Err(crate::traits::EvaluationError::NonFiniteResult(
224                    "computing the inverse-kinematics end-effector position".into(),
225                ));
226            }
227            x = next_x;
228            y = next_y;
229        }
230        Ok(Some((x, y)))
231    }
232
233    /// Compute the squared end-effector distance to the target.
234    /// Returns `None` if the configuration is infeasible.
235    pub fn squared_distance(
236        &self,
237        config: &[usize],
238    ) -> Result<Option<f64>, crate::traits::EvaluationError> {
239        let Some((x, y)) = self.end_effector(config)? else {
240            return Ok(None);
241        };
242        let dx = x - self.target_point.0;
243        let dy = y - self.target_point.1;
244        let squared_distance = dx * dx + dy * dy;
245        if !squared_distance.is_finite() {
246            return Err(crate::traits::EvaluationError::NonFiniteResult(
247                "computing the inverse-kinematics squared distance".into(),
248            ));
249        }
250        Ok(Some(squared_distance))
251    }
252
253    /// Whether the configuration represents a valid feasible solution.
254    pub fn is_valid_solution(&self, config: &[usize]) -> bool {
255        self.is_feasible(config)
256    }
257}
258
259impl Problem for MinimumDiscretePlanarInverseKinematics {
260    const NAME: &'static str = "MinimumDiscretePlanarInverseKinematics";
261    type Solution = Vec<usize>;
262    type Value = Min<f64>;
263
264    crate::problem_parameters![
265        ("num_links", num_links),
266        ("num_orientation_samples", num_orientation_samples),
267    ];
268
269    fn variant() -> Vec<(&'static str, &'static str)> {
270        crate::variant_params![]
271    }
272
273    fn evaluate(
274        &self,
275        config: &Self::Solution,
276    ) -> Result<Min<f64>, crate::traits::EvaluationError> {
277        if config.len() != self.num_links() {
278            return Err(crate::traits::EvaluationError::InvalidConfiguration(
279                "orientation assignment length does not match the links".into(),
280            ));
281        }
282        if config
283            .iter()
284            .enumerate()
285            .any(|(link, &orientation)| orientation >= self.orientation_samples[link].len())
286        {
287            return Err(crate::traits::EvaluationError::InvalidConfiguration(
288                "orientation assignment contains an out-of-range sample".into(),
289            ));
290        }
291        Ok({
292            match self.squared_distance(config)? {
293                Some(value) => Min(Some(value)),
294                None => Min(None),
295            }
296        })
297    }
298}
299
300impl crate::solvers::BruteForceProblem for MinimumDiscretePlanarInverseKinematics {
301    fn dimensions(&self) -> Vec<usize> {
302        self.orientation_samples
303            .iter()
304            .map(|samples| samples.len())
305            .collect()
306    }
307}
308
309crate::declare_variants! {
310    // AM-GM bounds the sample-count product; this is an upper bound, not the exact count.
311    default MinimumDiscretePlanarInverseKinematics => "(num_orientation_samples / num_links)^num_links",
312}
313
314crate::register_brute_force! {
315    MinimumDiscretePlanarInverseKinematics,
316}
317
318#[cfg(feature = "example-db")]
319pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
320    use std::f64::consts::FRAC_PI_2;
321    vec![crate::example_db::specs::ModelExampleSpec {
322        id: "minimum_discrete_planar_inverse_kinematics",
323        instance: Box::new(
324            MinimumDiscretePlanarInverseKinematics::new(
325                vec![2.0, 1.0],
326                (2.0, 1.0),
327                vec![vec![0.0, FRAC_PI_2], vec![0.0, FRAC_PI_2]],
328                vec![vec![(0, 0), (0, 1), (1, 1)]],
329            )
330            .unwrap(),
331        ),
332        optimal_config: serde_json::json!(vec![0, 1]),
333        optimal_value: serde_json::json!(0.0),
334    }]
335}
336
337#[cfg(test)]
338#[path = "../../unit_tests/models/misc/minimum_discrete_planar_inverse_kinematics.rs"]
339mod tests;