Skip to main content

swarmkit_sailing/
lib.rs

1//! Sailing-route PSO physics on top of the generic [`swarmkit`]
2//! particle-swarm library.
3//!
4//! Provides the spherical-Earth coordinate primitives, wind / boat /
5//! landmass traits, and PSO movers (init, mutation, time, range-cache)
6//! that the [`bywind`](https://crates.io/crates/bywind) sailing-route
7//! optimiser composes into a full search. Headless: no GUI, no GRIB2
8//! I/O — those live in `bywind`.
9//!
10//! - [`spherical`] — coordinate / segment / wind types and great-circle
11//!   primitives shared by every other module.
12//! - [`Boat`] / [`Sailboat`] — boat physics model and the trait the PSO
13//!   evaluates fitness against.
14//! - [`SearchSettings`] — outer / inner PSO sizing and topology choice.
15//! - [`RouteBounds`] / [`LonLatBbox`] — search-domain types.
16//! - [`Topology`] — `GBest`, `Ring`, `VonNeumann`, `Niched`.
17
18mod boat;
19mod boundary;
20mod dynamics;
21pub(crate) mod fit;
22mod init;
23mod mutation;
24mod path_baseline;
25mod range_cache;
26mod route_bounds;
27pub mod spherical;
28mod spherical_pso;
29mod time;
30mod traits;
31pub mod units;
32
33// Sub-stage timing counters for the search hot paths. Whole module is
34// gated on the `profile-timers` feature so non-feature builds get
35// nothing — no atomics, no extra calls, no extra fields.
36#[cfg(feature = "profile-timers")]
37pub mod profile_timers;
38
39pub use boat::Boat;
40pub use dynamics::{get_segment_fuel_and_time, get_segment_land_metres};
41pub use fit::{SailboatFitCalc, weighted_fitness};
42pub use init::{BaselineShares, InitShares, PathInit};
43pub use path_baseline::PathBaseline;
44pub use route_bounds::{DEFAULT_STEP_DISTANCE_FRACTION, RouteBounds};
45pub use spherical::{LatLon, LatLonDelta, LonLatBbox, Segment, TangentMetres, Wind};
46pub use traits::*;
47pub use units::{Floats, Path, PathXY, Time};
48
49#[cfg(feature = "probe-stats")]
50pub use range_cache::{ProbeCounters, ProbeStats, SegmentRangeTables};
51
52use boundary::SailingPathBoundary;
53use fit::PathFitCalc;
54use mutation::{CauchyKickMover, ShapeKickMover};
55use rand::SeedableRng as _;
56use rand::rngs::SmallRng;
57use spherical::{haversine, initial_bearing};
58use spherical_pso::SphericalPSOMover;
59use swarmkit::{
60    Best, Evolution, FitCalc, IntoGBestSearcher as _, IntoLBestSearcher as _,
61    IntoNichedSearcher as _, LBestKind, PSOCoeffs, ParticleMover as _, Searcher,
62};
63use time::TimeNestedMover;
64
65/// Outer-loop search topology. The match seam in [`search`] is the
66/// only topology-aware code; init, kicks, boundary, time PSO, and
67/// fitness are topology-agnostic.
68#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Hash)]
69#[non_exhaustive]
70pub enum Topology {
71    /// Stock gbest. Fastest convergence; collapses corridor diversity
72    /// onto whichever corridor wins the first few iterations.
73    #[default]
74    GBest,
75    /// Each baseline cohort (north / south polyline, straight-line)
76    /// gets its own social attractor. Preserves corridor diversity at
77    /// the cost of slower per-niche convergence. v0 is static (no
78    /// migration / merging).
79    Niched,
80    /// Ring lbest with k=1: each particle pulls toward the better of
81    /// `(i-1, i+1) mod n`. Slower convergence than gbest, longer
82    /// diversity.
83    Ring,
84    /// Lbest on an `r×c` torus, 4 neighbours each with wraparound.
85    /// Slightly faster diffusion than ring at the same swarm size;
86    /// falls back to ring when no balanced factorization exists.
87    VonNeumann,
88}
89
90impl Topology {
91    /// Lowercase string for CLI flags / TOML keys.
92    pub fn as_str(&self) -> &'static str {
93        match self {
94            Self::GBest => "gbest",
95            Self::Niched => "niched",
96            Self::Ring => "ring",
97            Self::VonNeumann => "von_neumann",
98        }
99    }
100
101    /// All variants in declaration order, for help text / completion.
102    pub const ALL: &'static [Self] = &[Self::GBest, Self::Niched, Self::Ring, Self::VonNeumann];
103}
104
105impl std::fmt::Display for Topology {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        f.write_str(self.as_str())
108    }
109}
110
111/// Unknown topology name from [`Topology`]'s `FromStr`. The message
112/// lists `Topology::ALL` for CLI / TOML diagnostics.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ParseTopologyError(pub String);
115
116impl std::fmt::Display for ParseTopologyError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        let valid = Topology::ALL
119            .iter()
120            .map(|t| t.as_str())
121            .collect::<Vec<_>>()
122            .join(", ");
123        write!(
124            f,
125            "unknown topology '{}' (expected one of: {valid})",
126            self.0
127        )
128    }
129}
130
131impl std::error::Error for ParseTopologyError {}
132
133impl std::str::FromStr for Topology {
134    type Err = ParseTopologyError;
135
136    fn from_str(s: &str) -> Result<Self, Self::Err> {
137        for &t in Self::ALL {
138            if t.as_str().eq_ignore_ascii_case(s) {
139                return Ok(t);
140            }
141        }
142        Err(ParseTopologyError(s.to_owned()))
143    }
144}
145
146/// Outer (space) + inner (time) PSO sizing and coefficients. Both PSOs
147/// share the velocity-update coefficients; the inner PSO runs once per
148/// outer particle per iteration.
149///
150/// `mutation_gamma_*_fraction`: per-waypoint independent Cauchy mutation
151/// scales, as a fraction of the straight-line route length,
152/// cosine-decayed across iterations. `0.0` disables.
153///
154/// `path_kick_*`: probability + initial/floor magnitudes of a
155/// coordinated `sin(k·π·t)` perpendicular shape kick (k ∈ {2, 3, 4}),
156/// the only mechanism that escapes smooth-arc basins to invent tacking
157/// topologies. `probability = 0` disables.
158#[derive(Clone, Copy, Debug)]
159pub struct SearchSettings {
160    pub particle_count_space: usize,
161    pub particle_count_time: usize,
162    pub max_iteration_space: usize,
163    pub max_iteration_time: usize,
164    pub inertia: f64,
165    pub cognitive_coeff: f64,
166    pub social_coeff: f64,
167    pub init_shares: InitShares,
168    /// Outer-init baseline shares. Default 80% polyline (40/40
169    /// north/south) + 20% straight-line.
170    pub baseline_shares: BaselineShares,
171    pub mutation_gamma_0_fraction: f64,
172    pub mutation_gamma_min_fraction: f64,
173    pub path_kick_probability: f64,
174    pub path_kick_gamma_0_fraction: f64,
175    pub path_kick_gamma_min_fraction: f64,
176    /// `None` draws fresh OS entropy; `Some(s)` reproduces the run.
177    pub seed: Option<u64>,
178    /// Inner time-PSO lookup-table samples along departure-time. ≥ 2.
179    pub range_k: usize,
180    /// Inner time-PSO lookup-table samples along `mcr_01` throttle. ≥ 2.
181    pub k_mcr: usize,
182    pub topology: Topology,
183}
184
185impl Default for SearchSettings {
186    fn default() -> Self {
187        Self {
188            particle_count_space: 40,
189            particle_count_time: 40,
190            max_iteration_space: 40,
191            max_iteration_time: 30,
192            inertia: 0.2,
193            cognitive_coeff: 1.6,
194            social_coeff: 0.85,
195            init_shares: InitShares::default(),
196            baseline_shares: BaselineShares::default(),
197            mutation_gamma_0_fraction: 0.0,
198            mutation_gamma_min_fraction: 0.0,
199            path_kick_probability: 0.1,
200            path_kick_gamma_0_fraction: 0.05,
201            path_kick_gamma_min_fraction: 0.005,
202            seed: None,
203            range_k: crate::range_cache::DEFAULT_RANGE_K,
204            k_mcr: crate::range_cache::DEFAULT_K_MCR,
205            topology: Topology::default(),
206        }
207    }
208}
209
210pub fn search<
211    const N: usize,
212    SB: Sailboat,
213    WS: WindSource,
214    LS: LandmassSource,
215    TFit: FitCalc<T = Path<N>> + SailboatFitData,
216>(
217    boat: &SB,
218    wind_source: &WS,
219    landmass: &LS,
220    route_bounds: RouteBounds,
221    fit_calc: &TFit,
222    settings: SearchSettings,
223) -> (Best<Path<N>>, Evolution<Path<N>>) {
224    // Per-search reset of the sub-stage counters so the dump at the end
225    // reflects only this search. No-op when the feature is off.
226    #[cfg(feature = "profile-timers")]
227    profile_timers::reset_all();
228
229    let SearchSettings {
230        particle_count_space,
231        particle_count_time,
232        max_iteration_space,
233        max_iteration_time,
234        inertia,
235        cognitive_coeff,
236        social_coeff,
237        init_shares,
238        baseline_shares,
239        mutation_gamma_0_fraction,
240        mutation_gamma_min_fraction,
241        path_kick_probability,
242        path_kick_gamma_0_fraction,
243        path_kick_gamma_min_fraction,
244        seed,
245        range_k,
246        k_mcr,
247        topology,
248    } = settings;
249
250    // Master RNG for both PathInit and the GBestSearcher's per-particle
251    // streams. With `seed = Some(s)` the run is fully reproducible; with
252    // `seed = None` we draw fresh OS entropy (legacy behaviour).
253    let mut master_rng: SmallRng = match seed {
254        Some(s) => SmallRng::seed_from_u64(s),
255        None => rand::make_rng(),
256    };
257
258    let pso_coeffs = PSOCoeffs::new(inertia, cognitive_coeff, social_coeff);
259
260    let path_fit = PathFitCalc::new(fit_calc);
261
262    // `init_with_partition` returns the per-baseline cohort ranges
263    // alongside the group. Topologies that don't care (gbest) just
264    // ignore the partition; niched feeds it to `NichedSearcher`.
265    let (mut group, partition) = PathInit::new(
266        &route_bounds,
267        boat,
268        wind_source,
269        &path_fit,
270        particle_count_space,
271        init_shares,
272        baseline_shares,
273    )
274    .init_with_partition(&mut master_rng);
275
276    // Mutation kick scales are expressed as fractions of the straight-line
277    // ground distance, so the noise budget rescales naturally with the
278    // route size. Mutation movers apply samples in tangent-frame metres,
279    // which means the line length must be metres too — degree-hypot would
280    // mis-scale routes that aren't equator-aligned.
281    let line_length_m = haversine(route_bounds.origin, route_bounds.destination);
282    let gamma_0 = (mutation_gamma_0_fraction * line_length_m).max(0.0);
283    let gamma_min = (mutation_gamma_min_fraction * line_length_m).max(0.0);
284    let path_gamma_0 = (path_kick_gamma_0_fraction * line_length_m).max(0.0);
285    let path_gamma_min = (path_kick_gamma_min_fraction * line_length_m).max(0.0);
286
287    // Perpendicular to the route's initial bearing, in compass radians.
288    // CCW rotation in geographic terms is `bearing − π/2` (compass is
289    // clockwise). Pole fallback is arbitrary east — Cauchy magnitudes are
290    // symmetric, so direction sign doesn't change the kick distribution.
291    let route_bearing =
292        initial_bearing(route_bounds.origin, route_bounds.destination).unwrap_or(0.0);
293    let perp_bearing = route_bearing - std::f64::consts::FRAC_PI_2;
294
295    // Spherical-aware velocity update: each waypoint's pull is converted
296    // to local east-north tangent metres before the inertia/cognitive/
297    // social arithmetic, then back to `(Δlon, Δlat)` for the position
298    // update. See `spherical_pso` for the full rationale.
299    let space_mover = SphericalPSOMover::<N, Path<N>>::new(pso_coeffs)
300        .chain(CauchyKickMover::<N, Path<N>>::new(gamma_0, gamma_min))
301        .chain(ShapeKickMover::<N, Path<N>>::new(
302            path_kick_probability,
303            path_gamma_0,
304            path_gamma_min,
305            perp_bearing,
306        ))
307        .adapt::<Best<Path<N>>>()
308        .bounded_by(SailingPathBoundary::new(
309            &route_bounds,
310            boat,
311            wind_source,
312            landmass,
313        ));
314
315    let time_mover = TimeNestedMover::new(
316        &path_fit,
317        pso_coeffs,
318        max_iteration_time,
319        particle_count_time,
320        range_k,
321        k_mcr,
322    );
323
324    // Topology dispatch: this match is the single seam where the choice of
325    // outer-loop searcher lands. Each arm constructs its own searcher and
326    // runs it to completion via `run_to_completion`, because the searchers
327    // are different concrete types. Everything above (init, mover chain,
328    // kick movers, boundary, time PSO) is topology-agnostic.
329    let mut evolution: Evolution<Path<N>> = Evolution::default();
330    let chained = space_mover.chain(time_mover);
331    let path_fit_for_searcher = PathFitCalc::new(fit_calc);
332    let last = match topology {
333        Topology::GBest => run_to_completion(
334            chained.into_gbest_searcher(path_fit_for_searcher),
335            &mut master_rng,
336            max_iteration_space,
337            &mut group,
338            &mut evolution,
339        ),
340        Topology::Niched => run_to_completion(
341            chained.into_niched_searcher(path_fit_for_searcher, partition),
342            &mut master_rng,
343            max_iteration_space,
344            &mut group,
345            &mut evolution,
346        ),
347        Topology::Ring => run_to_completion(
348            chained.into_lbest_searcher(path_fit_for_searcher, LBestKind::Ring { k: 1 }),
349            &mut master_rng,
350            max_iteration_space,
351            &mut group,
352            &mut evolution,
353        ),
354        Topology::VonNeumann => run_to_completion(
355            chained.into_lbest_searcher(path_fit_for_searcher, LBestKind::VonNeumann),
356            &mut master_rng,
357            max_iteration_space,
358            &mut group,
359            &mut evolution,
360        ),
361    };
362
363    #[cfg(feature = "profile-timers")]
364    profile_timers::dump_to_stderr();
365
366    (last, evolution)
367}
368
369/// Reseed `searcher` with the caller's master RNG and run it to the end
370/// of `max_iter` iterations, returning the final `Best`. Each topology
371/// arm in [`search`] funnels its constructed searcher through this
372/// helper so the dispatch match doesn't repeat `reseed + iter + fold`
373/// four times.
374///
375/// The `reseed` call overrides whatever RNG state
376/// `<Topology>Searcher::new` initialised (each constructor seeds from
377/// OS entropy by default) with the master RNG built from
378/// `settings.seed`, so a `Some(seed)` run is fully reproducible.
379///
380/// When `max_iter == 0` the iterator yields nothing and the returned
381/// `Best` is `Best::default()` (a sentinel with `best_fit = f64::MIN`).
382/// `SearchSettings::validate` rejects zero iteration counts, so this
383/// branch is unreachable for sanctioned callers.
384fn run_to_completion<const N: usize, S>(
385    mut searcher: S,
386    master_rng: &mut SmallRng,
387    max_iter: usize,
388    group: &mut swarmkit::Group<Path<N>>,
389    evolution: &mut Evolution<Path<N>>,
390) -> Best<Path<N>>
391where
392    S: Searcher<TUnit = Path<N>>,
393{
394    searcher.reseed(master_rng);
395    // Fold the per-iteration snapshots; the closure discards the
396    // accumulator and keeps the latest snapshot, so the final return
397    // value is whatever the searcher yielded last.
398    searcher
399        .iter(max_iter, group, Some(evolution))
400        .fold(Best::default(), |_acc, snapshot| snapshot)
401}
402
403/// Re-runs only the inner time PSO over `fixed_path`, holding
404/// `fixed_path.xy` constant and producing an optimized `t`.
405///
406/// The returned `Path<N>` carries the caller's xy unchanged. Mirrors
407/// what the inner PSO inside [`search`] would have produced for that
408/// xy if it had been a candidate of the outer space PSO.
409///
410/// `settings.particle_count_time` and `settings.max_iteration_time` control the
411/// PSO sizing; the `*_space` fields are unused. Initialization is random — the
412/// caller's existing `t` is *not* used as a seed.
413pub fn reoptimize_times<const N: usize, TFit: FitCalc<T = Path<N>> + SailboatFitData>(
414    fit_calc: &TFit,
415    settings: SearchSettings,
416    fixed_path: Path<N>,
417) -> Path<N> {
418    let SearchSettings {
419        particle_count_time,
420        max_iteration_time,
421        inertia,
422        cognitive_coeff,
423        social_coeff,
424        seed,
425        range_k,
426        k_mcr,
427        ..
428    } = settings;
429
430    let mut master_rng: SmallRng = match seed {
431        Some(s) => SmallRng::seed_from_u64(s),
432        None => rand::make_rng(),
433    };
434
435    let pso_coeffs = PSOCoeffs::new(inertia, cognitive_coeff, social_coeff);
436
437    let path_fit = PathFitCalc::new(fit_calc);
438    let best_t = time::reoptimize_time(
439        &path_fit,
440        particle_count_time,
441        pso_coeffs,
442        max_iteration_time,
443        range_k,
444        k_mcr,
445        fixed_path,
446        &mut master_rng,
447    );
448
449    Path {
450        xy: fixed_path.xy,
451        t: best_t,
452    }
453}