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.
Files changed (125) hide show
  1. ankusdrive/__init__.py +19 -0
  2. ankusdrive/__main__.py +3 -0
  3. ankusdrive/_legacy_cli.py +29 -0
  4. ankusdrive/analysis/__init__.py +12 -0
  5. ankusdrive/analysis/acoustics.py +543 -0
  6. ankusdrive/analysis/acoustics_bem.py +266 -0
  7. ankusdrive/analysis/buckling.py +154 -0
  8. ankusdrive/analysis/cfd.py +683 -0
  9. ankusdrive/analysis/cht.py +742 -0
  10. ankusdrive/analysis/convection.py +238 -0
  11. ankusdrive/analysis/cost.py +266 -0
  12. ankusdrive/analysis/dfx.py +274 -0
  13. ankusdrive/analysis/durability.py +286 -0
  14. ankusdrive/analysis/elmer.py +486 -0
  15. ankusdrive/analysis/em.py +700 -0
  16. ankusdrive/analysis/em_fullwave.py +241 -0
  17. ankusdrive/analysis/fluids.py +226 -0
  18. ankusdrive/analysis/fsi.py +267 -0
  19. ankusdrive/analysis/fsi_case.py +338 -0
  20. ankusdrive/analysis/fsi_template/ATTRIBUTION.md +13 -0
  21. ankusdrive/analysis/fsi_template/fluid-openfoam/0/U +41 -0
  22. ankusdrive/analysis/fsi_template/fluid-openfoam/0/p +45 -0
  23. ankusdrive/analysis/fsi_template/fluid-openfoam/0/phi +44 -0
  24. ankusdrive/analysis/fsi_template/fluid-openfoam/0/pointDisplacement +47 -0
  25. ankusdrive/analysis/fsi_template/fluid-openfoam/constant/dynamicMeshDict +18 -0
  26. ankusdrive/analysis/fsi_template/fluid-openfoam/constant/transportProperties +11 -0
  27. ankusdrive/analysis/fsi_template/fluid-openfoam/constant/turbulenceProperties +9 -0
  28. ankusdrive/analysis/fsi_template/fluid-openfoam/system/blockMeshDict +145 -0
  29. ankusdrive/analysis/fsi_template/fluid-openfoam/system/controlDict +46 -0
  30. ankusdrive/analysis/fsi_template/fluid-openfoam/system/fvSchemes +39 -0
  31. ankusdrive/analysis/fsi_template/fluid-openfoam/system/fvSolution +75 -0
  32. ankusdrive/analysis/fsi_template/fluid-openfoam/system/preciceDict +38 -0
  33. ankusdrive/analysis/fsi_template/precice-config.xml +65 -0
  34. ankusdrive/analysis/fsi_template/solid-calculix/all.msh +984 -0
  35. ankusdrive/analysis/fsi_template/solid-calculix/config.yml +10 -0
  36. ankusdrive/analysis/fsi_template/solid-calculix/fix1_beam.nam +8 -0
  37. ankusdrive/analysis/fsi_template/solid-calculix/flap.inp +25 -0
  38. ankusdrive/analysis/fsi_template/solid-calculix/flap_modal.inp +28 -0
  39. ankusdrive/analysis/fsi_template/solid-calculix/frequency.inp +32 -0
  40. ankusdrive/analysis/fsi_template/solid-calculix/interface_beam.nam +496 -0
  41. ankusdrive/analysis/granular.py +317 -0
  42. ankusdrive/analysis/impact.py +116 -0
  43. ankusdrive/analysis/kinematics.py +242 -0
  44. ankusdrive/analysis/laminate.py +398 -0
  45. ankusdrive/analysis/machine_elements.py +732 -0
  46. ankusdrive/analysis/machining.py +560 -0
  47. ankusdrive/analysis/materials/__init__.py +538 -0
  48. ankusdrive/analysis/materials/fcmat.json +1615 -0
  49. ankusdrive/analysis/materials/fcmat.py +212 -0
  50. ankusdrive/analysis/materials/optical.json +137 -0
  51. ankusdrive/analysis/materials/seed.json +888 -0
  52. ankusdrive/analysis/mbd.py +223 -0
  53. ankusdrive/analysis/meshbridge.py +1035 -0
  54. ankusdrive/analysis/molding.py +652 -0
  55. ankusdrive/analysis/molding_fill.py +1646 -0
  56. ankusdrive/analysis/nonlinear.py +359 -0
  57. ankusdrive/analysis/openfoam.py +1364 -0
  58. ankusdrive/analysis/optics.py +186 -0
  59. ankusdrive/analysis/optics_design.py +193 -0
  60. ankusdrive/analysis/optimize.py +320 -0
  61. ankusdrive/analysis/performance.py +257 -0
  62. ankusdrive/analysis/plates.py +185 -0
  63. ankusdrive/analysis/slicing.py +254 -0
  64. ankusdrive/analysis/standards/__init__.py +492 -0
  65. ankusdrive/analysis/standards/bearings.json +38 -0
  66. ankusdrive/analysis/standards/catalog.json +1749 -0
  67. ankusdrive/analysis/standards/stock.json +48 -0
  68. ankusdrive/analysis/standards/threads.json +44 -0
  69. ankusdrive/analysis/study.py +303 -0
  70. ankusdrive/analysis/su2_case.py +248 -0
  71. ankusdrive/analysis/thermal.py +274 -0
  72. ankusdrive/analysis/tolerance.py +491 -0
  73. ankusdrive/analysis/tolerance_cost.py +587 -0
  74. ankusdrive/analysis/topology.py +637 -0
  75. ankusdrive/analysis/verification.py +224 -0
  76. ankusdrive/analysis/vibration.py +543 -0
  77. ankusdrive/analysis/warpage.py +402 -0
  78. ankusdrive/bempp_runner.py +283 -0
  79. ankusdrive/builder_brief.py +308 -0
  80. ankusdrive/change.py +421 -0
  81. ankusdrive/cli.py +249 -0
  82. ankusdrive/client.py +277 -0
  83. ankusdrive/config.py +229 -0
  84. ankusdrive/dem_gpl_runner.py +414 -0
  85. ankusdrive/doctor.py +272 -0
  86. ankusdrive/drawing_gate.py +813 -0
  87. ankusdrive/em_fullwave_gpl_runner.py +239 -0
  88. ankusdrive/experiment.py +107 -0
  89. ankusdrive/families.py +521 -0
  90. ankusdrive/feature_templates.py +684 -0
  91. ankusdrive/gates/__init__.py +25 -0
  92. ankusdrive/gates/modal.py +168 -0
  93. ankusdrive/gates/performance.py +411 -0
  94. ankusdrive/gates/substitutability.py +274 -0
  95. ankusdrive/iface_registry.py +391 -0
  96. ankusdrive/inspection.py +597 -0
  97. ankusdrive/items.py +285 -0
  98. ankusdrive/jobs.py +235 -0
  99. ankusdrive/lifecycle.py +459 -0
  100. ankusdrive/mainthread.py +181 -0
  101. ankusdrive/manifest.py +153 -0
  102. ankusdrive/mcp_server.py +7967 -0
  103. ankusdrive/mechanism.py +340 -0
  104. ankusdrive/optics_gpl_runner.py +144 -0
  105. ankusdrive/orderable.py +1003 -0
  106. ankusdrive/project.py +387 -0
  107. ankusdrive/props.py +51 -0
  108. ankusdrive/realize.py +305 -0
  109. ankusdrive/recipes.py +471 -0
  110. ankusdrive/relations.py +355 -0
  111. ankusdrive/release.py +804 -0
  112. ankusdrive/render.py +465 -0
  113. ankusdrive/setup_cmd.py +185 -0
  114. ankusdrive/sheetmetal.py +1403 -0
  115. ankusdrive/solvers.py +1405 -0
  116. ankusdrive/units.py +287 -0
  117. ankusdrive/worker.py +20203 -0
  118. ankusdrive-0.5.0.dist-info/METADATA +621 -0
  119. ankusdrive-0.5.0.dist-info/RECORD +125 -0
  120. ankusdrive-0.5.0.dist-info/WHEEL +5 -0
  121. ankusdrive-0.5.0.dist-info/entry_points.txt +3 -0
  122. ankusdrive-0.5.0.dist-info/licenses/LICENSE +201 -0
  123. ankusdrive-0.5.0.dist-info/licenses/NOTICE +56 -0
  124. ankusdrive-0.5.0.dist-info/licenses/TRADEMARKS.md +113 -0
  125. 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,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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