acoustic_ofdm/
plots.rs

1// Copyright (c) 2026 Elias S. G. Carotti
2
3use std::error::Error;
4use std::path::Path;
5
6use plotters::coord::Shift;
7use plotters::prelude::*;
8use rustfft::num_complex::Complex32;
9
10use crate::debug::PassbandChannelCompareDump;
11
12fn constellation_bounds(points: &[Complex32]) -> Option<f32> {
13    let max_abs = points
14        .iter()
15        .map(|z| z.re.abs().max(z.im.abs()))
16        .fold(0.0f32, f32::max);
17    if max_abs <= 0.0 {
18        None
19    } else {
20        Some((1.2 * max_abs).max(1.25))
21    }
22}
23
24fn draw_constellation_panel(
25    area: &DrawingArea<BitMapBackend<'_>, Shift>,
26    title: &str,
27    points: &[Complex32],
28    lim: f32,
29) -> Result<(), Box<dyn Error>> {
30    let mut chart = ChartBuilder::on(area)
31        .caption(title, ("sans-serif", 24).into_font())
32        .margin(16)
33        .x_label_area_size(40)
34        .y_label_area_size(45)
35        .build_cartesian_2d(-lim..lim, -lim..lim)?;
36
37    chart
38        .configure_mesh()
39        .x_desc("In-Phase")
40        .y_desc("Quadrature")
41        .axis_desc_style(("sans-serif", 18))
42        .label_style(("sans-serif", 14))
43        .light_line_style(RGBAColor(0, 0, 0, 0.08))
44        .bold_line_style(RGBAColor(0, 0, 0, 0.18))
45        .draw()?;
46
47    chart.draw_series(std::iter::once(PathElement::new(
48        vec![(-lim, 0.0), (lim, 0.0)],
49        BLACK.mix(0.25),
50    )))?;
51    chart.draw_series(std::iter::once(PathElement::new(
52        vec![(0.0, -lim), (0.0, lim)],
53        BLACK.mix(0.25),
54    )))?;
55    chart.draw_series(points.iter().map(|z| {
56        Circle::new(
57            (z.re, z.im),
58            4,
59            ShapeStyle::from(&RGBColor(33, 145, 140)).stroke_width(1),
60        )
61    }))?;
62    Ok(())
63}
64
65pub fn save_constellation_comparison_png(
66    path: &Path,
67    pre_eq: &[Complex32],
68    post_eq: &[Complex32],
69) -> Result<(), Box<dyn Error>> {
70    if pre_eq.is_empty() && post_eq.is_empty() {
71        return Ok(());
72    }
73
74    let lim = constellation_bounds(pre_eq)
75        .into_iter()
76        .chain(constellation_bounds(post_eq))
77        .fold(1.25f32, f32::max);
78
79    let root = BitMapBackend::new(path, (1280, 720)).into_drawing_area();
80    root.fill(&RGBColor(245, 245, 240))?;
81    let (left, right) = root.split_horizontally(640);
82    draw_constellation_panel(&left, "Constellation: Pre-EQ", pre_eq, lim)?;
83    draw_constellation_panel(&right, "Constellation: Post-EQ", post_eq, lim)?;
84    root.present()?;
85    Ok(())
86}
87
88fn phase_deg(z: Complex32) -> f32 {
89    z.arg().to_degrees()
90}
91
92pub fn save_channel_compare_png(
93    path: &Path,
94    dump: &PassbandChannelCompareDump,
95) -> Result<(), Box<dyn Error>> {
96    if dump.rows.is_empty() {
97        return Ok(());
98    }
99
100    let mut bins = dump.rows.iter().map(|r| r.used_bin).collect::<Vec<_>>();
101    bins.sort_unstable();
102    bins.dedup();
103
104    let mut actual = Vec::with_capacity(bins.len());
105    let mut est_train = Vec::with_capacity(bins.len());
106    let mut est_pilot = Vec::with_capacity(bins.len());
107    for &bin in &bins {
108        let rows = dump
109            .rows
110            .iter()
111            .filter(|r| r.used_bin == bin)
112            .collect::<Vec<_>>();
113        let n = rows.len().max(1) as f32;
114        let a = rows
115            .iter()
116            .fold(Complex32::new(0.0, 0.0), |acc, r| acc + r.actual_h)
117            / n;
118        let t = rows
119            .iter()
120            .fold(Complex32::new(0.0, 0.0), |acc, r| acc + r.estimated_h_train)
121            / n;
122        let p = rows
123            .iter()
124            .fold(Complex32::new(0.0, 0.0), |acc, r| acc + r.estimated_h_pilot)
125            / n;
126        actual.push((bin as f32, a));
127        est_train.push((bin as f32, t));
128        est_pilot.push((bin as f32, p));
129    }
130
131    let x_min = *bins.first().unwrap() as f32 - 0.5;
132    let x_max = *bins.last().unwrap() as f32 + 0.5;
133    let mag_max = actual
134        .iter()
135        .chain(est_train.iter())
136        .chain(est_pilot.iter())
137        .map(|(_, z)| z.norm())
138        .fold(0.0f32, f32::max)
139        .max(1.0);
140
141    let root = BitMapBackend::new(path, (1280, 800)).into_drawing_area();
142    root.fill(&RGBColor(245, 245, 240))?;
143    let (top, bottom) = root.split_vertically(400);
144
145    {
146        let mut chart = ChartBuilder::on(&top)
147            .caption("Channel Envelope", ("sans-serif", 24).into_font())
148            .margin(16)
149            .x_label_area_size(40)
150            .y_label_area_size(55)
151            .build_cartesian_2d(x_min..x_max, 0.0f32..(1.2 * mag_max))?;
152        chart
153            .configure_mesh()
154            .x_desc("Used Bin")
155            .y_desc("|H|")
156            .axis_desc_style(("sans-serif", 18))
157            .label_style(("sans-serif", 14))
158            .draw()?;
159        chart
160            .draw_series(std::iter::once(PathElement::new(
161                actual
162                    .iter()
163                    .map(|(b, z)| (*b, z.norm()))
164                    .collect::<Vec<_>>(),
165                RGBColor(214, 39, 40),
166            )))?
167            .label("Actual")
168            .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], RGBColor(214, 39, 40)));
169        chart
170            .draw_series(std::iter::once(PathElement::new(
171                est_train
172                    .iter()
173                    .map(|(b, z)| (*b, z.norm()))
174                    .collect::<Vec<_>>(),
175                RGBColor(31, 119, 180),
176            )))?
177            .label("Training Estimate")
178            .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], RGBColor(31, 119, 180)));
179        chart
180            .draw_series(std::iter::once(PathElement::new(
181                est_pilot
182                    .iter()
183                    .map(|(b, z)| (*b, z.norm()))
184                    .collect::<Vec<_>>(),
185                RGBColor(44, 160, 44),
186            )))?
187            .label("Pilot-Adjusted")
188            .legend(|(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], RGBColor(44, 160, 44)));
189        chart
190            .configure_series_labels()
191            .border_style(BLACK.mix(0.3))
192            .background_style(WHITE.mix(0.8))
193            .draw()?;
194    }
195
196    {
197        let mut chart = ChartBuilder::on(&bottom)
198            .caption("Channel Phase", ("sans-serif", 24).into_font())
199            .margin(16)
200            .x_label_area_size(40)
201            .y_label_area_size(55)
202            .build_cartesian_2d(x_min..x_max, -180.0f32..180.0f32)?;
203        chart
204            .configure_mesh()
205            .x_desc("Used Bin")
206            .y_desc("Phase (deg)")
207            .axis_desc_style(("sans-serif", 18))
208            .label_style(("sans-serif", 14))
209            .draw()?;
210        chart.draw_series(std::iter::once(PathElement::new(
211            actual
212                .iter()
213                .map(|(b, z)| (*b, phase_deg(*z)))
214                .collect::<Vec<_>>(),
215            RGBColor(214, 39, 40),
216        )))?;
217        chart.draw_series(std::iter::once(PathElement::new(
218            est_train
219                .iter()
220                .map(|(b, z)| (*b, phase_deg(*z)))
221                .collect::<Vec<_>>(),
222            RGBColor(31, 119, 180),
223        )))?;
224        chart.draw_series(std::iter::once(PathElement::new(
225            est_pilot
226                .iter()
227                .map(|(b, z)| (*b, phase_deg(*z)))
228                .collect::<Vec<_>>(),
229            RGBColor(44, 160, 44),
230        )))?;
231    }
232
233    root.present()?;
234    Ok(())
235}
236
237// vim: set ts=4 sw=4 et: