1use crate::params::parse;
51use crate::{RunOptions, Spec, SpecScenario};
52use comnum_core::{Error, Result, ensure};
53use comnum_sim::grid::{Condition, Grid};
54use comnum_sim::json;
55use comnum_sim::loggers::{create_parent_dir, csv_field, run_header, value_json, write_json as write_json_file};
56use comnum_sim::runner::RunInfo;
57use serde::Deserialize;
58use serde_json::Value as Json;
59use std::path::Path;
60
61#[derive(Clone, Debug, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct SearchSpec {
65 pub along: String,
67 pub range: [f64; 2],
69 pub metric: String,
71 pub target: f64,
73 #[serde(default = "d_tolerance")]
75 pub tolerance: f64,
76 #[serde(default = "d_true")]
78 pub log: bool,
79 #[serde(default = "d_max_eval")]
81 pub max_evaluations: usize,
82}
83
84fn d_tolerance() -> f64 {
85 0.05
86}
87fn d_true() -> bool {
88 true
89}
90fn d_max_eval() -> usize {
91 30
92}
93
94impl SearchSpec {
95 pub fn from_json(v: &Json) -> Result<SearchSpec> {
100 let s: SearchSpec = parse("search", v.clone())?;
101 ensure(s.range[0] < s.range[1], || format!("search: the range {:?} must increase", s.range))?;
102 ensure(s.tolerance > 0.0, || "search: the tolerance must be positive".into())?;
103 ensure(s.max_evaluations >= 3, || "search: at least 3 evaluations".into())?;
104 ensure(!s.log || s.target > 0.0, || "search: a log search needs a positive target".into())?;
105 Ok(s)
106 }
107}
108
109#[derive(Clone, Debug, PartialEq)]
111pub struct Evaluation {
112 pub x: f64,
114 pub value: f64,
116 pub lo: f64,
118 pub hi: f64,
120 pub trials: u64,
122}
123
124#[derive(Clone, Debug, PartialEq)]
126pub struct SearchResult {
127 pub condition: Condition,
129 pub along: String,
131 pub metric: String,
133 pub target: f64,
135 pub value: f64,
137 pub lo: f64,
139 pub hi: f64,
141 pub converged: bool,
143 pub evaluations: Vec<Evaluation>,
145}
146
147fn crossing(a: f64, ga: f64, b: f64, gb: f64, gt: f64) -> f64 {
150 if (gb - ga).abs() < 1e-300 || !ga.is_finite() || !gb.is_finite() {
151 return 0.5 * (a + b);
152 }
153 a + (b - a) * (gt - ga) / (gb - ga)
154}
155
156fn combinations(grid: &Grid, along: &str) -> Vec<Condition> {
158 let mut out: Vec<Condition> = Vec::new();
159 for c in grid.conditions() {
160 let mut d = Condition::new();
161 for (k, v) in c.entries() {
162 if k != along {
163 d.set(k, v.clone());
164 }
165 }
166 if !out.contains(&d) {
167 out.push(d);
168 }
169 }
170 out
171}
172
173pub(crate) fn first_points(spec: &Spec) -> Option<Spec> {
177 let s = spec.search.as_ref()?;
178 let conditions = combinations(&spec.grid, &s.along).into_iter().map(|c| c.with(&s.along, s.range[0])).collect();
179 Some(Spec { grid: Grid::from_conditions(conditions), search: None, ..spec.clone() })
180}
181
182pub fn search(spec: &Spec, opts: &RunOptions) -> Result<Vec<SearchResult>> {
188 let s = spec.search.as_ref().ok_or_else(|| Error::Config(format!("{}: no search section", spec.name)))?;
189 let combos = combinations(&spec.grid, &s.along);
190 let info = spec.bare_runner(opts).run_info(&spec.name, combos.len());
191 let t0 = std::time::Instant::now();
192 let mut results = Vec::new();
193 for base in combos {
194 results.push(bisect(spec, opts, s, base)?);
195 }
196 if let Some(p) = opts.csv.as_ref().or(spec.csv.as_ref()) {
197 write_csv(p, &results)?;
198 }
199 if let Some(p) = opts.json.as_ref().or(spec.json.as_ref()) {
200 write_json(p, &info, t0.elapsed().as_secs_f64(), &results)?;
201 }
202 Ok(results)
203}
204
205fn evaluate(spec: &Spec, opts: &RunOptions, s: &SearchSpec, base: &Condition, x: f64) -> Result<Evaluation> {
207 let cond = base.clone().with(&s.along, x);
208 let grid = Grid::from_conditions(vec![cond.clone()]);
211 let r = spec.bare_runner(opts).run(&SpecScenario::new(spec), &grid)?.remove(0);
212 let est = r
213 .metric(&s.metric)
214 .ok_or_else(|| Error::Config(format!("search: no metric {:?} in ({})", s.metric, cond.label())))?;
215 let (lo, hi) = est.interval(spec.confidence);
216 if opts.progress.unwrap_or(spec.progress) {
217 eprintln!("[{}] ({}): {} = {:.4e} [{lo:.3e}, {hi:.3e}]", spec.name, cond.label(), s.metric, est.value());
218 }
219 Ok(Evaluation { x, value: est.value(), lo, hi, trials: r.trials })
220}
221
222fn bisect(spec: &Spec, opts: &RunOptions, s: &SearchSpec, base: Condition) -> Result<SearchResult> {
230 let eval = |x: f64| evaluate(spec, opts, s, &base, x);
231 let (mut a, mut b) = (eval(s.range[0])?, eval(s.range[1])?);
232 let mut evaluations = vec![a.clone(), b.clone()];
233 let side = |e: &Evaluation| (e.value - s.target).signum();
234 if side(&a) * side(&b) >= 0.0 {
235 return Err(Error::Config(format!(
236 "search ({}): the target {} of {} is not between {:.4e} at {} = {} and {:.4e} at {} = {}",
237 base.label(),
238 s.target,
239 s.metric,
240 a.value,
241 s.along,
242 a.x,
243 b.value,
244 s.along,
245 b.x
246 )));
247 }
248 while b.x - a.x > s.tolerance && evaluations.len() < s.max_evaluations {
249 let m = eval(0.5 * (a.x + b.x))?;
250 evaluations.push(m.clone());
251 if side(&m) == side(&a) {
252 a = m;
253 } else {
254 b = m;
255 }
256 }
257 Ok(operating_point(s, base, &a, &b, evaluations))
258}
259
260fn operating_point(
264 s: &SearchSpec,
265 base: Condition,
266 a: &Evaluation,
267 b: &Evaluation,
268 evaluations: Vec<Evaluation>,
269) -> SearchResult {
270 let g = |v: f64| if s.log { v.max(1e-300).log10() } else { v };
271 let gt = g(s.target);
272 let value = crossing(a.x, g(a.value), b.x, g(b.value), gt);
273 let x1 = crossing(a.x, g(a.lo), b.x, g(b.lo), gt);
274 let x2 = crossing(a.x, g(a.hi), b.x, g(b.hi), gt);
275 SearchResult {
276 condition: base,
277 along: s.along.clone(),
278 metric: s.metric.clone(),
279 target: s.target,
280 value,
281 lo: x1.min(x2).min(value),
282 hi: x1.max(x2).max(value),
283 converged: b.x - a.x <= s.tolerance,
284 evaluations,
285 }
286}
287
288fn write_csv(path: &Path, results: &[SearchResult]) -> Result<()> {
291 create_parent_dir(path)?;
292 let mut out = String::new();
293 if let Some(first) = results.first() {
294 let keys: Vec<&str> = first.condition.keys().collect();
295 let mut header: Vec<String> = keys.iter().map(|k| csv_field(k)).collect();
296 let a = &first.along;
297 header.extend([
298 "metric".into(),
299 "target".into(),
300 a.clone(),
301 format!("{a}_lo"),
302 format!("{a}_hi"),
303 "converged".into(),
304 "evaluations".into(),
305 ]);
306 out.push_str(&(header.join(",") + "\n"));
307 for r in results {
308 let mut row: Vec<String> = keys
309 .iter()
310 .map(|k| r.condition.get(k).map(|v| csv_field(&v.to_string())).unwrap_or_default())
311 .collect();
312 row.extend([
313 r.metric.clone(),
314 format!("{:e}", r.target),
315 format!("{}", r.value),
316 format!("{}", r.lo),
317 format!("{}", r.hi),
318 r.converged.to_string(),
319 r.evaluations.len().to_string(),
320 ]);
321 out.push_str(&(row.join(",") + "\n"));
322 }
323 }
324 std::fs::write(path, out)?;
325 Ok(())
326}
327
328fn write_json(path: &Path, info: &RunInfo, wall_s: f64, results: &[SearchResult]) -> Result<()> {
332 let items: Vec<String> = results
333 .iter()
334 .map(|r| {
335 let cond: Vec<(&str, String)> =
336 r.condition.entries().iter().map(|(k, v)| (k.as_str(), value_json(v))).collect();
337 let evals: Vec<String> = r
338 .evaluations
339 .iter()
340 .map(|e| {
341 json::object(&[
342 ("x", json::number(e.x)),
343 ("value", json::number(e.value)),
344 ("lo", json::number(e.lo)),
345 ("hi", json::number(e.hi)),
346 ("trials", e.trials.to_string()),
347 ])
348 })
349 .collect();
350 json::object(&[
351 ("condition", json::object(&cond)),
352 ("along", json::string(&r.along)),
353 ("metric", json::string(&r.metric)),
354 ("target", json::number(r.target)),
355 ("value", json::number(r.value)),
356 ("lo", json::number(r.lo)),
357 ("hi", json::number(r.hi)),
358 ("converged", r.converged.to_string()),
359 ("evaluations", json::array(&evals)),
360 ])
361 })
362 .collect();
363 let mut fields = run_header(info);
364 fields.push(("wall_s", json::number(wall_s)));
365 fields.push(("search", json::array(&items)));
366 write_json_file(path, &json::object(&fields))
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn spec(extra: &str) -> Spec {
374 Spec::from_yaml(&format!(
375 "name: s\nseed: 4\ngrid: {{m: [4, 16]}}\nlink: [{{type: source, constellation: {{kind: qam, m: $m}}, n_symbols: 4000}}, \
376 {{type: awgn_ebn0, ebn0_db: $ebn0_db}}]\nmetrics: {{report: [ber], scale: none}}\n\
377 stop: {{min_errors: {{ber: 300}}, max_trials: 300}}\n{extra}"
378 ))
379 .unwrap()
380 }
381
382 #[test]
383 fn the_operating_points_follow_the_theory() {
384 let s = spec("search: {along: ebn0_db, range: [2, 16], metric: ber, target: 1.0e-3, tolerance: 0.05}");
385 let r = search(&s, &RunOptions::default()).unwrap();
386 assert_eq!(r.len(), 2);
387 for (res, m) in r.iter().zip([4usize, 16]) {
388 let theory =
389 comnum_mapping::theory::ebn0_for_ber(comnum_mapping::constellation::ConstellationKind::Qam, m, 1e-3)
390 .unwrap();
391 assert!(res.converged && res.evaluations.len() <= 12);
392 assert!(res.lo - 0.1 <= theory && theory <= res.hi + 0.1, "M = {m}: {res:?} against {theory}");
393 assert!(res.hi - res.lo < 0.5);
394 }
395 }
396
397 #[test]
398 fn an_unbracketed_target_is_an_error() {
399 let s = spec("search: {along: ebn0_db, range: [12, 16], metric: ber, target: 1.0e-1}");
400 let e = search(&s, &RunOptions::default()).unwrap_err().to_string();
401 assert!(e.contains("not between"), "{e}");
402 assert!(
403 Spec::from_yaml("name: x\nlink: [source]\nsearch: {along: x, range: [2, 1], metric: ber, target: 1}")
404 .is_err()
405 );
406 }
407
408 #[test]
409 fn the_first_points_of_a_search_are_a_sweep() {
410 let s = spec("search: {along: ebn0_db, range: [4, 12], metric: ber, target: 1.0e-2}");
411 assert!(s.link_for(&s.conditions()[0]).is_err());
413 let sweep = first_points(&s).unwrap();
414 let labels: Vec<String> = sweep.conditions().iter().map(Condition::label).collect();
415 assert_eq!(labels, ["m=4, ebn0_db=4", "m=16, ebn0_db=4"]);
416 assert!(sweep.search.is_none() && sweep.link_for(&sweep.conditions()[1]).is_ok());
417 assert!(first_points(&sweep).is_none());
418 }
419
420 #[test]
421 fn reports_are_written() {
422 let dir = std::env::temp_dir().join(format!("comnum-search-{}", std::process::id()));
423 let s = spec("search: {along: ebn0_db, range: [4, 12], metric: ber, target: 1.0e-2, tolerance: 0.5}");
424 let opts = RunOptions { csv: Some(dir.join("s.csv")), json: Some(dir.join("s.json")), ..RunOptions::default() };
425 search(&s, &opts).unwrap();
426 let csv = std::fs::read_to_string(dir.join("s.csv")).unwrap();
427 assert!(csv.starts_with("m,metric,target,ebn0_db,ebn0_db_lo,ebn0_db_hi,converged,evaluations\n"), "{csv}");
428 assert_eq!(csv.lines().count(), 3);
429 let json = std::fs::read_to_string(dir.join("s.json")).unwrap();
430 for key in ["\"evaluations\"", "\"batch\"", "\"threads\"", "\"stop\"", "\"commit\"", "\"wall_s\""] {
432 assert!(json.contains(key), "{key} missing");
433 }
434 std::fs::remove_dir_all(&dir).ok();
435 }
436}