From 4e2d599c114fdaa457c554e8efbbdc407f9d4649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 5 Aug 2026 00:39:30 +0200 Subject: [PATCH 01/10] Say that the API reference is English on purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starlight served the 146 API pages on /es/ under its own notice, "Esta página aún no está disponible en tu idioma", which promised a translation that is not coming. The reference is generated from the docstrings, so its text is the code's own; keeping a Spanish copy of it in step with every signature change would cost more than it teaches, and the guides carry the teaching in both languages already. So the notice now says what is true: the API reference is published in English in both languages because it is generated from the code. The Spanish reference index says the same where it sends the reader there, and the parity check, which exempts exactly this subtree and nothing else, records the reason rather than the mechanism. --- site/scripts/check-i18n-parity.mjs | 9 +++++++-- site/src/content/docs/es/reference/index.md | 4 +++- site/src/content/i18n/es.json | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/site/scripts/check-i18n-parity.mjs b/site/scripts/check-i18n-parity.mjs index ac0f21332..e8b8939cf 100644 --- a/site/scripts/check-i18n-parity.mjs +++ b/site/scripts/check-i18n-parity.mjs @@ -27,8 +27,13 @@ function walk(dir) { return out; } -// The API reference (reference/api/) is generated in English only; Starlight -// locale fallback serves it on /es/. Exclude the subtree from parity. +// The API reference (reference/api/) is published in English in both +// languages, by decision: it is generated from the docstrings, which are the +// code's own text, and translating it would mean keeping a second copy of +// 146 pages in step with every signature change. Starlight's locale fallback +// serves it on /es/, where the untranslated notice says so (i18n/es.json). +// Everything else is bilingual and this check is what enforces that, so the +// exemption is this subtree and nothing else. const apiRef = join("reference", "api"); const isApiRef = (p) => p === apiRef || p.startsWith(apiRef + sep); diff --git a/site/src/content/docs/es/reference/index.md b/site/src/content/docs/es/reference/index.md index 957e62438..5c3db205a 100644 --- a/site/src/content/docs/es/reference/index.md +++ b/site/src/content/docs/es/reference/index.md @@ -19,7 +19,9 @@ fuente de un modelo, la bibliografía tiene el DOI. Si solo buscas la firma de una función, lo que quieres es la [referencia de la API](/phonometry/es/reference/api/): se genera a partir de -los docstrings del código y ocupa su propia sección de la barra lateral. +los docstrings del código y ocupa su propia sección de la barra lateral. Esa +sección está en inglés en los dos idiomas, porque su texto es el del propio +código; todo lo demás del sitio está traducido. ## [Teoría](/phonometry/es/reference/theory/) diff --git a/site/src/content/i18n/es.json b/site/src/content/i18n/es.json index 32ff9293a..1450cf4c2 100644 --- a/site/src/content/i18n/es.json +++ b/site/src/content/i18n/es.json @@ -2,5 +2,6 @@ "phonometry.references.title": "Referencias", "phonometry.report.download": "Descargar el informe (PDF)", "phonometry.video.download": "Descargar la animación (WebM)", - "phonometry.video.fallback": "Tu navegador no puede reproducir vídeo incrustado. Descarga la animación:" + "phonometry.video.fallback": "Tu navegador no puede reproducir vídeo incrustado. Descarga la animación:", + "i18n.untranslatedContent": "La referencia de la API se publica en inglés en los dos idiomas: se genera a partir de los docstrings del código, que son su texto original." } From 5d1945bcae9c4232636ccb118b8423580b2977cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 5 Aug 2026 00:53:56 +0200 Subject: [PATCH 02/10] Move the dates to the end and drop the byline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every guide opened with "By José Manuel Requena Plens · Published ..., updated ...", between the title and the first sentence, on 278 pages. Of the three things it said, two were already on the page: the name in the footer credit, which carries rel="author" and is the form a crawler reads, and the update date in the last-updated line at the foot of the article. Only the first publication date was unique to it. So the line is gone and that date moved to where its sibling already was. The last-updated line now reads "Published , updated ", one date when the two are the same day, in both languages. Nothing machine-readable changes: Head.astro computes author, datePublished and dateModified from the same git history and the same route data whatever the body shows, so the JSON-LD, the sitemap, the markdown copies and the llms artifacts are untouched. --- site/astro.config.mjs | 1 + site/src/components/LastUpdated.astro | 52 +++++++++++++++++ site/src/components/PageTitle.astro | 83 +++------------------------ 3 files changed, 61 insertions(+), 75 deletions(-) create mode 100644 site/src/components/LastUpdated.astro diff --git a/site/astro.config.mjs b/site/astro.config.mjs index e80d6dbcb..45db35fb9 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -517,6 +517,7 @@ export default defineConfig({ // Default H1 plus the page header chips run derived from the page's // own `references` frontmatter (see TOC-REDESIGN-NOTES.md). PageTitle: './src/components/PageTitle.astro', + LastUpdated: './src/components/LastUpdated.astro', // The generated brand lockup (mark plus wordmark) in place of the // plain text site title. SiteTitle: './src/components/SiteTitle.astro', diff --git a/site/src/components/LastUpdated.astro b/site/src/components/LastUpdated.astro new file mode 100644 index 000000000..2c632e41d --- /dev/null +++ b/site/src/components/LastUpdated.astro @@ -0,0 +1,52 @@ +--- +/** + * Starlight's last-updated line, with the date the page was first published. + * + * The two dates used to sit in different places: the first publication in a + * byline under the H1, the last update down here. The byline put the + * maintainer's name between the reader and the first sentence of 278 pages to + * say what the footer credit and the page's own JSON-LD already said, so it is + * gone; the date it carried that nothing else showed moved here, next to the + * update it belongs with. + * + * The dates themselves come from where they always did: the first commit that + * added the file (src/lib/page-dates.mjs) and Starlight's own `lastUpdated` + * route data. A page published and last touched on the same day shows one + * date, not the same date twice. + */ +import { firstPublished } from '../lib/page-dates.mjs'; + +const route = Astro.locals.starlightRoute; +const { lang, lastUpdated } = route; + +const published = firstPublished(route.entry?.filePath ?? ''); +const fmt = (date: Date) => date.toLocaleDateString(lang, { dateStyle: 'medium', timeZone: 'UTC' }); + +const publishedText = published ? fmt(new Date(published)) : undefined; +const updatedText = lastUpdated ? fmt(lastUpdated) : undefined; +const showUpdated = updatedText && updatedText !== publishedText; + +const t = lang.startsWith('es') + ? { published: 'Publicado el', updated: 'actualizado el', updatedOnly: 'Última actualización:' } + : { published: 'Published', updated: 'updated', updatedOnly: 'Last updated:' }; +--- + +{ + publishedText ? ( +

+ {t.published} + {showUpdated && ( + <> + {', '} + {t.updated} + + )} +

+ ) : ( + updatedText && ( +

+ {t.updatedOnly} +

+ ) + ) +} diff --git a/site/src/components/PageTitle.astro b/site/src/components/PageTitle.astro index 8f8970d51..51de0ade9 100644 --- a/site/src/components/PageTitle.astro +++ b/site/src/components/PageTitle.astro @@ -1,70 +1,36 @@ --- /** - * Default Starlight page title plus the page header chips run and, on the - * teaching pages, a visible byline. + * Default Starlight page title plus the page header chips run. * * The override originally existed only to give PageChips.astro a mount point * directly under the H1, inside the page header band and above the content * divider. Everything the chips run shows comes from the page's own * `references` frontmatter; see PageChips.astro and src/lib/reference-chips.ts. * - * The byline surfaces, as text, what every page already asserts in its - * JSON-LD (author, datePublished, dateModified): the machine-readable claim - * existed, but a reader, or an extractor quoting the page, had to reach the - * footer to learn who wrote it and when. Guides and Getting Started carry it; - * the API reference does not, where a byline between the H1 and a module - * synopsis would only be noise. + * A byline used to sit here too, naming the maintainer and the two dates. The + * footer credit already carries the name with rel="author", the page's JSON-LD + * carries author, datePublished and dateModified whatever the body shows, and + * the dates now sit together in the last-updated line at the end of the page + * (LastUpdated.astro), which is where a reader looks for them. What the byline + * added was the maintainer's name between the reader and the first sentence. */ import Default from '@astrojs/starlight/components/PageTitle.astro'; import PageChips from './PageChips.astro'; import PhIcon from './PhIcon.astro'; import { topics } from '../data/topics.mjs'; -import { firstPublished } from '../lib/page-dates.mjs'; interface RouteData { - entry?: { id?: string; filePath?: string }; - lastUpdated?: Date; + entry?: { id?: string }; } const route = (Astro.locals as { starlightRoute?: RouteData }).starlightRoute; const id = route?.entry?.id ?? ''; -const lang = id === 'es' || id.startsWith('es/') ? 'es' : 'en'; const slug = id.replace(/^es\/?/, ''); -// The guides, wherever their topic filed them, and the page that opens them. -// Derived rather than listed: the ten domains are every topic that is not one -// of the three carrying no teaching prose, so renaming or adding a domain -// keeps the byline without a second list to remember. -const NOT_A_DOMAIN = ['start', 'reference', 'api']; -const GUIDE_TOPICS = topics - .map((topic) => topic.id) - .filter((id) => id !== undefined && !NOT_A_DOMAIN.includes(id)); -const isGuide = GUIDE_TOPICS.some((topic) => slug === topic || slug.startsWith(`${topic}/`)); - // A topic's own overview page. It gets the drawing the sidebar gives that // topic, beside the heading: the row a reader clicked and the page they landed // on then carry the same mark. The heading itself is still Starlight's, so it // keeps its own type scale and colour; the wrapper only lays the two out. const topicId = topics.find((topic) => topic.link === `/${slug}/`)?.id; -const showByline = isGuide || slug === 'start/getting-started'; - -const BASE = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''); -const aboutHref = `${BASE}${lang === 'es' ? '/es' : ''}/start/about/`; - -const fmt = new Intl.DateTimeFormat(lang === 'es' ? 'es' : 'en', { - dateStyle: 'medium', -}); -const published = firstPublished(route?.entry?.filePath ?? ''); -const modified = route?.lastUpdated instanceof Date ? route.lastUpdated : undefined; - -const publishedText = published ? fmt.format(new Date(published)) : undefined; -const modifiedText = modified ? fmt.format(modified) : undefined; -// A page published and last touched the same day has one date, not two. -const showModified = modifiedText && modifiedText !== publishedText; - -const t = - lang === 'es' - ? { by: 'Por', published: 'Publicado el', updated: 'actualizado el' } - : { by: 'By', published: 'Published', updated: 'updated' }; --- { @@ -81,26 +47,6 @@ const t = ) } -{ - showByline && (publishedText || modifiedText) && ( - - ) -} From e88da13934c1bb85c7a263ba97352cb717d8f4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 5 Aug 2026 01:03:19 +0200 Subject: [PATCH 03/10] Open a topic on a phone with the list of what it holds On a wide screen the sidebar answers "what is in this topic?" before the reader asks: /signals/ opens with its seven rows down the left. On a phone the same page opens with a title and prose, the sidebar folded behind the menu button, and nothing on the first screen names a single section. The list is further down every landing page, authored as headings, but measured against the built site it starts between one and nearly three screens below the fold. So the landing page now opens with it. TopicSections renders the topic's own sidebar tree as a short navigation block: one row per section, with the number of pages inside, the whole row a target. It renders on a topic's landing page and nowhere else, and `md:sl-hidden` retires it the moment the real sidebar appears, so a desktop reader is not shown the same seven labels twice. The tree it reads is the one the sidebar renders, not a second list to keep in step: the topics plugin has already narrowed `starlightRoute.sidebar` to the current topic by the time components render, which is the same data Pagination.astro walks. --- site/src/components/MarkdownContent.astro | 7 +- site/src/components/TopicSections.astro | 146 ++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 site/src/components/TopicSections.astro diff --git a/site/src/components/MarkdownContent.astro b/site/src/components/MarkdownContent.astro index 3d6883f24..3f85dae87 100644 --- a/site/src/components/MarkdownContent.astro +++ b/site/src/components/MarkdownContent.astro @@ -5,6 +5,10 @@ * typography and land at the very end of the article, after all authored * sections. The matching table-of-contents entry comes from src/routeData.ts. * + * TopicSections opens a topic's landing page, on a narrow screen only, with + * the list of that topic's sections: what the sidebar shows a reader who has + * one, and what a phone keeps behind the menu button. + * * The image-zoom runtime (custom element, dialog template, icon symbols) is * mounted here by hand. starlight-image-zoom normally installs it by * overriding MarkdownContent itself, and it skips that override when the site @@ -16,6 +20,7 @@ import Default from '@astrojs/starlight/components/MarkdownContent.astro'; import ImageZoom from 'starlight-image-zoom/components/ImageZoom.astro'; import ZoomI18n from './ZoomI18n.astro'; import References from './References.astro'; +import TopicSections from './TopicSections.astro'; --- - + diff --git a/site/src/components/TopicSections.astro b/site/src/components/TopicSections.astro new file mode 100644 index 000000000..e8aaf421c --- /dev/null +++ b/site/src/components/TopicSections.astro @@ -0,0 +1,146 @@ +--- +/** + * What a topic contains, on the landing page of that topic, on a phone. + * + * On a wide screen the sidebar answers this before the reader asks: opening + * `/signals/` shows its seven rows down the left. On a narrow one the sidebar + * is behind the menu button, so the same page opens with a title and prose and + * nothing that names the sections at all; the reader has to know to press the + * hamburger to find out what the topic holds. The authored list of sections is + * further down every landing page, but measured against the built site it + * starts between 1.1 and 2.7 screens below the fold. + * + * So this puts the topic's own sidebar tree at the top of its landing page, + * and only there: `md:sl-hidden` hides it the moment the real sidebar appears, + * and the component renders nothing on any page that is not a landing page, + * which is where the sidebar is a level deeper than the reader's position. + * + * The tree comes from `Astro.locals.starlightRoute.sidebar`, which by the time + * components render is already the current topic's own tree (the topics plugin + * registers its middleware first; see src/routeData.ts). Groups contribute + * their label with the count of what is inside, links contribute themselves, + * and the entry pointing at the page the reader is on is dropped, since a link + * to here is not a section of anything. + */ +import { topics } from '../data/topics.mjs'; + +type Entry = + | { type: 'link'; href: string; label: string; isCurrent?: boolean } + | { type: 'group'; label: string; entries: Entry[] }; + +const route = Astro.locals.starlightRoute; +const sidebar = (route.sidebar ?? []) as Entry[]; +const here = Astro.url.pathname; +const lang = route.lang?.startsWith('es') ? 'es' : 'en'; + +const BASE = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''); +const localeLink = (link: string) => (lang === 'es' ? `/es${link}` : link); +const isLanding = topics.some((topic) => `${BASE}${localeLink(topic.link)}` === here); + +/** The first link inside a group, so its label can lead somewhere. */ +function firstLink(entries: Entry[]): { href: string; label: string } | undefined { + for (const entry of entries) { + if (entry.type === 'link') return entry; + const nested = firstLink(entry.entries); + if (nested) return nested; + } + return undefined; +} + +function countLinks(entries: Entry[]): number { + return entries.reduce( + (total, entry) => total + (entry.type === 'link' ? 1 : countLinks(entry.entries)), + 0, + ); +} + +const rows = sidebar + .map((entry) => { + if (entry.type === 'link') { + return entry.href === here ? undefined : { href: entry.href, label: entry.label, count: 0 }; + } + const target = firstLink(entry.entries); + if (!target) return undefined; + return { href: target.href, label: entry.label, count: countLinks(entry.entries) }; + }) + .filter((row): row is { href: string; label: string; count: number } => row !== undefined); + +const t = + lang === 'es' + ? { heading: 'En este tema', pages: (n: number) => `${n} páginas` } + : { heading: 'In this topic', pages: (n: number) => `${n} pages` }; +--- + +{ + isLanding && rows.length > 0 && ( + + ) +} + + From fd2b784a1f0043c6be5efed0af446b87863d1956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 5 Aug 2026 01:51:35 +0200 Subject: [PATCH 04/10] Show the example fiches the guides had generated and hidden The Spanish-regulation guide printed the snippet that renders an acoustic inspection fiche and then showed no fiche, unlike every other guide that ends in a report. It was not alone: sixty-seven fiches are generated, committed and shipped in the built site, and seven of them were embedded on no page in either language. One was the worked example of the very snippet its guide prints. The seven are in their guides now, in both languages: the RD 1367/2007 inspection fiche, the ISO 1999 NIPTS and HTLAN predictions, the machine enclosure and HVAC duct fiches, the reactive silencer, and the IEC 61400-11 tonality assessment. Three of those guides never mentioned `.report()` at all, so they gained the paragraph and the snippet as well as the preview; every number quoted in a description was read off the committed page. The gap was invisible to every gate, which is the part worth fixing. The fiche check closed the loop from the generator to the committed file and stopped there, and the EN/ES parity check compares the two trees with each other, so a fiche missing from both languages passed both. It now asks the remaining question, per language: is this fiche on a page a reader can reach, and does every embedded name exist? --- scripts/check_reports.py | 61 ++++++++++++- .../devices/noise-control/noise-control.mdx | 75 +++++++++++++++- .../docs/devices/noise-control/silencers.mdx | 41 +++++++++ .../sources/wind-turbine-noise.mdx | 52 +++++++++++ .../environment/spanish-noise-regulation.mdx | 11 +++ .../devices/noise-control/noise-control.mdx | 78 +++++++++++++++- .../es/devices/noise-control/silencers.mdx | 43 +++++++++ .../sources/wind-turbine-noise.mdx | 54 +++++++++++ .../environment/spanish-noise-regulation.mdx | 11 +++ .../hearing/noise-induced-hearing-loss.mdx | 90 +++++++++++++++++++ .../hearing/noise-induced-hearing-loss.mdx | 87 ++++++++++++++++++ 11 files changed, 599 insertions(+), 4 deletions(-) diff --git a/scripts/check_reports.py b/scripts/check_reports.py index afecfcd1c..a838a14a8 100644 --- a/scripts/check_reports.py +++ b/scripts/check_reports.py @@ -89,6 +89,7 @@ from __future__ import annotations +import re import sys from pathlib import Path @@ -176,9 +177,62 @@ def _problems() -> list[str]: return problems +#: Where the guides live, and the shape of the component that embeds a fiche. +DOCS_DIR = "site/src/content/docs" +EMBEDDED = re.compile(r"]*name=\"([a-z0-9_]+)\"", re.DOTALL) + + +def _shown_in(language: str) -> set[str]: + """Fiche names embedded on the pages of one language.""" + root = Path(DOCS_DIR) + pages = ( + [page for page in root.rglob("*.mdx") if page.relative_to(root).parts[0] == "es"] + if language == "es" + else [page for page in root.rglob("*.mdx") if page.relative_to(root).parts[0] != "es"] + ) + return { + name + for page in pages + for name in EMBEDDED.findall(page.read_text(encoding="utf-8")) + } + + +def _unshown() -> list[str]: + """Fiches no reader can see, and previews of fiches that do not exist. + + A fiche is generated so that a guide can show what the library prints. The + generator and the staleness check above close the loop from the code to the + committed file; nothing closed the one from the file to a reader. Seven + fiches were generated, committed, shipped in the built site and embedded on + no page in either language, one of them the worked example of the very + snippet its guide prints. + + Both languages are checked separately, because a fiche shown on the English + page and missing from the Spanish one is invisible to every other gate: the + EN/ES parity check compares the two file trees with each other, and a page + that exists in both is a page it passes. + """ + generated = {path.stem for path in Path(REPORT_DIR).glob("*.pdf")} + problems: list[str] = [] + for language, label in (("en", "English"), ("es", "Spanish")): + shown = _shown_in(language) + problems += [ + f"fiche generated but shown on no {label} page: {name} " + '(embed it with in the guide that ' + "documents its .report())" + for name in sorted(generated - shown) + ] + problems += [ + f"{label} page embeds a fiche that is not generated: {name} " + "(register it in scripts/generate_reports.py and run 'make reports')" + for name in sorted(shown - generated) + ] + return problems + + def main() -> int: """Report every stale fiche; return 1 if there is one.""" - problems = _problems() + problems = _problems() + _unshown() if problems: print( f"::error::{REPORT_DIR} is out of date - " @@ -189,7 +243,10 @@ def main() -> int: return 1 fiches = len(list(Path(REPORT_DIR).glob("*.pdf"))) - print(f"All {fiches} committed fiches match within tolerance.") + print( + f"All {fiches} committed fiches match within tolerance, " + "and every one of them is shown on a page." + ) return 0 diff --git a/site/src/content/docs/devices/noise-control/noise-control.mdx b/site/src/content/docs/devices/noise-control/noise-control.mdx index 7e32e668e..f91b6a892 100644 --- a/site/src/content/docs/devices/noise-control/noise-control.mdx +++ b/site/src/content/docs/devices/noise-control/noise-control.mdx @@ -22,6 +22,7 @@ references: import ThemeImage from '../../../../components/ThemeImage.astro'; import Video from '../../../../components/Video.astro'; +import ReportPreview from '../../../../components/ReportPreview.astro'; Three passive measures dominate applied noise control: **silencers** in a duct, the passive attenuations and regenerated noise of an **HVAC** run, and a @@ -150,6 +151,42 @@ look-up; there is no closed form in that edition. This module reproduces and interpolates the table. ::: +An `HvacSpectrumResult` also files itself: `.report()` renders a one-page +duct-noise fiche with the per-band table beside the spectrum, the boxed +single-number result — the A-weighted sound power level for a regenerated-noise +spectrum, the mean attenuation for an attenuation one — and, when a +`requirement` is declared, the verdict against it (a maximum $L_{WA}$ for +regenerated noise, a minimum mean attenuation for an attenuation). + +```python +from phonometry import ReportMetadata +from phonometry.noise_control import hvac + +octaves = [63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0] + +# The flow noise of a straight supply duct carrying air at 12 m/s. +duct = hvac.flow_noise_straight_duct(octaves, flow_velocity=12.0, area=0.04) +duct.report( + "hvac-duct-noise.pdf", # needs phonometry[report] + metadata=ReportMetadata( + specimen="Straight supply duct, 0.04 m2 cross-section (design case)", + test_room="Air-handling plant room (design case)", + measurement_standard="VDI 2081-1 prediction model", + requirement=45.0, # maximum acceptable L_WA, dB(A) + ), +) +``` + +The example fiche is regenerated with `make reports` and kept rendered in the +repository; click the preview to open the PDF. + + + ## 2. Machine enclosures A sealed enclosure reduces the radiated noise by its panel transmission loss @@ -228,7 +265,43 @@ plt.show() `enclosure_insertion_loss` returns an `EnclosureResult` with the panel `panel_transmission_loss`, the interior `correction`, the net `insertion_loss`, -the interior `room_constant`, and `.plot()`. +the interior `room_constant`, and `.plot()`. It also files itself: `.report()` +renders a one-page enclosure fiche with the per-band $R$, $C$ and +$\mathrm{IL}$ table beside the three curves, the boxed mean insertion loss with +the surface areas it was computed from, and, when a `requirement` is declared, +the verdict against that minimum mean insertion loss. + +```python +import numpy as np +from phonometry import ReportMetadata, enclosure_insertion_loss + +octaves = np.array([63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0]) +sheet_steel_R = np.array([18.0, 22.0, 28.0, 33.0, 38.0, 42.0, 45.0]) + +case = enclosure_insertion_loss( + sheet_steel_R, external_area=24.0, internal_area=30.0, + internal_absorption=0.30, frequencies=octaves, +) +case.report( + "enclosure.pdf", # needs phonometry[report] + metadata=ReportMetadata( + specimen="Sheet-steel close-fitting machine enclosure (design case)", + test_room="Machine hall, line 3 (design case)", + measurement_standard="Bies & Hansen 7.4.2 prediction model", + requirement=20.0, # minimum acceptable mean IL, dB + ), +) +``` + +The example fiche is regenerated with `make reports` and kept rendered in the +repository; click the preview to open the PDF. + + ## What this guide covers diff --git a/site/src/content/docs/devices/noise-control/silencers.mdx b/site/src/content/docs/devices/noise-control/silencers.mdx index 94f001b42..3e725ceea 100644 --- a/site/src/content/docs/devices/noise-control/silencers.mdx +++ b/site/src/content/docs/devices/noise-control/silencers.mdx @@ -30,6 +30,7 @@ references: import ThemeImage from '../../../../components/ThemeImage.astro'; import Video from '../../../../components/Video.astro'; +import ReportPreview from '../../../../components/ReportPreview.astro'; A silencer earns its keep in a duct: between an engine and its tailpipe, between a fan and the room it serves. Two working principles divide the @@ -325,6 +326,46 @@ Each returns a `ReactiveSilencerResult` with `transmission_loss`, and `.plot()`. Advanced layouts chain elements directly with `duct_matrix`, `shunt_matrix`, `cascade`, `transmission_loss` and `insertion_loss`. +`ReactiveSilencerResult` also files itself: `.report()` renders a one-page PDF +in the layout of a silencer performance sheet, with the method-basis line +naming the plane-wave four-pole method (Munjal Eq. (3.27); Bies §8.8-8.9), an +optional metadata header (client, device, test environment, instrumentation, +climate, date), the per-band table of the transmission loss (and of the +insertion loss when the impedances were given) beside those same curves, the +boxed mean transmission loss over the analysis bands together with the peak +value and the device kind, and an optional verdict against a declared minimum +mean transmission loss. Rendering needs reportlab and, for the embedded +figure, matplotlib (`pip install "phonometry[report,plot]"`); pass +`language="es"` for a Spanish fiche. + +```python +import numpy as np +from phonometry import ReportMetadata, expansion_chamber + +# A 0.5 m chamber of area ratio m = 8, at the octave-band centres. +freqs = np.array([63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0]) +res = expansion_chamber(freqs, length=0.5, chamber_area=0.08, pipe_area=0.01) +res.report( + "silencer_fiche.pdf", + metadata=ReportMetadata( + specimen="Simple expansion-chamber muffler (m = 8, design case)", + measurement_standard="Munjal Eq. (3.27) four-pole model", + laboratory="Phonometry Reference Laboratory", + requirement=6.0, # minimum acceptable mean transmission loss + ), +) # mean and peak transmission loss (dB) +``` + +The example fiche is regenerated with `make reports` and kept rendered in the +repository; click the preview to open the PDF. + + + ## 2. Reactive or dissipative? diff --git a/site/src/content/docs/environment/sources/wind-turbine-noise.mdx b/site/src/content/docs/environment/sources/wind-turbine-noise.mdx index c5f9bedef..a0db6c9ce 100644 --- a/site/src/content/docs/environment/sources/wind-turbine-noise.mdx +++ b/site/src/content/docs/environment/sources/wind-turbine-noise.mdx @@ -26,6 +26,7 @@ references: --- import ThemeImage from '../../../../components/ThemeImage.astro'; +import ReportPreview from '../../../../components/ReportPreview.astro'; IEC 61400-11 measures the acoustic emission of a wind turbine. This page covers its two closed-form quantities: the **apparent sound power level** referred to @@ -167,6 +168,57 @@ specific to IEC 61400-11 is the determination of the tone and masking levels and the Zwicker critical band from the spectrum. For a rating adjustment $K_T$, pass the mean audibility to the ISO 1996-2 `tonal_adjustment`. +### Assessment report (`.report()`) + +`WindTurbineTonalityResult.report(path)` renders a one-page PDF fiche laid out +like the tonality assessment of an environmental-noise laboratory, following +**IEC 61400-11:2012+A1:2018** (subclauses 9.5.2 to 9.5.8): a standard-basis +line, an optional metadata header block (source/situation, client, measurement +position, instrumentation and date), a critical-band analysis table (tone +frequency, critical bandwidth, tone level $L_{pt}$, masking-noise level +$L_{pn}$, tonality $\Delta L_{tn}$, audibility criterion $L_a$ and tonal +audibility $\Delta L_a$) beside the narrowband-spectrum plot with the critical +band, the masking level and the tone marked, the boxed tonal audibility +$\Delta L_a$ together with the tone frequency and the audibility decision, an +optional PASS/FAIL verdict row and a note on how $\Delta L_a$ is built, and a +footer with the fixed disclaimer. + +It uses the same `ReportMetadata` container and rendering engine as the +[ISO 532-1 loudness fiche](/phonometry/perception/psychoacoustics/loudness/#iso-532-1-report-report); +a supplied `requirement` is read as the maximum acceptable tonal audibility +$\Delta L_a$ in dB (a less audible tone passes). Rendering needs reportlab and, +for the figure the fiche embeds, matplotlib (`pip install +"phonometry[report,plot]"`); only `engine="reportlab"` is supported. The fiche +renders in English by default; pass `language="es"` for a Spanish fiche +(translated fixed strings and a comma decimal separator), e.g. +`res.report("tonality_fiche_es.pdf", language="es")`. + +```python +from phonometry import environment, ReportMetadata + +# The 500 Hz gearbox tone over the flat 30 dB floor of the snippet above. +res = environment.wind_turbine_tonality(levels, frequencies) +res.report( + "tonality_fiche.pdf", + metadata=ReportMetadata( + specimen="Horizontal-axis wind turbine, gearbox tone", + measurement_standard="IEC 61400-11", + laboratory="Phonometry Reference Laboratory", + requirement=6.0, # maximum acceptable tonal audibility (dB) + ), +) # tonal audibility (dB) and the decision +``` + +The example fiche is regenerated with `make reports` and kept rendered in the +repository; click the preview to open the PDF. + + + ## See also - API reference: [`environment.sources.wind_turbine`](/phonometry/reference/api/environment/wind-turbine/). diff --git a/site/src/content/docs/environment/spanish-noise-regulation.mdx b/site/src/content/docs/environment/spanish-noise-regulation.mdx index a2f35191c..a27f866c3 100644 --- a/site/src/content/docs/environment/spanish-noise-regulation.mdx +++ b/site/src/content/docs/environment/spanish-noise-regulation.mdx @@ -26,6 +26,7 @@ references: --- import ThemeImage from '../../../components/ThemeImage.astro'; +import ReportPreview from '../../../components/ReportPreview.astro'; Acoustic assessment in Spain does not stop at an $L_{Aeq}$. Real Decreto 1367/2007, which develops Ley 37/2003 del Ruido, defines an index of its own, @@ -289,6 +290,16 @@ verdict.plot() # the three criteria per period verdict.report("acta.pdf") # acoustic inspection fiche, in Spanish ``` +The example fiche is regenerated with `make reports` and kept rendered in the +repository; click the preview to open the PDF. + + + ## What this guide covers **Covered.** The corrected level $L_{Keq,T}$ and the $K_t$, $K_f$ and $K_i$ diff --git a/site/src/content/docs/es/devices/noise-control/noise-control.mdx b/site/src/content/docs/es/devices/noise-control/noise-control.mdx index 27cc4ed8c..f6fb76dfe 100644 --- a/site/src/content/docs/es/devices/noise-control/noise-control.mdx +++ b/site/src/content/docs/es/devices/noise-control/noise-control.mdx @@ -22,6 +22,7 @@ references: import ThemeImage from '../../../../../components/ThemeImage.astro'; import Video from '../../../../../components/Video.astro'; +import ReportPreview from '../../../../../components/ReportPreview.astro'; Tres medidas pasivas dominan el control de ruido aplicado: los **silenciadores** en un conducto, las atenuaciones pasivas y el ruido autogenerado de una @@ -156,6 +157,44 @@ de ASHRAE; no hay forma cerrada en esa edición. Este módulo reproduce e interpola la tabla. ::: +Un `HvacSpectrumResult` también se archiva solo: `.report()` genera una ficha de +una página con el ruido del conducto, la tabla por banda junto al espectro, el +resultado de número único en su recuadro — el nivel de potencia acústica +ponderado A para un espectro de ruido regenerado, la atenuación media para uno +de atenuación — y, cuando se declara un `requirement`, el veredicto frente a él +(un $L_{WA}$ máximo para el ruido regenerado, una atenuación media mínima para +una atenuación). + +```python +from phonometry import ReportMetadata +from phonometry.noise_control import hvac + +octaves = [63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0] + +# El ruido de flujo de un conducto de impulsión recto con aire a 12 m/s. +duct = hvac.flow_noise_straight_duct(octaves, flow_velocity=12.0, area=0.04) +duct.report( + "ruido-conducto-hvac.pdf", # necesita phonometry[report] + metadata=ReportMetadata( + specimen="Conducto de impulsión recto, sección de 0,04 m2", + test_room="Sala de climatización (caso de diseño)", + measurement_standard="Modelo de predicción VDI 2081-1", + requirement=45.0, # L_WA máximo admisible, dB(A) + ), + language="es", # ficha en español +) +``` + +La ficha de ejemplo se regenera con `make reports` y se mantiene renderizada en +el repositorio; pulse la vista previa para abrir el PDF. + + + ## 2. Cerramientos de máquina Un cerramiento sellado reduce el ruido radiado en la pérdida de transmisión del @@ -233,7 +272,44 @@ plt.show() `enclosure_insertion_loss` devuelve un `EnclosureResult` con la `panel_transmission_loss` del panel, la `correction` interior, la `insertion_loss` -neta, la `room_constant` interior y `.plot()`. +neta, la `room_constant` interior y `.plot()`. También se archiva solo: +`.report()` genera una ficha de cerramiento de una página con la tabla por banda +de $R$, $C$ e $\mathrm{IL}$ junto a las tres curvas, la pérdida de inserción +media en su recuadro con las áreas de las que sale y, cuando se declara un +`requirement`, el veredicto frente a esa pérdida de inserción media mínima. + +```python +import numpy as np +from phonometry import ReportMetadata, enclosure_insertion_loss + +octaves = np.array([63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0]) +sheet_steel_R = np.array([18.0, 22.0, 28.0, 33.0, 38.0, 42.0, 45.0]) + +case = enclosure_insertion_loss( + sheet_steel_R, external_area=24.0, internal_area=30.0, + internal_absorption=0.30, frequencies=octaves, +) +case.report( + "cerramiento.pdf", # necesita phonometry[report] + metadata=ReportMetadata( + specimen="Cerramiento de máquina ajustado de chapa de acero", + test_room="Nave de máquinas, línea 3 (caso de diseño)", + measurement_standard="Modelo de predicción Bies y Hansen 7.4.2", + requirement=20.0, # IL media mínima admisible, dB + ), + language="es", # ficha en español +) +``` + +La ficha de ejemplo se regenera con `make reports` y se mantiene renderizada en +el repositorio; pulse la vista previa para abrir el PDF. + + ## Qué cubre esta guía diff --git a/site/src/content/docs/es/devices/noise-control/silencers.mdx b/site/src/content/docs/es/devices/noise-control/silencers.mdx index a18a8bdbd..9865e244b 100644 --- a/site/src/content/docs/es/devices/noise-control/silencers.mdx +++ b/site/src/content/docs/es/devices/noise-control/silencers.mdx @@ -30,6 +30,7 @@ references: import ThemeImage from '../../../../../components/ThemeImage.astro'; import Video from '../../../../../components/Video.astro'; +import ReportPreview from '../../../../../components/ReportPreview.astro'; Un silenciador se gana el sueldo dentro de un conducto: entre un motor y su escape, entre un ventilador y la sala a la que sirve. Dos principios de @@ -332,6 +333,48 @@ Cada uno devuelve un `ReactiveSilencerResult` con `transmission_loss`, directamente con `duct_matrix`, `shunt_matrix`, `cascade`, `transmission_loss` e `insertion_loss`. +`ReactiveSilencerResult` también se archiva solo: `.report()` genera un PDF de +una página con la disposición de una hoja de prestaciones de silenciador, con +la línea de base metodológica que nombra el método de cuatro polos de onda +plana (Munjal Ec. (3.27); Bies §8.8-8.9), una cabecera de metadatos opcional +(cliente, dispositivo, entorno de ensayo, instrumentación, clima, fecha), la +tabla por bandas de la pérdida de transmisión (y de la de inserción cuando se +dieron las impedancias) junto a esas mismas curvas, la pérdida de transmisión +media sobre las bandas de análisis en su recuadro con el valor de pico y el +tipo de dispositivo, y un veredicto opcional frente a una pérdida de +transmisión media mínima declarada. El renderizado necesita reportlab y, para +la figura que incrusta la ficha, matplotlib (`pip install +"phonometry[report,plot]"`); pase `language="es"` para una ficha en español. + +```python +import numpy as np +from phonometry import ReportMetadata, expansion_chamber + +# Una cámara de 0,5 m con relación de áreas m = 8, en los centros de octava. +freqs = np.array([63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0]) +res = expansion_chamber(freqs, length=0.5, chamber_area=0.08, pipe_area=0.01) +res.report( + "ficha_silenciador.pdf", + metadata=ReportMetadata( + specimen="Silenciador de cámara de expansión simple (m = 8, diseño)", + measurement_standard="Modelo de cuatro polos, Munjal Ec. (3.27)", + laboratory="Laboratorio de referencia Phonometry", + requirement=6.0, # pérdida de transmisión media mínima + ), + language="es", # ficha en español (separador decimal coma) +) # pérdida de transmisión media y de pico (dB) +``` + +La ficha de ejemplo se regenera con `make reports` y se mantiene renderizada en +el repositorio; pulse la vista previa para abrir el PDF. + + + ## 2. ¿Reactivo o disipativo? Todo lo anterior funciona por reflexión, y la reflexión tiene una forma: diff --git a/site/src/content/docs/es/environment/sources/wind-turbine-noise.mdx b/site/src/content/docs/es/environment/sources/wind-turbine-noise.mdx index 60965962c..c6f20cb99 100644 --- a/site/src/content/docs/es/environment/sources/wind-turbine-noise.mdx +++ b/site/src/content/docs/es/environment/sources/wind-turbine-noise.mdx @@ -26,6 +26,7 @@ references: --- import ThemeImage from '../../../../../components/ThemeImage.astro'; +import ReportPreview from '../../../../../components/ReportPreview.astro'; La IEC 61400-11 mide la emisión acústica de un aerogenerador. Esta página cubre sus dos magnitudes en forma cerrada: el **nivel de potencia acústica aparente** @@ -175,6 +176,59 @@ IEC 61400-11 es la determinación de los niveles de tono y enmascaramiento y la banda crítica de Zwicker a partir del espectro. Para un ajuste de valoración $K_T$, pasa la audibilidad media al `tonal_adjustment` de la ISO 1996-2. +### Informe de evaluación (`.report()`) + +`WindTurbineTonalityResult.report(path)` genera una ficha PDF de una página con +la disposición de la evaluación de tonalidad de un laboratorio de ruido +ambiental, según la **IEC 61400-11:2012+A1:2018** (apartados 9.5.2 a 9.5.8): +una línea con la base normativa, un bloque opcional de metadatos (fuente o +situación, cliente, posición de medida, instrumentación y fecha), una tabla de +análisis en la banda crítica (frecuencia del tono, ancho de banda crítica, +nivel del tono $L_{pt}$, nivel de ruido de enmascaramiento $L_{pn}$, tonalidad +$\Delta L_{tn}$, criterio de audibilidad $L_a$ y audibilidad tonal +$\Delta L_a$) junto al gráfico del espectro de banda estrecha con la banda +crítica, el nivel de enmascaramiento y el tono marcados, la audibilidad tonal +$\Delta L_a$ enmarcada junto con la frecuencia del tono y la decisión de +audibilidad, una fila opcional de veredicto APTO/NO APTO y una nota sobre cómo +se construye $\Delta L_a$, y un pie con el descargo fijo. + +Usa el mismo contenedor `ReportMetadata` y el mismo motor de renderizado que la +[ficha de sonoridad ISO 532-1](/phonometry/es/perception/psychoacoustics/loudness/#informe-de-iso-532-1-report); +un `requirement` indicado se interpreta como la audibilidad tonal $\Delta L_a$ +máxima admisible en dB (un tono menos audible es apto). El renderizado necesita +reportlab y, para la figura que incrusta la ficha, matplotlib (`pip install +"phonometry[report,plot]"`); solo se admite `engine="reportlab"`. La ficha se +genera en inglés por defecto; pasa `language="es"` para una ficha en español +(cadenas fijas traducidas y separador decimal de coma), p. ej. +`res.report("tonality_fiche_es.pdf", language="es")`. + +```python +from phonometry import environment, ReportMetadata + +# El tono de multiplicadora de 500 Hz sobre el suelo plano de 30 dB de arriba. +res = environment.wind_turbine_tonality(levels, frequencies) +res.report( + "tonality_fiche.pdf", + metadata=ReportMetadata( + specimen="Aerogenerador de eje horizontal, tono de multiplicadora", + measurement_standard="IEC 61400-11", + laboratory="Laboratorio de referencia Phonometry", + requirement=6.0, # audibilidad tonal máxima admisible (dB) + ), + language="es", # ficha en español (separador decimal coma) +) # audibilidad tonal (dB) y la decisión +``` + +La ficha de ejemplo se regenera con `make reports` y se mantiene renderizada en +el repositorio; pulsa la vista previa para abrir el PDF. + + + ## Véase también - Referencia de la API: [`environment.sources.wind_turbine`](/phonometry/es/reference/api/environment/wind-turbine/). diff --git a/site/src/content/docs/es/environment/spanish-noise-regulation.mdx b/site/src/content/docs/es/environment/spanish-noise-regulation.mdx index f812b68f2..7ae3d37ba 100644 --- a/site/src/content/docs/es/environment/spanish-noise-regulation.mdx +++ b/site/src/content/docs/es/environment/spanish-noise-regulation.mdx @@ -26,6 +26,7 @@ references: --- import ThemeImage from '../../../../components/ThemeImage.astro'; +import ReportPreview from '../../../../components/ReportPreview.astro'; La evaluación acústica en España no termina en un $L_{Aeq}$. El Real Decreto 1367/2007, que desarrolla la Ley 37/2003 del Ruido, define un índice propio, el @@ -291,6 +292,16 @@ verdict.plot(language="es") # los tres criterios por periodo verdict.report("acta.pdf") # acta de inspección acústica, en español ``` +El acta de ejemplo se regenera con `make reports` y se mantiene renderizada en +el repositorio; pulse la vista previa para abrir el PDF. + + + ## Qué cubre esta guía **Cubierto.** El nivel corregido $L_{Keq,T}$ y las correcciones $K_t$, $K_f$ y diff --git a/site/src/content/docs/es/perception/hearing/noise-induced-hearing-loss.mdx b/site/src/content/docs/es/perception/hearing/noise-induced-hearing-loss.mdx index 330dbe853..3d90e5d81 100644 --- a/site/src/content/docs/es/perception/hearing/noise-induced-hearing-loss.mdx +++ b/site/src/content/docs/es/perception/hearing/noise-induced-hearing-loss.mdx @@ -30,6 +30,7 @@ references: --- import ThemeImage from '../../../../../components/ThemeImage.astro'; +import ReportPreview from '../../../../../components/ReportPreview.astro'; **ISO 1999:2013** estima la pérdida auditiva que sufre una población por el ruido laboral. Da el **desplazamiento permanente del umbral inducido por ruido** @@ -120,6 +121,53 @@ y los coeficientes $u$, $v$ de la Tabla 1 son mínimos donde el oído es más vulnerable, de modo que el mismo $L_{EX,8h}$ compra mucho más desplazamiento a 4 kHz que a 500 Hz. +### Informe de NIPTS (`.report()`) + +`NiptsResult.report(path)` genera una ficha PDF de una página con la +presentación de la predicción de pérdida auditiva inducida por ruido que emite +un servicio de higiene laboral para un grupo de exposición, según la +**ISO 1999:2013** (cláusula 6.3): la línea de base de la predicción, un bloque +de metadatos opcional (empresa, trabajador(es)/grupo, lugar de trabajo y fecha +de la evaluación), una tabla con el NIPTS mediano $N_{50}$ y el del fractil +pedido en las seis frecuencias audiométricas junto al gráfico del espectro con +su banda de fractiles, el desplazamiento promediado en el conjunto de +2/3/4 kHz enmarcado con las condiciones de exposición ($L_{EX,8h}$, la +duración en años y el porcentaje poblacional $Q$), y las notas que mantienen +honesta la ficha: los valores son una predicción estadística para la población +expuesta, no el audiograma de nadie, y la ISO 1999 deja al usuario la elección +de las frecuencias de discapacidad. `verbose=True` añade las columnas de +dispersión $d_u$/$d_l$; un `requirement` en los metadatos se interpreta como el +NIPTS representativo máximo admisible y añade una fila APTO/NO APTO (un +desplazamiento menor es apto). El renderizado necesita reportlab y matplotlib +(`pip install "phonometry[report,plot]"`); solo se admite +`engine="reportlab"`, y `language="es"` genera la ficha en español. + +```python +from phonometry import hearing, ReportMetadata + +# El caso de 90 dB(A) y 20 años de arriba, décimo más susceptible. +res = hearing.nipts(90.0, 20.0, fractile=0.9) +res.report( + "nipts_fiche.pdf", + metadata=ReportMetadata( + client="Taller de calderería de ejemplo", + specimen="Soldadores (grupo de exposición homogénea, 4 trabajadores)", + test_room="Nave de montaje de acero, línea 2", + ), + language="es", # ficha en español (separador decimal de coma) +) # NIPTS promediado en 2/3/4 kHz (dB) +``` + +La ficha de ejemplo se regenera con `make reports` y se mantiene renderizada en +el repositorio; pulse la vista previa para abrir el PDF. + + + ## 2. Edad y ruido combinados: HTLAN (cláusula 6.1) La componente de ruido no se suma sin más a la de edad: la Fórmula (1) de @@ -229,6 +277,48 @@ el `value` en el fractil pedido; el `HtlanResult` lleva `htla`, `nipts` y el sola es el tema de la guía de [umbral de audición](/phonometry/es/perception/hearing/hearing-threshold/). +### Informe de HTLAN (`.report()`) + +`HtlanResult.report(path)` genera la ficha hermana para el umbral combinado, +según la cláusula 6.1 de la **ISO 1999:2013**: la misma línea de base de la +predicción y el mismo encabezado de metadatos que la ficha de NIPTS, pero con +una tabla que lleva la componente de edad $H$, la de ruido $N$ y la combinada +$H'$ en cada frecuencia audiométrica, junto al gráfico de las tres curvas, con +el umbral promediado en el conjunto de 2/3/4 kHz enmarcado y las condiciones +del oyente y de la exposición (edad y sexo, $L_{EX,8h}$ durante los años de +exposición y el porcentaje poblacional $Q$). Sus notas añaden la que importa +aquí: la componente de edad es la base de datos A evaluada a partir de +ISO 7029:2017, cuyos valores difieren de la selección ilustrativa de la Tabla +A.3 de la ISO 1999, que procede de una edición anterior. `verbose=True` añade +el término de compresión $HN/120$ en una columna propia, y un `requirement` en +los metadatos se interpreta como el HTLAN representativo máximo admisible. + +```python +from phonometry import hearing, ReportMetadata + +# El operario de máquina de 60 años de esta sección, mediana. +res = hearing.htlan(60, "male", 95.0, 30.0, fractile=0.5) +res.report( + "htlan_fiche.pdf", + metadata=ReportMetadata( + client="Taller de calderería de ejemplo", + specimen="Operario de máquina (60 años, 30 años en el puesto)", + test_room="Nave de montaje de acero, línea 2", + ), + language="es", # ficha en español (separador decimal de coma) +) # HTLAN promediado en 2/3/4 kHz (dB HL) +``` + +La ficha de ejemplo se regenera con `make reports` y se mantiene renderizada en +el repositorio; pulse la vista previa para abrir el PDF. + + + ## Qué cubre esta guía **Cubierto.** La ISO 1999:2013 tal como la implementan `nipts` y `htlan`: el diff --git a/site/src/content/docs/perception/hearing/noise-induced-hearing-loss.mdx b/site/src/content/docs/perception/hearing/noise-induced-hearing-loss.mdx index b323cb69a..a39ef1c7d 100644 --- a/site/src/content/docs/perception/hearing/noise-induced-hearing-loss.mdx +++ b/site/src/content/docs/perception/hearing/noise-induced-hearing-loss.mdx @@ -30,6 +30,7 @@ references: --- import ThemeImage from '../../../../components/ThemeImage.astro'; +import ReportPreview from '../../../../components/ReportPreview.astro'; **ISO 1999:2013** estimates the hearing loss a population suffers from occupational noise. It gives the **noise-induced permanent threshold shift** @@ -125,6 +126,51 @@ cut-off level $L_0$ and the coefficients $u$, $v$ of Table 1 are lowest where the ear is most vulnerable, so the same $L_{EX,8h}$ buys far more shift at 4 kHz than at 500 Hz. +### NIPTS report (`.report()`) + +`NiptsResult.report(path)` renders a one-page PDF fiche laid out like the +noise-induced hearing loss prediction an occupational-hygiene service issues +for an exposure group, following **ISO 1999:2013** (clause 6.3): a +prediction-basis line, an optional metadata header block (company, +worker(s)/group, workplace and date of assessment), a table of the median +$N_{50}$ and the fractile NIPTS at the six audiometric frequencies beside the +result's own spectrum plot with its fractile band, the boxed shift averaged +over the 2/3/4 kHz hearing-handicap set together with the exposure conditions +($L_{EX,8h}$, the duration in years and the population percentage $Q$), and the +notes that keep the fiche honest: the values are a statistical prediction for +the exposed population rather than anyone's audiogram, and ISO 1999 leaves the +choice of handicap frequencies to the user. `verbose=True` adds the spread +columns $d_u$/$d_l$; a `requirement` in the metadata is read as the maximum +acceptable representative NIPTS and adds a PASS/FAIL row (a smaller shift +passes). Rendering needs reportlab and matplotlib +(`pip install "phonometry[report,plot]"`); only `engine="reportlab"` is +supported, and `language="es"` renders a Spanish fiche. + +```python +from phonometry import hearing, ReportMetadata + +# The 90 dB(A) / 20 year case above, most-susceptible tenth. +res = hearing.nipts(90.0, 20.0, fractile=0.9) +res.report( + "nipts_fiche.pdf", + metadata=ReportMetadata( + client="Example fabrication works", + specimen="Welders (homogeneous exposure group, 4 workers)", + test_room="Steel assembly hall, line 2", + ), +) # NIPTS averaged over 2/3/4 kHz (dB) +``` + +The example fiche is regenerated with `make reports` and kept rendered in the +repository; click the preview to open the PDF. + + + ## 2. Age and noise combined: HTLAN (clause 6.1) The noise component does not simply add to the age component: ISO 1999 @@ -229,6 +275,47 @@ and the `value` at the requested fractile; the `HtlanResult` carries `htla`, alone is the subject of the [hearing-threshold](/phonometry/perception/hearing/hearing-threshold/) guide. +### HTLAN report (`.report()`) + +`HtlanResult.report(path)` renders the companion fiche for the combined +threshold, following **ISO 1999:2013** clause 6.1: the same prediction-basis +line and metadata header as the NIPTS fiche, but a table that carries the age +component $H$, the noise component $N$ and the combined $H'$ at each +audiometric frequency, beside the three-curve plot, with the boxed threshold +averaged over the 2/3/4 kHz hearing-handicap set and the listener and exposure +conditions (age and sex, $L_{EX,8h}$ over the exposure years, and the +population percentage $Q$). Its notes add the one that matters here: the age +component is database A evaluated from ISO 7029:2017, whose values differ from +the illustrative Table A.3 selection of ISO 1999, which derives from an earlier +edition. `verbose=True` adds the compression term $HN/120$ as its own column, +and a `requirement` in the metadata is read as the maximum acceptable +representative HTLAN. + +```python +from phonometry import hearing, ReportMetadata + +# The 60-year-old machine operator of this section, median. +res = hearing.htlan(60, "male", 95.0, 30.0, fractile=0.5) +res.report( + "htlan_fiche.pdf", + metadata=ReportMetadata( + client="Example fabrication works", + specimen="Machine operator (60 years, 30 years in role)", + test_room="Steel assembly hall, line 2", + ), +) # HTLAN averaged over 2/3/4 kHz (dB HL) +``` + +The example fiche is regenerated with `make reports` and kept rendered in the +repository; click the preview to open the PDF. + + + ## What this guide covers **Covered.** ISO 1999:2013 as implemented by `nipts` and `htlan`: the median From b67755de61f218d7e9fcbf23fae7c9eef43b80fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 5 Aug 2026 01:51:35 +0200 Subject: [PATCH 05/10] Read the glossary as terms rather than as a table The glossary was twelve five-column tables. A table is the right shape for numbers and the wrong one for a sentence: measured on a phone, the definition column rendered 135 px wide while the one-word guide link took 256, and the row needed 775 px of horizontal scroll inside a 358 px screen. The definition is the content of that page, so each term is a block now: the notation and its unit above, the sentence at the full width of the column, the standard and the guide underneath as metadata. The definition gets 324 px on the same phone and there is nothing to scroll sideways; on a wide screen the spare width goes to a second column instead of stretching the prose. The deeper problem was that the same 120 entries existed four times: a table in each language, and a hand-written JSON-LD block of 120 DefinedTerm nodes in the frontmatter of each. Nothing kept the four in step and they had drifted. They come from one array now, src/data/glossary.mjs, which the component renders and emits the structured data from. Each page went from 2030 lines to 39. What cannot differ between the two languages is stored once, which is what stops it drifting: the notation, the designation where it is only a catalogue number, the route of the guide. What genuinely translates is a pair: the definition, the unit where the unit is a word, the clause, the twenty-one designations carrying translated prose, and the two entries named rather than symbolised. The link to a guide now takes its text from that guide's own title, read at build time, so the nine Spanish rows still advertising a title their page had stopped using are gone by construction, and a term pointing at a page that does not exist fails the build instead of shipping a dangling link. --- site/src/components/Glossary.astro | 262 +++ .../src/content/docs/es/reference/glossary.md | 2030 ----------------- .../content/docs/es/reference/glossary.mdx | 39 + site/src/content/docs/reference/glossary.md | 2030 ----------------- site/src/content/docs/reference/glossary.mdx | 39 + site/src/data/glossary.mjs | 1965 ++++++++++++++++ 6 files changed, 2305 insertions(+), 4060 deletions(-) create mode 100644 site/src/components/Glossary.astro delete mode 100644 site/src/content/docs/es/reference/glossary.md create mode 100644 site/src/content/docs/es/reference/glossary.mdx delete mode 100644 site/src/content/docs/reference/glossary.md create mode 100644 site/src/content/docs/reference/glossary.mdx create mode 100644 site/src/data/glossary.mjs diff --git a/site/src/components/Glossary.astro b/site/src/components/Glossary.astro new file mode 100644 index 000000000..7875d6770 --- /dev/null +++ b/site/src/components/Glossary.astro @@ -0,0 +1,262 @@ +--- +/** + * The glossary, rendered from src/data/glossary.mjs. + * + * The page used to be twelve five-column markdown tables. A table is the right + * shape for numbers and the wrong one for a sentence: measured on a phone, the + * definition column rendered 135 px wide while the one-word guide link took + * 256, and the row needed 775 px of horizontal scroll inside a 358 px screen. + * The definition is the content here, so each term is a block that gives the + * sentence the whole width, sets the notation and the unit above it, and puts + * the standard and the guide underneath as metadata. + * + * Everything comes from one array: the cards, the anchors, and the + * `DefinedTermSet` structured data this page has always published, which used + * to be a second hand-written copy of the same 120 entries in the frontmatter + * of each language. + * + * The link to a guide takes its text from that guide's own title, read from + * the content collection at build time, so a renamed page cannot leave a stale + * label behind: the old tables carried the title by hand and had gone stale in + * Spanish. A slug that resolves to no page is a build error rather than a + * dangling link, which is what the four-way copy could never notice. + */ +import { getCollection } from 'astro:content'; +import katex from 'katex'; +import { glossary } from '../data/glossary.mjs'; + +interface Props { + /** Locale of the page the glossary is rendered on. */ + lang?: 'en' | 'es'; +} +const { lang = 'en' } = Astro.props; + +const BASE = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, ''); +const SITE = 'https://jmrplens.github.io/phonometry'; + +/** A field that is either shared by both languages or given per language. */ +const localized = (value: string | Record | undefined) => + value === undefined ? undefined : typeof value === 'string' ? value : value[lang]; + +// Guide titles, by slug, in this locale. Starlight ids carry the locale prefix +// on the Spanish side and nothing on the English one. +const docs = await getCollection('docs'); +const titleOf = new Map( + docs.map((entry) => [entry.id.replace(/^es\//, ''), entry] as const) + .filter(([, entry]) => (lang === 'es') === entry.id.startsWith('es/')) + .map(([slug, entry]) => [slug, entry.data.title] as const), +); + +/** + * Inline maths in a notation string, as HTML. The notation is written the way + * the page reads it, `$L_{Aeq}$, SEL`, so the text between the delimiters is + * text and only the delimited runs are typeset. + */ +function notation(source: string): string { + return source + .split(/(\$[^$]+\$)/g) + .map((run) => + run.startsWith('$') && run.endsWith('$') + ? katex.renderToString(run.slice(1, -1), { throwOnError: false }) + : escape(run), + ) + .join(''); +} + +const escape = (text: string) => + text.replace(/&/g, '&').replace(//g, '>'); + +const terms = glossary.flatMap((group) => + group.terms.map((term) => { + const guide = term.guide; + const title = guide ? titleOf.get(guide) : undefined; + if (guide && !title) { + throw new Error( + `glossary: term "${term.id}" points at ${guide}, which is not a page in ${lang}`, + ); + } + return { + ...term, + group, + label: term.symbol ?? localized(term.name) ?? term.id, + unitText: localized(term.unit), + standardText: localized(term.standard), + clauseText: localized(term.clause), + qualifierText: localized(term.qualifier), + definitionText: localized(term.definition) as string, + href: guide ? `${BASE}${lang === 'es' ? '/es' : ''}/${guide}/` : undefined, + guideTitle: title, + }; + }), +); + +/** + * The notation as a machine reads it: the maths syntax removed, not just the + * delimiters, so `$L_{Aeq}$` is the "LAeq" a search engine can match against a + * query and not the TeX that produced it. + */ +const termCode = (label: string) => label.replace(/[$\\{}_^]/g, ''); + +const jsonld = { + '@context': 'https://schema.org', + '@type': 'DefinedTermSet', + '@id': `${SITE}${lang === 'es' ? '/es' : ''}/reference/glossary/#glossary`, + name: lang === 'es' ? 'Glosario acústico de phonometry' : 'phonometry acoustic glossary', + url: `${SITE}${lang === 'es' ? '/es' : ''}/reference/glossary/`, + inLanguage: lang, + hasDefinedTerm: terms.map((term) => { + const code = termCode(term.label); + const where = [term.standardText, term.clauseText].filter(Boolean).join(', '); + const unit = term.unitText ? ` ${lang === 'es' ? 'Unidad' : 'Unit'}: ${term.unitText}.` : ''; + return { + '@type': 'DefinedTerm', + '@id': `${SITE}${lang === 'es' ? '/es' : ''}/reference/glossary/#${term.id}`, + name: code, + termCode: code, + description: `${term.definitionText}${unit} ${lang === 'es' ? 'Definida en' : 'Defined in'}: ${where}.`, + inDefinedTermSet: { '@id': `${SITE}${lang === 'es' ? '/es' : ''}/reference/glossary/#glossary` }, + ...(term.href && term.guideTitle + ? { subjectOf: { '@type': 'TechArticle', name: term.guideTitle, url: `${SITE}${term.href.replace(BASE, '')}` } } + : {}), + }; + }), +}; + +const t = + lang === 'es' + ? { definedIn: 'Definida en', see: 'Guía' } + : { definedIn: 'Defined in', see: 'Guide' }; +--- + +