diff --git a/main.nf b/main.nf index bb70beb..f689482 100644 --- a/main.nf +++ b/main.nf @@ -162,6 +162,21 @@ process generate_phase_plot { """ } +process generate_dashboard { + publishDir "${params.outdir}", mode: 'copy' + + input: + path final_csv + + output: + path "dashboard.png" + + script: + """ + python3 -m horn_analysis.dashboard ${final_csv} dashboard.png + """ +} + // ======================================================================== // Auto mode processes // ======================================================================== @@ -396,6 +411,9 @@ workflow single { // 8. Impedance and phase plots generate_impedance_plot(ch_merged_results) generate_phase_plot(ch_merged_results) + + // 9. Combined dashboard + generate_dashboard(ch_merged_results) } workflow auto { diff --git a/packages/horn-analysis/pyproject.toml b/packages/horn-analysis/pyproject.toml index 545aabd..9d73c37 100644 --- a/packages/horn-analysis/pyproject.toml +++ b/packages/horn-analysis/pyproject.toml @@ -22,6 +22,7 @@ horn-score = "horn_analysis.scoring:main" horn-prescreen = "horn_analysis.prescreen:main" horn-rank = "horn_analysis.rank_pipeline:main" horn-auto-report = "horn_analysis.auto_report:main" +horn-dashboard = "horn_analysis.dashboard:main" [project.optional-dependencies] test = [ diff --git a/packages/horn-analysis/src/horn_analysis/compare.py b/packages/horn-analysis/src/horn_analysis/compare.py index 593a25c..d7e6d60 100644 --- a/packages/horn-analysis/src/horn_analysis/compare.py +++ b/packages/horn-analysis/src/horn_analysis/compare.py @@ -12,6 +12,9 @@ import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt +import numpy as np + +from horn_analysis import plot_theme def plot_multi_comparison( @@ -30,21 +33,26 @@ def plot_multi_comparison( Path to the output image. """ if kpi_table: - fig, (ax_plot, ax_table) = plt.subplots( - 2, 1, figsize=(12, 10), gridspec_kw={"height_ratios": [3, 1]}, + fig, (ax_plot, ax_table) = plot_theme.create_figure( + figsize=(12, 10), nrows=2, ncols=1, gridspec_kw={"height_ratios": [3, 1]}, ) else: - fig, ax_plot = plt.subplots(figsize=(12, 8)) + fig, ax_plot = plot_theme.create_figure(figsize=(12, 8)) - for csv_path, label in file_label_pairs: + all_freq = [] + all_spl = [] + for i, (csv_path, label) in enumerate(file_label_pairs): df = pd.read_csv(csv_path) - ax_plot.plot(df["frequency"], df["spl"], label=label) + color = plot_theme.MULTI_COLORS[i % len(plot_theme.MULTI_COLORS)] + ax_plot.plot(df["frequency"], df["spl"], label=label, color=color, linewidth=1.4) + all_freq.extend(df["frequency"].values) + all_spl.extend(df["spl"].values) + + plot_theme.setup_freq_axis(ax_plot, min(all_freq), max(all_freq)) + plot_theme.setup_spl_axis(ax_plot, np.array(all_spl)) + plot_theme.setup_grid(ax_plot) - ax_plot.set_xscale("log") ax_plot.set_title("Horn Frequency Response Comparison") - ax_plot.set_xlabel("Frequency (Hz)") - ax_plot.set_ylabel("Sound Pressure Level (dB)") - ax_plot.grid(True, which="both", ls="--") ax_plot.legend() if kpi_table: @@ -57,11 +65,11 @@ def plot_multi_comparison( label, f"{kpis.peak_spl_db:.1f}", f"{kpis.peak_frequency_hz:.0f}", - f"{kpis.f3_low_hz:.0f}" if kpis.f3_low_hz else "—", - f"{kpis.f3_high_hz:.0f}" if kpis.f3_high_hz else "—", - f"{kpis.bandwidth_octaves:.1f}" if kpis.bandwidth_octaves else "—", - f"{kpis.passband_ripple_db:.1f}" if kpis.passband_ripple_db is not None else "—", - f"{kpis.average_sensitivity_db:.1f}" if kpis.average_sensitivity_db is not None else "—", + f"{kpis.f3_low_hz:.0f}" if kpis.f3_low_hz else "\u2014", + f"{kpis.f3_high_hz:.0f}" if kpis.f3_high_hz else "\u2014", + f"{kpis.bandwidth_octaves:.1f}" if kpis.bandwidth_octaves else "\u2014", + f"{kpis.passband_ripple_db:.1f}" if kpis.passband_ripple_db is not None else "\u2014", + f"{kpis.average_sensitivity_db:.1f}" if kpis.average_sensitivity_db is not None else "\u2014", ]) col_labels = [ @@ -81,10 +89,8 @@ def plot_multi_comparison( table.set_fontsize(9) table.scale(1, 1.4) - plt.tight_layout() output_path = Path(output_file) - plt.savefig(output_path, dpi=150) - plt.close() + plot_theme.save_figure(fig, str(output_path)) print(f"Comparison plot saved to {output_path}") return output_path diff --git a/packages/horn-analysis/src/horn_analysis/compare_horns.py b/packages/horn-analysis/src/horn_analysis/compare_horns.py index 7a7dd79..ad30da3 100644 --- a/packages/horn-analysis/src/horn_analysis/compare_horns.py +++ b/packages/horn-analysis/src/horn_analysis/compare_horns.py @@ -1,7 +1,11 @@ import pandas as pd -import matplotlib.pyplot as plt +import matplotlib +matplotlib.use("Agg") import sys +from horn_analysis import plot_theme + + def plot_comparison(file_a, label_a, file_b, label_b, output_file): """ Reads two CSV files containing frequency response data and plots them on the same graph. @@ -17,22 +21,21 @@ def plot_comparison(file_a, label_a, file_b, label_b, output_file): df_a = pd.read_csv(file_a) df_b = pd.read_csv(file_b) - # Create the plot - plt.figure(figsize=(12, 8)) - plt.plot(df_a['frequency'], df_a['spl'], label=label_a) - plt.plot(df_b['frequency'], df_b['spl'], label=label_b) - - # Formatting - plt.xscale('log') - plt.title('Horn Frequency Response Comparison') - plt.xlabel('Frequency (Hz)') - plt.ylabel('Sound Pressure Level (dB)') - plt.grid(True, which="both", ls="--") - plt.legend() - - # Save the plot - plt.savefig(output_file) - plt.close() + fig, ax = plot_theme.create_figure(figsize=(12, 8)) + ax.plot(df_a['frequency'], df_a['spl'], label=label_a, + color=plot_theme.MULTI_COLORS[0], linewidth=1.4) + ax.plot(df_b['frequency'], df_b['spl'], label=label_b, + color=plot_theme.MULTI_COLORS[1], linewidth=1.4) + + all_freq = list(df_a['frequency'].values) + list(df_b['frequency'].values) + plot_theme.setup_freq_axis(ax, min(all_freq), max(all_freq)) + plot_theme.setup_grid(ax) + + ax.set_title('Horn Frequency Response Comparison') + ax.set_ylabel('Sound Pressure Level (dB)') + ax.legend() + + plot_theme.save_figure(fig, output_file) print(f"Comparison plot saved to {output_file}") if __name__ == "__main__": @@ -40,5 +43,5 @@ def plot_comparison(file_a, label_a, file_b, label_b, output_file): if len(sys.argv) != 6: print("Usage: python compare_horns.py ") sys.exit(1) - - plot_comparison(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) \ No newline at end of file + + plot_comparison(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]) diff --git a/packages/horn-analysis/src/horn_analysis/dashboard.py b/packages/horn-analysis/src/horn_analysis/dashboard.py new file mode 100644 index 0000000..93ec226 --- /dev/null +++ b/packages/horn-analysis/src/horn_analysis/dashboard.py @@ -0,0 +1,117 @@ +"""Combined 3-panel dashboard: SPL + impedance + phase/group-delay on shared x-axis.""" + +import argparse +from pathlib import Path + +import numpy as np +import pandas as pd +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +from horn_analysis import plot_theme + + +def generate_dashboard(csv_file: str, output_file: str): + """Generate a combined 3-panel dashboard from solver CSV output. + + Row 1 (tall): SPL with 5 dB divisions + Row 2: |Z| magnitude + angle(Z) on dual y-axis + Row 3: Unwrapped phase + group delay on dual y-axis + + Expects CSV columns: frequency, spl, z_real, z_imag, phase_deg. + + Args: + csv_file: Input CSV path with all solver output columns. + output_file: Output image path. + """ + df = pd.read_csv(csv_file) + freq = df["frequency"].values + + fig, (ax_spl, ax_z, ax_ph) = plot_theme.create_figure( + figsize=(11, 14), nrows=3, ncols=1, + sharex=True, gridspec_kw={"height_ratios": [2, 1.2, 1.2]}, + ) + + # -- Row 1: SPL -- + spl = df["spl"].values + ax_spl.plot(freq, spl, color=plot_theme.COLORS["primary"], linewidth=1.4) + ax_spl.set_title("Sound Pressure Level (SPL) vs. Frequency") + plot_theme.setup_spl_axis(ax_spl, spl) + plot_theme.setup_grid(ax_spl) + + # -- Row 2: Impedance (dual y-axis) -- + z_complex = df["z_real"].values + 1j * df["z_imag"].values + z_mag = np.abs(z_complex) + z_angle = np.degrees(np.angle(z_complex)) + + color_mag = plot_theme.COLORS["primary"] + ax_z.plot(freq, z_mag, color=color_mag, linewidth=1.4, label="|Z|") + ax_z.set_ylabel("|Z| (Pa\u00b7s/m)", color=color_mag) + ax_z.tick_params(axis="y", labelcolor=color_mag) + + ax_z2 = ax_z.twinx() + color_angle = plot_theme.COLORS["secondary"] + ax_z2.plot(freq, z_angle, color=color_angle, linestyle="--", linewidth=1.4, label="\u2220Z") + ax_z2.set_ylabel("\u2220Z (degrees)", color=color_angle) + ax_z2.tick_params(axis="y", labelcolor=color_angle) + + ax_z.set_title("Throat Impedance") + plot_theme.setup_grid(ax_z) + + lines_z1, labels_z1 = ax_z.get_legend_handles_labels() + lines_z2, labels_z2 = ax_z2.get_legend_handles_labels() + ax_z.legend(lines_z1 + lines_z2, labels_z1 + labels_z2, loc="upper right", fontsize=9) + + # -- Row 3: Phase + group delay (dual y-axis) -- + phase_deg = df["phase_deg"].values + phase_rad = np.radians(phase_deg) + phase_unwrapped = np.degrees(np.unwrap(phase_rad)) + + color_phase = plot_theme.COLORS["primary"] + ax_ph.plot(freq, phase_unwrapped, color=color_phase, linewidth=1.4, label="Phase") + ax_ph.set_ylabel("Phase (degrees)", color=color_phase) + ax_ph.tick_params(axis="y", labelcolor=color_phase) + + ax_ph2 = ax_ph.twinx() + if len(freq) > 1: + dphi = np.diff(np.unwrap(phase_rad)) + df_vals = np.diff(freq) + tau_g_ms = -dphi / (2 * np.pi * df_vals) * 1000 + freq_mid = (freq[:-1] + freq[1:]) / 2 + + color_gd = plot_theme.COLORS["tertiary"] + ax_ph2.plot(freq_mid, tau_g_ms, color=color_gd, linestyle="--", linewidth=1.2, label="Group delay") + ax_ph2.set_ylabel("Group Delay (ms)", color=color_gd) + ax_ph2.tick_params(axis="y", labelcolor=color_gd) + + ax_ph.set_title("Phase Response & Group Delay") + plot_theme.setup_grid(ax_ph) + + lines_ph1, labels_ph1 = ax_ph.get_legend_handles_labels() + lines_ph2, labels_ph2 = ax_ph2.get_legend_handles_labels() + ax_ph.legend(lines_ph1 + lines_ph2, labels_ph1 + labels_ph2, loc="upper right", fontsize=9) + + # Shared frequency axis on bottom panel only + plot_theme.setup_freq_axis(ax_ph, freq.min(), freq.max()) + # Apply log scale to upper panels (shared x) but hide their tick labels + ax_spl.set_xlabel("") + ax_z.set_xlabel("") + + plot_theme.save_figure(fig, output_file) + print(f"Dashboard saved to {output_file}") + + +def main(): + """CLI for combined dashboard generation.""" + parser = argparse.ArgumentParser( + description="Generate combined SPL/impedance/phase dashboard from solver CSV.", + ) + parser.add_argument("csv_file", type=str, help="Input CSV with frequency, spl, z_real, z_imag, phase_deg.") + parser.add_argument("output_file", type=str, help="Output image path.") + args = parser.parse_args() + generate_dashboard(args.csv_file, args.output_file) + + +if __name__ == "__main__": + main() diff --git a/packages/horn-analysis/src/horn_analysis/html_report.py b/packages/horn-analysis/src/horn_analysis/html_report.py index 151cced..74338c8 100644 --- a/packages/horn-analysis/src/horn_analysis/html_report.py +++ b/packages/horn-analysis/src/horn_analysis/html_report.py @@ -5,55 +5,38 @@ dependencies beyond matplotlib/numpy/pandas (already required). """ -import base64 import html -import io from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple import matplotlib matplotlib.use("Agg") -import matplotlib.pyplot as plt import numpy as np import pandas as pd from horn_core.parameters import DriverParameters from horn_analysis.scoring import TargetSpec +from horn_analysis import plot_theme -# -- Profile colour/style mapping ------------------------------------------ +# -- Profile badge styles (HTML-only; matplotlib styles come from plot_theme) -- -_PROFILE_STYLES = { - "conical": {"color": "#2563eb", "linestyle": "-", "badge_bg": "#dbeafe", "badge_fg": "#1e40af"}, - "exponential": {"color": "#16a34a", "linestyle": "--", "badge_bg": "#dcfce7", "badge_fg": "#166534"}, - "hyperbolic": {"color": "#d97706", "linestyle": "-.", "badge_bg": "#fef3c7", "badge_fg": "#92400e"}, +_BADGE_STYLES = { + "conical": {"badge_bg": "#dbeafe", "badge_fg": "#1e40af"}, + "exponential": {"badge_bg": "#dcfce7", "badge_fg": "#166534"}, + "hyperbolic": {"badge_bg": "#fef3c7", "badge_fg": "#92400e"}, } -_DEFAULT_STYLE = {"color": "#6b7280", "linestyle": "-", "badge_bg": "#f3f4f6", "badge_fg": "#374151"} +_DEFAULT_BADGE = {"badge_bg": "#f3f4f6", "badge_fg": "#374151"} -def _style_for(profile: str) -> dict: - return _PROFILE_STYLES.get(profile, _DEFAULT_STYLE) +def _badge_for(profile: str) -> dict: + return _BADGE_STYLES.get(profile, _DEFAULT_BADGE) # -- Helpers ---------------------------------------------------------------- -def _fig_to_b64(fig) -> str: - """Render a matplotlib figure to a base64 data-URI and close it.""" - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=150, bbox_inches="tight") - plt.close(fig) - buf.seek(0) - encoded = base64.b64encode(buf.read()).decode("ascii") - return f"data:image/png;base64,{encoded}" - - -def _target_band_span(ax, target: TargetSpec, alpha: float = 0.08): - """Add a shaded vertical span for the target frequency band.""" - ax.axvspan(target.f_low_hz, target.f_high_hz, color="#6366f1", alpha=alpha, label="Target band") - - -def _fmt(value, fmt: str = ".1f", fallback: str = "—") -> str: +def _fmt(value, fmt: str = ".1f", fallback: str = "\u2014") -> str: """Safely format an Optional numeric value.""" if value is None: return fallback @@ -70,24 +53,28 @@ def _plot_coupled_spl_comparison( target: TargetSpec, ) -> str: """Overlaid coupled SPL for top N candidates with target band.""" - fig, ax = plt.subplots(figsize=(11, 5.5)) + fig, ax = plot_theme.create_figure(figsize=(11, 5.5)) + all_freq = [] for csv_path, label in csv_pairs: df = pd.read_csv(csv_path) # Extract profile name from label (last word in parens) profile = label.rsplit("(", 1)[-1].rstrip(")") if "(" in label else "" - style = _style_for(profile) - ax.semilogx(df["frequency"], df["spl"], label=label, - color=style["color"], linestyle=style["linestyle"], linewidth=1.4) + style = plot_theme.profile_style(profile) + ax.plot(df["frequency"], df["spl"], label=label, + color=style["color"], linestyle=style["linestyle"], linewidth=1.4) + all_freq.extend(df["frequency"].values) - _target_band_span(ax, target) - ax.set_xlabel("Frequency (Hz)") + plot_theme.target_band_span(ax, target) + + if all_freq: + plot_theme.setup_freq_axis(ax, min(all_freq), max(all_freq)) ax.set_ylabel("SPL (dB)") - ax.set_title("Coupled SPL — Top Candidates") - ax.grid(True, which="both", ls="--", alpha=0.4) + ax.set_title("Coupled SPL \u2014 Top Candidates") + plot_theme.setup_grid(ax) ax.legend(fontsize=8, loc="best") fig.tight_layout() - return _fig_to_b64(fig) + return plot_theme.fig_to_b64(fig) def _plot_raw_profile_spl( @@ -95,30 +82,35 @@ def _plot_raw_profile_spl( target: TargetSpec, ) -> str: """Overlaid uncoupled SPL comparing horn profiles.""" - fig, ax = plt.subplots(figsize=(11, 5.5)) + fig, ax = plot_theme.create_figure(figsize=(11, 5.5)) + all_freq = [] for profile, csv_path in sorted(solver_csvs.items()): df = pd.read_csv(csv_path) - style = _style_for(profile) - ax.semilogx(df["frequency"], df["spl"], label=profile.capitalize(), - color=style["color"], linestyle=style["linestyle"], linewidth=1.4) + style = plot_theme.profile_style(profile) + ax.plot(df["frequency"], df["spl"], label=profile.capitalize(), + color=style["color"], linestyle=style["linestyle"], linewidth=1.4) + all_freq.extend(df["frequency"].values) + + plot_theme.target_band_span(ax, target) - _target_band_span(ax, target) - ax.set_xlabel("Frequency (Hz)") + if all_freq: + plot_theme.setup_freq_axis(ax, min(all_freq), max(all_freq)) ax.set_ylabel("SPL (dB)") ax.set_title("Raw Horn SPL by Profile (uncoupled)") - ax.grid(True, which="both", ls="--", alpha=0.4) + plot_theme.setup_grid(ax) ax.legend(fontsize=9) fig.tight_layout() - return _fig_to_b64(fig) + return plot_theme.fig_to_b64(fig) def _plot_profile_impedance(solver_csvs: Dict[str, str]) -> str: """|Z| magnitude + phase angle overlay for each profile (dual y-axis).""" - fig, ax1 = plt.subplots(figsize=(11, 5.5)) + fig, ax1 = plot_theme.create_figure(figsize=(11, 5.5)) ax2 = ax1.twinx() lines = [] + all_freq = [] for profile, csv_path in sorted(solver_csvs.items()): df = pd.read_csv(csv_path) freq = df["frequency"].values @@ -126,29 +118,32 @@ def _plot_profile_impedance(solver_csvs: Dict[str, str]) -> str: z_mag = np.abs(z_complex) z_angle = np.degrees(np.angle(z_complex)) - style = _style_for(profile) - l1, = ax1.semilogx(freq, z_mag, color=style["color"], - linestyle=style["linestyle"], linewidth=1.3, - label=f"|Z| {profile}") - l2, = ax2.semilogx(freq, z_angle, color=style["color"], - linestyle=":", linewidth=1.0, alpha=0.7, - label=f"∠Z {profile}") + style = plot_theme.profile_style(profile) + l1, = ax1.plot(freq, z_mag, color=style["color"], + linestyle=style["linestyle"], linewidth=1.3, + label=f"|Z| {profile}") + l2, = ax2.plot(freq, z_angle, color=style["color"], + linestyle=":", linewidth=1.0, alpha=0.7, + label=f"\u2220Z {profile}") lines.extend([l1, l2]) + all_freq.extend(freq) - ax1.set_xlabel("Frequency (Hz)") - ax1.set_ylabel("|Z| (Pa·s/m)") - ax2.set_ylabel("∠Z (degrees)") + if all_freq: + plot_theme.setup_freq_axis(ax1, min(all_freq), max(all_freq)) + ax1.set_ylabel("|Z| (Pa\u00b7s/m)") + ax2.set_ylabel("\u2220Z (degrees)") ax1.set_title("Throat Impedance by Profile") - ax1.grid(True, which="both", ls="--", alpha=0.3) + plot_theme.setup_grid(ax1) ax1.legend(handles=lines, fontsize=7, loc="upper right", ncol=2) fig.tight_layout() - return _fig_to_b64(fig) + return plot_theme.fig_to_b64(fig) def _plot_profile_phase(solver_csvs: Dict[str, str]) -> str: """Unwrapped phase + group delay subplots for each profile.""" - fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 8), sharex=True) + fig, (ax1, ax2) = plot_theme.create_figure(figsize=(11, 8), nrows=2, ncols=1, sharex=True) + all_freq = [] for profile, csv_path in sorted(solver_csvs.items()): df = pd.read_csv(csv_path) freq = df["frequency"].values @@ -157,10 +152,10 @@ def _plot_profile_phase(solver_csvs: Dict[str, str]) -> str: phase_rad = np.radians(phase_deg) phase_unwrapped = np.degrees(np.unwrap(phase_rad)) - style = _style_for(profile) - ax1.semilogx(freq, phase_unwrapped, color=style["color"], - linestyle=style["linestyle"], linewidth=1.3, - label=profile.capitalize()) + style = plot_theme.profile_style(profile) + ax1.plot(freq, phase_unwrapped, color=style["color"], + linestyle=style["linestyle"], linewidth=1.3, + label=profile.capitalize()) # Group delay if len(freq) > 1: @@ -168,32 +163,39 @@ def _plot_profile_phase(solver_csvs: Dict[str, str]) -> str: df_vals = np.diff(freq) tau_g_ms = -dphi / (2 * np.pi * df_vals) * 1000 freq_mid = (freq[:-1] + freq[1:]) / 2 - ax2.semilogx(freq_mid, tau_g_ms, color=style["color"], - linestyle=style["linestyle"], linewidth=1.0, - label=profile.capitalize()) + ax2.plot(freq_mid, tau_g_ms, color=style["color"], + linestyle=style["linestyle"], linewidth=1.0, + label=profile.capitalize()) + + all_freq.extend(freq) + + if all_freq: + f_min, f_max = min(all_freq), max(all_freq) + plot_theme.setup_freq_axis(ax1, f_min, f_max) + ax1.set_xlabel("") + plot_theme.setup_freq_axis(ax2, f_min, f_max) ax1.set_ylabel("Phase (degrees)") ax1.set_title("Unwrapped Phase Response") - ax1.grid(True, which="both", ls="--", alpha=0.4) + plot_theme.setup_grid(ax1) ax1.legend(fontsize=9) - ax2.set_xlabel("Frequency (Hz)") ax2.set_ylabel("Group Delay (ms)") - ax2.grid(True, which="both", ls="--", alpha=0.4) + plot_theme.setup_grid(ax2) ax2.legend(fontsize=9) fig.tight_layout() - return _fig_to_b64(fig) + return plot_theme.fig_to_b64(fig) # -- Table renderers -------------------------------------------------------- def _profile_badge(profile: str) -> str: - style = _style_for(profile) + badge = _badge_for(profile) return ( f'' + f'background:{badge["badge_bg"]};color:{badge["badge_fg"]}">' f'{html.escape(profile.capitalize())}' ) @@ -214,7 +216,7 @@ def _render_rankings_rows(ranked_results: List[dict]) -> str: f"{_fmt(r.get('bandwidth_coverage'), '.1%')}" f"{_fmt(r.get('passband_ripple_db'), '.1f')}" f"{_fmt(r.get('avg_sensitivity_db'), '.1f')}" - f"{f3l} — {f3h}" + f"{f3l} \u2014 {f3h}" f"{_fmt(kpi.get('peak_spl_db'), '.1f')}" f"" ) @@ -381,16 +383,16 @@ def generate_html_report( top_results = all_ranked[:top_n] # Summary card values - best_score = _fmt(top_results[0]["composite_score"], ".3f") if top_results else "—" + best_score = _fmt(top_results[0]["composite_score"], ".3f") if top_results else "\u2014" best_bw = _fmt( max((r.get("bandwidth_coverage", 0) for r in top_results), default=None), ".1%" - ) if top_results else "—" + ) if top_results else "\u2014" best_sens = _fmt( max((r.get("avg_sensitivity_db", 0) for r in top_results), default=None), ".1f" - ) if top_results else "—" + ) if top_results else "\u2014" best_ripple = _fmt( min((r.get("passband_ripple_db", 99) for r in top_results), default=None), ".1f" - ) if top_results else "—" + ) if top_results else "\u2014" # Generate plots plot_coupled_spl = _plot_coupled_spl_comparison(csv_pairs, target) diff --git a/packages/horn-analysis/src/horn_analysis/impedance_plot.py b/packages/horn-analysis/src/horn_analysis/impedance_plot.py index eecfa11..28779ec 100644 --- a/packages/horn-analysis/src/horn_analysis/impedance_plot.py +++ b/packages/horn-analysis/src/horn_analysis/impedance_plot.py @@ -9,6 +9,8 @@ matplotlib.use("Agg") import matplotlib.pyplot as plt +from horn_analysis import plot_theme + def plot_impedance(csv_file: str, output_file: str): """Plot throat impedance magnitude and phase from solver CSV output. @@ -26,31 +28,30 @@ def plot_impedance(csv_file: str, output_file: str): z_mag = np.abs(z_complex) z_angle = np.degrees(np.angle(z_complex)) - fig, ax1 = plt.subplots(figsize=(10, 6)) + fig, ax1 = plot_theme.create_figure(figsize=(10, 6)) - color_mag = "tab:blue" - ax1.set_xlabel("Frequency (Hz)") - ax1.set_ylabel("|Z| (Pa·s/m)", color=color_mag) - ax1.semilogx(freq, z_mag, color=color_mag, label="|Z|") + color_mag = plot_theme.COLORS["primary"] + ax1.set_ylabel("|Z| (Pa\u00b7s/m)", color=color_mag) + ax1.plot(freq, z_mag, color=color_mag, linewidth=1.4, label="|Z|") ax1.tick_params(axis="y", labelcolor=color_mag) ax2 = ax1.twinx() - color_phase = "tab:red" - ax2.set_ylabel("∠Z (degrees)", color=color_phase) - ax2.semilogx(freq, z_angle, color=color_phase, linestyle="--", label="∠Z") + color_phase = plot_theme.COLORS["secondary"] + ax2.set_ylabel("\u2220Z (degrees)", color=color_phase) + ax2.plot(freq, z_angle, color=color_phase, linestyle="--", linewidth=1.4, label="\u2220Z") ax2.tick_params(axis="y", labelcolor=color_phase) + plot_theme.setup_freq_axis(ax1, freq.min(), freq.max()) + plot_theme.setup_grid(ax1) + ax1.set_title("Throat Impedance vs Frequency") - ax1.grid(True, which="both", ls="--", alpha=0.5) # Combined legend lines1, labels1 = ax1.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() ax1.legend(lines1 + lines2, labels1 + labels2, loc="upper right") - plt.tight_layout() - plt.savefig(output_file, dpi=150) - plt.close() + plot_theme.save_figure(fig, output_file) print(f"Impedance plot saved to {output_file}") diff --git a/packages/horn-analysis/src/horn_analysis/phase_plot.py b/packages/horn-analysis/src/horn_analysis/phase_plot.py index 69cae84..1ec69ca 100644 --- a/packages/horn-analysis/src/horn_analysis/phase_plot.py +++ b/packages/horn-analysis/src/horn_analysis/phase_plot.py @@ -9,6 +9,8 @@ matplotlib.use("Agg") import matplotlib.pyplot as plt +from horn_analysis import plot_theme + def plot_phase(csv_file: str, output_file: str, group_delay: bool = False): """Plot phase response from solver CSV output. @@ -30,16 +32,22 @@ def plot_phase(csv_file: str, output_file: str, group_delay: bool = False): phase_unwrapped_deg = np.degrees(phase_unwrapped_rad) if group_delay: - fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), sharex=True) + fig, (ax1, ax2) = plot_theme.create_figure(figsize=(10, 8), nrows=2, ncols=1, sharex=True) else: - fig, ax1 = plt.subplots(figsize=(10, 6)) + fig, ax1 = plot_theme.create_figure(figsize=(10, 6)) - ax1.semilogx(freq, phase_unwrapped_deg, color="tab:blue") + ax1.plot(freq, phase_unwrapped_deg, color=plot_theme.COLORS["primary"], linewidth=1.4) ax1.set_ylabel("Phase (degrees)") ax1.set_title("Phase Response vs Frequency") - ax1.grid(True, which="both", ls="--", alpha=0.5) + + f_min, f_max = freq.min(), freq.max() if group_delay: + plot_theme.setup_freq_axis(ax1, f_min, f_max) + # Remove x-label from top panel since bottom panel will have it + ax1.set_xlabel("") + plot_theme.setup_grid(ax1) + # Group delay: τ_g = -dφ/dω = -(1/(2π)) * dφ_rad/df if len(freq) > 1: dphi = np.diff(phase_unwrapped_rad) @@ -49,20 +57,19 @@ def plot_phase(csv_file: str, output_file: str, group_delay: bool = False): # Convert to milliseconds tau_g_ms = tau_g * 1000 - ax2.semilogx(freq_mid, tau_g_ms, color="tab:green") + ax2.plot(freq_mid, tau_g_ms, color=plot_theme.COLORS["tertiary"], linewidth=1.4) ax2.set_ylabel("Group Delay (ms)") - ax2.set_xlabel("Frequency (Hz)") - ax2.grid(True, which="both", ls="--", alpha=0.5) + plot_theme.setup_freq_axis(ax2, f_min, f_max) + plot_theme.setup_grid(ax2) else: - ax2.set_xlabel("Frequency (Hz)") ax2.text(0.5, 0.5, "Insufficient data for group delay", ha="center", va="center", transform=ax2.transAxes) + ax2.set_xlabel("Frequency (Hz)") else: - ax1.set_xlabel("Frequency (Hz)") + plot_theme.setup_freq_axis(ax1, f_min, f_max) + plot_theme.setup_grid(ax1) - plt.tight_layout() - plt.savefig(output_file, dpi=150) - plt.close() + plot_theme.save_figure(fig, output_file) print(f"Phase plot saved to {output_file}") diff --git a/packages/horn-analysis/src/horn_analysis/plot_theme.py b/packages/horn-analysis/src/horn_analysis/plot_theme.py new file mode 100644 index 0000000..8be7887 --- /dev/null +++ b/packages/horn-analysis/src/horn_analysis/plot_theme.py @@ -0,0 +1,174 @@ +"""Shared plot theme for professional acoustic-style plots. + +Provides consistent styling across all horn-analysis plotting modules: +- Muted professional color palette +- Standard audio frequency axis ticks (20, 50, 100, 200, ..., 20k) +- Fixed dB divisions on SPL axes +- Dual-weight grid (major + minor) +- Sans-serif fonts, 150 DPI output +""" + +import base64 +import io + +import matplotlib +import matplotlib.pyplot as plt +import matplotlib.ticker as ticker +import numpy as np + + +# -- Color palette ------------------------------------------------------------ + +COLORS = { + "primary": "#1f4e79", # steel blue + "secondary": "#c0392b", # muted red + "tertiary": "#27864e", # forest green + "quaternary": "#7b4ea3", # muted purple + "quinary": "#d4831a", # burnt orange +} + +MULTI_COLORS = [ + "#1f4e79", + "#c0392b", + "#27864e", + "#7b4ea3", + "#d4831a", + "#2a9d8f", + "#6c5b7b", + "#c97b3d", +] + +PROFILE_STYLES = { + "conical": {"color": "#1f4e79", "linestyle": "-"}, + "exponential": {"color": "#27864e", "linestyle": "--"}, + "hyperbolic": {"color": "#d4831a", "linestyle": "-."}, +} + +_DEFAULT_PROFILE_STYLE = {"color": "#6b7280", "linestyle": "-"} + + +def profile_style(profile: str) -> dict: + """Return matplotlib line style dict for a horn profile name.""" + return PROFILE_STYLES.get(profile, _DEFAULT_PROFILE_STYLE) + + +# -- Standard audio frequency ticks ------------------------------------------- + +_AUDIO_TICKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000] + + +def _freq_label(f: float) -> str: + """Format a frequency value: use 'k' suffix for >= 1000 Hz.""" + if f >= 1000: + k = f / 1000 + return f"{k:g}k" + return f"{f:g}" + + +def setup_freq_axis(ax, f_min: float, f_max: float): + """Configure a log-scale frequency x-axis with standard audio ticks. + + Filters the standard audio tick set to the [f_min, f_max] data range + and applies minor ticks between major ticks. + """ + ax.set_xscale("log") + ax.set_xlabel("Frequency (Hz)") + + visible = [f for f in _AUDIO_TICKS if f_min <= f <= f_max] + if not visible: + visible = _AUDIO_TICKS + + ax.set_xticks(visible) + ax.set_xticklabels([_freq_label(f) for f in visible]) + ax.set_xlim(f_min, f_max) + ax.xaxis.set_minor_locator(ticker.LogLocator(base=10, subs=np.arange(2, 10), numticks=100)) + ax.xaxis.set_minor_formatter(ticker.NullFormatter()) + + +def setup_spl_axis(ax, spl_data, division: float = 5): + """Configure SPL y-axis with fixed dB divisions. + + Auto-ranges from the data, snapping to the nearest ``division`` boundary. + """ + spl_min = np.min(spl_data) + spl_max = np.max(spl_data) + y_lo = np.floor(spl_min / division) * division + y_hi = np.ceil(spl_max / division) * division + # Ensure at least one division of padding + if y_hi - y_lo < division * 2: + y_lo -= division + y_hi += division + ax.set_ylim(y_lo, y_hi) + ax.set_yticks(np.arange(y_lo, y_hi + division, division)) + ax.set_ylabel("SPL (dB)") + + +# -- Grid styling -------------------------------------------------------------- + +def setup_grid(ax): + """Apply dual-weight grid: heavier major lines, lighter minor lines.""" + ax.set_axisbelow(True) + ax.grid(True, which="major", color="#cccccc", linewidth=0.6) + ax.grid(True, which="minor", color="#e8e8e8", linewidth=0.3) + + +# -- Figure creation / saving -------------------------------------------------- + +def apply_theme(): + """Set matplotlib rcParams for a professional acoustic look.""" + matplotlib.rcParams.update({ + "font.family": "sans-serif", + "font.size": 10, + "figure.dpi": 150, + "figure.facecolor": "white", + "axes.facecolor": "white", + "axes.spines.top": False, + "axes.linewidth": 0.6, + "xtick.major.width": 0.6, + "ytick.major.width": 0.6, + "xtick.minor.width": 0.4, + "ytick.minor.width": 0.4, + }) + + +def create_figure(figsize=(10, 6), nrows=1, ncols=1, **kwargs): + """Create a themed figure and axes. + + Returns (fig, ax) for single-panel or (fig, axes) for multi-panel. + """ + apply_theme() + fig, axes = plt.subplots(nrows, ncols, figsize=figsize, **kwargs) + return fig, axes + + +def save_figure(fig, path: str): + """Apply tight_layout and save at 150 DPI.""" + fig.tight_layout() + fig.savefig(path, dpi=150, bbox_inches="tight") + plt.close(fig) + + +def fig_to_b64(fig) -> str: + """Render a matplotlib figure to a base64 data-URI and close it.""" + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=150, bbox_inches="tight") + plt.close(fig) + buf.seek(0) + encoded = base64.b64encode(buf.read()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + +# -- Annotation helpers -------------------------------------------------------- + +def annotate_f3(ax, f3_low: float, f3_high: float): + """Draw vertical dashed lines at the -3 dB cutoff frequencies.""" + for f3 in (f3_low, f3_high): + ax.axvline(f3, color="#888888", linestyle=":", linewidth=0.8, alpha=0.7) + + +def target_band_span(ax, target, alpha: float = 0.08): + """Add a shaded vertical span for the target frequency band. + + ``target`` must have ``.f_low_hz`` and ``.f_high_hz`` attributes. + """ + ax.axvspan(target.f_low_hz, target.f_high_hz, color="#6366f1", alpha=alpha, label="Target band") diff --git a/packages/horn-analysis/src/horn_analysis/plotter.py b/packages/horn-analysis/src/horn_analysis/plotter.py index 2c42f56..3695175 100644 --- a/packages/horn-analysis/src/horn_analysis/plotter.py +++ b/packages/horn-analysis/src/horn_analysis/plotter.py @@ -1,9 +1,11 @@ import pandas as pd import matplotlib matplotlib.use('Agg') -import matplotlib.pyplot as plt import argparse +from horn_analysis import plot_theme + + def plot_spl_vs_frequency(csv_file: str, output_image_file: str): """ Reads simulation results from a CSV and plots SPL vs. Frequency. @@ -15,18 +17,15 @@ def plot_spl_vs_frequency(csv_file: str, output_image_file: str): # Read the data using pandas data = pd.read_csv(csv_file) - # Create the plot - plt.figure(figsize=(10, 6)) - plt.plot(data['frequency'], data['spl'], marker='o', linestyle='-') - plt.grid(True) - plt.title('Sound Pressure Level (SPL) vs. Frequency') - plt.xlabel('Frequency (Hz)') - plt.ylabel('SPL (dB)') - plt.xscale('log') # Frequency is often better viewed on a log scale - - # Save the plot to a file - plt.savefig(output_image_file) - plt.close() + fig, ax = plot_theme.create_figure(figsize=(10, 6)) + ax.plot(data['frequency'], data['spl'], color=plot_theme.COLORS["primary"], linewidth=1.4) + ax.set_title('Sound Pressure Level (SPL) vs. Frequency') + + plot_theme.setup_freq_axis(ax, data['frequency'].min(), data['frequency'].max()) + plot_theme.setup_spl_axis(ax, data['spl'].values) + plot_theme.setup_grid(ax) + + plot_theme.save_figure(fig, output_image_file) print(f"Plot saved to {output_image_file}") def main(): @@ -35,8 +34,8 @@ def main(): parser.add_argument("csv_file", type=str, help="Path to the input CSV file.") parser.add_argument("output_image_file", type=str, help="Path to save the output plot image.") args = parser.parse_args() - + plot_spl_vs_frequency(args.csv_file, args.output_image_file) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/packages/horn-analysis/tests/test_analysis.py b/packages/horn-analysis/tests/test_analysis.py index d8bb1e1..2733951 100644 --- a/packages/horn-analysis/tests/test_analysis.py +++ b/packages/horn-analysis/tests/test_analysis.py @@ -230,3 +230,13 @@ def test_phase_plot_with_group_delay(self, solver_csv_with_impedance_phase, tmp_ plot_phase(str(solver_csv_with_impedance_phase), str(output), group_delay=True) assert output.exists() assert output.stat().st_size > 0 + + +class TestDashboard: + def test_dashboard_creates_image(self, solver_csv_with_impedance_phase, tmp_path): + from horn_analysis.dashboard import generate_dashboard + + output = tmp_path / "dashboard.png" + generate_dashboard(str(solver_csv_with_impedance_phase), str(output)) + assert output.exists() + assert output.stat().st_size > 0