Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions main.nf
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ========================================================================
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/horn-analysis/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
40 changes: 23 additions & 17 deletions packages/horn-analysis/src/horn_analysis/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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 = [
Expand All @@ -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

Expand Down
41 changes: 22 additions & 19 deletions packages/horn-analysis/src/horn_analysis/compare_horns.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -17,28 +21,27 @@ 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__":
# Expects: python compare_horns.py <file_a> <label_a> <file_b> <label_b> <output_file>
if len(sys.argv) != 6:
print("Usage: python compare_horns.py <file_a> <label_a> <file_b> <label_b> <output_file>")
sys.exit(1)
plot_comparison(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5])

plot_comparison(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5])
117 changes: 117 additions & 0 deletions packages/horn-analysis/src/horn_analysis/dashboard.py
Original file line number Diff line number Diff line change
@@ -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()
Loading