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
6 changes: 3 additions & 3 deletions main.nf
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ params.mode = "single"
params.throat_radius = 0.05 // Radius of the horn's throat in meters
params.mouth_radius = 0.2 // Radius of the horn's mouth in meters
params.length = 0.5 // Length of the horn in meters
params.profile = "conical" // Horn flare profile: conical, exponential, hyperbolic
params.profile = "conical" // Horn flare profile: conical, exponential, hyperbolic, tractrix, os, lecleach, cd
params.num_sections = 20 // Number of cross-sections for lofting

// Simulation Settings
Expand Down Expand Up @@ -862,7 +862,7 @@ workflow auto {
)

// 2. Generate geometry for each profile using throat radius from prescreen
ch_profiles = Channel.from("conical", "exponential", "hyperbolic")
ch_profiles = Channel.from("conical", "exponential", "hyperbolic", "tractrix", "os", "lecleach", "cd")

// Extract throat radius from prescreen result
ch_throat_radius = ch_prescreen.map { json_file ->
Expand Down Expand Up @@ -904,7 +904,7 @@ workflow auto {
)

// 9. 3D horn geometry renders (one per profile, parallel)
ch_render_profiles = Channel.from("conical", "exponential", "hyperbolic")
ch_render_profiles = Channel.from("conical", "exponential", "hyperbolic", "tractrix", "os", "lecleach", "cd")
render_auto_horn_3d(ch_render_profiles)
}

Expand Down
34 changes: 32 additions & 2 deletions packages/horn-analysis/src/horn_analysis/horn_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,39 @@ def _radius_profile(
elif profile == "hyperbolic":
m = np.arccosh(mouth_radius / throat_radius)
return throat_radius * np.cosh(m * z / length)
elif profile == "tractrix":
t = np.linspace(np.pi - 1e-6, np.pi / 2, 500)
y, x = np.sin(t), np.log(np.tan(t / 2)) + np.cos(t)
x -= x[0]
x_n, y_n = x / x[-1], y / y[-1]
return throat_radius + (mouth_radius - throat_radius) * np.interp(z, x_n * length, y_n)
elif profile == "os":
theta = np.arctan2(np.sqrt(mouth_radius**2 - throat_radius**2), length)
return np.sqrt(throat_radius**2 + (z * np.tan(theta)) ** 2)
elif profile == "lecleach":
t = np.linspace(np.pi - 1e-6, np.pi / 2, 500)
y, x = np.sin(t), np.log(np.tan(t / 2)) + np.cos(t)
x -= x[0]
idx = np.searchsorted(y, throat_radius / mouth_radius)
x_c, y_c = x[idx:] - x[idx], y[idx:]
return np.interp(z, x_c / x_c[-1] * length, y_c / y_c[-1] * mouth_radius)
elif profile == "cd":
frac = 0.3
z_t = frac * length
r_trans = throat_radius * (mouth_radius / throat_radius) ** frac
result = np.empty_like(z)
mask_exp = z <= z_t
result[mask_exp] = throat_radius * np.exp(
np.log(r_trans / throat_radius) * z[mask_exp] / z_t
)
result[~mask_exp] = r_trans + (mouth_radius - r_trans) * (
z[~mask_exp] - z_t
) / (length - z_t)
return result
else:
raise ValueError(
f"Unknown profile '{profile}'. "
f"Choose from: conical, exponential, hyperbolic"
f"Choose from: conical, exponential, hyperbolic, tractrix, os, lecleach, cd"
)


Expand Down Expand Up @@ -261,7 +290,8 @@ def main():
parser.add_argument("--length", type=float, required=True,
help="Horn length in metres")
parser.add_argument("--profile", type=str, default="conical",
choices=["conical", "exponential", "hyperbolic"],
choices=["conical", "exponential", "hyperbolic",
"tractrix", "os", "lecleach", "cd"],
help="Horn flare profile (default: conical)")
parser.add_argument("--no-profile-panel", action="store_true",
help="Omit the 2D cross-section panel")
Expand Down
4 changes: 4 additions & 0 deletions packages/horn-analysis/src/horn_analysis/html_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
"conical": {"badge_bg": "#dbeafe", "badge_fg": "#1e40af"},
"exponential": {"badge_bg": "#dcfce7", "badge_fg": "#166534"},
"hyperbolic": {"badge_bg": "#fef3c7", "badge_fg": "#92400e"},
"tractrix": {"badge_bg": "#ede9fe", "badge_fg": "#5b21b6"},
"os": {"badge_bg": "#ccfbf1", "badge_fg": "#115e59"},
"lecleach": {"badge_bg": "#fce7f3", "badge_fg": "#9d174d"},
"cd": {"badge_bg": "#e2e8f0", "badge_fg": "#334155"},
}

_DEFAULT_BADGE = {"badge_bg": "#f3f4f6", "badge_fg": "#374151"}
Expand Down
4 changes: 4 additions & 0 deletions packages/horn-analysis/src/horn_analysis/plot_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
"conical": {"color": "#1f4e79", "linestyle": "-"},
"exponential": {"color": "#27864e", "linestyle": "--"},
"hyperbolic": {"color": "#d4831a", "linestyle": "-."},
"tractrix": {"color": "#7b4ea3", "linestyle": ":"},
"os": {"color": "#0d9488", "linestyle": "-"},
"lecleach": {"color": "#be185d", "linestyle": "--"},
"cd": {"color": "#475569", "linestyle": "-."},
}

_DEFAULT_PROFILE_STYLE = {"color": "#6b7280", "linestyle": "-"}
Expand Down
4 changes: 4 additions & 0 deletions packages/horn-analysis/src/horn_analysis/single_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
"conical": {"badge_bg": "#dbeafe", "badge_fg": "#1e40af"},
"exponential": {"badge_bg": "#dcfce7", "badge_fg": "#166534"},
"hyperbolic": {"badge_bg": "#fef3c7", "badge_fg": "#92400e"},
"tractrix": {"badge_bg": "#ede9fe", "badge_fg": "#5b21b6"},
"os": {"badge_bg": "#ccfbf1", "badge_fg": "#115e59"},
"lecleach": {"badge_bg": "#fce7f3", "badge_fg": "#9d174d"},
"cd": {"badge_bg": "#e2e8f0", "badge_fg": "#334155"},
}

_DEFAULT_BADGE = {"badge_bg": "#f3f4f6", "badge_fg": "#374151"}
Expand Down
7 changes: 6 additions & 1 deletion packages/horn-analysis/tests/test_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,15 @@ def test_radius_profile_values(self):
from horn_analysis.horn_render import _radius_profile

r_t, r_m, L = 0.05, 0.2, 0.5
for profile in ("conical", "exponential", "hyperbolic"):
for profile in ("conical", "exponential", "hyperbolic", "os", "cd"):
r = _radius_profile(np.array([0.0, L]), r_t, r_m, L, profile)
assert r[0] == pytest.approx(r_t, rel=1e-10)
assert r[-1] == pytest.approx(r_m, rel=1e-10)
# Tractrix and Le Cléac'h use parametric interpolation; endpoints are approximate
for profile in ("tractrix", "lecleach"):
r = _radius_profile(np.array([0.0, L]), r_t, r_m, L, profile)
assert r[0] == pytest.approx(r_t, rel=0.02)
assert r[-1] == pytest.approx(r_m, rel=0.02)

def test_radius_profile_unknown_raises(self):
from horn_analysis.horn_render import _radius_profile
Expand Down
2 changes: 1 addition & 1 deletion packages/horn-core/src/horn_core/candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# Standard 1" compression driver throat radii
DEFAULT_THROAT_RADII = [0.0125, 0.0175, 0.025] # metres

DEFAULT_PROFILES = ["conical", "exponential", "hyperbolic"]
DEFAULT_PROFILES = ["conical", "exponential", "hyperbolic", "tractrix", "os", "lecleach", "cd"]


@dataclass
Expand Down
2 changes: 1 addition & 1 deletion packages/horn-core/src/horn_core/geometry_designer.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
# Speed of sound in air at ~20C
C0 = 343.0

DEFAULT_PROFILES = ["conical", "exponential", "hyperbolic"]
DEFAULT_PROFILES = ["conical", "exponential", "hyperbolic", "tractrix", "os", "lecleach", "cd"]


@dataclass
Expand Down
4 changes: 4 additions & 0 deletions packages/horn-core/src/horn_core/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ class FlareProfile(str, Enum):
EXPONENTIAL = "exponential"
HYPERBOLIC = "hyperbolic"
CONICAL = "conical"
TRACTRIX = "tractrix"
OS = "os"
LECLEACH = "lecleach"
CD = "cd"


@dataclass
Expand Down
12 changes: 6 additions & 6 deletions packages/horn-core/tests/test_candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def test_all_profiles_represented(self):
max_length=0.5, max_mouth_radius=0.2,
)
profiles = {c.profile for c in candidates}
assert profiles == {"conical", "exponential", "hyperbolic"}
assert profiles == {"conical", "exponential", "hyperbolic", "tractrix", "os", "lecleach", "cd"}

def test_custom_profiles(self):
candidates = generate_candidates(
Expand All @@ -67,15 +67,15 @@ def test_unique_candidate_ids(self):
assert len(ids) == len(set(ids))

def test_expected_grid_size(self):
"""With default throat radii (3) and 4x4 grid across 3 profiles,
maximum would be 3*3*4*4 = 144 but invalid combos are skipped."""
"""With default throat radii (3) and 4x4 grid across 7 profiles,
maximum would be 7*3*4*4 = 336 but invalid combos are skipped."""
candidates = generate_candidates(
target_f_low=500, target_f_high=4000,
max_length=0.5, max_mouth_radius=0.2,
)
# Some combos filtered (mouth <= throat), so count should be < 144
assert len(candidates) <= 144
assert len(candidates) > 50 # but we should still have plenty
# Some combos filtered (mouth <= throat), so count should be < 336
assert len(candidates) <= 336
assert len(candidates) > 100 # but we should still have plenty


class TestWriteCandidatesCsv:
Expand Down
18 changes: 9 additions & 9 deletions packages/horn-core/tests/test_geometry_designer.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,15 @@ def test_sim_range_brackets_target(self):

class TestGenerateFullautoCandidates:
def test_default_grid_size(self):
"""3 profiles x 1 throat x 3 mouth x 3 lengths = 27 max."""
"""7 profiles x 1 throat x 3 mouth x 3 lengths = 63 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
# All 63 should pass since derived mouth radii >> 0.025
assert len(candidates) == 63
assert derived.candidate_count == 63

def test_all_profiles_represented(self):
candidates, _ = generate_fullauto_candidates(
Expand All @@ -116,7 +116,7 @@ def test_all_profiles_represented(self):
throat_radii=[0.025],
)
profiles = {c.profile for c in candidates}
assert profiles == {"conical", "exponential", "hyperbolic"}
assert profiles == {"conical", "exponential", "hyperbolic", "tractrix", "os", "lecleach", "cd"}

def test_mouth_exceeds_throat(self):
"""Every candidate must have mouth_radius > throat_radius."""
Expand Down Expand Up @@ -163,15 +163,15 @@ def test_length_within_derived_range(self):
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."""
"""num_mouth_radii=2, num_lengths=2 -> 7*1*2*2=28 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
assert len(candidates) == 28

def test_multiple_throat_radii(self):
"""Two throat radii should roughly double the candidates."""
Expand Down Expand Up @@ -199,8 +199,8 @@ def test_large_throat_filters_small_mouths(self):
)
# Should still produce some valid candidates
assert len(candidates) > 0
# But fewer than the full grid
assert len(candidates) < 27
# But fewer than the full grid (7 profiles x 1 throat x 3 mouth x 3 lengths = 63)
assert len(candidates) < 63

def test_derived_geometry_populated(self):
_, derived = generate_fullauto_candidates(
Expand Down
99 changes: 98 additions & 1 deletion packages/horn-geometry/src/horn_geometry/generator.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import math

import gmsh
import numpy as np
from pathlib import Path
Expand Down Expand Up @@ -111,10 +113,105 @@ def radius_func(z: float) -> float:
return _loft_horn_profile(radius_func, length, num_sections, output_file, "hyperbolic_horn")


def create_tractrix_horn(
throat_radius: float,
mouth_radius: float,
length: float,
output_file: Path,
num_sections: int = 20,
) -> Path:
"""Generate a tractrix horn STEP file.

Scaled tractrix parametric curve: rapid initial expansion, decelerating toward mouth.
"""
t = np.linspace(np.pi - 1e-6, np.pi / 2, 500)
y, x = np.sin(t), np.log(np.tan(t / 2)) + np.cos(t)
x -= x[0]
x_n, y_n = x / x[-1], y / y[-1]

def radius_func(z: float) -> float:
return throat_radius + (mouth_radius - throat_radius) * float(
np.interp(z, x_n * length, y_n)
)

return _loft_horn_profile(radius_func, length, num_sections, output_file, "tractrix_horn")


def create_os_horn(
throat_radius: float,
mouth_radius: float,
length: float,
output_file: Path,
num_sections: int = 20,
) -> Path:
"""Generate an oblate spheroidal (OS / Geddes) horn STEP file.

Simple closed-form: r(z) = sqrt(r_t^2 + (z * tan(theta))^2).
"""
theta = math.atan2(math.sqrt(mouth_radius**2 - throat_radius**2), length)

def radius_func(z: float) -> float:
return math.sqrt(throat_radius**2 + (z * math.tan(theta)) ** 2)

return _loft_horn_profile(radius_func, length, num_sections, output_file, "os_horn")


def create_lecleach_horn(
throat_radius: float,
mouth_radius: float,
length: float,
output_file: Path,
num_sections: int = 20,
) -> Path:
"""Generate a Le Cléac'h horn STEP file.

Tractrix curve with constant a = mouth_radius, clipped where radius >= throat_radius.
Gentle expansion at throat, aggressive flare at mouth.
"""
t = np.linspace(np.pi - 1e-6, np.pi / 2, 500)
y, x = np.sin(t), np.log(np.tan(t / 2)) + np.cos(t)
x -= x[0]
idx = np.searchsorted(y, throat_radius / mouth_radius)
x_c, y_c = x[idx:] - x[idx], y[idx:]

def radius_func(z: float) -> float:
return float(np.interp(z, x_c / x_c[-1] * length, y_c / y_c[-1] * mouth_radius))

return _loft_horn_profile(radius_func, length, num_sections, output_file, "lecleach_horn")


def create_cd_horn(
throat_radius: float,
mouth_radius: float,
length: float,
output_file: Path,
num_sections: int = 30,
) -> Path:
"""Generate a constant directivity (CD) horn STEP file.

Compound: exponential throat (30% of length) transitioning to conical body.
"""
frac = 0.3
z_t = frac * length
r_trans = throat_radius * (mouth_radius / throat_radius) ** frac

def radius_func(z: float) -> float:
if z <= z_t:
return throat_radius * math.exp(math.log(r_trans / throat_radius) * z / z_t)
else:
return r_trans + (mouth_radius - r_trans) * (z - z_t) / (length - z_t)

return _loft_horn_profile(radius_func, length, num_sections, output_file, "cd_horn")


_PROFILE_DISPATCH = {
"conical": create_conical_horn,
"exponential": create_exponential_horn,
"hyperbolic": create_hyperbolic_horn,
"tractrix": create_tractrix_horn,
"os": create_os_horn,
"lecleach": create_lecleach_horn,
"cd": create_cd_horn,
}


Expand Down Expand Up @@ -160,7 +257,7 @@ def main():
parser.add_argument(
"--profile",
type=str,
choices=["conical", "exponential", "hyperbolic"],
choices=list(_PROFILE_DISPATCH.keys()),
default="conical",
help="Horn flare profile (default: conical).",
)
Expand Down
Loading