use swarmkit::FitCalc as _;
use swarmkit_sailing::{
Boat, EnsembleSailboatFitCalc, LandmassSource, Path, PathBaseline, RobustObjective,
RouteBounds, SailboatFitCalc, SeaPathBias, SearchSettings, get_segment_fuel_and_time,
get_segment_land_metres, reoptimize_times, search_with_progress, walk_segments_against_wind,
weighted_fitness,
};
use crate::config::EnsembleMode;
use crate::ensemble::BakedEnsembleWindMap;
use crate::landmass::{landmass_grid_at_resolution, landmass_grid_two_tier};
use crate::metrics::{SegmentMetrics, compute_segment_metrics};
use crate::route::{BenchmarkRoute, RouteEvolution, WaypointCount, debug_assert_path_no_nans};
use crate::wind_map::{BakeBounds, BakedWindMap, TimedWindMap};
use crate::{route_evolution_match, waypoint_match};
pub const BAKE_STEP: f64 = 0.25;
pub struct SearchResult {
pub route_evolution: RouteEvolution,
pub route_bounds: RouteBounds,
pub baked: BakedWindMap,
pub boat: Boat,
pub benchmark: Option<BenchmarkRoute>,
pub bake_duration: std::time::Duration,
pub search_duration: std::time::Duration,
pub ensemble: Option<EnsembleSpread>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EnsembleSpread {
pub per_member: Vec<MemberMetrics>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct MemberMetrics {
pub name: String,
pub time_s: f64,
pub fuel_kg: f64,
pub land_m: f64,
pub fitness: f64,
}
impl EnsembleSpread {
pub fn stats<F: Fn(&MemberMetrics) -> f64>(&self, field: F) -> (f64, f64, f64, f64) {
let k = self.per_member.len();
if k == 0 {
return (f64::NAN, f64::NAN, f64::NAN, f64::NAN);
}
let values: Vec<f64> = self.per_member.iter().map(&field).collect();
let mean = values.iter().sum::<f64>() / k as f64;
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / k as f64;
(mean, min, max, var.sqrt())
}
}
pub struct RealizationRun {
pub name: String,
pub route_evolution: RouteEvolution,
pub segment_stats: Vec<SegmentMetrics>,
pub fitness: f64,
}
#[expect(
clippy::too_many_arguments,
reason = "matches the inner search-blocking signature one-for-one; \
bundling args would just shuffle them around"
)]
pub fn run_realizations(
ensemble: &BakedEnsembleWindMap,
route_bounds: RouteBounds,
waypoint_count: WaypointCount,
mut search_settings: SearchSettings,
base_seed: u64,
ship: Boat,
weights: SearchWeights,
sdf_resolution_deg: f64,
fine_sdf_resolution_deg: Option<f64>,
) -> Result<Vec<RealizationRun>, SearchError> {
let names = ensemble.member_names().to_vec();
let mut runs = Vec::with_capacity(names.len());
let mut no_progress = |_: SearchProgressEvent| {};
for (k, name) in names.iter().enumerate() {
search_settings.seed = Some(base_seed.wrapping_add(k as u64));
let member_baked = ensemble.member(k).clone();
let result = run_search_blocking_with_baked(
WindInput::single(member_baked),
route_bounds,
waypoint_count,
search_settings,
ship,
weights,
sdf_resolution_deg,
fine_sdf_resolution_deg,
&mut no_progress,
)?;
let (segment_stats, fitness) = route_evolution_match!(&result.route_evolution, |evo| {
let frames = evo.frames();
let last = frames
.last()
.expect("search returns at least one iteration");
let best = last
.iter()
.max_by(|a, b| {
a.best_fit
.partial_cmp(&b.best_fit)
.unwrap_or(std::cmp::Ordering::Equal)
})
.expect("each iteration has at least one particle");
let stats = compute_segment_metrics(
&result.boat,
&result.baked,
best.best_pos,
route_bounds.step_distance_max,
);
(stats, best.best_fit)
});
runs.push(RealizationRun {
name: name.clone(),
route_evolution: result.route_evolution,
segment_stats,
fitness,
});
}
Ok(runs)
}
#[derive(Debug, Clone, PartialEq)]
pub enum SearchError {
NoFeasibleRoute { best_fit: f64 },
}
impl std::fmt::Display for SearchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoFeasibleRoute { best_fit } => write!(
f,
"search produced no feasible route (best_fit = {best_fit}) — \
every candidate path had at least one segment the boat can't \
traverse in the given wind. Try widening the route bounds, \
relaxing the boat polar, or moving the endpoints out of any \
pole-locked region.",
),
}
}
}
impl std::error::Error for SearchError {}
#[derive(Clone, Copy, Debug)]
pub struct SearchWeights {
pub time_weight: f64,
pub fuel_weight: f64,
pub land_weight: f64,
}
fn compute_benchmark<const N: usize, WS, LS>(
ship: &Boat,
wind_source: &WS,
landmass: &LS,
bounds: RouteBounds,
fit_calc: &SailboatFitCalc<'_, N, Boat, WS, LS>,
settings: SearchSettings,
) -> Option<BenchmarkRoute>
where
WS: swarmkit_sailing::WindSource,
LS: LandmassSource,
{
let polyline = landmass.find_sea_path(
bounds.origin,
bounds.destination,
&bounds,
SeaPathBias::None,
)?;
let baseline = PathBaseline::<N>::from_polyline_land_respecting(&polyline, &bounds, landmass);
let mut path = Path::default();
for i in 0..N {
path.xy.0[i] = baseline.positions[i].lon;
path.xy.1[i] = baseline.positions[i].lat;
}
let optimized = reoptimize_times(fit_calc, settings, path);
let segment_metrics = get_segment_fuel_and_time(
ship,
wind_source,
optimized,
fit_calc.departure_time,
fit_calc.step_distance_max,
);
let total_time: f64 = segment_metrics.iter().map(|(_, _, t)| *t).sum();
let total_fuel: f64 = segment_metrics.iter().map(|(_, fuel, _)| *fuel).sum();
let total_land_metres: f64 = (0..N - 1)
.map(|i| {
let a = optimized.lat_lon(i);
let b = optimized.lat_lon(i + 1);
get_segment_land_metres(landmass, a, b, fit_calc.step_distance_max)
})
.sum();
let fitness = fit_calc.calculate_fit(optimized);
let waypoints: Vec<(f64, f64)> = (0..N)
.map(|i| (optimized.xy.0[i], optimized.xy.1[i]))
.collect();
Some(BenchmarkRoute {
waypoints,
total_time,
total_fuel,
total_land_metres,
fitness,
})
}
#[expect(
clippy::too_many_arguments,
reason = "Nine first-class inputs the caller picks independently; \
a struct would just relocate the destructuring."
)]
pub fn run_search_blocking(
wind_map: &TimedWindMap,
bake_bounds: BakeBounds,
route_bounds: RouteBounds,
waypoint_count: WaypointCount,
search_settings: SearchSettings,
ship: Boat,
weights: SearchWeights,
sdf_resolution_deg: f64,
fine_sdf_resolution_deg: Option<f64>,
progress: &mut dyn FnMut(SearchProgressEvent),
) -> Result<SearchResult, SearchError> {
progress(SearchProgressEvent::Phase(SearchPhase::Baking));
let bake_start = std::time::Instant::now();
let baked = wind_map.bake(bake_bounds);
let bake_duration = bake_start.elapsed();
let mut result = run_search_blocking_with_baked(
WindInput::single(baked),
route_bounds,
waypoint_count,
search_settings,
ship,
weights,
sdf_resolution_deg,
fine_sdf_resolution_deg,
progress,
)?;
result.bake_duration = bake_duration;
Ok(result)
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum SearchProgressEvent {
Phase(SearchPhase),
BenchmarkReady(BenchmarkRoute),
Iteration {
iter_idx: usize,
total_iters: usize,
gbest_xs: Vec<f64>,
gbest_ys: Vec<f64>,
gbest_ts: Vec<f64>,
best_fit: f64,
},
}
#[non_exhaustive]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SearchPhase {
Loading,
Baking,
Benchmark,
RunningPso,
}
impl SearchPhase {
pub fn label(self) -> &'static str {
match self {
Self::Loading => "loading wind data…",
Self::Baking => "baking wind grid…",
Self::Benchmark => "computing benchmark…",
Self::RunningPso => "searching…",
}
}
}
pub enum WindInput {
Single(BakedWindMap),
Ensemble {
ensemble: BakedEnsembleWindMap,
mean: BakedWindMap,
mode: EnsembleMode,
},
}
impl WindInput {
pub fn single(baked: BakedWindMap) -> Self {
Self::Single(baked)
}
pub fn ensemble_full(ensemble: BakedEnsembleWindMap) -> Self {
let mean = ensemble.mean();
Self::Ensemble {
ensemble,
mean,
mode: EnsembleMode::Full,
}
}
pub fn ensemble_fast_mean(ensemble: BakedEnsembleWindMap) -> Self {
let mean = ensemble.mean();
Self::Ensemble {
ensemble,
mean,
mode: EnsembleMode::FastMean,
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "Eight first-class inputs the caller picks independently; \
the dispatch on `fine_sdf_resolution_deg` happens here so \
callers stay single-line."
)]
pub fn run_search_blocking_with_baked(
wind: WindInput,
route_bounds: RouteBounds,
waypoint_count: WaypointCount,
search_settings: SearchSettings,
ship: Boat,
weights: SearchWeights,
sdf_resolution_deg: f64,
fine_sdf_resolution_deg: Option<f64>,
progress: &mut dyn FnMut(SearchProgressEvent),
) -> Result<SearchResult, SearchError> {
let search_start = std::time::Instant::now();
match wind {
WindInput::Single(baked)
| WindInput::Ensemble {
mean: baked,
mode: EnsembleMode::FastMean,
..
} => match fine_sdf_resolution_deg {
None => {
let land = landmass_grid_at_resolution(sdf_resolution_deg);
run_search_inner(
baked,
route_bounds,
waypoint_count,
search_settings,
ship,
weights,
land,
search_start,
progress,
)
}
Some(fine) => {
let land = landmass_grid_two_tier(sdf_resolution_deg, fine);
run_search_inner(
baked,
route_bounds,
waypoint_count,
search_settings,
ship,
weights,
land,
search_start,
progress,
)
}
},
WindInput::Ensemble {
ensemble,
mean,
mode: EnsembleMode::Full,
} => match fine_sdf_resolution_deg {
None => {
let land = landmass_grid_at_resolution(sdf_resolution_deg);
run_search_inner_ensemble(
ensemble,
mean,
route_bounds,
waypoint_count,
search_settings,
ship,
weights,
land,
search_start,
progress,
)
}
Some(fine) => {
let land = landmass_grid_two_tier(sdf_resolution_deg, fine);
run_search_inner_ensemble(
ensemble,
mean,
route_bounds,
waypoint_count,
search_settings,
ship,
weights,
land,
search_start,
progress,
)
}
},
}
}
#[expect(
clippy::too_many_arguments,
reason = "the outer entry point partitions inputs by ownership; \
bundling them here would add a struct just to relocate them."
)]
#[expect(
clippy::panic_in_result_fn,
reason = "debug_assertions-only NaN guards catch upstream bugs before \
they corrupt downstream rendering; release builds compile \
them out."
)]
fn run_search_inner<LS: LandmassSource>(
baked: BakedWindMap,
route_bounds: RouteBounds,
waypoint_count: WaypointCount,
search_settings: SearchSettings,
ship: Boat,
weights: SearchWeights,
land: &LS,
search_start: std::time::Instant,
progress: &mut dyn FnMut(SearchProgressEvent),
) -> Result<SearchResult, SearchError> {
let (route_evolution, boat, benchmark, best_fit) = waypoint_match!(waypoint_count, N, wrap, {
let fit_calc = SailboatFitCalc::<N, _, _, _> {
time_weight: weights.time_weight,
fuel_weight: weights.fuel_weight,
land_weight: weights.land_weight,
departure_time: 0.0,
step_distance_max: route_bounds.step_distance_max,
ship: &ship,
wind_source: &baked,
landmass: land,
};
progress(SearchProgressEvent::Phase(SearchPhase::Benchmark));
let benchmark = compute_benchmark::<N, _, _>(
&ship,
&baked,
land,
route_bounds,
&fit_calc,
search_settings,
);
if let Some(b) = &benchmark {
progress(SearchProgressEvent::BenchmarkReady(b.clone()));
}
progress(SearchProgressEvent::Phase(SearchPhase::RunningPso));
let total_iters = search_settings.max_iteration_space;
let mut on_iter = |idx: usize, snapshot: &swarmkit::Best<Path<N>>| {
progress(SearchProgressEvent::Iteration {
iter_idx: idx,
total_iters,
gbest_xs: snapshot.best_pos.xy.0.0.to_vec(),
gbest_ys: snapshot.best_pos.xy.1.0.to_vec(),
gbest_ts: snapshot.best_pos.t.0.0.to_vec(),
best_fit: snapshot.best_fit,
});
};
let (gbest, evolution) = search_with_progress::<N, _, _, _, _>(
&ship,
&baked,
land,
route_bounds,
&fit_calc,
search_settings,
&mut on_iter,
);
if cfg!(debug_assertions) {
assert!(!gbest.best_fit.is_nan(), "NaN in gbest: best_fit");
debug_assert_path_no_nans(&gbest.best_pos, "gbest.best_pos");
for (iter_idx, particles) in evolution.frames().iter().enumerate() {
for (p_idx, particle) in particles.iter().enumerate() {
assert!(
!particle.best_fit.is_nan(),
"NaN in evolution[{iter_idx}][{p_idx}]: best_fit",
);
debug_assert_path_no_nans(
&particle.best_pos,
&format!("evolution[{iter_idx}][{p_idx}].best_pos"),
);
}
}
}
(wrap(evolution), ship, benchmark, gbest.best_fit)
});
if !best_fit.is_finite() {
return Err(SearchError::NoFeasibleRoute { best_fit });
}
let search_duration = search_start.elapsed();
Ok(SearchResult {
route_evolution,
route_bounds,
baked,
boat,
benchmark,
bake_duration: std::time::Duration::ZERO,
search_duration,
ensemble: None,
})
}
#[expect(
clippy::too_many_arguments,
reason = "outer dispatch partitions ownership; bundling would just relocate"
)]
#[expect(
clippy::panic_in_result_fn,
reason = "debug-only NaN guards catch upstream bugs"
)]
#[expect(
clippy::needless_pass_by_value,
reason = "ensemble is taken by value to make the dispatch's ownership \
transfer explicit at the call site; reference plus drop() \
clutter would obscure that. Cost: one extra move of an \
owned struct that's about to be dropped anyway."
)]
fn run_search_inner_ensemble<LS: LandmassSource>(
ensemble: BakedEnsembleWindMap,
mean: BakedWindMap,
route_bounds: RouteBounds,
waypoint_count: WaypointCount,
search_settings: SearchSettings,
ship: Boat,
weights: SearchWeights,
land: &LS,
search_start: std::time::Instant,
progress: &mut dyn FnMut(SearchProgressEvent),
) -> Result<SearchResult, SearchError> {
let (route_evolution, boat, benchmark, best_fit, ensemble_spread) =
waypoint_match!(waypoint_count, N, wrap, {
let ensemble_fit_calc = EnsembleSailboatFitCalc::<N, _, _, _> {
time_weight: weights.time_weight,
fuel_weight: weights.fuel_weight,
land_weight: weights.land_weight,
departure_time: 0.0,
step_distance_max: route_bounds.step_distance_max,
ship: &ship,
wind_ensemble: &ensemble,
landmass: land,
robust_objective: RobustObjective::Mean,
};
progress(SearchProgressEvent::Phase(SearchPhase::Benchmark));
let bench_fit_calc = SailboatFitCalc::<N, _, _, _> {
time_weight: weights.time_weight,
fuel_weight: weights.fuel_weight,
land_weight: weights.land_weight,
departure_time: 0.0,
step_distance_max: route_bounds.step_distance_max,
ship: &ship,
wind_source: &mean,
landmass: land,
};
let benchmark = compute_benchmark::<N, _, _>(
&ship,
&mean,
land,
route_bounds,
&bench_fit_calc,
search_settings,
);
if let Some(b) = &benchmark {
progress(SearchProgressEvent::BenchmarkReady(b.clone()));
}
progress(SearchProgressEvent::Phase(SearchPhase::RunningPso));
let total_iters = search_settings.max_iteration_space;
let mut on_iter = |idx: usize, snapshot: &swarmkit::Best<Path<N>>| {
progress(SearchProgressEvent::Iteration {
iter_idx: idx,
total_iters,
gbest_xs: snapshot.best_pos.xy.0.0.to_vec(),
gbest_ys: snapshot.best_pos.xy.1.0.to_vec(),
gbest_ts: snapshot.best_pos.t.0.0.to_vec(),
best_fit: snapshot.best_fit,
});
};
let (gbest, evolution) = search_with_progress::<N, _, _, _, _>(
&ship,
&mean,
land,
route_bounds,
&ensemble_fit_calc,
search_settings,
&mut on_iter,
);
if cfg!(debug_assertions) {
assert!(!gbest.best_fit.is_nan(), "NaN in gbest: best_fit");
debug_assert_path_no_nans(&gbest.best_pos, "gbest.best_pos");
for (iter_idx, particles) in evolution.frames().iter().enumerate() {
for (p_idx, particle) in particles.iter().enumerate() {
assert!(
!particle.best_fit.is_nan(),
"NaN in evolution[{iter_idx}][{p_idx}]: best_fit",
);
debug_assert_path_no_nans(
&particle.best_pos,
&format!("evolution[{iter_idx}][{p_idx}].best_pos"),
);
}
}
}
let gbest_pos = gbest.best_pos;
let land_m: f64 = (0..N - 1)
.map(|i| {
let a = gbest_pos.lat_lon(i);
let b = gbest_pos.lat_lon(i + 1);
get_segment_land_metres(land, a, b, route_bounds.step_distance_max)
})
.sum();
let per_member: Vec<MemberMetrics> = (0..ensemble.member_count())
.map(|k| {
let member = ensemble.member(k);
let member_fit_calc = SailboatFitCalc::<N, _, _, _> {
time_weight: weights.time_weight,
fuel_weight: weights.fuel_weight,
land_weight: weights.land_weight,
departure_time: 0.0,
step_distance_max: route_bounds.step_distance_max,
ship: &ship,
wind_source: member,
landmass: land,
};
let reopt_path = reoptimize_times(&member_fit_calc, search_settings, gbest_pos);
let (t, f) = walk_segments_against_wind(
&ship,
member,
reopt_path,
0.0,
route_bounds.step_distance_max,
);
let fitness = weighted_fitness(
t,
f,
land_m,
weights.time_weight,
weights.fuel_weight,
weights.land_weight,
);
MemberMetrics {
name: ensemble.member_names()[k].clone(),
time_s: t,
fuel_kg: f,
land_m,
fitness,
}
})
.collect();
let ensemble_spread = EnsembleSpread { per_member };
(
wrap(evolution),
ship,
benchmark,
gbest.best_fit,
ensemble_spread,
)
});
if !best_fit.is_finite() {
return Err(SearchError::NoFeasibleRoute { best_fit });
}
let search_duration = search_start.elapsed();
Ok(SearchResult {
route_evolution,
route_bounds,
baked: mean,
boat,
benchmark,
bake_duration: std::time::Duration::ZERO,
search_duration,
ensemble: Some(ensemble_spread),
})
}
#[expect(
clippy::too_many_arguments,
reason = "Eight first-class inputs the caller picks independently."
)]
pub fn run_time_reopt_blocking<const N: usize>(
baked: &BakedWindMap,
route_bounds: RouteBounds,
settings: SearchSettings,
ship: &Boat,
fixed_path: Path<N>,
weights: SearchWeights,
sdf_resolution_deg: f64,
fine_sdf_resolution_deg: Option<f64>,
) -> Path<N> {
match fine_sdf_resolution_deg {
None => {
let land = landmass_grid_at_resolution(sdf_resolution_deg);
run_time_reopt_inner(
baked,
route_bounds,
settings,
ship,
fixed_path,
weights,
land,
)
}
Some(fine) => {
let land = landmass_grid_two_tier(sdf_resolution_deg, fine);
run_time_reopt_inner(
baked,
route_bounds,
settings,
ship,
fixed_path,
weights,
land,
)
}
}
}
fn run_time_reopt_inner<const N: usize, LS: LandmassSource>(
baked: &BakedWindMap,
route_bounds: RouteBounds,
settings: SearchSettings,
ship: &Boat,
fixed_path: Path<N>,
weights: SearchWeights,
land: &LS,
) -> Path<N> {
let fit_calc = SailboatFitCalc {
time_weight: weights.time_weight,
fuel_weight: weights.fuel_weight,
land_weight: weights.land_weight,
departure_time: 0.0,
step_distance_max: route_bounds.step_distance_max,
ship,
wind_source: baked,
landmass: land,
};
reoptimize_times(&fit_calc, settings, fixed_path)
}