use std::io::{self, Read as _, Write as _};
use std::path::PathBuf;
use std::time::Instant;
use anyhow::{Context as _, Result, anyhow};
use bywind::{
BakedWindMap, BoatConfig, LonLatBbox, MapBounds, SearchConfig, SearchWeights, baked_codec,
run_search_blocking_with_baked,
};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use bywind::scenario::CliConfigFile;
const DEFAULT_ROUTES_DIR: &str = "profiling/tuning";
#[derive(Serialize, Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct TrialSpec {
#[serde(default)]
pub params: TrialParams,
pub seeds: Vec<u64>,
pub routes: Vec<String>,
#[serde(default)]
pub routes_dir: Option<PathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(deny_unknown_fields)]
pub struct TrialParams {
pub inertia: Option<f64>,
pub cognitive_coeff: Option<f64>,
pub social_coeff: Option<f64>,
pub path_kick_probability: Option<f64>,
pub path_kick_gamma_0_fraction: Option<f64>,
pub path_kick_gamma_min_fraction: Option<f64>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TrialResult {
pub fitness_mean: f64,
pub fitness_std: f64,
pub wall_seconds: f64,
pub per_route: Vec<RouteResult>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct RouteResult {
pub slug: String,
pub fitness_mean: f64,
pub fitness_std: f64,
pub seeds: Vec<SeedResult>,
pub wall_seconds: f64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SeedResult {
pub seed: u64,
pub fitness: f64,
pub wall_seconds: f64,
}
pub fn run() -> Result<(), AppError> {
let mut input = String::new();
io::stdin()
.read_to_string(&mut input)
.context("reading trial spec from stdin")?;
let spec: TrialSpec = serde_json::from_str(&input).context("parsing trial spec JSON")?;
if spec.seeds.is_empty() {
return Err(AppError::from(anyhow!(
"`seeds` must contain at least one value"
)));
}
if spec.routes.is_empty() {
return Err(AppError::from(anyhow!(
"`routes` must contain at least one slug"
)));
}
let routes_dir = spec
.routes_dir
.clone()
.unwrap_or_else(|| PathBuf::from(DEFAULT_ROUTES_DIR));
let shared_search_path = routes_dir.join("_search.toml");
let trial_start = Instant::now();
let mut per_route = Vec::with_capacity(spec.routes.len());
for slug in &spec.routes {
per_route.push(run_route(slug, &routes_dir, &shared_search_path, &spec)?);
}
let wall_seconds = trial_start.elapsed().as_secs_f64();
let route_means: Vec<f64> = per_route.iter().map(|r| r.fitness_mean).collect();
let fitness_mean = mean(&route_means);
let fitness_std = stddev(&route_means, fitness_mean);
let result = TrialResult {
fitness_mean,
fitness_std,
wall_seconds,
per_route,
};
let stdout = io::stdout();
let mut writer = stdout.lock();
serde_json::to_writer_pretty(&mut writer, &result).context("writing result JSON")?;
writer
.write_all(b"\n")
.context("writing trailing newline")?;
Ok(())
}
fn run_route(
slug: &str,
routes_dir: &std::path::Path,
shared_search_path: &std::path::Path,
spec: &TrialSpec,
) -> Result<RouteResult, AppError> {
let route_start = Instant::now();
let route_dir = routes_dir.join(slug);
let route_config_path = route_dir.join("config.toml");
let baked_path = route_dir.join("baked.bk1");
let mut cfg = CliConfigFile::default();
if shared_search_path.exists() {
cfg.merge_from(CliConfigFile::from_path(shared_search_path).map_err(anyhow::Error::from)?);
}
cfg.merge_from(CliConfigFile::from_path(&route_config_path).map_err(anyhow::Error::from)?);
let start = cfg
.run
.start
.ok_or_else(|| AppError::from(anyhow!("route `{slug}` has no [run].start in its TOML")))?;
let end = cfg
.run
.end
.ok_or_else(|| AppError::from(anyhow!("route `{slug}` has no [run].end in its TOML")))?;
let mut boat_cfg = BoatConfig::default();
cfg.boat.apply_to(&mut boat_cfg);
boat_cfg.validate().map_err(anyhow::Error::from)?;
let mut search_cfg = SearchConfig::default();
cfg.search.apply_to(&mut search_cfg);
if let Some(n) = cfg.run.waypoints {
search_cfg.waypoint_count = bywind::WaypointCount::from_usize(n).ok_or_else(|| {
AppError::from(anyhow!(
"route `{slug}` has unsupported waypoint count {n} (must be 5/8/10/15/20/30/40/50/60)",
))
})?;
}
if let Some(w) = cfg.run.time_weight {
search_cfg.time_weight = w;
}
if let Some(w) = cfg.run.fuel_weight {
search_cfg.fuel_weight = w;
}
if let Some(w) = cfg.run.land_weight {
search_cfg.land_weight = w;
}
if let Some(v) = spec.params.inertia {
search_cfg.inertia = v;
}
if let Some(v) = spec.params.cognitive_coeff {
search_cfg.cognitive_coeff = v;
}
if let Some(v) = spec.params.social_coeff {
search_cfg.social_coeff = v;
}
if let Some(v) = spec.params.path_kick_probability {
search_cfg.path_kick_probability = v;
}
if let Some(v) = spec.params.path_kick_gamma_0_fraction {
search_cfg.path_kick_gamma_0_fraction = v;
}
if let Some(v) = spec.params.path_kick_gamma_min_fraction {
search_cfg.path_kick_gamma_min_fraction = v;
}
search_cfg.validate().map_err(anyhow::Error::from)?;
let weights = SearchWeights {
time_weight: search_cfg.time_weight,
fuel_weight: search_cfg.fuel_weight,
land_weight: search_cfg.land_weight,
};
let mut baked = read_baked(&baked_path)?;
let map_bounds = map_bounds_from_baked(&baked);
let route_bounds = map_bounds.to_route_bounds_with_step_fraction(
start.into(),
end.into(),
search_cfg.step_distance_fraction,
);
let mut seed_results = Vec::with_capacity(spec.seeds.len());
for &seed in &spec.seeds {
let seed_start = Instant::now();
search_cfg.seed = Some(seed);
let result = run_search_blocking_with_baked(
baked,
route_bounds,
search_cfg.waypoint_count,
search_cfg.to_search_settings(),
boat_cfg.to_boat(),
weights,
search_cfg.sdf_resolution_deg,
)
.map_err(|e| AppError::no_result(anyhow!("route `{slug}` seed {seed} — {e}",)))?;
let evolution = result.route_evolution;
baked = result.baked;
let last = evolution.iter_count().saturating_sub(1);
let fitness = evolution
.gbest_at(last)
.ok_or_else(|| {
AppError::internal(anyhow!("route `{slug}` seed {seed} produced no iterations",))
})?
.best_fit;
seed_results.push(SeedResult {
seed,
fitness,
wall_seconds: seed_start.elapsed().as_secs_f64(),
});
}
let fits: Vec<f64> = seed_results.iter().map(|s| s.fitness).collect();
let fitness_mean = mean(&fits);
let fitness_std = stddev(&fits, fitness_mean);
Ok(RouteResult {
slug: slug.to_owned(),
fitness_mean,
fitness_std,
seeds: seed_results,
wall_seconds: route_start.elapsed().as_secs_f64(),
})
}
fn read_baked(path: &std::path::Path) -> Result<BakedWindMap, AppError> {
let file = std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
let reader = std::io::BufReader::new(file);
let baked =
baked_codec::decode(reader).with_context(|| format!("decoding {}", path.display()))?;
Ok(baked)
}
fn map_bounds_from_baked(baked: &BakedWindMap) -> MapBounds {
let step = baked.step();
let nx_steps = baked.nx().saturating_sub(1) as f64;
let ny_steps = baked.ny().saturating_sub(1) as f64;
MapBounds {
bbox: LonLatBbox::new(
baked.x_min(),
baked.x_min() + nx_steps * step,
baked.y_min(),
baked.y_min() + ny_steps * step,
),
}
}
fn mean(xs: &[f64]) -> f64 {
if xs.is_empty() {
return f64::NAN;
}
xs.iter().sum::<f64>() / xs.len() as f64
}
fn stddev(xs: &[f64], mean: f64) -> f64 {
if xs.len() < 2 {
return 0.0;
}
let variance: f64 = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / xs.len() as f64;
variance.sqrt()
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
reason = "tests rely on bit-exact comparisons of constant or stored f32/f64 values."
)]
use super::*;
#[test]
fn parse_trial_spec_minimal() {
let json = r#"{"seeds":[1,2],"routes":["short-easy"]}"#;
let spec: TrialSpec = serde_json::from_str(json).expect("valid");
assert_eq!(spec.seeds, vec![1, 2]);
assert_eq!(spec.routes, vec!["short-easy"]);
assert!(spec.params.inertia.is_none());
assert!(spec.routes_dir.is_none());
}
#[test]
fn parse_trial_spec_full() {
let json = r#"{
"params": {"inertia":0.5,"cognitive_coeff":1.6,"social_coeff":1.4},
"seeds": [42],
"routes": ["short-easy", "archipelago"],
"routes_dir": "profiling/tuning"
}"#;
let spec: TrialSpec = serde_json::from_str(json).expect("valid");
assert_eq!(spec.params.inertia, Some(0.5));
assert_eq!(spec.params.cognitive_coeff, Some(1.6));
assert_eq!(spec.params.social_coeff, Some(1.4));
assert_eq!(
spec.routes_dir.as_deref(),
Some(std::path::Path::new("profiling/tuning"))
);
}
#[test]
fn parse_trial_spec_rejects_unknown_field() {
let json = r#"{"seeds":[1],"routes":["x"],"nonsense":42}"#;
let err = serde_json::from_str::<TrialSpec>(json).expect_err("unknown field");
assert!(err.to_string().contains("nonsense"), "got: {err}");
}
#[test]
fn mean_and_stddev_match_textbook_values() {
let xs = [-1000.0, -1100.0, -900.0];
let m = mean(&xs);
assert!((m - -1000.0).abs() < 1e-9);
let s = stddev(&xs, m);
assert!((s - 81.6496580927726).abs() < 1e-6, "got {s}");
}
#[test]
fn stddev_handles_empty_and_single_element() {
assert_eq!(stddev(&[], 0.0), 0.0);
assert_eq!(stddev(&[42.0], 42.0), 0.0);
}
}