From 5b5fe50df35203fd6819826c211fe07ea28e44d0 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 6 May 2026 07:56:03 -0600 Subject: [PATCH] =?UTF-8?q?feat(heuropt-plot):=20v0.1.0=20=E2=80=94=20SVG?= =?UTF-8?q?=20visualization=20companion=20crate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds heuropt-plot, a tiny SVG-only plotter that takes heuropt results and emits scatter plots (pareto_front_svg) and line plots (convergence_svg). No heavy 'plotters' or 'tiny-skia' dep — hand- rolled SVG so the crate adds <100 KB to a build. Workspace setup: root Cargo.toml gains [workspace] with members = ['.', 'heuropt-plot']. heuropt-plot has its own version (0.1.0) and publishes independently against heuropt 0.8+. Adds examples/visualize.rs that wires it up: NSGA-II on Schaffer N.1, plain run() (no observer plumbing), final-front SVG written to disk. --- CHANGELOG.md | 13 ++ Cargo.toml | 4 + examples/visualize.rs | 53 ++++++ heuropt-plot/Cargo.toml | 17 ++ heuropt-plot/README.md | 46 +++++ heuropt-plot/src/lib.rs | 368 ++++++++++++++++++++++++++++++++++++++++ src/prelude.rs | 4 +- 7 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 examples/visualize.rs create mode 100644 heuropt-plot/Cargo.toml create mode 100644 heuropt-plot/README.md create mode 100644 heuropt-plot/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7397dbe..a3777c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`heuropt-plot` companion crate (v0.1.0)** at `heuropt-plot/`, + published independently. Lightweight SVG-only plotter for Pareto + fronts (`pareto_front_svg`) and convergence traces + (`convergence_svg`) — hand-rolled SVG output, no `plotters` / + `tiny-skia` dep so the crate stays a tiny optional addition. +- `examples/visualize.rs` — runs NSGA-II on Schaffer N.1 and writes + `pareto_front.svg` via the new `pareto_front_svg` helper. +- Workspace setup at the repo root: `[workspace] members = [".", + "heuropt-plot"]` so both crates share a target dir and one + `cargo` invocation builds the lot. + ## [0.8.0] — 2026-05-06 Theme: async evaluation. heuropt now supports problems where each diff --git a/Cargo.toml b/Cargo.toml index 721ddae..28f602a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,6 @@ +[workspace] +members = [".", "heuropt-plot"] + [package] name = "heuropt" version = "0.8.0" @@ -28,6 +31,7 @@ serde = { version = "1", features = ["derive"], optional = true } [dev-dependencies] gungraun = "0.18" +heuropt-plot = { path = "heuropt-plot" } proptest = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } diff --git a/examples/visualize.rs b/examples/visualize.rs new file mode 100644 index 0000000..c15e76f --- /dev/null +++ b/examples/visualize.rs @@ -0,0 +1,53 @@ +//! Visualize an NSGA-II run on Schaffer N.1 with the `heuropt-plot` +//! companion crate — produces a `pareto_front.svg` of the final +//! Pareto front. +//! +//! Run with: `cargo run --release --example visualize` + +use heuropt::prelude::*; +use heuropt_plot::pareto_front_svg; + +struct Schaffer; + +impl Problem for Schaffer { + type Decision = Vec; + fn objectives(&self) -> ObjectiveSpace { + ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]) + } + fn evaluate(&self, x: &Vec) -> Evaluation { + Evaluation::new(vec![x[0] * x[0], (x[0] - 2.0).powi(2)]) + } +} + +fn main() { + let problem = Schaffer; + let bounds = vec![(-5.0_f64, 5.0_f64)]; + let space = problem.objectives(); + + let mut opt = Nsga2::new( + Nsga2Config { + population_size: 50, + generations: 100, + seed: 42, + }, + RealBounds::new(bounds.clone()), + CompositeVariation { + crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5), + mutation: PolynomialMutation::new(bounds, 20.0, 1.0), + }, + ); + + let result = opt.run(&problem); + + let svg = pareto_front_svg( + &result.pareto_front, + &space, + 700, + 450, + "NSGA-II on Schaffer N.1 — final Pareto front", + ); + std::fs::write("pareto_front.svg", svg).expect("write pareto_front.svg"); + + println!("Final front size: {}", result.pareto_front.len()); + println!("Wrote pareto_front.svg"); +} diff --git a/heuropt-plot/Cargo.toml b/heuropt-plot/Cargo.toml new file mode 100644 index 0000000..7258d9f --- /dev/null +++ b/heuropt-plot/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "heuropt-plot" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +authors = ["Stephen Waits "] +description = "Lightweight SVG visualization for heuropt Pareto fronts and convergence traces." +license = "MIT" +readme = "README.md" +repository = "https://github.com/swaits/heuropt" +homepage = "https://github.com/swaits/heuropt" +documentation = "https://docs.rs/heuropt-plot" +keywords = ["optimization", "pareto", "svg", "plotting", "heuropt"] +categories = ["algorithms", "visualization"] + +[dependencies] +heuropt = { version = "0.8", path = ".." } diff --git a/heuropt-plot/README.md b/heuropt-plot/README.md new file mode 100644 index 0000000..eb2516c --- /dev/null +++ b/heuropt-plot/README.md @@ -0,0 +1,46 @@ +# heuropt-plot + +[![Crates.io](https://img.shields.io/crates/v/heuropt-plot.svg)](https://crates.io/crates/heuropt-plot) +[![Documentation](https://docs.rs/heuropt-plot/badge.svg)](https://docs.rs/heuropt-plot) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE) + +Lightweight SVG plotting helpers for [`heuropt`](https://crates.io/crates/heuropt) +results. + +Hand-rolled SVG output (no `plotters`, no `tiny-skia`, no +heavyweight dependency) so adding `heuropt-plot` to your project +costs ~20 KB of compiled code. + +## What's in the box + +- `pareto_front_svg` — render a 2-objective Pareto front as an SVG + scatter plot with axes and labels. +- `convergence_svg` — render a "best fitness so far" trace as an + SVG line plot. + +Output is a `String` of valid SVG. Write it to a file, embed it in +HTML, or pipe it to a browser. + +## Example + +```rust +use heuropt::prelude::*; +use heuropt_plot::pareto_front_svg; + +let space = ObjectiveSpace::new(vec![ + Objective::minimize("f1"), + Objective::minimize("f2"), +]); +let front = vec![ + Candidate::new((), Evaluation::new(vec![0.0, 1.0])), + Candidate::new((), Evaluation::new(vec![0.5, 0.5])), + Candidate::new((), Evaluation::new(vec![1.0, 0.0])), +]; + +let svg = pareto_front_svg(&front, &space, 600, 400, "Sample front"); +std::fs::write("front.svg", svg).unwrap(); +``` + +## License + +MIT — see [LICENSE](../LICENSE) at the repo root. diff --git a/heuropt-plot/src/lib.rs b/heuropt-plot/src/lib.rs new file mode 100644 index 0000000..bd9f036 --- /dev/null +++ b/heuropt-plot/src/lib.rs @@ -0,0 +1,368 @@ +//! Lightweight SVG plotting helpers for `heuropt` results. +//! +//! Two core primitives: +//! +//! - [`pareto_front_svg`] — render a 2-objective Pareto front as an +//! SVG scatter plot with axes and labels. +//! - [`convergence_svg`] — render a per-generation "best-fitness so +//! far" trace as an SVG line plot. +//! +//! Hand-rolled SVG output (no `plotters` / `tiny-skia` dep) so the +//! crate stays a tiny optional dependency. Output is a `String` of +//! valid SVG — write it to a file, embed it in HTML, or pipe it to a +//! browser. +//! +//! # Example +//! +//! ``` +//! use heuropt::prelude::*; +//! use heuropt_plot::pareto_front_svg; +//! +//! let space = ObjectiveSpace::new(vec![ +//! Objective::minimize("f1"), +//! Objective::minimize("f2"), +//! ]); +//! let front = vec![ +//! Candidate::new((), Evaluation::new(vec![0.0, 1.0])), +//! Candidate::new((), Evaluation::new(vec![0.5, 0.5])), +//! Candidate::new((), Evaluation::new(vec![1.0, 0.0])), +//! ]; +//! let svg = pareto_front_svg(&front, &space, 600, 400, "Sample front"); +//! assert!(svg.starts_with("")); +//! ``` + +use std::fmt::Write as _; + +use heuropt::core::candidate::Candidate; +use heuropt::core::objective::ObjectiveSpace; + +/// Render a 2-objective Pareto front as an SVG scatter plot. +/// +/// `width` and `height` are the SVG viewport dimensions in pixels. +/// `title` is rendered at the top. +/// +/// Points are plotted in minimization-oriented coordinates. +/// +/// # Panics +/// +/// If `objectives.len() != 2`. +pub fn pareto_front_svg( + front: &[Candidate], + objectives: &ObjectiveSpace, + width: u32, + height: u32, + title: &str, +) -> String { + assert_eq!( + objectives.len(), + 2, + "pareto_front_svg requires exactly 2 objectives", + ); + let oriented: Vec<[f64; 2]> = front + .iter() + .map(|c| { + let m = objectives.as_minimization(&c.evaluation.objectives); + [m[0], m[1]] + }) + .collect(); + let (xs_label, ys_label) = ( + objectives.objectives[0].name.as_str(), + objectives.objectives[1].name.as_str(), + ); + + let (xmin, xmax) = bounds(oriented.iter().map(|p| p[0])); + let (ymin, ymax) = bounds(oriented.iter().map(|p| p[1])); + let xspan = (xmax - xmin).max(1e-12); + let yspan = (ymax - ymin).max(1e-12); + + // Margins so axes/labels have room. + let m_left = 60.0_f64; + let m_right = 20.0_f64; + let m_top = 40.0_f64; + let m_bot = 50.0_f64; + let plot_w = width as f64 - m_left - m_right; + let plot_h = height as f64 - m_top - m_bot; + + let to_x = |v: f64| m_left + (v - xmin) / xspan * plot_w; + // Y is inverted: lower minimization value → higher pixel. + let to_y = |v: f64| m_top + plot_h - (v - ymin) / yspan * plot_h; + + let mut out = String::new(); + let _ = writeln!( + out, + "", + ); + let _ = writeln!( + out, + " ", + ); + let _ = writeln!( + out, + " {title}", + x = m_left, + title = escape_xml(title), + ); + + // Axes box. + let _ = writeln!( + out, + " ", + m_left, m_top, plot_w, plot_h, + ); + + // X-axis ticks (3 ticks). + for i in 0..=3 { + let t = i as f64 / 3.0; + let v = xmin + t * xspan; + let x = to_x(v); + let _ = writeln!( + out, + " ", + y0 = m_top + plot_h, + y1 = m_top + plot_h + 5.0, + ); + let _ = writeln!( + out, + " {v:.3}", + y = m_top + plot_h + 18.0, + ); + } + // Y-axis ticks. + for i in 0..=3 { + let t = i as f64 / 3.0; + let v = ymin + t * yspan; + let y = to_y(v); + let _ = writeln!( + out, + " ", + x0 = m_left - 5.0, + x1 = m_left, + ); + let _ = writeln!( + out, + " {v:.3}", + x = m_left - 8.0, + ); + } + + // Axis labels. + let _ = writeln!( + out, + " {xs_label}", + x = m_left + plot_w / 2.0, + y = height as f64 - 12.0, + xs_label = escape_xml(xs_label), + ); + let _ = writeln!( + out, + " {ys_label}", + y = m_top + plot_h / 2.0, + ys_label = escape_xml(ys_label), + ); + + // Points. + for p in &oriented { + let cx = to_x(p[0]); + let cy = to_y(p[1]); + let _ = writeln!( + out, + " ", + ); + } + + out.push_str(""); + out +} + +/// Render a per-generation "best fitness so far" trace as an SVG line +/// plot. `bests[i]` is the best fitness *after* generation `i`. +/// +/// `direction_minimize` controls which way is "improvement": `true` +/// for minimize problems, `false` for maximize. +pub fn convergence_svg( + bests: &[f64], + width: u32, + height: u32, + title: &str, + y_axis_label: &str, + _direction_minimize: bool, +) -> String { + let n = bests.len(); + if n == 0 { + return format!( + "\ + {}", + escape_xml(title) + ); + } + + let (ymin, ymax) = bounds(bests.iter().copied()); + let yspan = (ymax - ymin).max(1e-12); + let xspan = (n - 1).max(1) as f64; + + let m_left = 70.0_f64; + let m_right = 20.0_f64; + let m_top = 40.0_f64; + let m_bot = 50.0_f64; + let plot_w = width as f64 - m_left - m_right; + let plot_h = height as f64 - m_top - m_bot; + + let to_x = |i: usize| m_left + (i as f64) / xspan * plot_w; + let to_y = |v: f64| m_top + plot_h - (v - ymin) / yspan * plot_h; + + let mut out = String::new(); + let _ = writeln!( + out, + "", + ); + let _ = writeln!( + out, + " ", + ); + let _ = writeln!( + out, + " {title}", + x = m_left, + title = escape_xml(title), + ); + let _ = writeln!( + out, + " ", + m_left, m_top, plot_w, plot_h, + ); + + // X axis: generation index. + for i in 0..=4 { + let t = i as f64 / 4.0; + let g = (t * (n - 1) as f64).round() as usize; + let x = to_x(g); + let _ = writeln!( + out, + " ", + y0 = m_top + plot_h, + y1 = m_top + plot_h + 5.0, + ); + let _ = writeln!( + out, + " {g}", + y = m_top + plot_h + 18.0, + ); + } + // Y ticks. + for i in 0..=3 { + let t = i as f64 / 3.0; + let v = ymin + t * yspan; + let y = to_y(v); + let _ = writeln!( + out, + " ", + x0 = m_left - 5.0, + x1 = m_left, + ); + let _ = writeln!( + out, + " {v:.3e}", + x = m_left - 8.0, + ); + } + + // Axis labels. + let _ = writeln!( + out, + " generation", + x = m_left + plot_w / 2.0, + y = height as f64 - 12.0, + ); + let _ = writeln!( + out, + " {label}", + y = m_top + plot_h / 2.0, + label = escape_xml(y_axis_label), + ); + + // Polyline. + let mut points = String::new(); + for (i, &v) in bests.iter().enumerate() { + if i > 0 { + points.push(' '); + } + let _ = write!(points, "{:.2},{:.2}", to_x(i), to_y(v)); + } + let _ = writeln!( + out, + " ", + ); + + out.push_str(""); + out +} + +fn bounds>(it: I) -> (f64, f64) { + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for v in it { + if v.is_finite() { + if v < lo { + lo = v; + } + if v > hi { + hi = v; + } + } + } + if lo.is_infinite() { + (0.0, 1.0) + } else if (hi - lo).abs() < f64::EPSILON { + // All points equal — give a small artificial span. + (lo - 0.5, hi + 0.5) + } else { + (lo, hi) + } +} + +fn escape_xml(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +#[cfg(test)] +mod tests { + use super::*; + use heuropt::core::evaluation::Evaluation; + use heuropt::core::objective::Objective; + + #[test] + fn pareto_svg_well_formed() { + let space = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]); + let front = vec![ + Candidate::new((), Evaluation::new(vec![0.0, 1.0])), + Candidate::new((), Evaluation::new(vec![1.0, 0.0])), + ]; + let svg = pareto_front_svg(&front, &space, 400, 300, "test"); + assert!(svg.starts_with("")); + assert!(svg.contains("")); + } +} diff --git a/src/prelude.rs b/src/prelude.rs index 19d47f1..843a768 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -4,12 +4,12 @@ //! use heuropt::prelude::*; //! ``` +#[cfg(feature = "async")] +pub use crate::core::async_problem::AsyncProblem; pub use crate::core::{ Candidate, Direction, Evaluation, Objective, ObjectiveSpace, OptimizationResult, PartialProblem, Population, Problem, Rng, rng_from_seed, }; -#[cfg(feature = "async")] -pub use crate::core::async_problem::AsyncProblem; pub use crate::traits::{Initializer, Optimizer, Repair, Variation};