feat: wind-turbine noise — apparent sound power & tonal audibility (IEC 61400-11) (#146)
* docs: implementation plan for wind-turbine noise (IEC 61400-11) PR-B2
* feat(wind-turbine): apparent sound power + tonal audibility chain (IEC 61400-11)
Add wind_turbine_noise.py: slant_distance/apparent_sound_power_level (Formula
26) and wind_turbine_tonality (Formulae 30-34) returning a
WindTurbineTonalityResult with .plot(). Named to avoid colliding with the
existing ISO 1996-2 critical_bandwidth/tonal_adjustment/tonal_audibility; the
K_T rating reuses the ISO 1996-2 tonal_adjustment already in the library.
* feat(wind-turbine): conformance domain (IEC 61400-11), 168/168
* feat(wind-turbine): tonal-audibility figure (x4 variants)
* docs(wind-turbine): EN guide + site EN/ES, api-reference, indices, sidebar
* fix(wind-turbine): correct low-frequency band and non-adjacent tone lines
Two correctness fixes from review, against IEC 61400-11:2012+A1:2018:
- The 20-70 Hz candidate uses the fixed absolute 20-120 Hz critical band, not
a band centred on the tone frequency (subclause 9.5.3). The previous
|f - fc| <= CBW/2 window only coincided with 20-120 Hz at fc = 70.
- Tone lines need not be contiguous with the peak. A1:2018 struck "adjacent";
every line in the critical band above the tone threshold and within 10 dB of
the peak is a tone. The Hanning /1.5 correction is now applied per contiguous
run rather than once for the whole set, so separated tones are summed
correctly.
Add tests for a low-frequency tone (excluding sub-20 Hz lines) and for two
separated tone lines.
* fix(wind-turbine): review pass — validation, shared band helper, plot color
- wind_turbine_tonality: validate strictly-increasing and uniformly-spaced
frequencies (the narrowband-spectrum precondition of Formulae 30-34)
- extract shared _critical_band_edges() so the plots use the fixed 20-120 Hz
low-frequency band instead of fc±CBW/2 (matches the classification logic)
- correct the WindTurbineTonalityResult docstring return type
- distinct masking-level line colour in the generated figure (no longer
blends with the grid)
- regenerate the 4 wind_turbine_tonality figure variants
- add regression tests for the frequency-axis validation
* fix(wind-turbine): address CodeRabbit review
- tighten the uniform-spacing tolerance to 1e-3·df so a near-miss axis (e.g.
2,2,2.4 Hz) that would bias df/ENBW is rejected; add a near-miss test
- fix a duplicate-keyword TypeError in plot_epnl / plot_wind_turbine_tonality
when a caller passes lw/label/color; merge defaults so caller values win;
add a plot-kwargs regression test
- remove the unused _AUDIBILITY_REPORT_LIMIT constant (the multi-spectrum
'No relevant tones' reporting rule is out of scope); is_audible stays ΔL_a>0
per IEC 61400-11 §9.5.8 ('a tone is audible if the tonal audibility is above
0 dB')
- add unit=dB to the apparent-power conformance row for table consistency
- document band_levels as background-corrected and the narrowband-spectrum
constraints in api-reference; make the guide code examples self-contained
- markdownlint-clean the wind-turbine plan doc
fix: deterministic documentation-figure rendering (end the flaky figures job) (#131)
* fix: make documentation-figure rendering deterministic across machines
The "Documentation figures up to date" CI job was intermittently failing on the
heavy compute figures (excitation_signals, multiple_shock, sii_vocal_efforts,
sottek_specific_loudness, tonality_roughness_demo): their multi-threaded
numerical backends reorder floating-point reductions depending on the available
core count, so the CI runner produced byte-different SVG/PNG output than the
committed (single-threaded) baseline, and a plain re-run usually "fixed" it.
Pin the numerical thread pools to a single thread so rendering is reproducible
regardless of the host core count:
- generate_graphs.py / generate_diagrams.py set OMP/MKL/OPENBLAS/NUMEXPR/NUMBA/
VECLIB thread counts to 1 before the numeric backends import (covers direct
invocation).
- The Makefile `graphs` target also exports those plus PYTHONHASHSEED=0 before
the interpreter starts (PYTHONHASHSEED cannot be set from within the process);
CI runs `make graphs`, so it inherits this with no workflow change.
Regenerating every figure under these settings produces a zero-byte diff against
the committed images, confirming the current baseline is already the
single-threaded canonical output — so no figure files change here.
* fix: compare documentation figures within tolerance, not byte-exact
The "Documentation figures up to date" job regenerated .github/images with
`make graphs` and required a byte-identical result. That is unachievable on
GitHub's hardware-heterogeneous runner fleet: the pinned software stack
computes a few plotted path coordinates ~1 ULP apart depending on which CPU
microarchitecture (SIMD kernel) the run lands on, so the job failed
intermittently even though the figures were numerically and visually
identical. Single-thread pinning removes intra-machine reduction races but
cannot remove this cross-CPU last-bit difference.
Replace the byte diff with scripts/check_figures.py, which compares the
freshly regenerated figures against the committed versions within a tolerance:
* SVG -- non-numeric structure (elements, text, colours, ordering) must match
exactly; every numeric token must agree within an absolute/relative
tolerance. A moved element or relabelled axis fails; a last-bit
coordinate wobble passes.
* PNG -- identical dimensions, at most a small number of pixels changed beyond
a level threshold (catches localised changes a global RMS dilutes),
and a bounded RMS (catches broad changes the pixel count misses).
* other -- exact byte compare. Added/removed files always fail.
The thread-pinning determinism work from the previous commit is retained (it
still removes genuine intra-machine flakiness); this makes the check robust to
the cross-machine floating-point non-determinism it cannot eliminate.
* fix: harden figure check against hash-id drift and orphan figures
Two robustness gaps found reviewing the tolerance-aware figure check:
* SVG clip-path / marker ids are SHA256 hashes of the underlying float
geometry (matplotlib backend_svg._make_id). A cross-CPU 1-ULP wobble in that
geometry avalanches the whole hash, so the id text would change and trip the
strict structural gate -- the very drift the check exists to tolerate. Now
canonicalise every id and its url(#...)/href="#..." references to
first-appearance placeholders before comparing, so equivalent documents match
regardless of hash text while a genuine id add/remove/reorder/repoint fails.
* The generators only overwrite files, never delete, so a figure that is no
longer produced would survive on disk byte-identical to HEAD and pass the
"committed figure no longer generated" check. Clear the generated SVG/PNG at
the start of the `graphs` target before regenerating (animations *.gif/*.webm
are from the separate `animations` target and are preserved). A full
clear+regenerate reproduces the committed tree exactly, confirming no
committed figure is orphaned.
feat: wind-turbine noise — apparent sound power & tonal audibility (IEC 61400-11) (#146)
* docs: implementation plan for wind-turbine noise (IEC 61400-11) PR-B2
* feat(wind-turbine): apparent sound power + tonal audibility chain (IEC 61400-11)
Add wind_turbine_noise.py: slant_distance/apparent_sound_power_level (Formula
26) and wind_turbine_tonality (Formulae 30-34) returning a
WindTurbineTonalityResult with .plot(). Named to avoid colliding with the
existing ISO 1996-2 critical_bandwidth/tonal_adjustment/tonal_audibility; the
K_T rating reuses the ISO 1996-2 tonal_adjustment already in the library.
* feat(wind-turbine): conformance domain (IEC 61400-11), 168/168
* feat(wind-turbine): tonal-audibility figure (x4 variants)
* docs(wind-turbine): EN guide + site EN/ES, api-reference, indices, sidebar
* fix(wind-turbine): correct low-frequency band and non-adjacent tone lines
Two correctness fixes from review, against IEC 61400-11:2012+A1:2018:
- The 20-70 Hz candidate uses the fixed absolute 20-120 Hz critical band, not
a band centred on the tone frequency (subclause 9.5.3). The previous
|f - fc| <= CBW/2 window only coincided with 20-120 Hz at fc = 70.
- Tone lines need not be contiguous with the peak. A1:2018 struck "adjacent";
every line in the critical band above the tone threshold and within 10 dB of
the peak is a tone. The Hanning /1.5 correction is now applied per contiguous
run rather than once for the whole set, so separated tones are summed
correctly.
Add tests for a low-frequency tone (excluding sub-20 Hz lines) and for two
separated tone lines.
* fix(wind-turbine): review pass — validation, shared band helper, plot color
- wind_turbine_tonality: validate strictly-increasing and uniformly-spaced
frequencies (the narrowband-spectrum precondition of Formulae 30-34)
- extract shared _critical_band_edges() so the plots use the fixed 20-120 Hz
low-frequency band instead of fc±CBW/2 (matches the classification logic)
- correct the WindTurbineTonalityResult docstring return type
- distinct masking-level line colour in the generated figure (no longer
blends with the grid)
- regenerate the 4 wind_turbine_tonality figure variants
- add regression tests for the frequency-axis validation
* fix(wind-turbine): address CodeRabbit review
- tighten the uniform-spacing tolerance to 1e-3·df so a near-miss axis (e.g.
2,2,2.4 Hz) that would bias df/ENBW is rejected; add a near-miss test
- fix a duplicate-keyword TypeError in plot_epnl / plot_wind_turbine_tonality
when a caller passes lw/label/color; merge defaults so caller values win;
add a plot-kwargs regression test
- remove the unused _AUDIBILITY_REPORT_LIMIT constant (the multi-spectrum
'No relevant tones' reporting rule is out of scope); is_audible stays ΔL_a>0
per IEC 61400-11 §9.5.8 ('a tone is audible if the tonal audibility is above
0 dB')
- add unit=dB to the apparent-power conformance row for table consistency
- document band_levels as background-corrected and the narrowband-spectrum
constraints in api-reference; make the guide code examples self-contained
- markdownlint-clean the wind-turbine plan doc
docs: restructure into docs/, slim PyPI-safe README, bilingual Starlight site with theme-aware figures (#61)
* docs: split README content into docs/ topic pages
11 topic pages under docs/ (guides + reference), including the new
levels/spectrogram documentation and a Why PyOctaveBand page distilled
from the issue #38 IEC 61672-1 compliance analysis.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs: add auto-generated figures for levels/spectrogram/zero-phase docs
Four new generate_* functions (spectrogram example, LN levels over the
Fast envelope, zero-phase group-delay comparison, A-weighting HF accuracy),
a 'make graphs' target, and a CONTRIBUTING section formalizing that every
docs image must come from generate_graphs.py.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs: slim README to hook + quick start with absolute links (PyPI-safe)
The README is the PyPI landing page and PyPI does not resolve relative
links, so every link (docs pages, website, CONTRIBUTING, LICENSE) is now
absolute. The Documentation section sits right after installation with a
per-page table. Full content lives in docs/ and on the website.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs(site): scaffold Astro/Starlight with en/es i18n, KaTeX, and English content
Starlight site under site/ with base /PyOctaveBand for GitHub Pages,
locale-aware sidebar, KaTeX math rendering, and the 11 English pages
ported from docs/ (links rewritten to site routes, GitHub alerts
converted to Starlight asides).
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs: generate all figures in light and dark themes
generate_graphs.py now renders every figure twice (light + _dark suffix)
via a theme switch (dark_background style, foreground color swap).
- docs/ (GitHub): <picture> with prefers-color-scheme sources.
- site/ (Starlight): dual img.light-only/.dark-only following the theme
toggle via [data-theme] CSS.
- README keeps plain light images (PyPI sanitizes <picture>).
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs(site): add complete Spanish translation
Full natural translation of all 12 pages (landing, getting started,
7 guides, 3 reference pages) with theme-aware images and es/ routes.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* ci: deploy Starlight docs site to GitHub Pages
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* ci: bump actions to latest, dependabot cooldown 0, refresh all dependencies
- Workflows: checkout v7; docs.yml to setup-node v6, pnpm/action-setup v6,
upload-pages-artifact v5, deploy-pages v5 (pnpm stays 11.x).
- Dependabot: cooldown 0 days on all ecosystems, stop ignoring pip
minor/patch updates (grouped), add npm ecosystem for site/.
- Python deps to latest: scipy 1.18, matplotlib 3.11, numba 0.66,
mypy 2.1, ruff 0.15.20, pytest 9.1. numpy capped <2.5 in the dev/CI
environment because numba 0.66 requires it (users without the [perf]
extra are unaffected).
- Site deps to latest: astro 7.0.6, starlight 0.41.3, katex 0.17, sharp 0.35.
- Repo configured via API: homepage -> docs site URL, GitHub Pages enabled
with build_type=workflow (no manual post-merge step needed anymore).
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf
* docs: address review feedback (dependabot schema, snippets, workflow hardening)
- dependabot: remove cooldown blocks (default-days minimum is 1; no
cooldown IS the zero-delay behavior).
- docs.yml: scope pages/id-token permissions to the deploy job only and
set persist-credentials: false on checkout (third-party install scripts
run in the build job).
- Snippets: add missing numpy import, define the bank in the zero-phase
example, linspace endpoint=False (docs + site EN/ES + README).
- tsconfig: exclude node_modules and .astro explicitly.
- A-weighting +2.00 constant kept: verified numerically that
20*log10(RA(1000)) = -1.99986, so +2.00 yields 0.0001 dB at 1 kHz;
the suggested +2.06 would introduce a +0.06 dB offset.
Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf