1use rust_robotics_core::{RoboticsError, RoboticsResult};
32
33#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct AgentSpec {
36 pub preferred: [f64; 2],
38 pub offset: [f64; 2],
40 pub weight: f64,
42 pub lower: [f64; 2],
44 pub upper: [f64; 2],
46}
47
48impl AgentSpec {
49 pub fn new(preferred: [f64; 2], offset: [f64; 2]) -> Self {
51 Self {
52 preferred,
53 offset,
54 weight: 1.0,
55 lower: [f64::NEG_INFINITY; 2],
56 upper: [f64::INFINITY; 2],
57 }
58 }
59
60 pub fn with_box(mut self, lower: [f64; 2], upper: [f64; 2]) -> Self {
62 self.lower = lower;
63 self.upper = upper;
64 self
65 }
66
67 pub fn with_weight(mut self, weight: f64) -> Self {
69 self.weight = weight;
70 self
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct AdmmConfig {
77 pub rho: f64,
79 pub max_iters: usize,
81 pub tol: f64,
83}
84
85impl Default for AdmmConfig {
86 fn default() -> Self {
87 Self {
88 rho: 1.0,
89 max_iters: 200,
90 tol: 1e-6,
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub struct AdmmReport {
98 pub positions: Vec<[f64; 2]>,
100 pub center: [f64; 2],
102 pub primal_residuals: Vec<f64>,
104 pub dual_residuals: Vec<f64>,
106 pub iterations: usize,
108}
109
110fn clamp2(v: [f64; 2], lo: [f64; 2], hi: [f64; 2]) -> [f64; 2] {
111 [v[0].clamp(lo[0], hi[0]), v[1].clamp(lo[1], hi[1])]
112}
113
114pub fn solve_formation_consensus(
116 config: AdmmConfig,
117 agents: &[AgentSpec],
118) -> RoboticsResult<AdmmReport> {
119 if agents.is_empty() {
120 return Err(RoboticsError::InvalidParameter(
121 "consensus ADMM needs at least one agent".to_string(),
122 ));
123 }
124 if !config.rho.is_finite() || config.rho <= 0.0 {
125 return Err(RoboticsError::InvalidParameter(
126 "consensus ADMM rho must be finite and positive".to_string(),
127 ));
128 }
129 if config.max_iters == 0 {
130 return Err(RoboticsError::InvalidParameter(
131 "consensus ADMM max_iters must be positive".to_string(),
132 ));
133 }
134 for a in agents {
135 if !a.weight.is_finite() || a.weight <= 0.0 {
136 return Err(RoboticsError::InvalidParameter(
137 "consensus ADMM agent weight must be finite and positive".to_string(),
138 ));
139 }
140 if a.lower[0] > a.upper[0] || a.lower[1] > a.upper[1] {
141 return Err(RoboticsError::InvalidParameter(
142 "consensus ADMM agent box lower must not exceed upper".to_string(),
143 ));
144 }
145 }
146
147 let n = agents.len();
148 let rho = config.rho;
149 let mut x = vec![[0.0; 2]; n];
150 let mut u = vec![[0.0; 2]; n];
151 let mut z = [0.0; 2];
153 for a in agents {
154 z[0] += (a.preferred[0] - a.offset[0]) / n as f64;
155 z[1] += (a.preferred[1] - a.offset[1]) / n as f64;
156 }
157 for (xi, a) in x.iter_mut().zip(agents) {
158 *xi = clamp2([z[0] + a.offset[0], z[1] + a.offset[1]], a.lower, a.upper);
159 }
160
161 let mut primal_residuals = Vec::new();
162 let mut dual_residuals = Vec::new();
163 let mut iterations = 0;
164
165 for _ in 0..config.max_iters {
166 iterations += 1;
167 for (xi, (a, ui)) in x.iter_mut().zip(agents.iter().zip(u.iter())) {
170 let w = a.weight;
171 for k in 0..2 {
172 let target = (w * a.preferred[k] + rho * (z[k] + a.offset[k] - ui[k])) / (w + rho);
173 xi[k] = target;
174 }
175 *xi = clamp2(*xi, a.lower, a.upper);
176 }
177
178 let z_prev = z;
180 z = [0.0; 2];
181 for (xi, (a, ui)) in x.iter().zip(agents.iter().zip(u.iter())) {
182 for k in 0..2 {
183 z[k] += (xi[k] - a.offset[k] + ui[k]) / n as f64;
184 }
185 }
186
187 let mut primal_sq = 0.0;
189 for (xi, (a, ui)) in x.iter().zip(agents.iter().zip(u.iter_mut())) {
190 for k in 0..2 {
191 let r = xi[k] - z[k] - a.offset[k];
192 ui[k] += r;
193 primal_sq += r * r;
194 }
195 }
196 let dz0 = z[0] - z_prev[0];
197 let dz1 = z[1] - z_prev[1];
198 let dual = rho * ((n as f64) * (dz0 * dz0 + dz1 * dz1)).sqrt();
199 let primal = primal_sq.sqrt();
200 primal_residuals.push(primal);
201 dual_residuals.push(dual);
202
203 if primal < config.tol && dual < config.tol {
204 break;
205 }
206 }
207
208 Ok(AdmmReport {
209 positions: x,
210 center: z,
211 primal_residuals,
212 dual_residuals,
213 iterations,
214 })
215}
216
217#[derive(Debug, Clone, PartialEq)]
219pub struct GraphConsensusReport {
220 pub positions: Vec<[f64; 2]>,
222 pub center: [f64; 2],
224 pub disagreement: Vec<f64>,
226 pub iterations: usize,
228}
229
230#[allow(clippy::needless_range_loop)]
243pub fn solve_graph_consensus(
244 config: AdmmConfig,
245 agents: &[AgentSpec],
246 edges: &[(usize, usize)],
247) -> RoboticsResult<GraphConsensusReport> {
248 if agents.is_empty() {
249 return Err(RoboticsError::InvalidParameter(
250 "graph consensus needs at least one agent".to_string(),
251 ));
252 }
253 if !config.rho.is_finite() || config.rho <= 0.0 || config.max_iters == 0 {
254 return Err(RoboticsError::InvalidParameter(
255 "graph consensus rho must be positive and max_iters > 0".to_string(),
256 ));
257 }
258 let n = agents.len();
259 for a in agents {
260 if !a.weight.is_finite() || a.weight <= 0.0 {
261 return Err(RoboticsError::InvalidParameter(
262 "graph consensus agent weight must be finite and positive".to_string(),
263 ));
264 }
265 }
266 let mut neighbors = vec![Vec::new(); n];
267 for &(i, j) in edges {
268 if i >= n || j >= n || i == j {
269 return Err(RoboticsError::InvalidParameter(
270 "graph consensus edge endpoints out of range or self-loop".to_string(),
271 ));
272 }
273 neighbors[i].push(j);
274 neighbors[j].push(i);
275 }
276
277 let rho = config.rho;
278 let ashift: Vec<[f64; 2]> = agents
280 .iter()
281 .map(|a| [a.preferred[0] - a.offset[0], a.preferred[1] - a.offset[1]])
282 .collect();
283 let mut y = ashift.clone();
284 let mut alpha = vec![[0.0; 2]; n];
285
286 let project = |y_i: [f64; 2], a: &AgentSpec| -> [f64; 2] {
287 let x = clamp2(
288 [y_i[0] + a.offset[0], y_i[1] + a.offset[1]],
289 a.lower,
290 a.upper,
291 );
292 [x[0] - a.offset[0], x[1] - a.offset[1]]
293 };
294
295 let mut disagreement = Vec::new();
296 let mut iterations = 0;
297
298 for _ in 0..config.max_iters {
299 iterations += 1;
300 let y_prev = y.clone();
301 for i in 0..n {
303 let w = agents[i].weight;
304 let deg = neighbors[i].len() as f64;
305 for k in 0..2 {
306 let mut neigh_sum = 0.0;
307 for &j in &neighbors[i] {
308 neigh_sum += y_prev[i][k] + y_prev[j][k];
309 }
310 y[i][k] =
311 (w * ashift[i][k] - alpha[i][k] + rho * neigh_sum) / (w + 2.0 * rho * deg);
312 }
313 y[i] = project(y[i], &agents[i]);
314 }
315 for i in 0..n {
317 for k in 0..2 {
318 let mut diff = 0.0;
319 for &j in &neighbors[i] {
320 diff += y[i][k] - y[j][k];
321 }
322 alpha[i][k] += rho * diff;
323 }
324 }
325 let mut dis_sq = 0.0;
327 for &(i, j) in edges {
328 for k in 0..2 {
329 let d = y[i][k] - y[j][k];
330 dis_sq += d * d;
331 }
332 }
333 let mut step_sq = 0.0;
334 for i in 0..n {
335 for k in 0..2 {
336 let d = y[i][k] - y_prev[i][k];
337 step_sq += d * d;
338 }
339 }
340 disagreement.push(dis_sq.sqrt());
341 if dis_sq.sqrt() < config.tol && step_sq.sqrt() < config.tol {
342 break;
343 }
344 }
345
346 let mut center = [0.0; 2];
347 for yi in &y {
348 center[0] += yi[0] / n as f64;
349 center[1] += yi[1] / n as f64;
350 }
351 let positions: Vec<[f64; 2]> = y
352 .iter()
353 .zip(agents)
354 .map(|(yi, a)| [yi[0] + a.offset[0], yi[1] + a.offset[1]])
355 .collect();
356
357 Ok(GraphConsensusReport {
358 positions,
359 center,
360 disagreement,
361 iterations,
362 })
363}
364
365#[derive(Debug, Clone, PartialEq)]
369pub struct AgentTrajectory {
370 pub reference: Vec<[f64; 2]>,
372 pub offset: [f64; 2],
374 pub weight: f64,
376 pub lower: [f64; 2],
378 pub upper: [f64; 2],
380}
381
382impl AgentTrajectory {
383 pub fn new(reference: Vec<[f64; 2]>, offset: [f64; 2]) -> Self {
385 Self {
386 reference,
387 offset,
388 weight: 1.0,
389 lower: [f64::NEG_INFINITY; 2],
390 upper: [f64::INFINITY; 2],
391 }
392 }
393
394 pub fn with_box(mut self, lower: [f64; 2], upper: [f64; 2]) -> Self {
396 self.lower = lower;
397 self.upper = upper;
398 self
399 }
400
401 pub fn with_weight(mut self, weight: f64) -> Self {
403 self.weight = weight;
404 self
405 }
406}
407
408#[derive(Debug, Clone, PartialEq)]
410pub struct HorizonConsensusReport {
411 pub center: Vec<[f64; 2]>,
413 pub trajectories: Vec<Vec<[f64; 2]>>,
415 pub primal_residuals: Vec<f64>,
417 pub dual_residuals: Vec<f64>,
419 pub iterations: usize,
421}
422
423#[allow(clippy::needless_range_loop)]
427fn cholesky(a: &[Vec<f64>]) -> Vec<Vec<f64>> {
428 let m = a.len();
429 let mut l = vec![vec![0.0; m]; m];
430 for i in 0..m {
431 for j in 0..=i {
432 let mut sum = a[i][j];
433 for k in 0..j {
434 sum -= l[i][k] * l[j][k];
435 }
436 if i == j {
437 l[i][j] = sum.max(0.0).sqrt();
438 } else {
439 l[i][j] = sum / l[j][j];
440 }
441 }
442 }
443 l
444}
445
446fn chol_solve(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
448 let m = l.len();
449 let mut y = vec![0.0; m];
450 for i in 0..m {
451 let mut sum = b[i];
452 for k in 0..i {
453 sum -= l[i][k] * y[k];
454 }
455 y[i] = sum / l[i][i];
456 }
457 let mut x = vec![0.0; m];
458 for i in (0..m).rev() {
459 let mut sum = y[i];
460 for k in (i + 1)..m {
461 sum -= l[k][i] * x[k];
462 }
463 x[i] = sum / l[i][i];
464 }
465 x
466}
467
468#[allow(clippy::needless_range_loop)]
491pub fn solve_horizon_consensus(
492 config: AdmmConfig,
493 agents: &[AgentTrajectory],
494 smooth_weight: f64,
495 anchor: Option<[f64; 2]>,
496) -> RoboticsResult<HorizonConsensusReport> {
497 if agents.is_empty() {
498 return Err(RoboticsError::InvalidParameter(
499 "horizon consensus needs at least one agent".to_string(),
500 ));
501 }
502 if !config.rho.is_finite() || config.rho <= 0.0 || config.max_iters == 0 {
503 return Err(RoboticsError::InvalidParameter(
504 "horizon consensus rho must be positive and max_iters > 0".to_string(),
505 ));
506 }
507 if !smooth_weight.is_finite() || smooth_weight < 0.0 {
508 return Err(RoboticsError::InvalidParameter(
509 "horizon consensus smooth_weight must be finite and non-negative".to_string(),
510 ));
511 }
512 let horizon = agents[0].reference.len();
513 if horizon == 0 {
514 return Err(RoboticsError::InvalidParameter(
515 "horizon consensus reference trajectories must be non-empty".to_string(),
516 ));
517 }
518 for a in agents {
519 if a.reference.len() != horizon {
520 return Err(RoboticsError::InvalidParameter(
521 "horizon consensus reference trajectories must share one length".to_string(),
522 ));
523 }
524 if !a.weight.is_finite() || a.weight <= 0.0 {
525 return Err(RoboticsError::InvalidParameter(
526 "horizon consensus agent weight must be finite and positive".to_string(),
527 ));
528 }
529 if a.lower[0] > a.upper[0] || a.lower[1] > a.upper[1] {
530 return Err(RoboticsError::InvalidParameter(
531 "horizon consensus agent box lower must not exceed upper".to_string(),
532 ));
533 }
534 }
535 if let Some(p) = anchor {
536 if !p[0].is_finite() || !p[1].is_finite() {
537 return Err(RoboticsError::InvalidParameter(
538 "horizon consensus anchor must be finite".to_string(),
539 ));
540 }
541 }
542
543 let n = agents.len();
544 let rho = config.rho;
545 let anchored = anchor.is_some();
546
547 let mut a_mat = vec![vec![0.0; horizon]; horizon];
551 for t in 0..horizon {
552 a_mat[t][t] += rho * n as f64;
553 }
554 if smooth_weight > 0.0 {
555 for t in 1..horizon.saturating_sub(1) {
557 let idx = [t - 1, t, t + 1];
558 let coeff = [1.0, -2.0, 1.0];
559 for (a_i, &ia) in idx.iter().enumerate() {
560 for (b_i, &ib) in idx.iter().enumerate() {
561 a_mat[ia][ib] += smooth_weight * coeff[a_i] * coeff[b_i];
562 }
563 }
564 }
565 }
566
567 let free: Vec<usize> = if anchored {
569 (1..horizon).collect()
570 } else {
571 (0..horizon).collect()
572 };
573 let m = free.len();
574 let mut a_red = vec![vec![0.0; m]; m];
576 for (r, &ir) in free.iter().enumerate() {
577 for (c, &ic) in free.iter().enumerate() {
578 a_red[r][c] = a_mat[ir][ic];
579 }
580 }
581 let chol = if m > 0 { cholesky(&a_red) } else { Vec::new() };
583
584 let mut z = vec![[0.0; 2]; horizon];
586 for t in 0..horizon {
587 for a in agents {
588 z[t][0] += (a.reference[t][0] - a.offset[0]) / n as f64;
589 z[t][1] += (a.reference[t][1] - a.offset[1]) / n as f64;
590 }
591 }
592 if let Some(p) = anchor {
593 z[0] = p;
594 }
595 let mut x: Vec<Vec<[f64; 2]>> = agents
596 .iter()
597 .map(|a| {
598 (0..horizon)
599 .map(|t| {
600 clamp2(
601 [z[t][0] + a.offset[0], z[t][1] + a.offset[1]],
602 a.lower,
603 a.upper,
604 )
605 })
606 .collect()
607 })
608 .collect();
609 let mut u = vec![vec![[0.0; 2]; horizon]; n];
610
611 let mut primal_residuals = Vec::new();
612 let mut dual_residuals = Vec::new();
613 let mut iterations = 0;
614
615 for _ in 0..config.max_iters {
616 iterations += 1;
617 for (xi, (a, ui)) in x.iter_mut().zip(agents.iter().zip(u.iter())) {
619 let w = a.weight;
620 for t in 0..horizon {
621 for k in 0..2 {
622 xi[t][k] = (w * a.reference[t][k] + rho * (z[t][k] + a.offset[k] - ui[t][k]))
623 / (w + rho);
624 }
625 xi[t] = clamp2(xi[t], a.lower, a.upper);
626 }
627 }
628
629 let z_prev = z.clone();
631 for k in 0..2 {
632 let mut b_full = vec![0.0; horizon];
634 for t in 0..horizon {
635 let mut s = 0.0;
636 for (xi, (a, ui)) in x.iter().zip(agents.iter().zip(u.iter())) {
637 s += xi[t][k] - a.offset[k] + ui[t][k];
638 }
639 b_full[t] = rho * s;
640 }
641 if m == 0 {
642 continue;
643 }
644 let mut b_red = vec![0.0; m];
646 for (r, &ir) in free.iter().enumerate() {
647 let mut v = b_full[ir];
648 if anchored {
649 v -= a_mat[ir][0] * z[0][k];
650 }
651 b_red[r] = v;
652 }
653 let sol = chol_solve(&chol, &b_red);
654 for (r, &ir) in free.iter().enumerate() {
655 z[ir][k] = sol[r];
656 }
657 }
658
659 let mut primal_sq = 0.0;
661 for (xi, (a, ui)) in x.iter().zip(agents.iter().zip(u.iter_mut())) {
662 for t in 0..horizon {
663 for k in 0..2 {
664 let r = xi[t][k] - z[t][k] - a.offset[k];
665 ui[t][k] += r;
666 primal_sq += r * r;
667 }
668 }
669 }
670 let mut dz_sq = 0.0;
671 for t in 0..horizon {
672 for k in 0..2 {
673 let d = z[t][k] - z_prev[t][k];
674 dz_sq += d * d;
675 }
676 }
677 let primal = primal_sq.sqrt();
678 let dual = rho * (n as f64 * dz_sq).sqrt();
679 primal_residuals.push(primal);
680 dual_residuals.push(dual);
681 if primal < config.tol && dual < config.tol {
682 break;
683 }
684 }
685
686 Ok(HorizonConsensusReport {
687 center: z,
688 trajectories: x,
689 primal_residuals,
690 dual_residuals,
691 iterations,
692 })
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 #[test]
700 fn unconstrained_recovers_closed_form_center() {
701 let agents = [
703 AgentSpec::new([1.0, 0.0], [1.0, 0.0]),
704 AgentSpec::new([0.0, 2.0], [-1.0, 0.0]),
705 AgentSpec::new([-1.0, -1.0], [0.0, 1.0]),
706 ];
707 let report = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
708 let mut zx = 0.0;
709 let mut zy = 0.0;
710 for a in &agents {
711 zx += (a.preferred[0] - a.offset[0]) / 3.0;
712 zy += (a.preferred[1] - a.offset[1]) / 3.0;
713 }
714 assert!(
715 (report.center[0] - zx).abs() < 1e-4,
716 "{} vs {}",
717 report.center[0],
718 zx
719 );
720 assert!(
721 (report.center[1] - zy).abs() < 1e-4,
722 "{} vs {}",
723 report.center[1],
724 zy
725 );
726 for (xi, a) in report.positions.iter().zip(&agents) {
728 assert!((xi[0] - (report.center[0] + a.offset[0])).abs() < 1e-3);
729 assert!((xi[1] - (report.center[1] + a.offset[1])).abs() < 1e-3);
730 }
731 }
732
733 #[test]
734 fn residuals_decrease_to_convergence() {
735 let agents = [
736 AgentSpec::new([2.0, 1.0], [1.0, 1.0]),
737 AgentSpec::new([-2.0, 1.0], [-1.0, 1.0]),
738 AgentSpec::new([-2.0, -1.0], [-1.0, -1.0]),
739 AgentSpec::new([2.0, -1.0], [1.0, -1.0]),
740 ];
741 let report = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
742 let first = report.primal_residuals[0];
743 let last = *report.primal_residuals.last().unwrap();
744 assert!(last < first, "primal should shrink: {first} -> {last}");
745 assert!(last < 1e-5, "should converge: {last}");
746 }
747
748 #[test]
749 fn box_constraint_is_respected() {
750 let agents = [
752 AgentSpec::new([0.0, 0.0], [1.0, 0.0]),
753 AgentSpec::new([0.0, 0.0], [-1.0, 0.0]).with_box([-0.4, -1.0], [0.4, 1.0]),
754 ];
755 let report = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
756 let x1 = report.positions[1];
757 assert!(
758 x1[0] >= -0.4 - 1e-9 && x1[0] <= 0.4 + 1e-9,
759 "x {x1:?} in box"
760 );
761 }
762
763 #[test]
764 fn is_deterministic() {
765 let agents = [
766 AgentSpec::new([1.0, 0.0], [0.5, 0.5]),
767 AgentSpec::new([0.0, 1.0], [-0.5, 0.5]),
768 AgentSpec::new([-1.0, 0.0], [-0.5, -0.5]),
769 ];
770 let a = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
771 let b = solve_formation_consensus(AdmmConfig::default(), &agents).unwrap();
772 assert_eq!(a.positions, b.positions);
773 assert_eq!(a.center, b.center);
774 assert_eq!(a.primal_residuals, b.primal_residuals);
775 }
776
777 fn ring_edges(n: usize) -> Vec<(usize, usize)> {
778 (0..n).map(|i| (i, (i + 1) % n)).collect()
779 }
780
781 fn line_edges(n: usize) -> Vec<(usize, usize)> {
782 (0..n - 1).map(|i| (i, i + 1)).collect()
783 }
784
785 fn complete_edges(n: usize) -> Vec<(usize, usize)> {
786 let mut e = Vec::new();
787 for i in 0..n {
788 for j in (i + 1)..n {
789 e.push((i, j));
790 }
791 }
792 e
793 }
794
795 #[test]
796 fn graph_consensus_matches_centralized_center() {
797 let agents = [
798 AgentSpec::new([2.0, 1.0], [1.0, 1.0]),
799 AgentSpec::new([-2.0, 1.0], [-1.0, 1.0]),
800 AgentSpec::new([-2.0, -1.0], [-1.0, -1.0]),
801 AgentSpec::new([2.0, -1.0], [1.0, -1.0]),
802 ];
803 let config = AdmmConfig {
804 max_iters: 2000,
805 tol: 1e-8,
806 ..AdmmConfig::default()
807 };
808 let report = solve_graph_consensus(config, &agents, &ring_edges(4)).unwrap();
809 let mut zx = 0.0;
811 let mut zy = 0.0;
812 for a in &agents {
813 zx += (a.preferred[0] - a.offset[0]) / 4.0;
814 zy += (a.preferred[1] - a.offset[1]) / 4.0;
815 }
816 assert!(
817 (report.center[0] - zx).abs() < 1e-3,
818 "{} vs {}",
819 report.center[0],
820 zx
821 );
822 assert!(
823 (report.center[1] - zy).abs() < 1e-3,
824 "{} vs {}",
825 report.center[1],
826 zy
827 );
828 assert!(*report.disagreement.last().unwrap() < 1e-6);
829 }
830
831 #[test]
832 fn connectivity_speeds_convergence() {
833 let agents: Vec<AgentSpec> = (0..6)
834 .map(|i| {
835 let f = i as f64;
836 AgentSpec::new([f - 2.5, (f * 1.3).sin()], [0.0, 0.0])
837 })
838 .collect();
839 let config = AdmmConfig {
840 max_iters: 5000,
841 tol: 1e-6,
842 ..AdmmConfig::default()
843 };
844 let line = solve_graph_consensus(config, &agents, &line_edges(6)).unwrap();
845 let complete = solve_graph_consensus(config, &agents, &complete_edges(6)).unwrap();
846 assert!(
847 complete.iterations < line.iterations,
848 "complete graph ({}) should converge faster than line ({})",
849 complete.iterations,
850 line.iterations
851 );
852 }
853
854 #[test]
855 fn graph_consensus_is_deterministic() {
856 let agents = [
857 AgentSpec::new([1.0, 0.0], [0.5, 0.5]),
858 AgentSpec::new([0.0, 1.0], [-0.5, 0.5]),
859 AgentSpec::new([-1.0, 0.0], [-0.5, -0.5]),
860 ];
861 let e = ring_edges(3);
862 let a = solve_graph_consensus(AdmmConfig::default(), &agents, &e).unwrap();
863 let b = solve_graph_consensus(AdmmConfig::default(), &agents, &e).unwrap();
864 assert_eq!(a.positions, b.positions);
865 assert_eq!(a.disagreement, b.disagreement);
866 }
867
868 #[test]
869 fn graph_consensus_rejects_bad_edges() {
870 let agents = [AgentSpec::new([0.0, 0.0], [0.0, 0.0])];
871 assert!(solve_graph_consensus(AdmmConfig::default(), &agents, &[(0, 5)]).is_err());
872 assert!(solve_graph_consensus(AdmmConfig::default(), &agents, &[(0, 0)]).is_err());
873 }
874
875 #[test]
876 fn horizon_one_step_no_smoothing_matches_static() {
877 let statics = [
880 AgentSpec::new([2.0, 1.0], [1.0, 1.0]),
881 AgentSpec::new([-2.0, 1.0], [-1.0, 1.0]),
882 AgentSpec::new([-2.0, -1.0], [-1.0, -1.0]),
883 ];
884 let trajs: Vec<AgentTrajectory> = statics
885 .iter()
886 .map(|s| AgentTrajectory::new(vec![s.preferred], s.offset))
887 .collect();
888 let stat = solve_formation_consensus(AdmmConfig::default(), &statics).unwrap();
889 let horiz = solve_horizon_consensus(AdmmConfig::default(), &trajs, 0.0, None).unwrap();
890 assert!((horiz.center[0][0] - stat.center[0]).abs() < 1e-6);
891 assert!((horiz.center[0][1] - stat.center[1]).abs() < 1e-6);
892 }
893
894 #[test]
895 #[allow(clippy::needless_range_loop)]
896 fn smoothing_reduces_center_acceleration() {
897 let h = 16;
899 let refs: Vec<[f64; 2]> = (0..h)
900 .map(|t| {
901 let f = t as f64;
902 [f * 0.3, if t % 2 == 0 { 1.0 } else { -1.0 }]
903 })
904 .collect();
905 let agents = vec![AgentTrajectory::new(refs, [0.0, 0.0])];
906 let config = AdmmConfig {
907 max_iters: 2000,
908 tol: 1e-9,
909 ..AdmmConfig::default()
910 };
911 let accel = |r: &HorizonConsensusReport| {
912 let z = &r.center;
913 let mut s = 0.0;
914 for t in 1..z.len() - 1 {
915 for k in 0..2 {
916 let a = z[t + 1][k] - 2.0 * z[t][k] + z[t - 1][k];
917 s += a * a;
918 }
919 }
920 s
921 };
922 let stiff = solve_horizon_consensus(config, &agents, 0.0, None).unwrap();
923 let smooth = solve_horizon_consensus(config, &agents, 20.0, None).unwrap();
924 assert!(
925 accel(&smooth) < 0.25 * accel(&stiff),
926 "smoothing should cut acceleration: {} vs {}",
927 accel(&smooth),
928 accel(&stiff)
929 );
930 }
931
932 #[test]
933 fn anchor_fixes_first_center_step() {
934 let h = 8;
935 let refs: Vec<[f64; 2]> = (0..h).map(|t| [t as f64, 0.0]).collect();
936 let agents = vec![AgentTrajectory::new(refs, [0.0, 0.0])];
937 let anchor = [-5.0, 2.0];
938 let report = solve_horizon_consensus(
939 AdmmConfig {
940 max_iters: 500,
941 ..AdmmConfig::default()
942 },
943 &agents,
944 5.0,
945 Some(anchor),
946 )
947 .unwrap();
948 assert!((report.center[0][0] - anchor[0]).abs() < 1e-9);
949 assert!((report.center[0][1] - anchor[1]).abs() < 1e-9);
950 }
951
952 #[test]
953 fn horizon_box_is_respected() {
954 let h = 6;
955 let refs: Vec<[f64; 2]> = (0..h).map(|t| [t as f64, 3.0]).collect();
956 let agents = vec![
957 AgentTrajectory::new(refs.clone(), [0.0, 0.5]),
958 AgentTrajectory::new(refs, [0.0, -0.5]).with_box([-100.0, -1.0], [100.0, 1.0]),
959 ];
960 let report = solve_horizon_consensus(AdmmConfig::default(), &agents, 1.0, None).unwrap();
961 for p in &report.trajectories[1] {
962 assert!(p[1] >= -1.0 - 1e-9 && p[1] <= 1.0 + 1e-9, "y {p:?} in box");
963 }
964 }
965
966 #[test]
967 fn horizon_consensus_is_deterministic() {
968 let refs: Vec<[f64; 2]> = (0..10)
969 .map(|t| [t as f64 * 0.5, (t as f64).sin()])
970 .collect();
971 let agents = vec![
972 AgentTrajectory::new(refs.clone(), [0.5, 0.5]),
973 AgentTrajectory::new(refs, [-0.5, -0.5]),
974 ];
975 let a =
976 solve_horizon_consensus(AdmmConfig::default(), &agents, 3.0, Some([0.0, 0.0])).unwrap();
977 let b =
978 solve_horizon_consensus(AdmmConfig::default(), &agents, 3.0, Some([0.0, 0.0])).unwrap();
979 assert_eq!(a.center, b.center);
980 assert_eq!(a.trajectories, b.trajectories);
981 assert_eq!(a.primal_residuals, b.primal_residuals);
982 }
983
984 #[test]
985 fn horizon_rejects_invalid_input() {
986 assert!(solve_horizon_consensus(AdmmConfig::default(), &[], 1.0, None).is_err());
987 let a = vec![AgentTrajectory::new(vec![[0.0, 0.0]], [0.0, 0.0])];
988 assert!(solve_horizon_consensus(AdmmConfig::default(), &a, -1.0, None).is_err());
990 let mixed = vec![
992 AgentTrajectory::new(vec![[0.0, 0.0], [1.0, 0.0]], [0.0, 0.0]),
993 AgentTrajectory::new(vec![[0.0, 0.0]], [0.0, 0.0]),
994 ];
995 assert!(solve_horizon_consensus(AdmmConfig::default(), &mixed, 1.0, None).is_err());
996 let empty = vec![AgentTrajectory::new(vec![], [0.0, 0.0])];
998 assert!(solve_horizon_consensus(AdmmConfig::default(), &empty, 1.0, None).is_err());
999 }
1000
1001 #[test]
1002 fn rejects_invalid_input() {
1003 assert!(solve_formation_consensus(AdmmConfig::default(), &[]).is_err());
1004 let bad_rho = AdmmConfig {
1005 rho: 0.0,
1006 ..AdmmConfig::default()
1007 };
1008 assert!(
1009 solve_formation_consensus(bad_rho, &[AgentSpec::new([0.0, 0.0], [0.0, 0.0])]).is_err()
1010 );
1011 }
1012}