From 35767fd5967407ef519ace51eece584066d9da52 Mon Sep 17 00:00:00 2001 From: Tim Richardson Date: Fri, 27 Feb 2026 09:22:26 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20fullauto=20mode=20=E2=80=94=20der?= =?UTF-8?q?ive=20horn=20geometry=20from=20frequency=20band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `fullauto` pipeline mode where the user specifies only a target frequency band (e.g. 500–4000 Hz) and the system derives optimal horn geometry using analytical acoustics formulas, then explores a grid of candidates via FEM. Key changes: - New geometry_designer module with analytical derivation functions - Prescreen mouth_radius/length now optional for fullauto compatibility - HTML report gains geometry columns and Design Summary section - 6 new Nextflow processes wiring the fullauto workflow - 25 unit tests for geometry derivation Co-Authored-By: Claude Opus 4.6 --- README.md | 24 ++ main.nf | 288 +++++++++++++++++- nextflow.config | 20 ++ .../src/horn_analysis/auto_report.py | 11 +- .../src/horn_analysis/html_report.py | 82 ++++- .../src/horn_analysis/prescreen.py | 8 +- .../src/horn_core/geometry_designer.py | 236 ++++++++++++++ .../horn-core/tests/test_geometry_designer.py | 238 +++++++++++++++ 8 files changed, 896 insertions(+), 11 deletions(-) create mode 100644 packages/horn-core/src/horn_core/geometry_designer.py create mode 100644 packages/horn-core/tests/test_geometry_designer.py diff --git a/README.md b/README.md index 5f4ddd4..f0e923f 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ The pipeline takes horn geometry parameters and driver characteristics as input, - [Docker](https://www.docker.com/get-started) - [just](https://github.com/casey/just) (task runner) - [Nextflow](https://www.nextflow.io/docs/latest/getstarted.html#installation) (for running the full pipeline) +- **Java 11–22** (required by Nextflow; Java 25+ is not supported). On macOS: `brew install openjdk@21` ### Build @@ -122,6 +123,19 @@ nextflow run main.nf -profile docker | `drivers_db` | Path to driver database JSON | `data/drivers.json` | | `top_n` | Number of top results to return | `10` | +### Fullauto mode (`--mode fullauto`) + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `target_f_low` | Target low frequency (Hz) | `500` | +| `target_f_high` | Target high frequency (Hz) | `4000` | +| `drivers_db` | Path to driver database JSON | `data/drivers.json` | +| `top_n` | Number of top results to return | `10` | +| `num_mouth_radii` | Mouth radius grid points | `3` | +| `num_lengths` | Length grid points | `3` | +| `num_intervals` | Frequency steps per simulation | `100` | +| `num_bands` | Parallel frequency band jobs | `8` | + ## Example Usage ### Quick development run @@ -163,6 +177,16 @@ nextflow run main.nf -profile docker --mode auto \ This runs only 3 FEM simulations (one per profile) and couples all pre-screened drivers in pure Python via the transfer function. Outputs: `auto_ranking.json`, `auto_comparison.png`, `auto_summary.txt`, and a self-contained `auto_report.html` (open in any browser — all plots are base64-embedded). +### Fullauto: frequency-band-only horn design +```bash +# Specify ONLY a target frequency band — geometry is derived analytically +# from horn acoustics formulas, then a grid of ~27 candidates is FEM-simulated +nextflow run main.nf -profile docker --mode fullauto \ + --target_f_low 500 --target_f_high 4000 --num_intervals 10 +``` + +This derives mouth radius from `c0/(2*pi*f_low)` and length from quarter-wave to half-wave, generates a grid of 3 profiles x 3 mouth radii x 3 lengths, runs FEM on all candidates, couples with pre-screened drivers, and ranks. The HTML report includes a Design Summary section showing the analytical derivation and geometry columns in the rankings table. + ### CLI tools for individual steps ```bash # Pre-screen drivers for a target spec diff --git a/main.nf b/main.nf index 4099675..26cd2bb 100644 --- a/main.nf +++ b/main.nf @@ -2,7 +2,8 @@ // ======================================================================== // Mode: "single" (default) runs one horn profile; "auto" runs all 3 -// profiles and ranks driver-horn combinations. +// profiles and ranks driver-horn combinations; "fullauto" derives +// geometry from a target frequency band and explores a grid. // ======================================================================== params.mode = "single" @@ -29,6 +30,10 @@ params.target_f_high = 4000 params.drivers_db = "data/drivers.json" params.top_n = 10 +// Fullauto mode settings +params.num_mouth_radii = 3 // Mouth radius grid points for fullauto +params.num_lengths = 3 // Length grid points for fullauto + // ======================================================================== // Shared processes // ======================================================================== @@ -351,6 +356,219 @@ generate_auto_report( target=target, output_dir='report', top_n=5, + mouth_radius=${params.mouth_radius}, + horn_length=${params.length}, +) +" + """ +} + +// ======================================================================== +// Fullauto mode processes +// ======================================================================== + +process derive_fullauto_geometry { + publishDir "${params.outdir}/fullauto", mode: 'copy' + + input: + path prescreen_json + + output: + path "candidates.csv" + path "design.json" + + script: + """ + python3 -m horn_core.geometry_designer \ + --target-f-low ${params.target_f_low} \ + --target-f-high ${params.target_f_high} \ + --prescreen-json ${prescreen_json} \ + --num-mouth-radii ${params.num_mouth_radii} \ + --num-lengths ${params.num_lengths} \ + --output candidates.csv \ + --design-json design.json + """ +} + +process generate_fullauto_geometry { + input: + tuple val(candidate_id), val(profile), val(throat_radius), val(mouth_radius), val(length) + + output: + tuple val(candidate_id), val(profile), val(mouth_radius), val(length), path("horn_${candidate_id}.step") + + script: + """ + python3 -m horn_geometry.generator \ + --throat-radius ${throat_radius} \ + --mouth-radius ${mouth_radius} \ + --length ${length} \ + --profile ${profile} \ + --num-sections ${params.num_sections} \ + --output-file horn_${candidate_id}.step + """ +} + +process run_fullauto_simulation { + input: + tuple val(candidate_id), val(profile), val(mouth_radius), val(length), path(horn_step), val(band_index), val(sim_min_freq), val(sim_max_freq) + + output: + tuple val(candidate_id), path("results_${candidate_id}_${band_index}.csv") + + script: + def band_width = (sim_max_freq - sim_min_freq) / (params.num_bands as double) + def min_f = sim_min_freq + band_width * band_index + def max_f = sim_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 ${candidate_id} band ${band_index}: ${min_f} Hz to ${max_f} Hz" + python3 -m horn_solver.solver \ + --step-file ${horn_step} \ + --output-file results_${candidate_id}_${band_index}.csv \ + --min-freq ${min_f} \ + --max-freq ${max_f} \ + --num-intervals ${num_intervals_per_band} \ + --length ${length} \ + --mesh-size ${params.mesh_size} + """ +} + +process merge_fullauto_results { + publishDir "${params.outdir}/fullauto", mode: 'copy' + + input: + tuple val(candidate_id), path(csv_files) + + output: + tuple val(candidate_id), path("${candidate_id}_results.csv") + + script: + """ + python3 -c " +import pandas as pd, glob +files = glob.glob('results_${candidate_id}_*.csv') +df = pd.concat((pd.read_csv(f) for f in files), ignore_index=True) +df.sort_values(by='frequency').to_csv('${candidate_id}_results.csv', index=False) +" + """ +} + +process score_and_rank_fullauto { + publishDir "${params.outdir}/fullauto", mode: 'copy' + + input: + path solver_csvs + path prescreen_json + path drivers_db + path candidates_csv + + output: + path "ranked_results.json" + + script: + """ + python3 -c " +import json, glob, csv +from pathlib import Path +from horn_drivers.loader import load_drivers +from horn_analysis.rank_pipeline import rank_horn_drivers +from horn_analysis.scoring import TargetSpec + +prescreen = json.loads(Path('${prescreen_json}').read_text()) +throat_radius = prescreen['throat_radius_m'] +target = TargetSpec(f_low_hz=${params.target_f_low}, f_high_hz=${params.target_f_high}) + +# Load only pre-screened drivers +all_drivers = load_drivers('${drivers_db}') +driver_ids = set(prescreen['drivers']) +drivers = [d for d in all_drivers if d.driver_id in driver_ids] + +# Build candidate lookup for geometry annotation +candidates_lookup = {} +with open('${candidates_csv}') as f: + for row in csv.DictReader(f): + candidates_lookup[row['candidate_id']] = row + +all_results = [] +for csv_path in sorted(glob.glob('*_results.csv')): + candidate_id = Path(csv_path).stem.replace('_results', '') + cand = candidates_lookup.get(candidate_id, {}) + results = rank_horn_drivers( + solver_csv=csv_path, + horn_label=candidate_id, + throat_radius=throat_radius, + drivers=drivers, + target=target, + top_n=${params.top_n}, + ) + # Annotate each result with geometry info + for r in results: + r['mouth_radius'] = float(cand.get('mouth_radius', 0)) + r['length'] = float(cand.get('length', 0)) + r['profile'] = cand.get('profile', '') + all_results.extend(results) + +# Sort all by composite score and take overall top N +all_results.sort(key=lambda r: r['composite_score'], reverse=True) +all_results = all_results[:${params.top_n}] + +Path('ranked_results.json').write_text(json.dumps(all_results, indent=2)) +print(f'Ranked {len(all_results)} driver-horn combinations') +" + """ +} + +process generate_fullauto_report { + publishDir "${params.outdir}/fullauto", mode: 'copy' + + input: + path ranked_json + path solver_csvs + path drivers_db + path prescreen_json + path design_json + + output: + path "report/auto_ranking.json" + path "report/auto_comparison.png" + path "report/auto_summary.txt" + path "report/auto_report.html" + + script: + """ + python3 -c " +import json, glob +from pathlib import Path +from horn_drivers.loader import load_drivers +from horn_analysis.scoring import TargetSpec +from horn_analysis.auto_report import generate_auto_report + +prescreen = json.loads(Path('${prescreen_json}').read_text()) +throat_radius = prescreen['throat_radius_m'] +design = json.loads(Path('${design_json}').read_text()) + +all_ranked = json.loads(Path('${ranked_json}').read_text()) + +solver_csvs = {} +for csv_path in sorted(glob.glob('*_results.csv')): + candidate_id = Path(csv_path).stem.replace('_results', '') + solver_csvs[candidate_id] = csv_path + +driver_list = load_drivers('${drivers_db}') +drivers = {d.driver_id: d for d in driver_list} + +target = TargetSpec(f_low_hz=${params.target_f_low}, f_high_hz=${params.target_f_high}) + +generate_auto_report( + all_ranked=all_ranked, + solver_csvs=solver_csvs, + drivers=drivers, + throat_radius=throat_radius, + target=target, + output_dir='report', + top_n=5, + derived_geometry=design, ) " """ @@ -447,8 +665,74 @@ workflow auto { ) } +workflow fullauto { + // 1. Pre-screen drivers (no mouth/length needed) + ch_drivers_db = Channel.fromPath(params.drivers_db) + ch_prescreen = prescreen_drivers( + params.target_f_low, + params.target_f_high, + 0, // placeholder — not used by prescreen filtering + 0, // placeholder — not used by prescreen filtering + ch_drivers_db, + ) + + // 2. Derive geometry from frequency band + prescreen throat radius + ch_geom_derived = derive_fullauto_geometry(ch_prescreen) + ch_candidates_csv = ch_geom_derived.map { csv, json -> csv } + ch_design_json = ch_geom_derived.map { csv, json -> json } + + // 3. Parse candidates CSV into channel of tuples + ch_candidates = ch_candidates_csv + .splitCsv(header: true) + .map { row -> + tuple(row.candidate_id, row.profile, row.throat_radius as double, + row.mouth_radius as double, row.length as double) + } + + // 4. Generate STEP geometry for each candidate + ch_geometries = generate_fullauto_geometry(ch_candidates) + + // 5. Read sim freq range from design.json and combine with band indices + ch_sim_range = ch_design_json.map { json_file -> + def data = new groovy.json.JsonSlurper().parse(json_file) + tuple(data.sim_freq_range[0] as double, data.sim_freq_range[1] as double) + } + + ch_band_indices = Channel.from(0.. csv }.collect() + ch_ranked = score_and_rank_fullauto( + ch_all_csvs, + ch_prescreen, + ch_drivers_db, + ch_candidates_csv, + ) + + // 9. Generate report with design summary + generate_fullauto_report( + ch_ranked, + ch_all_csvs, + ch_drivers_db, + ch_prescreen, + ch_design_json, + ) +} + workflow { - if (params.mode == "auto") { + if (params.mode == "fullauto") { + fullauto() + } else if (params.mode == "auto") { auto() } else { single() diff --git a/nextflow.config b/nextflow.config index 9ea0e42..eeca7d4 100644 --- a/nextflow.config +++ b/nextflow.config @@ -56,6 +56,26 @@ profiles { withName: generate_auto_report { container = 'horn-analysis:latest' } + // Fullauto mode processes + withName: derive_fullauto_geometry { + container = 'horn-analysis:latest' + } + withName: generate_fullauto_geometry { + container = 'horn-geometry:latest' + } + withName: run_fullauto_simulation { + container = 'horn-solver:latest' + cpus = 2 + } + withName: merge_fullauto_results { + container = 'horn-analysis:latest' + } + withName: score_and_rank_fullauto { + container = 'horn-analysis:latest' + } + withName: generate_fullauto_report { + container = 'horn-analysis:latest' + } } } standard { diff --git a/packages/horn-analysis/src/horn_analysis/auto_report.py b/packages/horn-analysis/src/horn_analysis/auto_report.py index f9490c8..889b03c 100644 --- a/packages/horn-analysis/src/horn_analysis/auto_report.py +++ b/packages/horn-analysis/src/horn_analysis/auto_report.py @@ -7,7 +7,7 @@ import argparse import json from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Optional import numpy as np import pandas as pd @@ -27,6 +27,9 @@ def generate_auto_report( target: TargetSpec, output_dir: str, top_n: int = 5, + mouth_radius: float | None = None, + horn_length: float | None = None, + derived_geometry: Optional[dict] = None, ) -> Path: """Generate the auto-select report with rankings, plots, and CSVs. @@ -38,6 +41,9 @@ def generate_auto_report( target: Target frequency specification. output_dir: Directory for output files. top_n: Number of top candidates to include in detailed output. + mouth_radius: Horn mouth radius in metres (for report display). + horn_length: Horn length in metres (for report display). + derived_geometry: Optional dict from geometry_designer (fullauto mode). Returns: Path to the output directory. @@ -132,6 +138,9 @@ def generate_auto_report( target=target, csv_pairs=csv_pairs, top_n=top_n, + mouth_radius=mouth_radius, + horn_length=horn_length, + derived_geometry=derived_geometry, ) (out / "auto_report.html").write_text(html_report) diff --git a/packages/horn-analysis/src/horn_analysis/html_report.py b/packages/horn-analysis/src/horn_analysis/html_report.py index 151cced..73f6ddb 100644 --- a/packages/horn-analysis/src/horn_analysis/html_report.py +++ b/packages/horn-analysis/src/horn_analysis/html_report.py @@ -198,18 +198,38 @@ def _profile_badge(profile: str) -> str: ) -def _render_rankings_rows(ranked_results: List[dict]) -> str: +def _render_rankings_rows( + ranked_results: List[dict], + drivers: Dict[str, DriverParameters], + show_geometry: bool = False, +) -> str: rows = [] for rank, r in enumerate(ranked_results, 1): kpi = r.get("kpi", {}) f3l = _fmt(kpi.get("f3_low_hz"), ".0f") f3h = _fmt(kpi.get("f3_high_hz"), ".0f") + drv = drivers.get(r.get("driver_id", "")) + drv_type = html.escape(drv.driver_type or "—") if drv else "—" + drv_size = html.escape(drv.nominal_diameter or "—") if drv else "—" + drv_power = _fmt(drv.power_w, ".0f") if drv else "—" + + geom_cols = "" + if show_geometry: + geom_cols = ( + f"{_fmt(r.get('mouth_radius'), '.4f')}" + f"{_fmt(r.get('length'), '.4f')}" + ) + rows.append( f"" f"{rank}" f"{html.escape(r.get('manufacturer', ''))}" f"{html.escape(r.get('model_name', ''))}" + f"{drv_type}" + f"{drv_size}" + f"{drv_power}" f"{_profile_badge(r.get('horn_label', ''))}" + f"{geom_cols}" f"{_fmt(r.get('composite_score'), '.3f')}" f"{_fmt(r.get('bandwidth_coverage'), '.1%')}" f"{_fmt(r.get('passband_ripple_db'), '.1f')}" @@ -284,6 +304,9 @@ def _render_drivers_rows(drivers: Dict[str, DriverParameters]) -> str: .plot img {{ max-width: 100%; height: auto; border-radius: 6px; border: 1px solid #e2e8f0; }} .plot-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }} @media (max-width: 800px) {{ .plot-grid {{ grid-template-columns: 1fr; }} }} + .design-summary {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 20px; margin-bottom: 24px; }} + .design-summary dt {{ font-weight: 600; color: #475569; font-size: 0.85em; }} + .design-summary dd {{ margin: 0 0 10px 0; font-size: 0.95em; }} .footer {{ margin-top: 40px; padding-top: 16px; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.8em; text-align: center; }} @@ -293,7 +316,9 @@ def _render_drivers_rows(drivers: Dict[str, DriverParameters]) -> str:

Horn Auto-Select Report

Target: {target_low:.0f} Hz — {target_high:.0f} Hz  |  - Throat radius: {throat_radius:.4f} m  |  + Throat: {throat_radius:.4f} m  |  + Mouth: {mouth_radius}  |  + Length: {horn_length}  |  Profiles: {profiles}  |  Generated: {timestamp}

@@ -307,11 +332,15 @@ def _render_drivers_rows(drivers: Dict[str, DriverParameters]) -> str:
Lowest ripple
{best_ripple}
+{design_summary_section} +

Rankings

- + + {geometry_header_cols} + @@ -355,6 +384,28 @@ def _render_drivers_rows(drivers: Dict[str, DriverParameters]) -> str: # -- Public entry point ----------------------------------------------------- +def _render_design_summary(derived_geometry: dict) -> str: + """Render the Design Summary section for fullauto mode.""" + mr = derived_geometry.get("mouth_radius_range", []) + lr = derived_geometry.get("length_range", []) + sr = derived_geometry.get("sim_freq_range", []) + return ( + '

Design Summary

\n' + '
' + f'
Ideal mouth radius
{_fmt(derived_geometry.get("ideal_mouth_radius"), ".4f")} m ' + f'(from c₀/2πflow)
' + f'
Mouth radius range
{_fmt(mr[0] if mr else None, ".4f")} — ' + f'{_fmt(mr[1] if len(mr) > 1 else None, ".4f")} m (±30%)
' + f'
Length range
{_fmt(lr[0] if lr else None, ".4f")} — ' + f'{_fmt(lr[1] if len(lr) > 1 else None, ".4f")} m ' + f'(λ/4 to λ/2)
' + f'
Simulation freq range
{_fmt(sr[0] if sr else None, ".0f")} — ' + f'{_fmt(sr[1] if len(sr) > 1 else None, ".0f")} Hz (±0.5 octave)
' + f'
Candidate count
{derived_geometry.get("candidate_count", "—")}
' + '
' + ) + + def generate_html_report( all_ranked: List[dict], solver_csvs: Dict[str, str], @@ -363,6 +414,9 @@ def generate_html_report( target: TargetSpec, csv_pairs: List[Tuple[str, str]], top_n: int = 5, + mouth_radius: float | None = None, + horn_length: float | None = None, + derived_geometry: Optional[dict] = None, ) -> str: """Generate a self-contained HTML report string. @@ -374,11 +428,15 @@ def generate_html_report( target: Target frequency specification. csv_pairs: List of (csv_path, label) for coupled SPL plots. top_n: Number of top candidates to include. + mouth_radius: Horn mouth radius in metres (for report display). + horn_length: Horn length in metres (for report display). + derived_geometry: Optional dict from geometry_designer (fullauto mode). Returns: Complete HTML document as a string. """ top_results = all_ranked[:top_n] + show_geometry = derived_geometry is not None # Summary card values best_score = _fmt(top_results[0]["composite_score"], ".3f") if top_results else "—" @@ -399,15 +457,29 @@ def generate_html_report( plot_phase = _plot_profile_phase(solver_csvs) # Render tables - rankings_rows = _render_rankings_rows(top_results) + rankings_rows = _render_rankings_rows(top_results, drivers, show_geometry=show_geometry) drivers_rows = _render_drivers_rows(drivers) + # Conditional sections for fullauto + design_summary_section = _render_design_summary(derived_geometry) if show_geometry else "" + geometry_header_cols = '' if show_geometry else "" + + # Mouth/Length display: for fullauto show "varies", for auto show fixed value + if show_geometry: + mouth_display = "varies" + length_display = "varies" + else: + mouth_display = f"{mouth_radius:.4f} m" if mouth_radius is not None else "—" + length_display = f"{horn_length:.3f} m" if horn_length is not None else "—" + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") return _HTML_TEMPLATE.format_map({ "target_low": target.f_low_hz, "target_high": target.f_high_hz, "throat_radius": throat_radius, + "mouth_radius": mouth_display, + "horn_length": length_display, "profiles": ", ".join(sorted(solver_csvs.keys())), "timestamp": timestamp, "n_scored": len(all_ranked), @@ -416,6 +488,8 @@ def generate_html_report( "best_bw": best_bw, "best_sens": best_sens, "best_ripple": best_ripple, + "design_summary_section": design_summary_section, + "geometry_header_cols": geometry_header_cols, "rankings_rows": rankings_rows, "plot_coupled_spl": plot_coupled_spl, "plot_raw_spl": plot_raw_spl, diff --git a/packages/horn-analysis/src/horn_analysis/prescreen.py b/packages/horn-analysis/src/horn_analysis/prescreen.py index 3d280d3..05ec19f 100644 --- a/packages/horn-analysis/src/horn_analysis/prescreen.py +++ b/packages/horn-analysis/src/horn_analysis/prescreen.py @@ -21,8 +21,8 @@ class PrescreenConfig: """Configuration for driver pre-screening.""" target_f_low_hz: float target_f_high_hz: float - mouth_radius_m: float - length_m: float + mouth_radius_m: Optional[float] = None + length_m: Optional[float] = None min_ebp: float = 50.0 sd_ratio_range: Tuple[float, float] = (0.3, 3.0) @@ -129,8 +129,8 @@ def main(): parser.add_argument("--drivers-db", required=True, help="Driver database JSON file.") parser.add_argument("--target-f-low", type=float, required=True, help="Target low frequency (Hz).") parser.add_argument("--target-f-high", type=float, required=True, help="Target high frequency (Hz).") - parser.add_argument("--mouth-radius", type=float, required=True, help="Horn mouth radius (m).") - parser.add_argument("--length", type=float, required=True, help="Horn length (m).") + parser.add_argument("--mouth-radius", type=float, default=None, help="Horn mouth radius (m). Optional for fullauto mode.") + parser.add_argument("--length", type=float, default=None, help="Horn length (m). Optional for fullauto mode.") parser.add_argument("--min-ebp", type=float, default=50.0, help="Minimum EBP threshold.") parser.add_argument("--output", type=str, default="prescreen_result.json", help="Output JSON file.") args = parser.parse_args() diff --git a/packages/horn-core/src/horn_core/geometry_designer.py b/packages/horn-core/src/horn_core/geometry_designer.py new file mode 100644 index 0000000..10e6d6a --- /dev/null +++ b/packages/horn-core/src/horn_core/geometry_designer.py @@ -0,0 +1,236 @@ +"""Analytical geometry derivation for fullauto horn design. + +Given only a target frequency band, derives optimal horn geometry +(mouth radius, length) using horn acoustics formulas, then generates +a focused grid of candidates for FEM evaluation. + +Key formulas: + - Mouth radius from low-freq target: r_mouth = c0 / (2*pi*f_low) + (mouth circumference = wavelength at f_low) + - Length range: quarter-wave to half-wave at f_low + L_min = c0 / (4*f_low), L_max = c0 / (2*f_low) + - Simulation freq range: extend +/-0.5 octave beyond target band + to capture rolloff for accurate f3 detection +""" + +import json +import math +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import List, Optional + +import numpy as np + +from horn_core.candidates import CandidateGeometry, write_candidates_csv + +# Speed of sound in air at ~20C +C0 = 343.0 + +DEFAULT_PROFILES = ["conical", "exponential", "hyperbolic"] + + +@dataclass +class DerivedGeometry: + """Stores analytical geometry derivation results.""" + + target_f_low: float + target_f_high: float + ideal_mouth_radius: float + mouth_radius_range: tuple # (min, max) + length_range: tuple # (min, max) + sim_freq_range: tuple # (min, max) extended for rolloff + candidate_count: int + + +def derive_mouth_radius(f_low: float) -> float: + """Derive ideal mouth radius from low-frequency target. + + The mouth circumference should equal the wavelength at f_low + for efficient low-frequency radiation. + + r_mouth = c0 / (2 * pi * f_low) + """ + return C0 / (2 * math.pi * f_low) + + +def derive_mouth_radius_range( + f_low: float, spread: float = 0.3 +) -> tuple: + """Derive mouth radius range as +/- spread around ideal. + + Returns (min_radius, max_radius). + """ + ideal = derive_mouth_radius(f_low) + return (ideal * (1 - spread), ideal * (1 + spread)) + + +def derive_length_range(f_low: float) -> tuple: + """Derive horn length range from quarter-wave to half-wave at f_low. + + L_min = c0 / (4 * f_low) (quarter wavelength) + L_max = c0 / (2 * f_low) (half wavelength) + """ + wavelength = C0 / f_low + return (wavelength / 4, wavelength / 2) + + +def derive_simulation_freq_range( + f_low: float, f_high: float +) -> tuple: + """Extend target band by +/-0.5 octave for rolloff capture. + + This ensures the f3 points (where SPL drops 3dB) can be + accurately detected even if they fall outside the target band. + """ + sim_min = f_low / (2 ** 0.5) # -0.5 octave + sim_max = f_high * (2 ** 0.5) # +0.5 octave + return (sim_min, sim_max) + + +def generate_fullauto_candidates( + target_f_low: float, + target_f_high: float, + throat_radii: List[float], + num_mouth_radii: int = 3, + num_lengths: int = 3, + profiles: Optional[List[str]] = None, +) -> tuple: + """Generate geometry candidates from frequency band specification. + + Derives mouth radius and length ranges analytically, then creates + a focused grid of candidates. + + Args: + target_f_low: Target low-frequency cutoff (Hz). + target_f_high: Target high-frequency cutoff (Hz). + throat_radii: List of throat radii (m) from prescreen. + num_mouth_radii: Number of mouth radius grid points. + num_lengths: Number of length grid points. + profiles: Horn profile types. Defaults to all three. + + Returns: + Tuple of (candidates, derived_geometry). + """ + if profiles is None: + profiles = DEFAULT_PROFILES + + mouth_range = derive_mouth_radius_range(target_f_low) + length_range = derive_length_range(target_f_low) + sim_range = derive_simulation_freq_range(target_f_low, target_f_high) + + mouth_radii = np.linspace(mouth_range[0], mouth_range[1], num_mouth_radii).tolist() + lengths = np.linspace(length_range[0], length_range[1], num_lengths).tolist() + + candidates = [] + idx = 0 + for profile in profiles: + for r_throat in throat_radii: + for r_mouth in mouth_radii: + for horn_length in lengths: + if r_mouth <= r_throat: + continue + candidate_id = f"fa_{profile[:3]}_{idx:04d}" + candidates.append( + CandidateGeometry( + candidate_id=candidate_id, + profile=profile, + throat_radius=r_throat, + mouth_radius=round(r_mouth, 6), + length=round(horn_length, 6), + ) + ) + idx += 1 + + derived = DerivedGeometry( + target_f_low=target_f_low, + target_f_high=target_f_high, + ideal_mouth_radius=derive_mouth_radius(target_f_low), + mouth_radius_range=mouth_range, + length_range=length_range, + sim_freq_range=sim_range, + candidate_count=len(candidates), + ) + + return candidates, derived + + +def main(): + """CLI for fullauto geometry derivation.""" + import argparse + + parser = argparse.ArgumentParser( + description="Derive horn geometry from target frequency band.", + ) + parser.add_argument( + "--target-f-low", type=float, required=True, help="Target low frequency (Hz)." + ) + parser.add_argument( + "--target-f-high", type=float, required=True, help="Target high frequency (Hz)." + ) + parser.add_argument( + "--prescreen-json", + type=str, + required=True, + help="Prescreen result JSON (provides throat_radius_m).", + ) + parser.add_argument( + "--num-mouth-radii", type=int, default=3, help="Mouth radius grid points." + ) + parser.add_argument( + "--num-lengths", type=int, default=3, help="Length grid points." + ) + parser.add_argument( + "--output", type=str, default="candidates.csv", help="Output CSV path." + ) + parser.add_argument( + "--design-json", + type=str, + default="design.json", + help="Output design summary JSON.", + ) + args = parser.parse_args() + + prescreen = json.loads(Path(args.prescreen_json).read_text()) + throat_radius = prescreen["throat_radius_m"] + + candidates, derived = generate_fullauto_candidates( + target_f_low=args.target_f_low, + target_f_high=args.target_f_high, + throat_radii=[throat_radius], + num_mouth_radii=args.num_mouth_radii, + num_lengths=args.num_lengths, + ) + + write_candidates_csv(candidates, args.output) + print(f"Generated {len(candidates)} candidates -> {args.output}") + + design = { + "target_f_low": derived.target_f_low, + "target_f_high": derived.target_f_high, + "ideal_mouth_radius": derived.ideal_mouth_radius, + "mouth_radius_range": list(derived.mouth_radius_range), + "length_range": list(derived.length_range), + "sim_freq_range": list(derived.sim_freq_range), + "candidate_count": derived.candidate_count, + } + Path(args.design_json).write_text(json.dumps(design, indent=2)) + print(f"Design summary -> {args.design_json}") + + print(f"\nDerived geometry for {args.target_f_low}-{args.target_f_high} Hz:") + print(f" Ideal mouth radius: {derived.ideal_mouth_radius:.4f} m") + print( + f" Mouth radius range: {derived.mouth_radius_range[0]:.4f} - " + f"{derived.mouth_radius_range[1]:.4f} m" + ) + print( + f" Length range: {derived.length_range[0]:.4f} - " + f"{derived.length_range[1]:.4f} m" + ) + print( + f" Simulation freq range: {derived.sim_freq_range[0]:.1f} - " + f"{derived.sim_freq_range[1]:.1f} Hz" + ) + + +if __name__ == "__main__": + main() diff --git a/packages/horn-core/tests/test_geometry_designer.py b/packages/horn-core/tests/test_geometry_designer.py new file mode 100644 index 0000000..ac7df79 --- /dev/null +++ b/packages/horn-core/tests/test_geometry_designer.py @@ -0,0 +1,238 @@ +"""Tests for analytical geometry derivation (fullauto mode).""" + +import json +import math + +import pytest + +from horn_core.geometry_designer import ( + C0, + DerivedGeometry, + derive_length_range, + derive_mouth_radius, + derive_mouth_radius_range, + derive_simulation_freq_range, + generate_fullauto_candidates, +) + + +class TestDeriveMouthRadius: + def test_formula_500hz(self): + """At 500 Hz: r = 343 / (2*pi*500) = 0.1092 m.""" + expected = C0 / (2 * math.pi * 500) + assert derive_mouth_radius(500) == pytest.approx(expected, rel=1e-6) + assert derive_mouth_radius(500) == pytest.approx(0.1092, rel=1e-2) + + def test_formula_1000hz(self): + """At 1000 Hz: r = 343 / (2*pi*1000) = 0.0546 m.""" + expected = C0 / (2 * math.pi * 1000) + assert derive_mouth_radius(1000) == pytest.approx(expected, rel=1e-6) + + def test_lower_freq_gives_larger_radius(self): + """Lower frequency -> larger mouth radius.""" + assert derive_mouth_radius(200) > derive_mouth_radius(500) + assert derive_mouth_radius(500) > derive_mouth_radius(2000) + + def test_formula_100hz_large_horn(self): + """At 100 Hz: r = 343 / (2*pi*100) = 0.546 m (large horn).""" + r = derive_mouth_radius(100) + assert r == pytest.approx(0.546, rel=1e-2) + + def test_formula_4000hz_small_horn(self): + """At 4000 Hz: r = 343 / (2*pi*4000) = 0.01365 m (small horn).""" + r = derive_mouth_radius(4000) + assert r == pytest.approx(0.01365, rel=1e-2) + + +class TestDeriveMouthRadiusRange: + def test_default_spread(self): + """Default +-30% around ideal.""" + ideal = derive_mouth_radius(500) + lo, hi = derive_mouth_radius_range(500) + assert lo == pytest.approx(ideal * 0.7, rel=1e-6) + assert hi == pytest.approx(ideal * 1.3, rel=1e-6) + + def test_custom_spread(self): + ideal = derive_mouth_radius(500) + lo, hi = derive_mouth_radius_range(500, spread=0.5) + assert lo == pytest.approx(ideal * 0.5, rel=1e-6) + assert hi == pytest.approx(ideal * 1.5, rel=1e-6) + + def test_range_brackets_ideal(self): + ideal = derive_mouth_radius(1000) + lo, hi = derive_mouth_radius_range(1000) + assert lo < ideal < hi + + +class TestDeriveLengthRange: + def test_formula_500hz(self): + """At 500 Hz: wavelength=0.686m, L_min=0.1715, L_max=0.343.""" + wavelength = C0 / 500 + l_min, l_max = derive_length_range(500) + assert l_min == pytest.approx(wavelength / 4, rel=1e-6) + assert l_max == pytest.approx(wavelength / 2, rel=1e-6) + + def test_lower_freq_gives_longer_horn(self): + lo_min, lo_max = derive_length_range(200) + hi_min, hi_max = derive_length_range(2000) + assert lo_min > hi_min + assert lo_max > hi_max + + def test_max_is_double_min(self): + """L_max should always be 2x L_min (half-wave vs quarter-wave).""" + l_min, l_max = derive_length_range(500) + assert l_max == pytest.approx(2 * l_min, rel=1e-9) + + +class TestDeriveSimulationFreqRange: + def test_extends_half_octave(self): + """Should extend by +/-0.5 octave.""" + sim_min, sim_max = derive_simulation_freq_range(500, 4000) + assert sim_min == pytest.approx(500 / math.sqrt(2), rel=1e-6) + assert sim_max == pytest.approx(4000 * math.sqrt(2), rel=1e-6) + + def test_sim_range_brackets_target(self): + sim_min, sim_max = derive_simulation_freq_range(500, 4000) + assert sim_min < 500 + assert sim_max > 4000 + + +class TestGenerateFullautoCandidates: + def test_default_grid_size(self): + """3 profiles x 1 throat x 3 mouth x 3 lengths = 27 max.""" + candidates, derived = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.025], + ) + # All 27 should pass since derived mouth radii >> 0.025 + assert len(candidates) == 27 + assert derived.candidate_count == 27 + + def test_all_profiles_represented(self): + candidates, _ = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.025], + ) + profiles = {c.profile for c in candidates} + assert profiles == {"conical", "exponential", "hyperbolic"} + + def test_mouth_exceeds_throat(self): + """Every candidate must have mouth_radius > throat_radius.""" + candidates, _ = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.025], + ) + for c in candidates: + assert c.mouth_radius > c.throat_radius, ( + f"{c.candidate_id}: mouth={c.mouth_radius} <= throat={c.throat_radius}" + ) + + def test_unique_candidate_ids(self): + candidates, _ = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.025], + ) + ids = [c.candidate_id for c in candidates] + assert len(ids) == len(set(ids)) + + def test_mouth_radius_within_derived_range(self): + """All mouth radii should fall within the derived range.""" + candidates, derived = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.025], + ) + lo, hi = derived.mouth_radius_range + for c in candidates: + # Allow 1e-6 tolerance for rounding in candidate generation + assert lo - 1e-6 <= c.mouth_radius <= hi + 1e-6 + + def test_length_within_derived_range(self): + """All lengths should fall within the derived range.""" + candidates, derived = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.025], + ) + lo, hi = derived.length_range + for c in candidates: + assert lo - 1e-9 <= c.length <= hi + 1e-9 + + def test_custom_grid_size(self): + """num_mouth_radii=2, num_lengths=2 -> 3*1*2*2=12 max.""" + candidates, _ = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.025], + num_mouth_radii=2, + num_lengths=2, + ) + assert len(candidates) == 12 + + def test_multiple_throat_radii(self): + """Two throat radii should roughly double the candidates.""" + single, _ = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.025], + ) + double, _ = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.020, 0.025], + ) + assert len(double) == 2 * len(single) + + def test_large_throat_filters_small_mouths(self): + """When throat is large relative to derived mouth radius, + some candidates should be filtered.""" + # At 4000 Hz, ideal mouth radius ~0.0137m. With throat=0.012, + # some mouth radii in the range will be <= throat. + candidates, derived = generate_fullauto_candidates( + target_f_low=4000, + target_f_high=8000, + throat_radii=[0.012], + ) + # Should still produce some valid candidates + assert len(candidates) > 0 + # But fewer than the full grid + assert len(candidates) < 27 + + def test_derived_geometry_populated(self): + _, derived = generate_fullauto_candidates( + target_f_low=500, + target_f_high=4000, + throat_radii=[0.025], + ) + assert isinstance(derived, DerivedGeometry) + assert derived.target_f_low == 500 + assert derived.target_f_high == 4000 + assert derived.ideal_mouth_radius > 0 + assert derived.mouth_radius_range[0] < derived.mouth_radius_range[1] + assert derived.length_range[0] < derived.length_range[1] + assert derived.sim_freq_range[0] < 500 + assert derived.sim_freq_range[1] > 4000 + + def test_low_freq_produces_large_horn(self): + """100 Hz target should produce mouth radius ~0.55m.""" + _, derived = generate_fullauto_candidates( + target_f_low=100, + target_f_high=1000, + throat_radii=[0.025], + ) + assert derived.ideal_mouth_radius == pytest.approx(0.546, rel=1e-2) + assert derived.length_range[1] > 1.0 # half-wave at 100 Hz > 1.7m + + def test_high_freq_produces_small_horn(self): + """4000 Hz target should produce mouth radius ~0.014m.""" + _, derived = generate_fullauto_candidates( + target_f_low=4000, + target_f_high=8000, + throat_radii=[0.005], + ) + assert derived.ideal_mouth_radius == pytest.approx(0.01365, rel=1e-2) + assert derived.length_range[1] < 0.05 # half-wave at 4000 Hz ~0.043m
#ManufacturerModelProfileScore#ManufacturerModelTypeSizePower (W)ProfileScore BW Cov.Ripple (dB)Sensitivity (dB)f3 range (Hz)Peak (dB)
Mouth R (m)Length (m)