ankusdrive 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ankusdrive/__init__.py +19 -0
- ankusdrive/__main__.py +3 -0
- ankusdrive/_legacy_cli.py +29 -0
- ankusdrive/analysis/__init__.py +12 -0
- ankusdrive/analysis/acoustics.py +543 -0
- ankusdrive/analysis/acoustics_bem.py +266 -0
- ankusdrive/analysis/buckling.py +154 -0
- ankusdrive/analysis/cfd.py +683 -0
- ankusdrive/analysis/cht.py +742 -0
- ankusdrive/analysis/convection.py +238 -0
- ankusdrive/analysis/cost.py +266 -0
- ankusdrive/analysis/dfx.py +274 -0
- ankusdrive/analysis/durability.py +286 -0
- ankusdrive/analysis/elmer.py +486 -0
- ankusdrive/analysis/em.py +700 -0
- ankusdrive/analysis/em_fullwave.py +241 -0
- ankusdrive/analysis/fluids.py +226 -0
- ankusdrive/analysis/fsi.py +267 -0
- ankusdrive/analysis/fsi_case.py +338 -0
- ankusdrive/analysis/fsi_template/ATTRIBUTION.md +13 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/0/U +41 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/0/p +45 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/0/phi +44 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/0/pointDisplacement +47 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/constant/dynamicMeshDict +18 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/constant/transportProperties +11 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/constant/turbulenceProperties +9 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/system/blockMeshDict +145 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/system/controlDict +46 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/system/fvSchemes +39 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/system/fvSolution +75 -0
- ankusdrive/analysis/fsi_template/fluid-openfoam/system/preciceDict +38 -0
- ankusdrive/analysis/fsi_template/precice-config.xml +65 -0
- ankusdrive/analysis/fsi_template/solid-calculix/all.msh +984 -0
- ankusdrive/analysis/fsi_template/solid-calculix/config.yml +10 -0
- ankusdrive/analysis/fsi_template/solid-calculix/fix1_beam.nam +8 -0
- ankusdrive/analysis/fsi_template/solid-calculix/flap.inp +25 -0
- ankusdrive/analysis/fsi_template/solid-calculix/flap_modal.inp +28 -0
- ankusdrive/analysis/fsi_template/solid-calculix/frequency.inp +32 -0
- ankusdrive/analysis/fsi_template/solid-calculix/interface_beam.nam +496 -0
- ankusdrive/analysis/granular.py +317 -0
- ankusdrive/analysis/impact.py +116 -0
- ankusdrive/analysis/kinematics.py +242 -0
- ankusdrive/analysis/laminate.py +398 -0
- ankusdrive/analysis/machine_elements.py +732 -0
- ankusdrive/analysis/machining.py +560 -0
- ankusdrive/analysis/materials/__init__.py +538 -0
- ankusdrive/analysis/materials/fcmat.json +1615 -0
- ankusdrive/analysis/materials/fcmat.py +212 -0
- ankusdrive/analysis/materials/optical.json +137 -0
- ankusdrive/analysis/materials/seed.json +888 -0
- ankusdrive/analysis/mbd.py +223 -0
- ankusdrive/analysis/meshbridge.py +1035 -0
- ankusdrive/analysis/molding.py +652 -0
- ankusdrive/analysis/molding_fill.py +1646 -0
- ankusdrive/analysis/nonlinear.py +359 -0
- ankusdrive/analysis/openfoam.py +1364 -0
- ankusdrive/analysis/optics.py +186 -0
- ankusdrive/analysis/optics_design.py +193 -0
- ankusdrive/analysis/optimize.py +320 -0
- ankusdrive/analysis/performance.py +257 -0
- ankusdrive/analysis/plates.py +185 -0
- ankusdrive/analysis/slicing.py +254 -0
- ankusdrive/analysis/standards/__init__.py +492 -0
- ankusdrive/analysis/standards/bearings.json +38 -0
- ankusdrive/analysis/standards/catalog.json +1749 -0
- ankusdrive/analysis/standards/stock.json +48 -0
- ankusdrive/analysis/standards/threads.json +44 -0
- ankusdrive/analysis/study.py +303 -0
- ankusdrive/analysis/su2_case.py +248 -0
- ankusdrive/analysis/thermal.py +274 -0
- ankusdrive/analysis/tolerance.py +491 -0
- ankusdrive/analysis/tolerance_cost.py +587 -0
- ankusdrive/analysis/topology.py +637 -0
- ankusdrive/analysis/verification.py +224 -0
- ankusdrive/analysis/vibration.py +543 -0
- ankusdrive/analysis/warpage.py +402 -0
- ankusdrive/bempp_runner.py +283 -0
- ankusdrive/builder_brief.py +308 -0
- ankusdrive/change.py +421 -0
- ankusdrive/cli.py +249 -0
- ankusdrive/client.py +277 -0
- ankusdrive/config.py +229 -0
- ankusdrive/dem_gpl_runner.py +414 -0
- ankusdrive/doctor.py +272 -0
- ankusdrive/drawing_gate.py +813 -0
- ankusdrive/em_fullwave_gpl_runner.py +239 -0
- ankusdrive/experiment.py +107 -0
- ankusdrive/families.py +521 -0
- ankusdrive/feature_templates.py +684 -0
- ankusdrive/gates/__init__.py +25 -0
- ankusdrive/gates/modal.py +168 -0
- ankusdrive/gates/performance.py +411 -0
- ankusdrive/gates/substitutability.py +274 -0
- ankusdrive/iface_registry.py +391 -0
- ankusdrive/inspection.py +597 -0
- ankusdrive/items.py +285 -0
- ankusdrive/jobs.py +235 -0
- ankusdrive/lifecycle.py +459 -0
- ankusdrive/mainthread.py +181 -0
- ankusdrive/manifest.py +153 -0
- ankusdrive/mcp_server.py +7967 -0
- ankusdrive/mechanism.py +340 -0
- ankusdrive/optics_gpl_runner.py +144 -0
- ankusdrive/orderable.py +1003 -0
- ankusdrive/project.py +387 -0
- ankusdrive/props.py +51 -0
- ankusdrive/realize.py +305 -0
- ankusdrive/recipes.py +471 -0
- ankusdrive/relations.py +355 -0
- ankusdrive/release.py +804 -0
- ankusdrive/render.py +465 -0
- ankusdrive/setup_cmd.py +185 -0
- ankusdrive/sheetmetal.py +1403 -0
- ankusdrive/solvers.py +1405 -0
- ankusdrive/units.py +287 -0
- ankusdrive/worker.py +20203 -0
- ankusdrive-0.5.0.dist-info/METADATA +621 -0
- ankusdrive-0.5.0.dist-info/RECORD +125 -0
- ankusdrive-0.5.0.dist-info/WHEEL +5 -0
- ankusdrive-0.5.0.dist-info/entry_points.txt +3 -0
- ankusdrive-0.5.0.dist-info/licenses/LICENSE +201 -0
- ankusdrive-0.5.0.dist-info/licenses/NOTICE +56 -0
- ankusdrive-0.5.0.dist-info/licenses/TRADEMARKS.md +113 -0
- ankusdrive-0.5.0.dist-info/top_level.txt +1 -0
ankusdrive/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""AnkusDrive — CLI + MCP layer over FreeCAD's Python API."""
|
|
2
|
+
|
|
3
|
+
# Single source of truth for the package version. pyproject.toml reads this
|
|
4
|
+
# attr (setuptools dynamic version), publish.yml greps this line at tag
|
|
5
|
+
# push, and the CLI exposes it via --version.
|
|
6
|
+
__version__ = "0.5.0"
|
|
7
|
+
|
|
8
|
+
# Normalise the environment ONCE, before anything reads it. The DriftPin ->
|
|
9
|
+
# AnkusDrive rename (#295) renamed ~40 env vars; this promotes any surviving
|
|
10
|
+
# DRIFTPIN_* var to its new name so the ~40 read sites downstream only ever
|
|
11
|
+
# name the new spelling. Import-time on purpose: the CLI, the MCP server and
|
|
12
|
+
# the worker inside freecadcmd all reach their env through this package.
|
|
13
|
+
from . import config as _config
|
|
14
|
+
|
|
15
|
+
_config.adopt_legacy_env()
|
|
16
|
+
|
|
17
|
+
from .client import Worker, WorkerError # noqa: E402
|
|
18
|
+
|
|
19
|
+
__all__ = ["Worker", "WorkerError", "__version__"]
|
ankusdrive/__main__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Deprecated ``driftpin`` entry point — the pre-0.5 name of this CLI.
|
|
2
|
+
|
|
3
|
+
The project was renamed DriftPin -> AnkusDrive (#295). The command came with
|
|
4
|
+
it, but the old name is baked into things this package cannot edit: MCP-host
|
|
5
|
+
``mcpServers`` blocks, shell aliases, CI steps, and the scripts users wrote
|
|
6
|
+
against it. Dropping it outright turns every one of those into
|
|
7
|
+
``command not found`` with nothing pointing at the new name.
|
|
8
|
+
|
|
9
|
+
So ``driftpin`` stays installed for one minor release as a shim that says what
|
|
10
|
+
happened and then does exactly what it always did. Deprecated: delete this
|
|
11
|
+
module and its ``[project.scripts]`` entry in 0.6.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> int:
|
|
19
|
+
sys.stderr.write(
|
|
20
|
+
"driftpin: renamed to `ankusdrive` in 0.5 — this shim runs it for you, "
|
|
21
|
+
"and goes away in 0.6. Update your MCP host config, scripts and aliases "
|
|
22
|
+
"(see https://github.com/gchen19/AnkusDrive/blob/main/MIGRATION.md).\n"
|
|
23
|
+
)
|
|
24
|
+
from .cli import main as _main
|
|
25
|
+
return _main()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""AnkusDrive non-FreeCAD physics & analysis.
|
|
2
|
+
|
|
3
|
+
Pure-Python math that *consumes* geometry (or explicit numbers) and hands back
|
|
4
|
+
structured results. Importable both by the FreeCAD worker handlers and
|
|
5
|
+
standalone — nothing in this subpackage imports FreeCAD, so every module here is
|
|
6
|
+
unit-testable without spawning ``freecadcmd``.
|
|
7
|
+
|
|
8
|
+
See ``docs/SIMULATION_TOOLS.md`` (catalog + rollout) and
|
|
9
|
+
``docs/SIMULATION_EXAMPLES.md`` (per-family execution examples + verification
|
|
10
|
+
toys). ``materials`` is the foundational module — fatigue, fracture, and cost all
|
|
11
|
+
read from it.
|
|
12
|
+
"""
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""Acoustics screening — cavity modes, resonator tuning, wall attenuation.
|
|
2
|
+
|
|
3
|
+
Pure-Python, FreeCAD-free. A Tier-A screening estimator from
|
|
4
|
+
``docs/SIMULATION_NEXT.md``: the closed-form acoustics an enclosure / duct /
|
|
5
|
+
resonator design needs *before* any FEM. Four kinds under one tool:
|
|
6
|
+
|
|
7
|
+
- ``cavity_modes`` — rigid rectangular cavity eigenfrequencies
|
|
8
|
+
f = (c/2)·√((n_x/L_x)² + (n_y/L_y)² + (n_z/L_z)²) (exact)
|
|
9
|
+
- ``helmholtz`` — neck-on-volume resonator f = (c/2π)·√(A/(V·L_eff)),
|
|
10
|
+
L_eff = L + 1.7·r (two flanged-end corrections) (correlation ±10 %)
|
|
11
|
+
- ``mass_law`` — normal-incidence transmission loss of a limp wall
|
|
12
|
+
TL = 20·log₁₀(f·m″) − 47 dB (correlation ±3 dB)
|
|
13
|
+
- ``duct_cutoff`` — first cross-mode cutoff: rectangular f_c = c/(2·a),
|
|
14
|
+
circular f_c = 1.8412·c/(π·d); below it only plane waves (exact)
|
|
15
|
+
|
|
16
|
+
Fidelity is labeled per kind (``SIMULATION_NEXT.md`` contract): the exact rows
|
|
17
|
+
carry ``fidelity="exact"``; the correlations carry ``band_pct`` / ``band_db``.
|
|
18
|
+
The rigid-cavity modes double as the oracle the Elmer ``HelmholtzSolve``
|
|
19
|
+
acoustic FEM (Tier B1, ``acoustic_fem_submit`` — case builders at the bottom of
|
|
20
|
+
this module) is gated against. Lengths mm, temperatures °C.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import glob
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
|
|
28
|
+
# speed of sound in dry air: c = sqrt(gamma*R*T)
|
|
29
|
+
_GAMMA_AIR = 1.400
|
|
30
|
+
_R_AIR = 287.05 # J/kg/K
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def speed_of_sound(t_ambient_c: float = 20.0) -> float:
|
|
34
|
+
"""c = √(γ·R·T) for dry air — 343.2 m/s at 20 °C (exact ideal-gas form)."""
|
|
35
|
+
return math.sqrt(_GAMMA_AIR * _R_AIR * (t_ambient_c + 273.15))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _cavity_modes(lx_m, ly_m, lz_m, c, n_modes):
|
|
39
|
+
"""All (n_x,n_y,n_z) rigid-wall eigenfrequencies up to n_modes, ascending."""
|
|
40
|
+
modes = []
|
|
41
|
+
n_max = 8 # indices beyond this are far past any screening interest
|
|
42
|
+
for nx in range(n_max + 1):
|
|
43
|
+
for ny in range(n_max + 1):
|
|
44
|
+
for nz in range(n_max + 1):
|
|
45
|
+
if nx == ny == nz == 0:
|
|
46
|
+
continue
|
|
47
|
+
f = (c / 2.0) * math.sqrt(
|
|
48
|
+
(nx / lx_m) ** 2 + (ny / ly_m) ** 2 + (nz / lz_m) ** 2)
|
|
49
|
+
modes.append((f, [nx, ny, nz]))
|
|
50
|
+
modes.sort(key=lambda m: m[0])
|
|
51
|
+
return modes[:n_modes]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def acoustic_screen(
|
|
55
|
+
kind: str,
|
|
56
|
+
# cavity_modes
|
|
57
|
+
lx_mm: float | None = None,
|
|
58
|
+
ly_mm: float | None = None,
|
|
59
|
+
lz_mm: float | None = None,
|
|
60
|
+
n_modes: int = 10,
|
|
61
|
+
# helmholtz
|
|
62
|
+
neck_area_mm2: float | None = None,
|
|
63
|
+
neck_length_mm: float | None = None,
|
|
64
|
+
cavity_volume_mm3: float | None = None,
|
|
65
|
+
# mass_law
|
|
66
|
+
frequency_hz: float | None = None,
|
|
67
|
+
surface_density_kg_m2: float | None = None,
|
|
68
|
+
# duct_cutoff
|
|
69
|
+
duct_width_mm: float | None = None,
|
|
70
|
+
duct_diameter_mm: float | None = None,
|
|
71
|
+
# shared
|
|
72
|
+
t_ambient_c: float = 20.0,
|
|
73
|
+
c_m_s: float | None = None,
|
|
74
|
+
) -> dict:
|
|
75
|
+
"""Closed-form acoustics screen (no solver) — see the module docstring for the
|
|
76
|
+
four kinds and their formulas.
|
|
77
|
+
|
|
78
|
+
``kind``: 'cavity_modes' (needs lx/ly/lz_mm; returns the lowest ``n_modes``
|
|
79
|
+
rigid-cavity eigenfrequencies with their [n_x,n_y,n_z] indices — exact, and
|
|
80
|
+
the oracle the acoustic_fem_submit solve is gated against) | 'helmholtz' (neck_area_mm2,
|
|
81
|
+
neck_length_mm, cavity_volume_mm3; flanged end correction L_eff = L + 1.7·r —
|
|
82
|
+
±10 %) | 'mass_law' (frequency_hz, surface_density_kg_m2; limp-wall
|
|
83
|
+
normal-incidence TL — ±3 dB, and +6 dB per doubling of f or m″) |
|
|
84
|
+
'duct_cutoff' (duct_width_mm or duct_diameter_mm; below f_c only plane waves
|
|
85
|
+
propagate — exact). Sound speed from dry air at ``t_ambient_c`` unless
|
|
86
|
+
``c_m_s`` is given.
|
|
87
|
+
|
|
88
|
+
Returns {kind, c_m_s, fidelity, band_pct, band_db, valid_range_ok, warnings,
|
|
89
|
+
escalate_to} plus per kind: cavity_modes → {modes:[{f_hz, n}], f_fundamental_hz};
|
|
90
|
+
helmholtz → {f_resonance_hz, neck_radius_mm, l_eff_mm}; mass_law → {tl_db,
|
|
91
|
+
fm_product}; duct_cutoff → {f_cutoff_hz, geometry}. Raises ValueError on an
|
|
92
|
+
unknown kind, missing inputs for the kind, or non-positive dimensions."""
|
|
93
|
+
c = float(c_m_s) if c_m_s is not None else speed_of_sound(t_ambient_c)
|
|
94
|
+
if c <= 0:
|
|
95
|
+
raise ValueError("c_m_s must be > 0")
|
|
96
|
+
warnings: list[str] = []
|
|
97
|
+
out = {
|
|
98
|
+
"kind": kind,
|
|
99
|
+
"c_m_s": round(c, 2),
|
|
100
|
+
"band_pct": None,
|
|
101
|
+
"band_db": None,
|
|
102
|
+
# the Tier-B1 higher-order twin: the Elmer HelmholtzSolve FEM, whose
|
|
103
|
+
# gates are exactly these screens (duct standing wave, cavity modes).
|
|
104
|
+
"escalate_to": "acoustic_fem_submit",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if kind == "cavity_modes":
|
|
108
|
+
if not (lx_mm and ly_mm and lz_mm) or min(lx_mm, ly_mm, lz_mm) <= 0:
|
|
109
|
+
raise ValueError("cavity_modes needs positive lx_mm, ly_mm, lz_mm")
|
|
110
|
+
if n_modes < 1:
|
|
111
|
+
raise ValueError("n_modes must be >= 1")
|
|
112
|
+
modes = _cavity_modes(lx_mm / 1e3, ly_mm / 1e3, lz_mm / 1e3, c, n_modes)
|
|
113
|
+
out["fidelity"] = "exact"
|
|
114
|
+
out["modes"] = [{"f_hz": round(f, 3), "n": n} for f, n in modes]
|
|
115
|
+
out["f_fundamental_hz"] = round(modes[0][0], 3)
|
|
116
|
+
|
|
117
|
+
elif kind == "helmholtz":
|
|
118
|
+
if not (neck_area_mm2 and neck_length_mm and cavity_volume_mm3) or \
|
|
119
|
+
min(neck_area_mm2, neck_length_mm, cavity_volume_mm3) <= 0:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"helmholtz needs positive neck_area_mm2, neck_length_mm, "
|
|
122
|
+
"cavity_volume_mm3")
|
|
123
|
+
a_m2 = neck_area_mm2 * 1e-6
|
|
124
|
+
v_m3 = cavity_volume_mm3 * 1e-9
|
|
125
|
+
r_m = math.sqrt(a_m2 / math.pi) # equivalent circular neck
|
|
126
|
+
l_eff = neck_length_mm / 1e3 + 1.7 * r_m # two flanged ends (0.85·r each)
|
|
127
|
+
f = (c / (2.0 * math.pi)) * math.sqrt(a_m2 / (v_m3 * l_eff))
|
|
128
|
+
out["fidelity"] = "correlation"
|
|
129
|
+
out["band_pct"] = 10.0
|
|
130
|
+
out["f_resonance_hz"] = round(f, 3)
|
|
131
|
+
out["neck_radius_mm"] = round(r_m * 1e3, 4)
|
|
132
|
+
out["l_eff_mm"] = round(l_eff * 1e3, 4)
|
|
133
|
+
# lumped model needs the resonator small vs wavelength
|
|
134
|
+
wavelength_m = c / f
|
|
135
|
+
if v_m3 ** (1.0 / 3.0) > wavelength_m / 4.0:
|
|
136
|
+
warnings.append(
|
|
137
|
+
"cavity dimension is not small vs wavelength/4 — the lumped "
|
|
138
|
+
"Helmholtz model degrades toward a cavity-mode problem")
|
|
139
|
+
|
|
140
|
+
elif kind == "mass_law":
|
|
141
|
+
if not (frequency_hz and surface_density_kg_m2) or \
|
|
142
|
+
min(frequency_hz, surface_density_kg_m2) <= 0:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"mass_law needs positive frequency_hz, surface_density_kg_m2")
|
|
145
|
+
fm = frequency_hz * surface_density_kg_m2
|
|
146
|
+
tl = 20.0 * math.log10(fm) - 47.0
|
|
147
|
+
out["fidelity"] = "correlation"
|
|
148
|
+
out["band_db"] = 3.0
|
|
149
|
+
out["tl_db"] = round(tl, 2)
|
|
150
|
+
out["fm_product"] = round(fm, 3)
|
|
151
|
+
if tl < 0:
|
|
152
|
+
warnings.append(
|
|
153
|
+
"f·m″ below the mass-law floor (TL < 0 dB) — the wall is "
|
|
154
|
+
"acoustically transparent at this frequency; the law does not apply")
|
|
155
|
+
else:
|
|
156
|
+
warnings.extend(
|
|
157
|
+
[] if tl < 60 else
|
|
158
|
+
["TL > 60 dB — flanking/coincidence limits real walls below "
|
|
159
|
+
"the mass-law line"])
|
|
160
|
+
|
|
161
|
+
elif kind == "duct_cutoff":
|
|
162
|
+
if duct_width_mm is not None and duct_diameter_mm is not None:
|
|
163
|
+
raise ValueError("give duct_width_mm OR duct_diameter_mm, not both")
|
|
164
|
+
if duct_width_mm is not None:
|
|
165
|
+
if duct_width_mm <= 0:
|
|
166
|
+
raise ValueError("duct_width_mm must be > 0")
|
|
167
|
+
f_c = c / (2.0 * duct_width_mm / 1e3)
|
|
168
|
+
out["geometry"] = "rectangular"
|
|
169
|
+
elif duct_diameter_mm is not None:
|
|
170
|
+
if duct_diameter_mm <= 0:
|
|
171
|
+
raise ValueError("duct_diameter_mm must be > 0")
|
|
172
|
+
# first asymmetric mode of a rigid circular duct: ka = 1.8412
|
|
173
|
+
f_c = 1.8412 * c / (math.pi * duct_diameter_mm / 1e3)
|
|
174
|
+
out["geometry"] = "circular"
|
|
175
|
+
else:
|
|
176
|
+
raise ValueError("duct_cutoff needs duct_width_mm or duct_diameter_mm")
|
|
177
|
+
out["fidelity"] = "exact"
|
|
178
|
+
out["f_cutoff_hz"] = round(f_c, 3)
|
|
179
|
+
|
|
180
|
+
else:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"unknown kind {kind!r}; choose from ['cavity_modes', 'duct_cutoff', "
|
|
183
|
+
"'helmholtz', 'mass_law']")
|
|
184
|
+
|
|
185
|
+
out["warnings"] = warnings
|
|
186
|
+
out["valid_range_ok"] = not warnings
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --- Tier B1: Elmer HelmholtzSolve cases (SIMULATION_NEXT) -------------------
|
|
191
|
+
#
|
|
192
|
+
# Two driven-Helmholtz cases whose gates are the exact screens above:
|
|
193
|
+
#
|
|
194
|
+
# - duct: a 1-D closed duct driven p=1 at x=0, rigid at x=L. Exact field
|
|
195
|
+
# p(x) = cos(k(L-x))/cos(kL), so the rigid-end pressure 1/cos(kL) is a
|
|
196
|
+
# machine-tight oracle (drive off-resonance: kL away from pi/2 + n*pi).
|
|
197
|
+
# - cavity: a 2-D rigid rectangular cavity driven by a Wave Flux patch on a
|
|
198
|
+
# corner of the left edge (a flux source keeps the homogeneous problem
|
|
199
|
+
# ALL-rigid, so the resonances are exactly the acoustic_screen cavity modes).
|
|
200
|
+
# A Scanning sweep tabulates the in-phase response A(f); A flips sign through
|
|
201
|
+
# each eigenfrequency, and interpolating the zero of 1/A against f**2 (the
|
|
202
|
+
# response is ~C/(f_n**2 - f**2)) localizes f_n to well under the sweep step.
|
|
203
|
+
#
|
|
204
|
+
# Case generation is pure-Python (testable without Elmer); the solve needs the
|
|
205
|
+
# ElmerSolver binary and runs through acoustic_fem_submit.
|
|
206
|
+
|
|
207
|
+
def duct_end_pressure(kl: float) -> float:
|
|
208
|
+
"""Exact rigid-end pressure of the driven closed duct: p(L)/p(0) = 1/cos(kL)."""
|
|
209
|
+
return 1.0 / math.cos(kl)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def duct_mean_pressure(kl: float) -> float:
|
|
213
|
+
"""Exact domain mean of the duct standing wave: sin(kL)/(kL*cos(kL))."""
|
|
214
|
+
return math.sin(kl) / (kl * math.cos(kl))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def helmholtz_duct_mesh_files(n_elements: int, length_m: float) -> dict:
|
|
218
|
+
"""Native Elmer 1-D line mesh: tag 1 = driven end x=0, tag 2 = rigid end x=L."""
|
|
219
|
+
n_nodes = n_elements + 1
|
|
220
|
+
nodes = "".join(
|
|
221
|
+
f"{i} -1 {(i - 1) * length_m / n_elements:.10g} 0.0 0.0\n"
|
|
222
|
+
for i in range(1, n_nodes + 1))
|
|
223
|
+
elements = "".join(
|
|
224
|
+
f"{e} 1 202 {e} {e + 1}\n" for e in range(1, n_elements + 1))
|
|
225
|
+
boundary = f"1 1 1 0 101 1\n2 2 {n_elements} 0 101 {n_nodes}\n"
|
|
226
|
+
header = f"{n_nodes} {n_elements} 2\n2\n101 2\n202 {n_elements}\n"
|
|
227
|
+
return {"mesh.header": header, "mesh.nodes": nodes,
|
|
228
|
+
"mesh.elements": elements, "mesh.boundary": boundary}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def write_helmholtz_duct_case(
|
|
232
|
+
case_dir: str,
|
|
233
|
+
*,
|
|
234
|
+
length_m: float = 1.0,
|
|
235
|
+
kl: float = 2.0,
|
|
236
|
+
n_elements: int = 200,
|
|
237
|
+
c_m_s: float = 343.0,
|
|
238
|
+
mesh_name: str = "duct",
|
|
239
|
+
scalars: str = "duct.dat",
|
|
240
|
+
) -> dict:
|
|
241
|
+
"""Write the driven closed-duct Helmholtz case. ``kl`` sets the drive
|
|
242
|
+
frequency f = kL*c/(2*pi*L); keep it away from the quarter-wave resonances
|
|
243
|
+
(pi/2 + n*pi) where the exact answer diverges. Returns {case_dir, sif,
|
|
244
|
+
mesh_db, scalars, frequency_hz, kl, p_end_exact, p_mean_exact}."""
|
|
245
|
+
if length_m <= 0 or n_elements < 8 or c_m_s <= 0:
|
|
246
|
+
raise ValueError("need length_m > 0, n_elements >= 8, c_m_s > 0")
|
|
247
|
+
if abs(math.cos(kl)) < 0.05:
|
|
248
|
+
raise ValueError(f"kl = {kl:g} is within 5% of a duct resonance — "
|
|
249
|
+
"the exact oracle diverges there; pick another kl")
|
|
250
|
+
frequency_hz = kl * c_m_s / (2.0 * math.pi * length_m)
|
|
251
|
+
mesh_dir = os.path.join(case_dir, mesh_name)
|
|
252
|
+
os.makedirs(mesh_dir, exist_ok=True)
|
|
253
|
+
for name, content in helmholtz_duct_mesh_files(n_elements, length_m).items():
|
|
254
|
+
with open(os.path.join(mesh_dir, name), "w", encoding="utf-8") as f:
|
|
255
|
+
f.write(content)
|
|
256
|
+
sif = f"""Header
|
|
257
|
+
Mesh DB "." "{mesh_name}"
|
|
258
|
+
End
|
|
259
|
+
|
|
260
|
+
Simulation
|
|
261
|
+
Coordinate System = Cartesian 1D
|
|
262
|
+
Simulation Type = Steady State
|
|
263
|
+
Steady State Max Iterations = 1
|
|
264
|
+
Output Intervals = 0
|
|
265
|
+
Frequency = {frequency_hz:.10g}
|
|
266
|
+
End
|
|
267
|
+
|
|
268
|
+
Body 1
|
|
269
|
+
Equation = 1
|
|
270
|
+
Material = 1
|
|
271
|
+
End
|
|
272
|
+
|
|
273
|
+
Equation 1
|
|
274
|
+
Active Solvers(1) = 1
|
|
275
|
+
End
|
|
276
|
+
|
|
277
|
+
Material 1
|
|
278
|
+
Density = 1.205
|
|
279
|
+
Sound Speed = {c_m_s:.10g}
|
|
280
|
+
End
|
|
281
|
+
|
|
282
|
+
Solver 1
|
|
283
|
+
Equation = Helmholtz Equation
|
|
284
|
+
Procedure = "HelmholtzSolve" "HelmholtzSolver"
|
|
285
|
+
Variable = Pressure
|
|
286
|
+
Variable Dofs = 2
|
|
287
|
+
Linear System Solver = Direct
|
|
288
|
+
Linear System Direct Method = UMFPACK
|
|
289
|
+
End
|
|
290
|
+
|
|
291
|
+
Solver 2
|
|
292
|
+
Equation = SaveScalars
|
|
293
|
+
Procedure = "SaveData" "SaveScalars"
|
|
294
|
+
Filename = "{scalars}"
|
|
295
|
+
Variable 1 = Pressure 1
|
|
296
|
+
Operator 1 = "boundary mean"
|
|
297
|
+
Variable 2 = Pressure 2
|
|
298
|
+
Operator 2 = "boundary mean"
|
|
299
|
+
Variable 3 = Pressure 1
|
|
300
|
+
Operator 3 = "mean"
|
|
301
|
+
End
|
|
302
|
+
|
|
303
|
+
Boundary Condition 1
|
|
304
|
+
Target Boundaries(1) = 1
|
|
305
|
+
Pressure 1 = 1.0
|
|
306
|
+
Pressure 2 = 0.0
|
|
307
|
+
End
|
|
308
|
+
|
|
309
|
+
Boundary Condition 2
|
|
310
|
+
Target Boundaries(1) = 2
|
|
311
|
+
Save Scalars = True
|
|
312
|
+
End
|
|
313
|
+
"""
|
|
314
|
+
with open(os.path.join(case_dir, "case.sif"), "w", encoding="utf-8") as f:
|
|
315
|
+
f.write(sif)
|
|
316
|
+
with open(os.path.join(case_dir, "ELMERSOLVER_STARTINFO"), "w", encoding="utf-8") as f:
|
|
317
|
+
f.write("case.sif\n")
|
|
318
|
+
return {
|
|
319
|
+
"case_dir": case_dir,
|
|
320
|
+
"sif": "case.sif",
|
|
321
|
+
"mesh_db": mesh_name,
|
|
322
|
+
"scalars": scalars,
|
|
323
|
+
"frequency_hz": frequency_hz,
|
|
324
|
+
"kl": kl,
|
|
325
|
+
"p_end_exact": duct_end_pressure(kl),
|
|
326
|
+
"p_mean_exact": duct_mean_pressure(kl),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def parse_helmholtz_duct(case_dir: str, scalars: str = "duct.dat") -> dict | None:
|
|
331
|
+
"""Last SaveScalars row -> {p_end_re, p_end_im, p_mean_re}. None if absent."""
|
|
332
|
+
path = os.path.join(case_dir, scalars)
|
|
333
|
+
if not os.path.exists(path):
|
|
334
|
+
hits = glob.glob(os.path.join(case_dir, scalars + "*"))
|
|
335
|
+
hits = [h for h in hits if not h.endswith(".names")]
|
|
336
|
+
if not hits:
|
|
337
|
+
return None
|
|
338
|
+
path = hits[0]
|
|
339
|
+
rows = [ln.split() for ln in open(path, encoding="utf-8") if ln.strip()]
|
|
340
|
+
if not rows:
|
|
341
|
+
return None
|
|
342
|
+
last = [float(v) for v in rows[-1]]
|
|
343
|
+
return {"p_end_re": last[0], "p_end_im": last[1], "p_mean_re": last[2]}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def helmholtz_cavity_mesh_files(
|
|
347
|
+
lx_m: float, ly_m: float, nx: int, ny: int, drive_fraction: float = 0.125,
|
|
348
|
+
) -> dict:
|
|
349
|
+
"""Native Elmer 2-D quad mesh of the rectangular cavity. Boundary tag 1 is
|
|
350
|
+
the Wave Flux drive patch (the bottom ``drive_fraction`` of the left edge —
|
|
351
|
+
off-center so oblique modes are excited too); tag 3 is a one-element probe
|
|
352
|
+
at the far corner (Lx, Ly) — a corner is never on a nodal line and keeps a
|
|
353
|
+
fixed sign per mode, so the in-phase probe flips sign cleanly through each
|
|
354
|
+
resonance; tag 2 is every other rigid wall."""
|
|
355
|
+
nnx, nny = nx + 1, ny + 1
|
|
356
|
+
|
|
357
|
+
def nid(i, j):
|
|
358
|
+
return j * nnx + i + 1
|
|
359
|
+
|
|
360
|
+
def parent(i, j):
|
|
361
|
+
return j * nx + i + 1
|
|
362
|
+
|
|
363
|
+
nodes = []
|
|
364
|
+
for j in range(nny):
|
|
365
|
+
for i in range(nnx):
|
|
366
|
+
nodes.append(
|
|
367
|
+
f"{nid(i, j)} -1 {i * lx_m / nx:.10g} {j * ly_m / ny:.10g} 0.0\n")
|
|
368
|
+
elements = []
|
|
369
|
+
eid = 0
|
|
370
|
+
for j in range(ny):
|
|
371
|
+
for i in range(nx):
|
|
372
|
+
eid += 1
|
|
373
|
+
elements.append(
|
|
374
|
+
f"{eid} 1 404 {nid(i, j)} {nid(i + 1, j)} "
|
|
375
|
+
f"{nid(i + 1, j + 1)} {nid(i, j + 1)}\n")
|
|
376
|
+
boundary, bid = [], 0
|
|
377
|
+
drive_n = max(2, int(round(ny * drive_fraction)))
|
|
378
|
+
for j in range(ny): # left edge: drive patch then rigid
|
|
379
|
+
bid += 1
|
|
380
|
+
tag = 1 if j < drive_n else 2
|
|
381
|
+
boundary.append(f"{bid} {tag} {parent(0, j)} 0 202 {nid(0, j)} {nid(0, j + 1)}\n")
|
|
382
|
+
for j in range(ny): # right edge; top element is the corner probe
|
|
383
|
+
bid += 1
|
|
384
|
+
tag = 3 if j == ny - 1 else 2
|
|
385
|
+
boundary.append(f"{bid} {tag} {parent(nx - 1, j)} 0 202 {nid(nx, j)} {nid(nx, j + 1)}\n")
|
|
386
|
+
for i in range(nx): # bottom + top edges
|
|
387
|
+
bid += 1
|
|
388
|
+
boundary.append(f"{bid} 2 {parent(i, 0)} 0 202 {nid(i, 0)} {nid(i + 1, 0)}\n")
|
|
389
|
+
bid += 1
|
|
390
|
+
boundary.append(f"{bid} 2 {parent(i, ny - 1)} 0 202 {nid(i, ny)} {nid(i + 1, ny)}\n")
|
|
391
|
+
header = f"{nnx * nny} {nx * ny} {bid}\n2\n202 {bid}\n404 {nx * ny}\n"
|
|
392
|
+
return {"mesh.header": header, "mesh.nodes": "".join(nodes),
|
|
393
|
+
"mesh.elements": "".join(elements), "mesh.boundary": "".join(boundary)}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _frequency_table(freqs) -> str:
|
|
397
|
+
"""A tabulated Elmer dependency: Frequency = Variable time, evaluated at the
|
|
398
|
+
integer scan steps 1..N -> exactly the listed frequencies."""
|
|
399
|
+
rows = "".join(f" {i + 1}.0 {f:.10g}\n" for i, f in enumerate(freqs))
|
|
400
|
+
return f"Variable time\n Real\n{rows} End"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def write_helmholtz_cavity_case(
|
|
404
|
+
case_dir: str,
|
|
405
|
+
*,
|
|
406
|
+
lx_m: float = 0.5,
|
|
407
|
+
ly_m: float = 0.4,
|
|
408
|
+
nx: int = 50,
|
|
409
|
+
ny: int = 40,
|
|
410
|
+
mode_nx: int = 1,
|
|
411
|
+
mode_ny: int = 0,
|
|
412
|
+
span_pct: float = 8.0,
|
|
413
|
+
n_steps: int = 13,
|
|
414
|
+
c_m_s: float = 343.0,
|
|
415
|
+
mesh_name: str = "cav",
|
|
416
|
+
scalars: str = "cav.dat",
|
|
417
|
+
) -> dict:
|
|
418
|
+
"""Write the flux-driven cavity sweep around the exact (mode_nx, mode_ny)
|
|
419
|
+
rigid-cavity eigenfrequency (from the same closed form acoustic_screen
|
|
420
|
+
uses). The sweep spans ±span_pct% in n_steps Scanning steps; sweep points
|
|
421
|
+
avoid landing exactly on the eigenvalue (singular matrix). Returns
|
|
422
|
+
{case_dir, sif, mesh_db, scalars, f_exact_hz, freqs, mode}."""
|
|
423
|
+
if min(lx_m, ly_m) <= 0 or nx < 8 or ny < 8:
|
|
424
|
+
raise ValueError("need positive lx_m/ly_m and nx, ny >= 8")
|
|
425
|
+
if mode_nx < 0 or mode_ny < 0 or mode_nx + mode_ny == 0:
|
|
426
|
+
raise ValueError("mode_nx/mode_ny must be >= 0 with at least one > 0")
|
|
427
|
+
if n_steps < 5:
|
|
428
|
+
raise ValueError("n_steps must be >= 5 to bracket the resonance")
|
|
429
|
+
f_exact = (c_m_s / 2.0) * math.sqrt((mode_nx / lx_m) ** 2 + (mode_ny / ly_m) ** 2)
|
|
430
|
+
span = span_pct / 100.0
|
|
431
|
+
# even n_steps straddles f_exact without ever evaluating exactly on it
|
|
432
|
+
n_steps += (n_steps % 2 == 1)
|
|
433
|
+
freqs = [f_exact * (1.0 - span + 2.0 * span * i / (n_steps - 1))
|
|
434
|
+
for i in range(n_steps)]
|
|
435
|
+
mesh_dir = os.path.join(case_dir, mesh_name)
|
|
436
|
+
os.makedirs(mesh_dir, exist_ok=True)
|
|
437
|
+
for name, content in helmholtz_cavity_mesh_files(lx_m, ly_m, nx, ny).items():
|
|
438
|
+
with open(os.path.join(mesh_dir, name), "w", encoding="utf-8") as f:
|
|
439
|
+
f.write(content)
|
|
440
|
+
sif = f"""Header
|
|
441
|
+
Mesh DB "." "{mesh_name}"
|
|
442
|
+
End
|
|
443
|
+
|
|
444
|
+
Simulation
|
|
445
|
+
Coordinate System = Cartesian 2D
|
|
446
|
+
Simulation Type = Scanning
|
|
447
|
+
Timestep Intervals = {n_steps}
|
|
448
|
+
Output Intervals = 0
|
|
449
|
+
Frequency = {_frequency_table(freqs)}
|
|
450
|
+
End
|
|
451
|
+
|
|
452
|
+
Body 1
|
|
453
|
+
Equation = 1
|
|
454
|
+
Material = 1
|
|
455
|
+
End
|
|
456
|
+
|
|
457
|
+
Equation 1
|
|
458
|
+
Active Solvers(1) = 1
|
|
459
|
+
End
|
|
460
|
+
|
|
461
|
+
Material 1
|
|
462
|
+
Density = 1.205
|
|
463
|
+
Sound Speed = {c_m_s:.10g}
|
|
464
|
+
End
|
|
465
|
+
|
|
466
|
+
Solver 1
|
|
467
|
+
Equation = Helmholtz Equation
|
|
468
|
+
Procedure = "HelmholtzSolve" "HelmholtzSolver"
|
|
469
|
+
Variable = Pressure
|
|
470
|
+
Variable Dofs = 2
|
|
471
|
+
Linear System Solver = Direct
|
|
472
|
+
Linear System Direct Method = UMFPACK
|
|
473
|
+
End
|
|
474
|
+
|
|
475
|
+
Solver 2
|
|
476
|
+
Equation = SaveScalars
|
|
477
|
+
Procedure = "SaveData" "SaveScalars"
|
|
478
|
+
Filename = "{scalars}"
|
|
479
|
+
Variable 1 = Pressure 1
|
|
480
|
+
Operator 1 = "boundary mean"
|
|
481
|
+
End
|
|
482
|
+
|
|
483
|
+
Boundary Condition 1
|
|
484
|
+
Target Boundaries(1) = 1
|
|
485
|
+
Wave Flux 1 = 1.0
|
|
486
|
+
Wave Flux 2 = 0.0
|
|
487
|
+
End
|
|
488
|
+
|
|
489
|
+
Boundary Condition 2
|
|
490
|
+
Target Boundaries(1) = 3
|
|
491
|
+
Save Scalars = True
|
|
492
|
+
End
|
|
493
|
+
"""
|
|
494
|
+
with open(os.path.join(case_dir, "case.sif"), "w", encoding="utf-8") as f:
|
|
495
|
+
f.write(sif)
|
|
496
|
+
with open(os.path.join(case_dir, "ELMERSOLVER_STARTINFO"), "w", encoding="utf-8") as f:
|
|
497
|
+
f.write("case.sif\n")
|
|
498
|
+
return {
|
|
499
|
+
"case_dir": case_dir,
|
|
500
|
+
"sif": "case.sif",
|
|
501
|
+
"mesh_db": mesh_name,
|
|
502
|
+
"scalars": scalars,
|
|
503
|
+
"f_exact_hz": f_exact,
|
|
504
|
+
"freqs": freqs,
|
|
505
|
+
"mode": [mode_nx, mode_ny],
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def parse_helmholtz_cavity(case_dir: str, scalars: str = "cav.dat") -> list | None:
|
|
510
|
+
"""SaveScalars sweep rows -> [(response, frequency_hz), ...]. The 'max abs'
|
|
511
|
+
operator keeps the sign of the extreme value — exactly what the resonance
|
|
512
|
+
locator needs. None when the file is absent/empty."""
|
|
513
|
+
path = os.path.join(case_dir, scalars)
|
|
514
|
+
if not os.path.exists(path):
|
|
515
|
+
hits = glob.glob(os.path.join(case_dir, scalars + "*"))
|
|
516
|
+
hits = [h for h in hits if not h.endswith(".names")]
|
|
517
|
+
if not hits:
|
|
518
|
+
return None
|
|
519
|
+
path = hits[0]
|
|
520
|
+
rows = []
|
|
521
|
+
for ln in open(path, encoding="utf-8"):
|
|
522
|
+
parts = ln.split()
|
|
523
|
+
if len(parts) >= 2:
|
|
524
|
+
rows.append((float(parts[0]), float(parts[1])))
|
|
525
|
+
return rows or None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def locate_resonance(rows) -> float | None:
|
|
529
|
+
"""Eigenfrequency from a swept in-phase response: A(f) ~ C/(f_n**2 - f**2)
|
|
530
|
+
flips sign through f_n, so 1/A is ~linear in f**2 there — interpolate its
|
|
531
|
+
zero on the sign-flip pair with the largest response magnitude. Returns the
|
|
532
|
+
f_n estimate in Hz, or None if no flip was captured."""
|
|
533
|
+
best, best_mag = None, 0.0
|
|
534
|
+
for (a1, f1), (a2, f2) in zip(rows, rows[1:]):
|
|
535
|
+
if a1 == 0.0 or a2 == 0.0 or (a1 > 0) == (a2 > 0):
|
|
536
|
+
continue
|
|
537
|
+
mag = min(abs(a1), abs(a2))
|
|
538
|
+
if mag > best_mag:
|
|
539
|
+
best_mag = mag
|
|
540
|
+
x1, x2 = f1 * f1, f2 * f2
|
|
541
|
+
y1, y2 = 1.0 / a1, 1.0 / a2
|
|
542
|
+
best = math.sqrt(x1 - y1 * (x2 - x1) / (y2 - y1))
|
|
543
|
+
return best
|