From fae349b9a102eae15914d8a3addacb5d8d66ee33 Mon Sep 17 00:00:00 2001 From: Tim Richardson Date: Sun, 1 Mar 2026 19:15:43 +0000 Subject: [PATCH] feat: add self-contained HTML report for single-mode pipeline The single-mode pipeline previously produced only loose PNG files and kpis.json. This adds an integrated HTML report (single_report.html) that mirrors the auto-mode's professional styling with base64-embedded plots, KPI cards, geometry summary, and conditional directivity section. Co-Authored-By: Claude Opus 4.6 --- main.nf | 135 +++++++- nextflow.config | 26 ++ packages/horn-analysis/pyproject.toml | 1 + .../src/horn_analysis/single_report.py | 298 ++++++++++++++++++ 4 files changed, 449 insertions(+), 11 deletions(-) create mode 100644 packages/horn-analysis/src/horn_analysis/single_report.py diff --git a/main.nf b/main.nf index f9bb0c2..f75c625 100644 --- a/main.nf +++ b/main.nf @@ -229,27 +229,45 @@ process render_auto_horn_3d { } process run_simulation_directivity { - publishDir "${params.outdir}", mode: 'copy' - input: - path horn_step + tuple path(horn_step), val(band_index) output: - path "directivity.csv" + path "directivity_${band_index}.csv" script: + def band_width = (params.max_freq - params.min_freq) / (params.num_bands as double) + def min_f = params.min_freq + band_width * band_index + def max_f = params.min_freq + band_width * (band_index + 1) + def num_intervals_per_band = Math.ceil(params.num_intervals / (params.num_bands as double)) as int """ + echo "Running directivity band ${band_index}: ${min_f} Hz to ${max_f} Hz" python3 -m horn_solver.solver \ --step-file ${horn_step} \ - --output-file solver_directivity.csv \ - --min-freq ${params.min_freq} \ - --max-freq ${params.max_freq} \ - --num-intervals ${params.num_intervals} \ + --output-file solver_directivity_${band_index}.csv \ + --min-freq ${min_f} \ + --max-freq ${max_f} \ + --num-intervals ${num_intervals_per_band} \ --length ${params.length} \ --mesh-size ${params.mesh_size} \ --radiation-model bem \ --compute-directivity \ - --directivity-file directivity.csv + --directivity-file directivity_${band_index}.csv + """ +} + +process merge_directivity_results { + publishDir "${params.outdir}", mode: 'copy' + + input: + path(csv_files) + + output: + path "directivity.csv" + + script: + """ + python3 -c "import pandas as pd; import glob; all_files = glob.glob('directivity_*.csv'); df = pd.concat((pd.read_csv(f) for f in all_files), ignore_index=True); df = df.drop_duplicates(subset=['frequency', 'theta_deg'], keep='first'); df.sort_values(by=['frequency', 'theta_deg']).to_csv('directivity.csv', index=False)" """ } @@ -271,6 +289,80 @@ process generate_directivity_plots { """ } +process generate_single_report { + publishDir "${params.outdir}", mode: 'copy' + + input: + path kpis_json + path final_csv + path spl_png + path impedance_png + path phase_png + path dashboard_png + path horn_3d_png + + output: + path "single_report.html" + + script: + """ + horn-single-report \ + --kpis ${kpis_json} \ + --final-csv ${final_csv} \ + --throat-radius ${params.throat_radius} \ + --mouth-radius ${params.mouth_radius} \ + --length ${params.length} \ + --profile ${params.profile} \ + --spl-png ${spl_png} \ + --impedance-png ${impedance_png} \ + --phase-png ${phase_png} \ + --dashboard-png ${dashboard_png} \ + --horn-3d-png ${horn_3d_png} \ + --output single_report.html + """ +} + +process generate_single_report_with_directivity { + publishDir "${params.outdir}", mode: 'copy' + + input: + path kpis_json + path final_csv + path spl_png + path impedance_png + path phase_png + path dashboard_png + path horn_3d_png + path polar_png + path contour_png + path beamwidth_png + path di_png + + output: + path "single_report.html" + + script: + """ + horn-single-report \ + --kpis ${kpis_json} \ + --final-csv ${final_csv} \ + --throat-radius ${params.throat_radius} \ + --mouth-radius ${params.mouth_radius} \ + --length ${params.length} \ + --profile ${params.profile} \ + --spl-png ${spl_png} \ + --impedance-png ${impedance_png} \ + --phase-png ${phase_png} \ + --dashboard-png ${dashboard_png} \ + --horn-3d-png ${horn_3d_png} \ + --polar-png ${polar_png} \ + --contour-png ${contour_png} \ + --beamwidth-png ${beamwidth_png} \ + --di-png ${di_png} \ + --output single_report.html + """ +} + // ======================================================================== // Auto mode processes // ======================================================================== @@ -730,10 +822,31 @@ workflow single { params.profile ) - // 11. Directivity (opt-in, requires BEM) + // 11. Directivity (opt-in, requires BEM) — parallelized across frequency bands if (params.directivity) { - ch_directivity_csv = run_simulation_directivity(ch_step_file) + ch_dir_band_indices = Channel.from(0.. dict: + return _BADGE_STYLES.get(profile, _DEFAULT_BADGE) + + +def _profile_badge(profile: str) -> str: + badge = _badge_for(profile) + return ( + f'' + f'{html.escape(profile.capitalize())}' + ) + + +def _fmt(value, fmt: str = ".1f", fallback: str = "\u2014") -> str: + """Safely format an Optional numeric value.""" + if value is None: + return fallback + try: + return f"{value:{fmt}}" + except (ValueError, TypeError): + return fallback + + +def _img_b64(path: str) -> str: + """Read a PNG file and return a data-URI string.""" + data = Path(path).read_bytes() + encoded = base64.b64encode(data).decode("ascii") + return f"data:image/png;base64,{encoded}" + + +# -- HTML template ------------------------------------------------------------- + +_HTML_TEMPLATE = """\ + + + + + +Horn Simulation Report + + + +
+ +

Horn Simulation Report

+

+ {profile_badge}  |  + Throat: {throat_mm:.1f} mm  |  + Mouth: {mouth_mm:.1f} mm  |  + Length: {length_mm:.1f} mm  |  + {freq_range} + Generated: {timestamp} +

+ +
+
Peak SPL (dB)
{peak_spl}
+
Peak Freq (Hz)
{peak_freq}
+
f3 Low (Hz)
{f3_low}
+
f3 High (Hz)
{f3_high}
+
Bandwidth (Hz)
{bandwidth_hz}
+
Bandwidth (oct)
{bandwidth_oct}
+
Ripple (dB)
{ripple}
+
Avg Sensitivity (dB)
{avg_sens}
+
+ +

Horn Geometry

+
+
3D horn render
+
+
+
Profile
{profile_badge_dd}
+
Throat radius
{throat_radius_m:.4f} m
+
Mouth radius
{mouth_radius_m:.4f} m
+
Horn length
{length_m:.3f} m
+
Throat diameter
{throat_mm:.1f} mm
+
Mouth diameter
{mouth_mm:.1f} mm
+
+
+
+ +

Frequency Response

+
Frequency response
+ +

Acoustic Dashboard

+
Dashboard
+ +

Impedance & Phase

+
+
Impedance
+
Phase response
+
+ +{directivity_section} + + + +
+ + +""" + + +def generate_single_report( + throat_radius: float, + mouth_radius: float, + length: float, + profile: str, + kpis: dict, + spl_png: str, + impedance_png: str, + phase_png: str, + dashboard_png: str, + horn_3d_png: str, + final_csv: Optional[str] = None, + directivity_polar_png: Optional[str] = None, + directivity_contour_png: Optional[str] = None, + beamwidth_png: Optional[str] = None, + directivity_index_png: Optional[str] = None, +) -> str: + """Generate a self-contained HTML report string for a single-mode run. + + All PNG paths are read from disk and base64-encoded into the HTML. + """ + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + # Freq range from CSV if available + freq_range = "" + if final_csv: + try: + import pandas as pd + df = pd.read_csv(final_csv) + fmin = df["frequency"].min() + fmax = df["frequency"].max() + freq_range = f"{fmin:.0f} \u2014 {fmax:.0f} Hz  |  " + except Exception: + pass + + # Directivity section + directivity_section = "" + has_dir = all(p is not None for p in [ + directivity_polar_png, directivity_contour_png, + beamwidth_png, directivity_index_png, + ]) + if has_dir: + directivity_section = ( + '

Directivity Analysis

\n' + '
\n' + f'
Polar directivity
\n' + f'
Directivity contour
\n' + '
\n' + '
\n' + f'
Beamwidth
\n' + f'
Directivity index
\n' + '
' + ) + + throat_mm = throat_radius * 2 * 1000 + mouth_mm = mouth_radius * 2 * 1000 + length_mm = length * 1000 + + return _HTML_TEMPLATE.format_map({ + "profile_badge": _profile_badge(profile), + "profile_badge_dd": _profile_badge(profile), + "throat_radius_m": throat_radius, + "mouth_radius_m": mouth_radius, + "length_m": length, + "throat_mm": throat_mm, + "mouth_mm": mouth_mm, + "length_mm": length_mm, + "freq_range": freq_range, + "timestamp": timestamp, + # KPI cards + "peak_spl": _fmt(kpis.get("peak_spl_db"), ".1f"), + "peak_freq": _fmt(kpis.get("peak_freq_hz"), ".0f"), + "f3_low": _fmt(kpis.get("f3_low_hz"), ".0f"), + "f3_high": _fmt(kpis.get("f3_high_hz"), ".0f"), + "bandwidth_hz": _fmt(kpis.get("bandwidth_hz"), ".0f"), + "bandwidth_oct": _fmt(kpis.get("bandwidth_octaves"), ".2f"), + "ripple": _fmt(kpis.get("passband_ripple_db"), ".1f"), + "avg_sens": _fmt(kpis.get("avg_sensitivity_db"), ".1f"), + # Embedded images + "horn_3d_src": _img_b64(horn_3d_png), + "spl_src": _img_b64(spl_png), + "impedance_src": _img_b64(impedance_png), + "phase_src": _img_b64(phase_png), + "dashboard_src": _img_b64(dashboard_png), + # Conditional section + "directivity_section": directivity_section, + }) + + +def main(): + parser = argparse.ArgumentParser(description="Generate single-mode HTML report") + parser.add_argument("--kpis", required=True, help="Path to kpis.json") + parser.add_argument("--final-csv", default=None, help="Path to final_results.csv") + parser.add_argument("--throat-radius", type=float, required=True) + parser.add_argument("--mouth-radius", type=float, required=True) + parser.add_argument("--length", type=float, required=True) + parser.add_argument("--profile", required=True) + parser.add_argument("--spl-png", required=True) + parser.add_argument("--impedance-png", required=True) + parser.add_argument("--phase-png", required=True) + parser.add_argument("--dashboard-png", required=True) + parser.add_argument("--horn-3d-png", required=True) + parser.add_argument("--polar-png", default=None) + parser.add_argument("--contour-png", default=None) + parser.add_argument("--beamwidth-png", default=None) + parser.add_argument("--di-png", default=None) + parser.add_argument("--output", required=True, help="Output HTML path") + args = parser.parse_args() + + kpis = json.loads(Path(args.kpis).read_text()) + + report_html = generate_single_report( + throat_radius=args.throat_radius, + mouth_radius=args.mouth_radius, + length=args.length, + profile=args.profile, + kpis=kpis, + spl_png=args.spl_png, + impedance_png=args.impedance_png, + phase_png=args.phase_png, + dashboard_png=args.dashboard_png, + horn_3d_png=args.horn_3d_png, + final_csv=args.final_csv, + directivity_polar_png=args.polar_png, + directivity_contour_png=args.contour_png, + beamwidth_png=args.beamwidth_png, + directivity_index_png=args.di_png, + ) + + out = Path(args.output) + out.write_text(report_html) + print(f"Report written to {out}") + + +if __name__ == "__main__": + main()