use swarmkit::FitCalc as _;
use swarmkit_sailing::{
Boat, LandmassSource, Path, PathBaseline, RouteBounds, SailboatFitCalc, SeaPathBias,
SearchSettings, get_segment_fuel_and_time, get_segment_land_metres, reoptimize_times, search,
};
use crate::landmass::landmass_grid_at_resolution;
use crate::route::{BenchmarkRoute, RouteEvolution, WaypointCount, debug_assert_path_no_nans};
use crate::waypoint_match;
use crate::wind_map::{BakeBounds, BakedWindMap, TimedWindMap};
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,
}
#[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 = "Eight 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,
) -> Result<SearchResult, SearchError> {
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(
baked,
route_bounds,
waypoint_count,
search_settings,
ship,
weights,
sdf_resolution_deg,
)?;
result.bake_duration = bake_duration;
Ok(result)
}
#[expect(
clippy::panic_in_result_fn,
reason = "debug-only `cfg!(debug_assertions)` asserts catch NaN \
before it corrupts downstream rendering; release builds \
compile them out."
)]
pub fn run_search_blocking_with_baked(
baked: BakedWindMap,
route_bounds: RouteBounds,
waypoint_count: WaypointCount,
search_settings: SearchSettings,
ship: Boat,
weights: SearchWeights,
sdf_resolution_deg: f64,
) -> Result<SearchResult, SearchError> {
let search_start = std::time::Instant::now();
let land = landmass_grid_at_resolution(sdf_resolution_deg);
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,
};
let (gbest, evolution) = search::<N, _, _, _, _>(
&ship,
&baked,
land,
route_bounds,
&fit_calc,
search_settings,
);
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 benchmark = compute_benchmark::<N, _, _>(
&ship,
&baked,
land,
route_bounds,
&fit_calc,
search_settings,
);
(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,
})
}
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,
) -> Path<N> {
let land = landmass_grid_at_resolution(sdf_resolution_deg);
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)
}