Skip to main content

copp\copp\copp2\dp2/
reach_set2.rs

1//! Reachable-set construction for second-order path parameterization.
2//!
3//! # Method identity
4//! This module implements the reachable-set stage via **Reachability Analysis (RA)** in
5//! a **Dynamic Programming (DP)** compatible form, which can be used by:
6//! - **Time-Optimal Path Parameterization (TOPP2)**,
7//! - **Convex-Objective Path Parameterization (COPP2)**.
8//!
9//! # Discrete variables (local notation)
10//! On a path grid `s[0..=n]`:
11//! - `a[k]` denotes $\dot{s}_k^2$ (nonnegative scalar state);
12//! - reachable interval at station `k` is `[a_min[k], a_max[k]]`.
13//!
14//! # High-level pipeline
15//! 1. Validate boundary feasibility at both interval ends.
16//! 2. Backward pass from terminal boundary to construct feasible intervals.
17//! 3. Optional forward clipping (when bidirectional mode is enabled) to enforce start boundary.
18//! 4. Return interval arrays `a_min` / `a_max` for downstream solvers.
19
20use crate::copp::copp2::formulation::Topp2Problem;
21use crate::copp::{ApproxOrdering, approx_order};
22use crate::diag::{
23    CoppError, DebugVerboser, SilentVerboser, SummaryVerboser, TraceVerboser, Verboser, Verbosity,
24    check_abs_rel_tol, check_strictly_positive, format_duration_human,
25};
26use crate::math::numerical::{
27    EPS_ZERO, LP_BOUND, Lp2dWarmStart, LpToleranceOptions, lp_1d, lp_2d_incre_max_y, normalize_lp2d,
28};
29use core::f64;
30
31/// Reachable intervals of $a(s)=\dot{s}^2$ for TOPP2/COPP2.
32/// For each point `s[k]`, the reachable set is `a_min[k] <= a[k] <= a_max[k]`.
33pub struct ReachSet2 {
34    /// Upper reachable bound `a_max[k]` at each station.
35    pub a_max: Vec<f64>,
36    /// Lower reachable bound `a_min[k]` at each station.
37    pub a_min: Vec<f64>,
38}
39
40/// Compute backward-only reachable intervals of $a(s)=\dot{s}^2$ using Reachability Analysis.
41///
42/// Performs a single backward pass from the terminal boundary; the start boundary
43/// is **not** enforced.  Use this when only the terminal state is constrained, or
44/// as an intermediate step before bidirectional analysis.
45///
46/// # Returns
47/// [`ReachSet2`](crate::solver::reach_set2::ReachSet2) with `a_min[k] <= a[k] <= a_max[k]` for every station.
48///
49/// # Errors
50/// Returns [`CoppError`](crate::diag::CoppError) when boundary states are infeasible, LP subproblems fail,
51/// or numerical comparisons violate configured tolerances.
52///
53/// # Contract
54/// - `problem` indices and boundaries must be consistent with the constraints domain.
55/// - `options` tolerances must be positive and numerically meaningful.
56#[inline]
57pub fn reach_set2_backward(
58    problem: &Topp2Problem,
59    options: &ReachSet2Options,
60) -> Result<ReachSet2, CoppError> {
61    match options.verbosity {
62        Verbosity::Silent => reach_set2_core::<false>(problem, (options, SilentVerboser)),
63        Verbosity::Summary => reach_set2_core::<false>(problem, (options, SummaryVerboser::new())),
64        Verbosity::Debug => reach_set2_core::<false>(problem, (options, DebugVerboser::new())),
65        Verbosity::Trace => reach_set2_core::<false>(problem, (options, TraceVerboser::new())),
66    }
67}
68
69/// Compute bidirectional reachable intervals of $a(s)=\dot{s}^2$.
70///
71/// Performs a backward pass then clips the result with a forward pass to enforce
72/// **both** the start and terminal boundary constraints simultaneously.
73///
74/// # Returns
75/// [`ReachSet2`](crate::solver::reach_set2::ReachSet2) with `a_min[k] <= a[k] <= a_max[k]` for every station.
76///
77/// # Errors
78/// Returns [`CoppError`](crate::diag::CoppError) when boundary states are infeasible, LP subproblems fail,
79/// or numerical comparisons violate configured tolerances.
80///
81/// # Contract
82/// - `problem` indices and boundaries must be consistent with the constraints domain.
83/// - `options` tolerances must be positive and numerically meaningful.
84#[inline]
85pub fn reach_set2_bidirectional(
86    problem: &Topp2Problem,
87    options: &ReachSet2Options,
88) -> Result<ReachSet2, CoppError> {
89    match options.verbosity {
90        Verbosity::Silent => reach_set2_core::<true>(problem, (options, SilentVerboser)),
91        Verbosity::Summary => reach_set2_core::<true>(problem, (options, SummaryVerboser::new())),
92        Verbosity::Debug => reach_set2_core::<true>(problem, (options, DebugVerboser::new())),
93        Verbosity::Trace => reach_set2_core::<true>(problem, (options, TraceVerboser::new())),
94    }
95}
96
97/// Core RA implementation with layered verbosity.
98fn reach_set2_core<const BIDIRECTION: bool>(
99    problem: &Topp2Problem,
100    options_verboser: (&ReachSet2Options, impl Verboser),
101) -> Result<ReachSet2, CoppError> {
102    let (options, mut verboser) = options_verboser;
103    if verboser.is_enabled(Verbosity::Summary) {
104        verboser.record_start_time();
105        crate::verbosity_log!(
106            crate::diag::Verbosity::Summary,
107            "reach_set2 started: {} <= idx_s <= {}, a_start = {}, a_final = {}.",
108            problem.idx_s_interval.0,
109            problem.idx_s_interval.1,
110            problem.a_boundary.0,
111            problem.a_boundary.1,
112        );
113        if BIDIRECTION {
114            crate::verbosity_log!(
115                crate::diag::Verbosity::Summary,
116                "Bidirectional reachable set will be computed."
117            );
118        } else {
119            crate::verbosity_log!(
120                crate::diag::Verbosity::Summary,
121                "Backward reachable set will be computed."
122            );
123        }
124    }
125
126    let (idx_s_start, idx_s_final) = problem.idx_s_interval;
127    let a_max_0 = problem.constraints.amax_unchecked(problem.idx_s_interval.0);
128    if matches!(
129        approx_order(
130            problem.a_boundary.0,
131            a_max_0,
132            options.a_cmp_abs_tol,
133            options.a_cmp_rel_tol,
134        ),
135        ApproxOrdering::Greater
136    ) {
137        let err = CoppError::Infeasible(
138            "reach_set2".into(),
139            format!(
140                "The initial state a_start = {} cannot be greater than the maximum feasible state a_max[0] = {a_max_0} at idx_s_start = {idx_s_start}",
141                problem.a_boundary.0,
142            ),
143        );
144        if verboser.is_enabled(Verbosity::Debug) {
145            crate::verbosity_log!(crate::diag::Verbosity::Summary, "{err:?}");
146        } else if verboser.is_enabled(Verbosity::Summary) {
147            crate::verbosity_log!(
148                crate::diag::Verbosity::Debug,
149                "reach_set2: the backward pass failed at index {idx_s_start} due to infeasibility of the initial state."
150            );
151        }
152        return Err(err);
153    }
154    let a_max_f = problem.constraints.amax_unchecked(problem.idx_s_interval.1);
155    if matches!(
156        approx_order(
157            problem.a_boundary.1,
158            a_max_f,
159            options.a_cmp_abs_tol,
160            options.a_cmp_rel_tol,
161        ),
162        ApproxOrdering::Greater
163    ) {
164        let err = CoppError::Infeasible(
165            "reach_set2".into(),
166            format!(
167                "The final state a_final = {} cannot be greater than the maximum feasible state a_max[n] = {a_max_f} at idx_s_final = {idx_s_final}",
168                problem.a_boundary.1,
169            ),
170        );
171        if verboser.is_enabled(Verbosity::Debug) {
172            crate::verbosity_log!(crate::diag::Verbosity::Summary, "{err:?}");
173        } else if verboser.is_enabled(Verbosity::Summary) {
174            crate::verbosity_log!(
175                crate::diag::Verbosity::Debug,
176                "reach_set2: the backward pass failed at index {idx_s_final} due to infeasibility of the final state."
177            );
178        }
179        return Err(err);
180    }
181
182    // Step 1. Initialize a_max and a_min at s_final
183    let n = idx_s_final - idx_s_start;
184    let mut a_max = vec![f64::INFINITY; n + 1];
185    let mut a_min = vec![0.0; n + 1];
186    *a_max.last_mut().unwrap() = problem.a_boundary.1;
187    *a_min.last_mut().unwrap() = problem.a_boundary.1;
188    // Step 2. Backward pass
189    if verboser.is_enabled(Verbosity::Debug) {
190        crate::verbosity_log!(crate::diag::Verbosity::Summary, "Backward pass started.");
191    }
192
193    let mut a_b = Vec::<(f64, f64, f64)>::with_capacity(2 + 2 * problem.constraints.acc_rows());
194    let mut a_max_next = problem.a_boundary.1;
195    let mut a_min_next = problem.a_boundary.1;
196    for (k, (a_max_k, a_min_k)) in a_max
197        .iter_mut()
198        .zip(a_min.iter_mut())
199        .take(n)
200        .enumerate()
201        .rev()
202    {
203        let idx_s = idx_s_start + k;
204        if verboser.is_enabled(Verbosity::Trace) {
205            crate::verbosity_log!(
206                crate::diag::Verbosity::Summary,
207                "\tBackward pass at k = {k} (idx_s = {idx_s}) to compute a[k]: {a_min_next} <= a[k+1] <= {a_max_next}."
208            );
209        }
210
211        a_b.clear();
212        a_b.push((1.0, 0.0, a_max_next));
213        a_b.push((-1.0, 0.0, -a_min_next));
214        a_b.push((0.0, 1.0, problem.constraints.amax_unchecked(idx_s)));
215        problem.constraints.fill_acc_topp2::<true>(&mut a_b, idx_s);
216        // a_b.0 * a[k+1] + a_b.1 * a[k] <= a_b.2
217        normalize_lp2d(&mut a_b);
218
219        let a_next_mid = 0.5 * (a_max_next + a_min_next);
220        let (a_max_curr, a_min_curr) = match approx_order(
221            a_max_next,
222            a_min_next,
223            options.a_cmp_abs_tol,
224            options.a_cmp_rel_tol,
225        ) {
226            ApproxOrdering::Equal => {
227                if verboser.is_enabled(Verbosity::Trace) {
228                    crate::verbosity_log!(
229                        crate::diag::Verbosity::Summary,
230                        "\t\ta[k+1] = {a_next_mid}"
231                    );
232                }
233                lp_1d::<true>(
234                    a_b.iter().skip(2).map(|&coeffs| {
235                        // coeffs.0 * a_next  + coeffs.1* a_curr <= coeffs.2
236                        // coeffs.1 * a_curr <= coeffs.2 - coeffs.0 * a_next
237                        (coeffs.1, coeffs.2 - coeffs.0 * a_next_mid)
238                    }),
239                    &LpToleranceOptions::with_feas_tol(options.lp_feas_tol),
240                )
241            }
242            ApproxOrdering::Greater => {
243                if verboser.is_enabled(Verbosity::Trace) {
244                    crate::verbosity_log!(
245                        crate::diag::Verbosity::Summary,
246                        "\t\t{a_min_next} <= a[k+1] <= {a_max_next}"
247                    );
248                }
249                // Check whether the forward pass of `amin_curr` can be skipped
250                let (a_test_max, a_test_min) = lp_1d::<true>(
251                    a_b.iter().skip(2).map(|&coeffs| {
252                        // coeffs.0 * a_next  + coeffs.1* a_curr <= coeffs.2
253                        // coeffs.0 * a_next <= coeffs.2 - coeffs.1 * a_curr
254                        (coeffs.0, coeffs.2 - coeffs.1 * *a_min_k)
255                    }),
256                    &LpToleranceOptions::with_feas_tol(options.lp_feas_tol),
257                );
258                let flag_need_min = a_test_max.is_nan()
259                    || a_test_min.is_nan()
260                    || a_test_max < a_min_next
261                    || a_test_max > a_max_next;
262                if verboser.is_enabled(Verbosity::Trace) {
263                    crate::verbosity_log!(
264                        crate::diag::Verbosity::Summary,
265                        "\t\tBackward skip checks at idx_s = {idx_s}: need_max = true, need_min = {flag_need_min}."
266                    );
267                }
268                if flag_need_min {
269                    backward_bound_a_next::<true, true>(&mut a_b, a_next_mid, options.lp_feas_tol)
270                } else {
271                    (
272                        backward_bound_a_next::<true, false>(
273                            &mut a_b,
274                            a_next_mid,
275                            options.lp_feas_tol,
276                        )
277                        .0,
278                        *a_min_k,
279                    )
280                }
281            }
282            ApproxOrdering::Less => {
283                let err = CoppError::Infeasible(
284                    "reach_set2".into(),
285                    format!(
286                        "The reachable set is empty at index {idx_s} during the backward pass where a_max_next = {a_max_next}, a_min_next = {a_min_next}"
287                    ),
288                );
289                if verboser.is_enabled(Verbosity::Debug) {
290                    crate::verbosity_log!(crate::diag::Verbosity::Summary, "{err:?}");
291                } else if verboser.is_enabled(Verbosity::Summary) {
292                    crate::verbosity_log!(
293                        crate::diag::Verbosity::Debug,
294                        "reach_set2: the backward pass failed at index {idx_s} due to infeasibility."
295                    );
296                }
297                return Err(err);
298            }
299        };
300
301        if a_max_curr.is_nan() || a_min_curr.is_nan() {
302            let err = CoppError::Infeasible(
303                "reach_set2".into(),
304                format!(
305                    "The reachable set is empty at index {idx_s} during the backward pass where a_max = {a_max_curr}, a_min = {a_min_curr}"
306                ),
307            );
308            if verboser.is_enabled(Verbosity::Debug) {
309                crate::verbosity_log!(crate::diag::Verbosity::Summary, "{err:?}");
310            } else if verboser.is_enabled(Verbosity::Summary) {
311                crate::verbosity_log!(
312                    crate::diag::Verbosity::Debug,
313                    "reach_set2: the backward pass failed at index {idx_s} due to infeasibility."
314                );
315            }
316            return Err(err);
317        }
318        if verboser.is_enabled(Verbosity::Trace) {
319            crate::verbosity_log!(
320                crate::diag::Verbosity::Summary,
321                "\t\tBackward LP result at k = {k}: {a_min_curr} <= a[k] <= {a_max_curr}."
322            );
323        }
324
325        *a_max_k = a_max_curr.min(problem.constraints.amax_unchecked(idx_s));
326        *a_min_k = a_min_curr.max(0.0);
327
328        if verboser.is_enabled(Verbosity::Trace) {
329            crate::verbosity_log!(
330                crate::diag::Verbosity::Summary,
331                "\t\tAfter clipping with path constraints at k = {k}: {} <= a[k] <= {}.",
332                *a_min_k,
333                *a_max_k
334            );
335        }
336
337        // The interval endpoints are computed from the next interval by subtraction, so
338        // rounding is at the scale of `a_max_next`, not of the endpoints themselves.
339        match approx_order(
340            *a_max_k,
341            *a_min_k,
342            options
343                .a_cmp_abs_tol
344                .max(options.a_cmp_rel_tol * a_max_next.abs()),
345            options.a_cmp_rel_tol,
346        ) {
347            ApproxOrdering::Less => {
348                let err = CoppError::Infeasible(
349                    "reach_set2".into(),
350                    format!(
351                        "The reachable set is empty at k = {k} (idx_s = {idx_s}) during the backward pass where a_max = {a_max_k}, a_min = {a_min_k}"
352                    ),
353                );
354                if verboser.is_enabled(Verbosity::Debug) {
355                    crate::verbosity_log!(crate::diag::Verbosity::Summary, "{err:?}");
356                } else if verboser.is_enabled(Verbosity::Summary) {
357                    crate::verbosity_log!(
358                        crate::diag::Verbosity::Debug,
359                        "reach_set2: the backward pass failed at k = {k} (idx_s = {idx_s}) due to infeasibility."
360                    );
361                }
362                return Err(err);
363            }
364            ApproxOrdering::Equal => {
365                if verboser.is_enabled(Verbosity::Debug) && k < n - 1 {
366                    crate::verbosity_log!(
367                        crate::diag::Verbosity::Summary,
368                        "The backward reachable set at k = {k} (idx_s = {idx_s}) is degenerate since a_max = {a_max_k} and a_min = {a_min_k} are approximately equal."
369                    );
370                }
371                *a_max_k = 0.5 * (*a_max_k + *a_min_k);
372                *a_min_k = *a_max_k;
373            }
374            ApproxOrdering::Greater => {}
375        }
376        a_max_next = *a_max_k;
377        a_min_next = *a_min_k;
378
379        if verboser.is_enabled(Verbosity::Trace) {
380            crate::verbosity_log!(
381                crate::diag::Verbosity::Summary,
382                "\t\tBackward propagated interval to k-1: {a_min_next} <= a[k] <= {a_max_next}."
383            );
384        }
385    }
386
387    if BIDIRECTION {
388        if verboser.is_enabled(Verbosity::Debug) {
389            crate::verbosity_log!(crate::diag::Verbosity::Summary, "Forward pass started.");
390        }
391
392        *a_max.first_mut().unwrap() = problem.a_boundary.0;
393        *a_min.first_mut().unwrap() = problem.a_boundary.0;
394        let mut a_max_prev = problem.a_boundary.0;
395        let mut a_min_prev = problem.a_boundary.0;
396        for (k, (a_max_k, a_min_k)) in a_max.iter_mut().zip(a_min.iter_mut()).enumerate().skip(1) {
397            let idx_s = idx_s_start + k;
398            if verboser.is_enabled(Verbosity::Trace) {
399                crate::verbosity_log!(
400                    crate::diag::Verbosity::Summary,
401                    "\tForward pass at k = {k} (idx_s = {idx_s}): prev interval {} <= a[k-1] <= {}, backward interval {} <= a[k] <= {}.",
402                    a_min_prev,
403                    a_max_prev,
404                    *a_min_k,
405                    *a_max_k
406                );
407            }
408
409            a_b.clear();
410            a_b.push((1.0, 0.0, a_max_prev));
411            a_b.push((-1.0, 0.0, -a_min_prev));
412            a_b.push((0.0, 1.0, problem.constraints.amax_unchecked(idx_s)));
413            problem
414                .constraints
415                .fill_acc_topp2::<false>(&mut a_b, idx_s_start + k - 1);
416            // a_b.0 * a[k-1] + a_b.1 * a[k] <= a_b.2
417            normalize_lp2d(&mut a_b);
418
419            let a_prev_mid = 0.5 * (a_max_prev + a_min_prev);
420            let (a_max_curr, a_min_curr) = match approx_order(
421                a_max_prev,
422                a_min_prev,
423                options.a_cmp_abs_tol,
424                options.a_cmp_rel_tol,
425            ) {
426                ApproxOrdering::Equal => {
427                    lp_1d::<true>(
428                        a_b.iter().skip(2).map(|&coeffs| {
429                            // coeffs.0 * a_prev  + coeffs.1* a_curr <= coeffs.2
430                            // coeffs.1 * a_curr <= coeffs.2 - coeffs.0 * a_prev
431                            (coeffs.1, coeffs.2 - coeffs.0 * a_prev_mid)
432                        }),
433                        &LpToleranceOptions::with_feas_tol(options.lp_feas_tol),
434                    )
435                }
436                ApproxOrdering::Greater => {
437                    // Check whether the forward pass of `amax_curr` can be skipped
438                    let (a_test_max, a_test_min) = lp_1d::<true>(
439                        a_b.iter().skip(2).map(|&coeffs| {
440                            // coeffs.0 * a_prev  + coeffs.1* a_curr <= coeffs.2
441                            // coeffs.0 * a_prev <= coeffs.2 - coeffs.1 * a_curr
442                            (coeffs.0, coeffs.2 - coeffs.1 * *a_max_k)
443                        }),
444                        &LpToleranceOptions::with_feas_tol(options.lp_feas_tol),
445                    );
446                    let flag_need_max = a_test_max.is_nan()
447                        || a_test_min.is_nan()
448                        || a_test_max < a_min_prev
449                        || a_test_max > a_max_prev;
450                    // Check whether the forward pass of `amin_curr` can be skipped
451                    let (a_test_max, a_test_min) = lp_1d::<true>(
452                        a_b.iter().skip(2).map(|&coeffs| {
453                            // coeffs.0 * a_prev  + coeffs.1* a_curr <= coeffs.2
454                            // coeffs.0 * a_prev <= coeffs.2 - coeffs.1 * a_curr
455                            (coeffs.0, coeffs.2 - coeffs.1 * *a_min_k)
456                        }),
457                        &LpToleranceOptions::with_feas_tol(options.lp_feas_tol),
458                    );
459                    let flag_need_min = a_test_max.is_nan()
460                        || a_test_min.is_nan()
461                        || a_test_max < a_min_prev
462                        || a_test_max > a_max_prev;
463
464                    if verboser.is_enabled(Verbosity::Trace) {
465                        crate::verbosity_log!(
466                            crate::diag::Verbosity::Summary,
467                            "\t\tForward skip checks at idx_s = {idx_s}: need_max = {flag_need_max}, need_min = {flag_need_min}."
468                        );
469                    }
470
471                    // forward and backward is the same
472                    match (flag_need_max, flag_need_min) {
473                        (true, true) => backward_bound_a_next::<true, true>(
474                            &mut a_b,
475                            a_prev_mid,
476                            options.lp_feas_tol,
477                        ),
478                        (true, false) => (
479                            backward_bound_a_next::<true, false>(
480                                &mut a_b,
481                                a_prev_mid,
482                                options.lp_feas_tol,
483                            )
484                            .0,
485                            *a_min_k,
486                        ),
487                        (false, true) => (
488                            *a_max_k,
489                            backward_bound_a_next::<false, true>(
490                                &mut a_b,
491                                a_prev_mid,
492                                options.lp_feas_tol,
493                            )
494                            .1,
495                        ),
496                        (false, false) => (*a_max_k, *a_min_k),
497                    }
498                }
499                ApproxOrdering::Less => {
500                    let err = CoppError::Infeasible(
501                        "reach_set2".into(),
502                        format!(
503                            "The reachable set is empty at index {idx_s} during the forward pass where a_max_prev = {a_max_prev}, a_min_prev = {a_min_prev}"
504                        ),
505                    );
506                    if verboser.is_enabled(Verbosity::Debug) {
507                        crate::verbosity_log!(crate::diag::Verbosity::Summary, "{err:?}");
508                    } else if verboser.is_enabled(Verbosity::Summary) {
509                        crate::verbosity_log!(
510                            crate::diag::Verbosity::Debug,
511                            "reach_set2: the forward pass failed at index {idx_s} due to infeasibility."
512                        );
513                    }
514                    return Err(err);
515                }
516            };
517
518            if verboser.is_enabled(Verbosity::Trace) {
519                crate::verbosity_log!(
520                    crate::diag::Verbosity::Summary,
521                    "\t\tForward LP result at idx_s = {idx_s}: {a_min_curr} <= a[k] <= {a_max_curr}."
522                );
523            }
524
525            if a_max_curr.is_nan() || a_min_curr.is_nan() {
526                let err = CoppError::Infeasible(
527                    "reach_set2".into(),
528                    format!(
529                        "The reachable set is empty at index {idx_s} during the forward pass where a_max = {a_max_curr}, a_min = {a_min_curr}"
530                    ),
531                );
532                if verboser.is_enabled(Verbosity::Debug) {
533                    crate::verbosity_log!(crate::diag::Verbosity::Summary, "{err:?}");
534                } else if verboser.is_enabled(Verbosity::Summary) {
535                    crate::verbosity_log!(
536                        crate::diag::Verbosity::Debug,
537                        "reach_set2: the forward pass failed at index {idx_s} due to infeasibility."
538                    );
539                }
540                return Err(err);
541            }
542
543            let a_prev_scale = a_max_prev.abs();
544            a_max_prev = a_max_curr.min(*a_max_k);
545            a_min_prev = a_min_curr.max(*a_min_k);
546            if verboser.is_enabled(Verbosity::Trace) {
547                crate::verbosity_log!(
548                    crate::diag::Verbosity::Summary,
549                    "\t\tAfter intersecting with backward interval at idx_s = {idx_s}: {a_min_prev} <= a[k] <= {a_max_prev}."
550                );
551            }
552            match approx_order(
553                a_max_prev,
554                a_min_prev,
555                options
556                    .a_cmp_abs_tol
557                    .max(options.a_cmp_rel_tol * a_prev_scale),
558                options.a_cmp_rel_tol,
559            ) {
560                ApproxOrdering::Less => {
561                    let err = CoppError::Infeasible(
562                        "reach_set2".into(),
563                        format!(
564                            "The reachable set is empty at index {idx_s} during the forward pass where a_max = {a_max_prev}, a_min = {a_min_prev}"
565                        ),
566                    );
567                    if verboser.is_enabled(Verbosity::Debug) {
568                        crate::verbosity_log!(crate::diag::Verbosity::Summary, "{err:?}");
569                    } else if verboser.is_enabled(Verbosity::Summary) {
570                        crate::verbosity_log!(
571                            crate::diag::Verbosity::Debug,
572                            "reach_set2: the forward pass failed at index {idx_s} due to infeasibility."
573                        );
574                    }
575                    return Err(err);
576                }
577                ApproxOrdering::Equal => {
578                    if verboser.is_enabled(Verbosity::Debug) && k < n - 1 {
579                        crate::verbosity_log!(
580                            crate::diag::Verbosity::Summary,
581                            "The forward reachable set at idx_s = {idx_s} is degenerate since a_max_prev and a_min_prev are approximately equal at {a_prev_mid}."
582                        );
583                    }
584                    a_max_prev = 0.5 * (a_max_prev + a_min_prev);
585                    a_min_prev = a_max_prev;
586                }
587                ApproxOrdering::Greater => {}
588            }
589            if verboser.is_enabled(Verbosity::Trace) {
590                crate::verbosity_log!(
591                    crate::diag::Verbosity::Summary,
592                    "\t\tForward propagated interval to next step: {a_min_prev} <= a[k] <= {a_max_prev}."
593                );
594            }
595            *a_max_k = a_max_prev;
596            *a_min_k = a_min_prev;
597        }
598    }
599
600    if verboser.is_enabled(Verbosity::Summary) {
601        crate::verbosity_log!(
602            crate::diag::Verbosity::Summary,
603            "reach_set2: {}backward total elapsed time = {}.",
604            if BIDIRECTION { "forward + " } else { "" },
605            format_duration_human(verboser.elapsed())
606        );
607    }
608
609    Ok(ReachSet2 { a_max, a_min })
610}
611
612/// Backward propagation to compute feasible `a[k]` bounds at the current step given the next step's `a[k+1]=a_next` bounds.
613/// a_b.0 * a[k+1] + a_b.1 * a[k] <= a_b.2
614/// The rows must be unit-normalized; the first three are `a_next <= a_max_next`,
615/// `-a_next <= -a_min_next` and `a[k] <= amax[k]`.
616/// Returns (a_max_curr, a_min_curr)
617fn backward_bound_a_next<const MAX: bool, const MIN: bool>(
618    a_b: &mut [(f64, f64, f64)],
619    a_next_mid: f64,
620    lp_fea_tol: f64,
621) -> (f64, f64) {
622    // The incremental LP only moves the initial point down along `a[k]`, so it must start
623    // from a value no smaller than the true maximum. Bound `a[k]` from above using every
624    // row with a positive `a[k]` coefficient over the `a_next` box; `LP_BOUND` is only a
625    // fallback for the degenerate case where nothing bounds `a[k]`.
626    let a_next_abs = a_b[0].2.abs().max(a_b[1].2.abs());
627    let a_curr_upper = a_b
628        .iter()
629        .skip(2)
630        .filter(|&&(_, b, _)| b > EPS_ZERO)
631        .map(|&(a, b, c)| (c + a.abs() * a_next_abs) / b)
632        .fold(f64::INFINITY, f64::min);
633    let warm_start = Lp2dWarmStart {
634        x0: (
635            a_next_mid,
636            if a_curr_upper.is_finite() {
637                a_curr_upper
638            } else {
639                LP_BOUND
640            },
641        ),
642        skip: 3,
643    };
644
645    let a_curr_max = if MAX {
646        let (_, a_curr_max) = lp_2d_incre_max_y::<_, false>(
647            a_b,
648            &warm_start,
649            &LpToleranceOptions::with_feas_tol(lp_fea_tol),
650        );
651        a_curr_max
652    } else {
653        f64::INFINITY
654    };
655
656    let a_curr_min = if MIN {
657        // Better than transform the sign inside the LP kernel itself.
658        a_b.iter_mut().for_each(|(_, b, _)| {
659            *b = -*b;
660        });
661        let (_, a_curr_min_neg) = lp_2d_incre_max_y::<_, false>(
662            a_b,
663            &warm_start,
664            &LpToleranceOptions::with_feas_tol(lp_fea_tol),
665        );
666        -(a_curr_min_neg.min(0.0))
667    } else {
668        0.0
669    };
670
671    (a_curr_max, a_curr_min)
672}
673
674/// Builder for [`ReachSet2Options`](crate::solver::reach_set2::ReachSet2Options).
675pub struct ReachSet2OptionsBuilder {
676    /// Feasibility tolerance used by LP subproblems in reachable-set computation.
677    pub lp_feas_tol: f64,
678    /// Absolute tolerance for comparing interval bounds `a_max` and `a_min`.
679    pub a_cmp_abs_tol: f64,
680    /// Relative tolerance for comparing interval bounds `a_max` and `a_min`.
681    pub a_cmp_rel_tol: f64,
682    /// Verbosity level for diagnostics during reachability analysis.
683    pub verbosity: Verbosity,
684}
685
686impl Default for ReachSet2OptionsBuilder {
687    #[inline]
688    fn default() -> Self {
689        Self {
690            lp_feas_tol: 1e-8,
691            a_cmp_abs_tol: 1e-8,
692            a_cmp_rel_tol: 1e-8,
693            verbosity: Verbosity::default(),
694        }
695    }
696}
697
698impl ReachSet2OptionsBuilder {
699    /// Create a new [`ReachSet2OptionsBuilder`](crate::solver::reach_set2::ReachSet2OptionsBuilder) with default values.
700    pub fn new() -> Self {
701        Default::default()
702    }
703
704    /// Set the tolerance for checking the feasibility of the linear program.
705    /// The default value is 1E-8.
706    pub fn lp_feas_tol(mut self, tol: f64) -> Self {
707        self.lp_feas_tol = tol;
708        self
709    }
710
711    /// Set the absolute tolerance for comparing `a_max` and `a_min` to determine whether the reachable set is empty (`a_max < a_min`) or degenerated (`a_max == a_min`).
712    /// Let `tol = max(a_cmp_abs_tol, a_cmp_rel_tol * max(|a_max|, |a_min|, |a_nbr|))`, where `a_nbr` is the
713    /// neighbouring state the interval was propagated from (its rounding is inherited by the endpoints).
714    /// + If `a_max < a_min - tol`, then the reachable set is empty.
715    /// + If `a_max > a_min + tol`, then the reachable set is non-degenerated.
716    /// + Otherwise, the reachable set is degenerated into a single point.
717    ///
718    /// The default value is 1E-8.
719    #[inline]
720    pub fn a_cmp_abs_tol(mut self, tol: f64) -> Self {
721        self.a_cmp_abs_tol = tol;
722        self
723    }
724
725    /// Set the relative tolerance for comparing `a_max` and `a_min`. More details refer to `a_cmp_abs_tol`.
726    /// The default value is 1E-8.
727    #[inline]
728    pub fn a_cmp_rel_tol(mut self, tol: f64) -> Self {
729        self.a_cmp_rel_tol = tol;
730        self
731    }
732
733    /// Set the verbosity level for logging. More details refer to [`Verbosity`](crate::diag::Verbosity).
734    /// The default value is [`Verbosity::Silent`](crate::diag::Verbosity::Silent).
735    #[inline]
736    pub fn verbosity(mut self, verbosity: Verbosity) -> Self {
737        self.verbosity = verbosity;
738        self
739    }
740
741    /// Build the [`ReachSet2Options`](crate::solver::reach_set2::ReachSet2Options) from the builder where the validity of the options is checked.
742    #[inline]
743    pub fn build(self) -> Result<ReachSet2Options, CoppError> {
744        self.validate()?;
745        Ok(ReachSet2Options {
746            lp_feas_tol: self.lp_feas_tol,
747            a_cmp_abs_tol: self.a_cmp_abs_tol,
748            a_cmp_rel_tol: self.a_cmp_rel_tol,
749            verbosity: self.verbosity,
750        })
751    }
752
753    /// This function checks the validity of the options and returns an error if any option is invalid.
754    #[inline]
755    pub fn validate(&self) -> Result<(), CoppError> {
756        check_strictly_positive("ReachSet2OptionsBuilder", "lp_feas_tol", self.lp_feas_tol)?;
757        check_abs_rel_tol(
758            "ReachSet2OptionsBuilder",
759            "a_cmp_abs_tol",
760            self.a_cmp_abs_tol,
761            "a_cmp_rel_tol",
762            self.a_cmp_rel_tol,
763        )?;
764        Ok(())
765    }
766}
767
768/// The options for [`reach_set2`](crate::solver::reach_set2).
769pub struct ReachSet2Options {
770    pub(crate) lp_feas_tol: f64,
771    pub(crate) a_cmp_abs_tol: f64,
772    pub(crate) a_cmp_rel_tol: f64,
773    pub(crate) verbosity: Verbosity,
774}
775
776impl ReachSet2Options {
777    #[inline]
778    /// Return the LP feasibility tolerance used by reachability subproblems.
779    pub fn lp_feas_tol(&self) -> f64 {
780        self.lp_feas_tol
781    }
782    #[inline]
783    /// Return the absolute tolerance used for comparing `a` bounds.
784    pub fn a_cmp_abs_tol(&self) -> f64 {
785        self.a_cmp_abs_tol
786    }
787    #[inline]
788    /// Return the relative tolerance used for comparing `a` bounds.
789    pub fn a_cmp_rel_tol(&self) -> f64 {
790        self.a_cmp_rel_tol
791    }
792    #[inline]
793    /// Return the verbosity level used for reach-set diagnostics.
794    pub fn verbosity(&self) -> Verbosity {
795        self.verbosity
796    }
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use crate::copp::copp2::stable::basic::Topp2ProblemBuilder;
803    use crate::copp::copp2::stable::reach_set2::reach_set2_backward;
804    use crate::copp::copp2::stable::topp2_ra::topp2_ra;
805    use crate::path::{add_symmetric_axial_limits_for_test, lissajous_path_for_test};
806    use crate::robot::robot_core::Robot;
807
808    #[test]
809    fn test_reach_set2() -> Result<(), CoppError> {
810        let dim = 7;
811        let n: usize = 1000;
812
813        let options = ReachSet2OptionsBuilder::new()
814            .lp_feas_tol(1E-9)
815            .a_cmp_abs_tol(1E-9)
816            .a_cmp_rel_tol(1E-9)
817            .verbosity(Verbosity::Summary)
818            .build()?;
819
820        let mut robot = Robot::with_capacity(dim, n);
821        let mut rng = rand::rng();
822
823        let (s, path, _, _) = lissajous_path_for_test(dim, n, &mut rng).map_err(|e| {
824            CoppError::InvalidInput(
825                "test_reach_set2_with_path_helper".into(),
826                format!("failed to generate test path: {e}"),
827            )
828        })?;
829
830        robot
831            .with_s(&s.as_view())?
832            .with_q_from_path_3rd(&path, 0, n)?;
833
834        add_symmetric_axial_limits_for_test(&mut robot, 1.0, 1.0, Some(5.0)).map_err(|e| {
835            CoppError::InvalidInput(
836                "test_reach_set2_with_path_helper".into(),
837                format!("failed to add symmetric axial limits: {e}"),
838            )
839        })?;
840
841        let topp2_problem = Topp2ProblemBuilder::new(&robot, (0, n - 1), (0.0, 0.0)).build()?;
842
843        let reach_set_back = reach_set2_backward(&topp2_problem, &options)?;
844        let reach_set_for = reach_set2_bidirectional(&topp2_problem, &options)?;
845        let a_ra = topp2_ra(&topp2_problem, &options)?;
846
847        crate::verbosity_log!(
848            crate::diag::Verbosity::Summary,
849            "a_max_back.len() = {};",
850            reach_set_back.a_max.len()
851        );
852        crate::verbosity_log!(
853            crate::diag::Verbosity::Summary,
854            "a_max_for.len() = {};",
855            reach_set_for.a_max.len()
856        );
857        crate::verbosity_log!(
858            crate::diag::Verbosity::Summary,
859            "a_ra.len() = {};",
860            a_ra.len()
861        );
862
863        Ok(())
864    }
865}