Skip to main content

scirs2_optimize/global/
particle_swarm.rs

1//! Particle Swarm Optimization (PSO) algorithm for global optimization
2//!
3//! PSO is a population-based stochastic optimization algorithm inspired by
4//! the social behavior of bird flocking or fish schooling. Each particle
5//! moves through the search space with a velocity influenced by its own
6//! best position and the global best position found by the swarm.
7//!
8//! Features:
9//! - Standard PSO with inertia weight
10//! - Constriction coefficient variant (Clerc & Kennedy)
11//! - Ring and star topology for neighborhood communication
12//! - Adaptive parameter scheduling (inertia weight, c1/c2)
13//! - Stagnation detection with random re-initialization
14//! - Velocity clamping with dynamic bounds
15
16use crate::error::OptimizeError;
17use crate::unconstrained::OptimizeResult;
18use scirs2_core::ndarray::{Array1, ArrayView1};
19use scirs2_core::random::rngs::StdRng;
20use scirs2_core::random::{rng, Rng, RngExt, SeedableRng};
21
22/// Swarm topology for neighborhood communication
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub enum Topology {
25    /// Star (global best): all particles communicate with all others
26    Star,
27    /// Ring: each particle communicates with its immediate neighbors
28    Ring,
29}
30
31impl Default for Topology {
32    fn default() -> Self {
33        Topology::Star
34    }
35}
36
37/// Velocity update strategy
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum VelocityStrategy {
40    /// Standard inertia weight formulation
41    InertiaWeight,
42    /// Clerc's constriction coefficient
43    Constriction,
44}
45
46impl Default for VelocityStrategy {
47    fn default() -> Self {
48        VelocityStrategy::InertiaWeight
49    }
50}
51
52/// Options for Particle Swarm Optimization
53#[derive(Debug, Clone)]
54pub struct ParticleSwarmOptions {
55    /// Number of particles in the swarm
56    pub swarm_size: usize,
57    /// Maximum number of iterations
58    pub maxiter: usize,
59    /// Cognitive parameter (attraction to personal best)
60    pub c1: f64,
61    /// Social parameter (attraction to global best)
62    pub c2: f64,
63    /// Inertia weight (used in InertiaWeight strategy)
64    pub w: f64,
65    /// Minimum velocity fraction of range
66    pub vmin: f64,
67    /// Maximum velocity fraction of range
68    pub vmax: f64,
69    /// Tolerance for convergence
70    pub tol: f64,
71    /// Random seed for reproducibility
72    pub seed: Option<u64>,
73    /// Whether to use adaptive parameters (linearly decrease w, adapt c1/c2)
74    pub adaptive: bool,
75    /// Swarm topology
76    pub topology: Topology,
77    /// Velocity update strategy
78    pub velocity_strategy: VelocityStrategy,
79    /// Number of stagnation iterations before re-initialization of worst particles
80    pub stagnation_limit: usize,
81    /// Fraction of particles to re-initialize on stagnation
82    pub reinit_fraction: f64,
83    /// Ring neighborhood size (for Ring topology, must be odd)
84    pub ring_neighbors: usize,
85}
86
87impl Default for ParticleSwarmOptions {
88    fn default() -> Self {
89        Self {
90            swarm_size: 50,
91            maxiter: 500,
92            c1: 2.0,
93            c2: 2.0,
94            w: 0.9,
95            vmin: -0.5,
96            vmax: 0.5,
97            tol: 1e-8,
98            seed: None,
99            adaptive: false,
100            topology: Topology::Star,
101            velocity_strategy: VelocityStrategy::InertiaWeight,
102            stagnation_limit: 20,
103            reinit_fraction: 0.2,
104            ring_neighbors: 3,
105        }
106    }
107}
108
109/// Bounds for variables
110pub type Bounds = Vec<(f64, f64)>;
111
112/// Particle in the swarm
113#[derive(Debug, Clone)]
114struct Particle {
115    /// Current position
116    position: Array1<f64>,
117    /// Current velocity
118    velocity: Array1<f64>,
119    /// Personal best position
120    best_position: Array1<f64>,
121    /// Personal best value
122    best_value: f64,
123}
124
125/// Particle Swarm Optimization solver
126pub struct ParticleSwarm<F>
127where
128    F: Fn(&ArrayView1<f64>) -> f64 + Clone,
129{
130    func: F,
131    bounds: Bounds,
132    options: ParticleSwarmOptions,
133    ndim: usize,
134    particles: Vec<Particle>,
135    global_best_position: Array1<f64>,
136    global_best_value: f64,
137    rng: StdRng,
138    nfev: usize,
139    iteration: usize,
140    inertia_weight: f64,
141    /// Constriction coefficient (chi)
142    constriction_chi: f64,
143    /// Counter for stagnation detection (iterations without global improvement)
144    stagnation_counter: usize,
145    /// Previous global best for stagnation detection
146    prev_global_best: f64,
147}
148
149impl<F> ParticleSwarm<F>
150where
151    F: Fn(&ArrayView1<f64>) -> f64 + Clone,
152{
153    /// Create new Particle Swarm Optimization solver
154    pub fn new(func: F, bounds: Bounds, options: ParticleSwarmOptions) -> Self {
155        let ndim = bounds.len();
156        let seed = options.seed.unwrap_or_else(|| rng().random());
157        let mut rng_gen = StdRng::seed_from_u64(seed);
158
159        // Compute constriction coefficient (Clerc & Kennedy 2002)
160        let phi = options.c1 + options.c2;
161        let constriction_chi = if phi > 4.0 {
162            2.0 / (phi - 2.0 + (phi * phi - 4.0 * phi).sqrt()).abs()
163        } else {
164            0.7298 // Default constriction factor
165        };
166
167        // Initialize particles
168        let mut particles = Vec::with_capacity(options.swarm_size);
169        let mut global_best_position = Array1::zeros(ndim);
170        let mut global_best_value = f64::INFINITY;
171        let mut nfev = 0;
172
173        for _ in 0..options.swarm_size {
174            // Random initial position within bounds
175            let mut position = Array1::zeros(ndim);
176            let mut velocity = Array1::zeros(ndim);
177
178            for j in 0..ndim {
179                let (lb, ub) = bounds[j];
180                position[j] = rng_gen.random_range(lb..ub);
181                velocity[j] = rng_gen.random_range(options.vmin..options.vmax) * (ub - lb);
182            }
183
184            // Evaluate initial position
185            let value = func(&position.view());
186            nfev += 1;
187
188            // Update global best if necessary
189            if value < global_best_value {
190                global_best_value = value;
191                global_best_position = position.clone();
192            }
193
194            particles.push(Particle {
195                position: position.clone(),
196                velocity,
197                best_position: position,
198                best_value: value,
199            });
200        }
201
202        Self {
203            func,
204            bounds,
205            options: options.clone(),
206            ndim,
207            particles,
208            global_best_position,
209            global_best_value,
210            rng: rng_gen,
211            nfev,
212            iteration: 0,
213            inertia_weight: options.w,
214            constriction_chi,
215            stagnation_counter: 0,
216            prev_global_best: global_best_value,
217        }
218    }
219
220    /// Update the inertia weight adaptively
221    fn update_inertia_weight(&mut self) {
222        if self.options.adaptive {
223            // Linear decrease from w to 0.4
224            let w_max = self.options.w;
225            let w_min = 0.4;
226            let progress = self.iteration as f64 / self.options.maxiter as f64;
227            self.inertia_weight = w_max - (w_max - w_min) * progress;
228        }
229    }
230
231    /// Get adaptive cognitive/social parameters
232    fn adaptive_coefficients(&self) -> (f64, f64) {
233        if !self.options.adaptive {
234            return (self.options.c1, self.options.c2);
235        }
236
237        // Adaptive: c1 decreases from 2.5 to 0.5, c2 increases from 0.5 to 2.5
238        let progress = self.iteration as f64 / self.options.maxiter as f64;
239        let c1 = 2.5 - 2.0 * progress;
240        let c2 = 0.5 + 2.0 * progress;
241        (c1, c2)
242    }
243
244    /// Get neighborhood best for a particle using the configured topology
245    fn neighborhood_best(&self, particle_idx: usize) -> &Array1<f64> {
246        match self.options.topology {
247            Topology::Star => &self.global_best_position,
248            Topology::Ring => {
249                let n = self.particles.len();
250                let half_neighbors = self.options.ring_neighbors / 2;
251                let mut best_idx = particle_idx;
252                let mut best_val = self.particles[particle_idx].best_value;
253
254                for offset in 1..=half_neighbors {
255                    // Left neighbor (wrapping)
256                    let left = if particle_idx >= offset {
257                        particle_idx - offset
258                    } else {
259                        n - (offset - particle_idx)
260                    };
261                    if self.particles[left].best_value < best_val {
262                        best_val = self.particles[left].best_value;
263                        best_idx = left;
264                    }
265
266                    // Right neighbor (wrapping)
267                    let right = (particle_idx + offset) % n;
268                    if self.particles[right].best_value < best_val {
269                        best_val = self.particles[right].best_value;
270                        best_idx = right;
271                    }
272                }
273
274                &self.particles[best_idx].best_position
275            }
276        }
277    }
278
279    /// Update particle velocity and position
280    fn update_particle(&mut self, idx: usize) {
281        let (c1, c2) = self.adaptive_coefficients();
282        let nbest = self.neighborhood_best(idx).clone();
283
284        let particle = &mut self.particles[idx];
285
286        // Update velocity
287        match self.options.velocity_strategy {
288            VelocityStrategy::InertiaWeight => {
289                for j in 0..self.ndim {
290                    let r1 = self.rng.random_range(0.0..1.0);
291                    let r2 = self.rng.random_range(0.0..1.0);
292
293                    let cognitive = c1 * r1 * (particle.best_position[j] - particle.position[j]);
294                    let social = c2 * r2 * (nbest[j] - particle.position[j]);
295
296                    particle.velocity[j] =
297                        self.inertia_weight * particle.velocity[j] + cognitive + social;
298                }
299            }
300            VelocityStrategy::Constriction => {
301                for j in 0..self.ndim {
302                    let r1 = self.rng.random_range(0.0..1.0);
303                    let r2 = self.rng.random_range(0.0..1.0);
304
305                    let cognitive = c1 * r1 * (particle.best_position[j] - particle.position[j]);
306                    let social = c2 * r2 * (nbest[j] - particle.position[j]);
307
308                    particle.velocity[j] =
309                        self.constriction_chi * (particle.velocity[j] + cognitive + social);
310                }
311            }
312        }
313
314        // Clamp velocity
315        for j in 0..self.ndim {
316            let (lb, ub) = self.bounds[j];
317            let range = ub - lb;
318            let v_max = self.options.vmax * range;
319            let v_min = self.options.vmin * range;
320            particle.velocity[j] = particle.velocity[j].max(v_min).min(v_max);
321        }
322
323        // Update position
324        for j in 0..self.ndim {
325            particle.position[j] += particle.velocity[j];
326
327            // Enforce bounds with reflection
328            let (lb, ub) = self.bounds[j];
329            if particle.position[j] < lb {
330                particle.position[j] = lb + (lb - particle.position[j]).min(ub - lb);
331                particle.velocity[j] *= -0.5; // Partial reflection
332            } else if particle.position[j] > ub {
333                particle.position[j] = ub - (particle.position[j] - ub).min(ub - lb);
334                particle.velocity[j] *= -0.5; // Partial reflection
335            }
336
337            // Final clamping to ensure bounds
338            particle.position[j] = particle.position[j].max(lb).min(ub);
339        }
340
341        // Evaluate new position
342        let value = (self.func)(&particle.position.view());
343        self.nfev += 1;
344
345        // Update personal best
346        if value < particle.best_value {
347            particle.best_value = value;
348            particle.best_position = particle.position.clone();
349
350            // Update global best
351            if value < self.global_best_value {
352                self.global_best_value = value;
353                self.global_best_position = particle.position.clone();
354            }
355        }
356    }
357
358    /// Check convergence criterion
359    fn check_convergence(&self) -> bool {
360        // Check if all particles have converged to the same region
361        let mut max_distance: f64 = 0.0;
362
363        for particle in &self.particles {
364            let distance = (&particle.position - &self.global_best_position)
365                .mapv(|x| x.abs())
366                .sum();
367            max_distance = max_distance.max(distance);
368        }
369
370        max_distance < self.options.tol
371    }
372
373    /// Detect and handle stagnation by re-initializing worst particles
374    fn handle_stagnation(&mut self) {
375        // Check if global best improved
376        let improvement = self.prev_global_best - self.global_best_value;
377        if improvement.abs() < 1e-15 * self.global_best_value.abs().max(1.0) {
378            self.stagnation_counter += 1;
379        } else {
380            self.stagnation_counter = 0;
381        }
382        self.prev_global_best = self.global_best_value;
383
384        // If stagnated too long, re-initialize a fraction of worst particles
385        if self.stagnation_counter >= self.options.stagnation_limit {
386            self.stagnation_counter = 0;
387
388            let n_reinit =
389                (self.options.reinit_fraction * self.particles.len() as f64).ceil() as usize;
390            let n_reinit = n_reinit.max(1).min(self.particles.len());
391
392            // Sort particles by personal best value (worst first)
393            let mut indices: Vec<usize> = (0..self.particles.len()).collect();
394            indices.sort_by(|&a, &b| {
395                self.particles[b]
396                    .best_value
397                    .partial_cmp(&self.particles[a].best_value)
398                    .unwrap_or(std::cmp::Ordering::Equal)
399            });
400
401            // Re-initialize worst particles
402            for &idx in indices.iter().take(n_reinit) {
403                let particle = &mut self.particles[idx];
404                for j in 0..self.ndim {
405                    let (lb, ub) = self.bounds[j];
406                    particle.position[j] = self.rng.random_range(lb..ub);
407                    particle.velocity[j] =
408                        self.rng.random_range(self.options.vmin..self.options.vmax) * (ub - lb);
409                }
410                let value = (self.func)(&particle.position.view());
411                self.nfev += 1;
412                particle.best_position = particle.position.clone();
413                particle.best_value = value;
414
415                if value < self.global_best_value {
416                    self.global_best_value = value;
417                    self.global_best_position = particle.position.clone();
418                }
419            }
420        }
421    }
422
423    /// Run one iteration of the algorithm
424    fn step(&mut self) -> bool {
425        self.iteration += 1;
426        self.update_inertia_weight();
427
428        // Update all particles
429        for i in 0..self.options.swarm_size {
430            self.update_particle(i);
431        }
432
433        // Handle stagnation
434        self.handle_stagnation();
435
436        self.check_convergence()
437    }
438
439    /// Run the particle swarm optimization algorithm
440    pub fn run(&mut self) -> OptimizeResult<f64> {
441        let mut converged = false;
442
443        for _ in 0..self.options.maxiter {
444            converged = self.step();
445
446            if converged {
447                break;
448            }
449        }
450
451        OptimizeResult {
452            x: self.global_best_position.clone(),
453            fun: self.global_best_value,
454            nfev: self.nfev,
455            func_evals: self.nfev,
456            nit: self.iteration,
457            success: converged,
458            message: if converged {
459                "Optimization converged successfully"
460            } else {
461                "Maximum number of iterations reached"
462            }
463            .to_string(),
464            ..Default::default()
465        }
466    }
467
468    /// Get the current global best value
469    pub fn best_value(&self) -> f64 {
470        self.global_best_value
471    }
472
473    /// Get the current global best position
474    pub fn best_position(&self) -> &Array1<f64> {
475        &self.global_best_position
476    }
477
478    /// Get the number of function evaluations
479    pub fn function_evaluations(&self) -> usize {
480        self.nfev
481    }
482}
483
484/// Perform global optimization using particle swarm optimization
485///
486/// # Arguments
487///
488/// * `func` - Objective function to minimize
489/// * `bounds` - Variable bounds as Vec<(lower, upper)>
490/// * `options` - PSO configuration options
491///
492/// # Returns
493///
494/// `OptimizeResult<f64>` with the optimization result
495#[allow(dead_code)]
496pub fn particle_swarm<F>(
497    func: F,
498    bounds: Bounds,
499    options: Option<ParticleSwarmOptions>,
500) -> Result<OptimizeResult<f64>, OptimizeError>
501where
502    F: Fn(&ArrayView1<f64>) -> f64 + Clone,
503{
504    if bounds.is_empty() {
505        return Err(OptimizeError::InvalidInput(
506            "Bounds must not be empty".to_string(),
507        ));
508    }
509    for (i, &(lb, ub)) in bounds.iter().enumerate() {
510        if lb >= ub {
511            return Err(OptimizeError::InvalidInput(format!(
512                "Lower bound must be less than upper bound for dimension {} ({} >= {})",
513                i, lb, ub
514            )));
515        }
516    }
517
518    let options = options.unwrap_or_default();
519    if options.swarm_size == 0 {
520        return Err(OptimizeError::InvalidInput(
521            "Swarm size must be > 0".to_string(),
522        ));
523    }
524
525    let mut solver = ParticleSwarm::new(func, bounds, options);
526    Ok(solver.run())
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    /// Sphere function: sum(x_i^2), minimum at origin
534    fn sphere(x: &ArrayView1<f64>) -> f64 {
535        x.iter().map(|xi| xi * xi).sum()
536    }
537
538    /// Rastrigin function: multimodal test function
539    fn rastrigin(x: &ArrayView1<f64>) -> f64 {
540        let n = x.len() as f64;
541        10.0 * n
542            + x.iter()
543                .map(|xi| xi * xi - 10.0 * (2.0 * std::f64::consts::PI * xi).cos())
544                .sum::<f64>()
545    }
546
547    /// Rosenbrock function
548    fn rosenbrock(x: &ArrayView1<f64>) -> f64 {
549        let mut sum = 0.0;
550        for i in 0..x.len() - 1 {
551            sum += 100.0 * (x[i + 1] - x[i].powi(2)).powi(2) + (1.0 - x[i]).powi(2);
552        }
553        sum
554    }
555
556    #[test]
557    fn test_pso_sphere_2d() {
558        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
559        let mut opts = ParticleSwarmOptions::default();
560        opts.seed = Some(42);
561        opts.swarm_size = 30;
562        opts.maxiter = 200;
563
564        let result = particle_swarm(sphere, bounds, Some(opts));
565        assert!(result.is_ok());
566        let res = result.expect("should succeed");
567        assert!(
568            res.fun < 0.01,
569            "Sphere function should be near 0, got {}",
570            res.fun
571        );
572    }
573
574    #[test]
575    fn test_pso_sphere_adaptive() {
576        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
577        let mut opts = ParticleSwarmOptions::default();
578        opts.seed = Some(123);
579        opts.swarm_size = 40;
580        opts.maxiter = 300;
581        opts.adaptive = true;
582
583        let result = particle_swarm(sphere, bounds, Some(opts));
584        assert!(result.is_ok());
585        let res = result.expect("should succeed");
586        assert!(
587            res.fun < 0.01,
588            "Adaptive PSO should find sphere min, got {}",
589            res.fun
590        );
591    }
592
593    #[test]
594    fn test_pso_constriction_coefficient() {
595        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
596        let mut opts = ParticleSwarmOptions::default();
597        opts.seed = Some(99);
598        opts.swarm_size = 40;
599        opts.maxiter = 300;
600        opts.velocity_strategy = VelocityStrategy::Constriction;
601
602        let result = particle_swarm(sphere, bounds, Some(opts));
603        assert!(result.is_ok());
604        let res = result.expect("should succeed");
605        assert!(
606            res.fun < 0.1,
607            "Constriction PSO should find near-optimal, got {}",
608            res.fun
609        );
610    }
611
612    #[test]
613    fn test_pso_ring_topology() {
614        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
615        let mut opts = ParticleSwarmOptions::default();
616        opts.seed = Some(55);
617        opts.swarm_size = 30;
618        opts.maxiter = 300;
619        opts.topology = Topology::Ring;
620        opts.ring_neighbors = 5;
621
622        let result = particle_swarm(sphere, bounds, Some(opts));
623        assert!(result.is_ok());
624        let res = result.expect("should succeed");
625        assert!(
626            res.fun < 0.1,
627            "Ring topology should find near-optimal, got {}",
628            res.fun
629        );
630    }
631
632    #[test]
633    fn test_pso_rastrigin() {
634        // Rastrigin has many local minima; PSO should find near-global
635        let bounds = vec![(-5.12, 5.12), (-5.12, 5.12)];
636        let mut opts = ParticleSwarmOptions::default();
637        opts.seed = Some(42);
638        opts.swarm_size = 60;
639        opts.maxiter = 500;
640        opts.adaptive = true;
641
642        let result = particle_swarm(rastrigin, bounds, Some(opts));
643        assert!(result.is_ok());
644        let res = result.expect("should succeed");
645        // Rastrigin global min is 0 at origin
646        assert!(
647            res.fun < 5.0,
648            "PSO should find reasonable Rastrigin value, got {}",
649            res.fun
650        );
651    }
652
653    #[test]
654    fn test_pso_rosenbrock() {
655        let bounds = vec![(-5.0, 5.0), (-5.0, 5.0)];
656        let mut opts = ParticleSwarmOptions::default();
657        opts.seed = Some(42);
658        opts.swarm_size = 50;
659        opts.maxiter = 500;
660        opts.adaptive = true;
661
662        let result = particle_swarm(rosenbrock, bounds, Some(opts));
663        assert!(result.is_ok());
664        let res = result.expect("should succeed");
665        // Rosenbrock min is 0 at (1, 1)
666        assert!(
667            res.fun < 5.0,
668            "PSO should find reasonable Rosenbrock value, got {}",
669            res.fun
670        );
671    }
672
673    #[test]
674    fn test_pso_invalid_bounds() {
675        // Empty bounds
676        let result = particle_swarm(sphere, vec![], None);
677        assert!(result.is_err());
678
679        // Lower >= upper
680        let bounds = vec![(5.0, 2.0)];
681        let result = particle_swarm(sphere, bounds, None);
682        assert!(result.is_err());
683    }
684
685    #[test]
686    fn test_pso_invalid_swarm_size() {
687        let bounds = vec![(-5.0, 5.0)];
688        let mut opts = ParticleSwarmOptions::default();
689        opts.swarm_size = 0;
690        let result = particle_swarm(sphere, bounds, Some(opts));
691        assert!(result.is_err());
692    }
693
694    #[test]
695    fn test_pso_convergence_detection() {
696        // Simple 1D function with very tight tolerance
697        let bounds = vec![(-1.0, 1.0)];
698        let mut opts = ParticleSwarmOptions::default();
699        opts.seed = Some(42);
700        opts.swarm_size = 20;
701        opts.maxiter = 1000;
702        opts.tol = 1e-3; // Relaxed tolerance for convergence detection
703
704        let func = |x: &ArrayView1<f64>| -> f64 { x[0] * x[0] };
705        let result = particle_swarm(func, bounds, Some(opts));
706        assert!(result.is_ok());
707        let res = result.expect("should succeed");
708        assert!(res.nit > 0);
709        assert!(res.nfev > 0);
710    }
711
712    #[test]
713    fn test_pso_stagnation_restart() {
714        // Function with flat region that triggers stagnation
715        let bounds = vec![(-10.0, 10.0), (-10.0, 10.0)];
716        let mut opts = ParticleSwarmOptions::default();
717        opts.seed = Some(42);
718        opts.swarm_size = 20;
719        opts.maxiter = 100;
720        opts.stagnation_limit = 5;
721        opts.reinit_fraction = 0.3;
722
723        let result = particle_swarm(sphere, bounds, Some(opts));
724        assert!(result.is_ok());
725        let res = result.expect("should succeed");
726        // Should still find a reasonable solution
727        assert!(res.fun < 1.0);
728    }
729
730    #[test]
731    fn test_pso_high_dimensional() {
732        let n = 10;
733        let bounds: Vec<(f64, f64)> = vec![(-5.0, 5.0); n];
734        let mut opts = ParticleSwarmOptions::default();
735        opts.seed = Some(42);
736        opts.swarm_size = 100;
737        opts.maxiter = 500;
738        opts.adaptive = true;
739
740        let result = particle_swarm(sphere, bounds, Some(opts));
741        assert!(result.is_ok());
742        let res = result.expect("should succeed");
743        assert!(
744            res.fun < 1.0,
745            "High-dim PSO should converge reasonably, got {}",
746            res.fun
747        );
748    }
749
750    #[test]
751    fn test_pso_options_default() {
752        let opts = ParticleSwarmOptions::default();
753        assert_eq!(opts.swarm_size, 50);
754        assert_eq!(opts.maxiter, 500);
755        assert!((opts.c1 - 2.0).abs() < 1e-10);
756        assert!((opts.c2 - 2.0).abs() < 1e-10);
757        assert!((opts.w - 0.9).abs() < 1e-10);
758        assert_eq!(opts.topology, Topology::Star);
759        assert_eq!(opts.velocity_strategy, VelocityStrategy::InertiaWeight);
760    }
761
762    #[test]
763    fn test_pso_solver_accessors() {
764        let bounds = vec![(-5.0, 5.0)];
765        let mut opts = ParticleSwarmOptions::default();
766        opts.seed = Some(42);
767        opts.swarm_size = 10;
768
769        let solver = ParticleSwarm::new(sphere, bounds, opts);
770        assert!(solver.best_value().is_finite());
771        assert_eq!(solver.best_position().len(), 1);
772        assert!(solver.function_evaluations() > 0);
773    }
774}