diff --git a/.github/reports/iso12354_detailed_airborne_example.pdf b/.github/reports/iso12354_detailed_airborne_example.pdf new file mode 100644 index 000000000..ff54e10b4 Binary files /dev/null and b/.github/reports/iso12354_detailed_airborne_example.pdf differ diff --git a/.github/reports/iso12354_detailed_airborne_example.webp b/.github/reports/iso12354_detailed_airborne_example.webp new file mode 100644 index 000000000..a8c8129c0 Binary files /dev/null and b/.github/reports/iso12354_detailed_airborne_example.webp differ diff --git a/.github/reports/iso12354_detailed_impact_example.pdf b/.github/reports/iso12354_detailed_impact_example.pdf new file mode 100644 index 000000000..c08127044 Binary files /dev/null and b/.github/reports/iso12354_detailed_impact_example.pdf differ diff --git a/.github/reports/iso12354_detailed_impact_example.webp b/.github/reports/iso12354_detailed_impact_example.webp new file mode 100644 index 000000000..7da6ec487 Binary files /dev/null and b/.github/reports/iso12354_detailed_impact_example.webp differ diff --git a/.github/reports/iso3745_precision_power_example.pdf b/.github/reports/iso3745_precision_power_example.pdf new file mode 100644 index 000000000..d185cb929 Binary files /dev/null and b/.github/reports/iso3745_precision_power_example.pdf differ diff --git a/.github/reports/iso3745_precision_power_example.webp b/.github/reports/iso3745_precision_power_example.webp new file mode 100644 index 000000000..0ee986cc7 Binary files /dev/null and b/.github/reports/iso3745_precision_power_example.webp differ 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/scripts/generate_reports.py b/scripts/generate_reports.py index c0e377c14..a793f28b2 100644 --- a/scripts/generate_reports.py +++ b/scripts/generate_reports.py @@ -38,6 +38,7 @@ os.environ.setdefault(_threads_var, "1") import argparse +import sys from collections.abc import Callable, Sequence from typing import Any @@ -46,6 +47,17 @@ import phonometry as ph from phonometry import ReportMetadata +# The ISO 12354 Annex L / Annex G building is assembled once, in tests/, and +# read from there by the tests and by scripts/conformance_report.py. The two +# detailed-prediction fiches below show that same building, so they read it +# from the same place rather than becoming a third transcription of a worked +# example whose inputs the registry already records two corrections to. +_TESTS = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "tests")) +if _TESTS not in sys.path: + sys.path.insert(0, _TESTS) + +import reference_data as ref + #: Committed output directory for the example fiches. _DEFAULT_DIR = os.path.normpath( os.path.join(os.path.dirname(__file__), "..", ".github", "reports") @@ -2238,6 +2250,154 @@ def _installed_structure_borne_example() -> tuple[object, ReportMetadata, str]: ) +def _detailed_building() -> tuple[dict[str, Any], np.ndarray, np.ndarray]: + """The Annex L building in situ, its bands and the floating-floor gain. + + Assembled from the same fixture the tests and the conformance report read, + so the two detailed fiches show the building those two already pin rather + than a transcription of it. + """ + import iso12354_building as bld + + bands = np.asarray(ref.ISO12354_ANNEX_L_BANDS, dtype=np.float64) + situ = {key: ph.in_situ_element(el, bands) for key, el in bld.elements().items()} + delta = ph.floating_floor_improvement( + bands, resonance_frequency=bld.floating_floor_resonance() + ) + return situ, bands, delta + + +def _detailed_airborne_example() -> tuple[object, ReportMetadata, str]: + """Detailed airborne fiche: the ISO 12354-1:2017 Annex L worked example. + + The building the two parts of the standard share: two dwellings one above + the other, a 220 mm concrete separating floor of 20 m2 carrying a floating + floor, two 365 mm autoclaved aerated concrete external walls and two 200 mm + calcium-silicate internal walls, joined by the eight Annex E junctions. + The per-band model of Clause 4.2 runs the direct path and the twelve + flanking paths band by band, and their energy summation rates to + R'w = 57 dB, the Annex L result. Two of the annex's printed inputs are + taken corrected rather than as printed, both registered in docs/ERRATA.md: + the Formula (C.1) perimeter sums, derived from Formula (C.4) with the + unrounded Annex E junction indices, and the external walls' internal loss + factor, 0,012 5 from the element specification rather than the 0,013 of the + input block. + """ + import iso12354_building as bld + + situ, bands, delta = _detailed_building() + result = ph.detailed_airborne_prediction( + bands, + direct_index=ph.direct_reduction_index( + situ["floor"].sound_reduction_index, delta_r_source=delta + ), + flanking_paths=bld.airborne_paths(situ, delta), + ) + metadata = ReportMetadata( + specimen="220 mm concrete separating floor with floating floor (Annex L)", + client="Example client", + area=20.0, + source_volume=50.0, + receiving_volume=50.0, + test_room="Dwelling above to dwelling below (example)", + measurement_standard="EN/ISO 12354-1", + test_date="2026-08-05", + laboratory="Phonometry reference example", + operator="phonometry", + report_id="EXAMPLE-12354-1-L", + notes=( + "Detailed per-band model (Clause 4.2) over the Annex L building: " + "365 mm AAC external walls, 200 mm calcium-silicate internal " + "walls, eight Annex E junctions, thirteen paths." + ), + requirement=52.0, + ) + return result, metadata, "iso12354_detailed_airborne_example.pdf" + + +def _detailed_impact_example() -> tuple[object, ReportMetadata, str]: + """Detailed impact fiche: the ISO 12354-2:2017 Annex G worked example. + + The airborne example's building, in the impact direction: the same 220 mm + concrete floor and floating floor excited by the tapping machine, the + per-band normalized impact level of the direct path reduced by the + floating floor's improvement, and the four flanking paths of Clause 4.2. + The energy summation rates to L'n,w = 41 dB, the Annex G result. + """ + import iso12354_building as bld + + situ, bands, delta = _detailed_building() + result = ph.detailed_impact_prediction( + bands, + direct_level=ph.direct_impact_level( + situ["floor"].impact_level, delta_l=delta + ), + flanking_paths=bld.impact_paths(situ, delta), + ) + metadata = ReportMetadata( + specimen="220 mm concrete floor with floating floor (Annex G)", + client="Example client", + area=20.0, + mass_per_area=484.0, + receiving_volume=50.0, + test_room="Dwelling above to dwelling below (example)", + measurement_standard="EN/ISO 12354-2", + test_date="2026-08-05", + laboratory="Phonometry reference example", + operator="phonometry", + report_id="EXAMPLE-12354-2-G", + notes=( + "Detailed per-band model (Clause 4.2) over the Annex L/G " + "building: direct path plus four flanking paths, floating floor " + "improvement from the resonance frequency of the annex." + ), + requirement=50.0, + ) + return result, metadata, "iso12354_detailed_impact_example.pdf" + + +def _precision_sound_power_example() -> tuple[object, ReportMetadata, str]: + """Precision sound-power fiche: ISO 3745:2012 in an anechoic room. + + The guide's own worked example: a mid-frequency-peaked machine measured + over the forty standardized hemisphere positions of Annex E at a radius of + 1 m (surface S = 2*pi*r^2 = 6,283 m2), with a base spectrum peaked near + 1 kHz and a 1 dB per-position spread from a seeded generator, so the fiche + and the guide print the same numbers. The determination gives an + A-weighted sound power level LWA = 89,3 dB(A) re 1 pW. The expanded + uncertainty is the Clause 10.5 example: the method's own sigma_omc = + 2,0 dB at k = 2, over the ISO 3745 Table 1 reproducibility standard + deviation of the band. + """ + freqs = np.array([125, 250, 500, 1000, 2000, 4000, 8000], dtype=float) + base = 70.0 + 8.0 * np.exp(-(np.log2(freqs / 1000.0) ** 2) / 2.0) + rng = np.random.default_rng(7) + levels = base[None, :] + rng.normal(0.0, 1.0, (40, freqs.size)) + result = ph.emission.sound_power_anechoic( + levels, + "hemisphere", + radius=1.0, + frequencies=freqs, + sigma_omc=2.0, + ) + metadata = ReportMetadata( + client="Example manufacturing plant", + specimen="Mid-frequency-peaked machine (guide example)", + test_room="Qualified anechoic room, 40-position hemisphere array", + measurement_standard="ISO 3745", + test_date="2026-08-05", + laboratory="Phonometry reference example", + operator="phonometry", + report_id="EXAMPLE-3745", + notes=( + "Precision grade: Annex E hemisphere array at r = 1 m, " + "meteorological corrections at the 23 C / 101,325 kPa reference, " + "expanded uncertainty from the Clause 10.5 example." + ), + ) + return result, metadata, "iso3745_precision_power_example.pdf" + + def _scattering_example() -> tuple[object, ReportMetadata, str]: """ISO 17497-1 fiche: a random-incidence scattering-coefficient measurement. @@ -3334,6 +3494,9 @@ def _duct_path_example() -> tuple[object, ReportMetadata, str]: "hvac_duct_noise_example.pdf": _hvac_example, "duct_path_example.pdf": _duct_path_example, "rd1367_activity_example.pdf": _rd1367_example, + "iso12354_detailed_airborne_example.pdf": _detailed_airborne_example, + "iso12354_detailed_impact_example.pdf": _detailed_impact_example, + "iso3745_precision_power_example.pdf": _precision_sound_power_example, } #: The registered factories alone, in generation order. diff --git a/site/.pa11yci.json b/site/.pa11yci.json index a6fcc49ce..1ac4df6b8 100644 --- a/site/.pa11yci.json +++ b/site/.pa11yci.json @@ -50,6 +50,8 @@ "http://localhost:4321/phonometry/es/reference/conformance/", "http://localhost:4321/phonometry/reference/errata/", "http://localhost:4321/phonometry/es/reference/errata/", + "http://localhost:4321/phonometry/reference/glossary/", + "http://localhost:4321/phonometry/es/reference/glossary/", "http://localhost:4321/phonometry/buildings/", "http://localhost:4321/phonometry/buildings/rooms/", "http://localhost:4321/phonometry/es/buildings/", 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/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/components/Glossary.astro b/site/src/components/Glossary.astro new file mode 100644 index 000000000..605227fc6 --- /dev/null +++ b/site/src/components/Glossary.astro @@ -0,0 +1,287 @@ +--- +/** + * 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'; +import { basePath, siteUrl } from '../data/site.mjs'; + +interface Props { + /** Locale of the page the glossary is rendered on. */ + lang?: 'en' | 'es'; +} +const { lang = 'en' } = Astro.props; + +// The two site-wide strings live in one place, which is where astro.config.mjs +// and the audit scripts read them from; a second copy here would be a second +// thing to remember on a move. +const BASE = basePath; +const SITE = `${siteUrl}${basePath}`; +/** The locale prefix this page's URLs carry, after the base. */ +const LOCALE = lang === 'es' ? '/es' : ''; + +/** 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, + // The path without the base: the href a browser follows carries it, the + // absolute URL in the structured data carries it too, and building both + // from the slug keeps neither from having to strip it back off. + path: guide ? `${LOCALE}/${guide}/` : undefined, + href: guide ? `${BASE}${LOCALE}/${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) => plain(label); + +/** + * A string 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 a definition carrying `$\mathrm{THD}_F$` reads as "THDF" rather + * than as the TeX that produced it. + */ +function plain(source: string): string { + return source + .replace(/\\(?:text|mathrm|mathit|mathbf|operatorname)\{([^}]*)\}/g, '$1') + .replace(/\\[a-zA-Z]+/g, '') + .replace(/[$\\{}_^]/g, ''); +} + +const jsonld = { + '@context': 'https://schema.org', + '@type': 'DefinedTermSet', + '@id': `${SITE}${LOCALE}/reference/glossary/#glossary`, + name: lang === 'es' ? 'Glosario acústico de phonometry' : 'phonometry acoustic glossary', + url: `${SITE}${LOCALE}/reference/glossary/`, + inLanguage: lang, + hasDefinedTerm: terms.map((term) => { + const code = termCode(term.label); + const where = [term.standardText, term.clauseText].filter(Boolean).map(plain).join(', '); + const unit = term.unitText + ? ` ${lang === 'es' ? 'Unidad' : 'Unit'}: ${plain(term.unitText)}.` + : ''; + return { + '@type': 'DefinedTerm', + '@id': `${SITE}${LOCALE}/reference/glossary/#${term.id}`, + name: code, + termCode: code, + description: `${plain(term.definitionText)}${unit} ${lang === 'es' ? 'Definida en' : 'Defined in'}: ${where}.`, + inDefinedTermSet: { '@id': `${SITE}${LOCALE}/reference/glossary/#glossary` }, + ...(term.path && term.guideTitle + ? { subjectOf: { '@type': 'TechArticle', name: term.guideTitle, url: `${SITE}${term.path}` } } + : {}), + }; + }), +}; + +const t = + lang === 'es' + ? { definedIn: 'Definida en', see: 'Guía' } + : { definedIn: 'Defined in', see: 'Guide' }; +--- + +