Skip to content
This documentation describes version 4.0.0, which is not released yet. The current version on PyPI is 3.3.0 and does not carry everything described here.

Your own catalogues

Standards: RFC 8259RFC 6901RFC 4180EN 29052ISO 12354

The library publishes the tables of its books and standards, and nothing a manufacturer prints. A data sheet is revised under the same title without notice, its terms rarely allow copying it, and the value a project needs is the one on the sheet the project was specified against, not the one on today’s revision. So the data sheets, declarations of performance, test reports and measurements of your own live in a catalogue file of yours, and phonometry.io reads that file into rows of the same classes every PUBLISHED_* catalogue hands out. A porous core from your sheet goes into the porous models as a porous material from a book does, a spectrum from your test report goes into a room model, and every lookup by name searches your catalogue, the published one or both at once.

The rows are yours and stay in your objects: the library keeps no copy, opens no link a file names and sends nothing anywhere. For the same reason a manufacturer’s table pasted into an issue or a pull request is never merged into the library.

The quickest way to see the shape of a file is to write one. write_catalogue writes any published table in the form read_catalogue reads, as a template to copy and edit: here the foam of Allard & Atalla’s Table 13.1.

import json
import pathlib
from phonometry import io, materials
allard = {
key: row
for key, row in materials.PUBLISHED_POROUS.items()
if row.table == "allard-2009-table-13-1"
}
written = io.write_catalogue(allard, "template.json", catalogue="template-allard")
print([path.name for path in written]) # ['template.json']
template = json.loads(pathlib.Path("template.json").read_text(encoding="utf-8"))
print(list(template))
# ['schema', 'schema_version', 'catalogue', 'row_type', 'about', 'provenance',
# 'phonometry_version', 'rows']
print(template["provenance"]["kind"]) # publication
print(template["rows"][0]["key"], template["rows"][0]["flow_resistivity_pa_s_m2"])
# foam 12569.0

A table of rows of one class goes out whole, with the book as its provenance and the day of the export as the day it was consulted. What the library works out from the printed cells (here the shear modulus, from the Young’s modulus and the Poisson ratio) is never written: it is worked out again when the file is read. A file already at the path is refused with FileExistsError unless you pass overwrite=True.

A catalogue file is one JSON object, UTF-8, with a closed set of keys at the top:

KeyWhat it holds
schema, schema_version"phonometry-catalogue" and 1. A newer version is refused with “upgrade phonometry”.
catalogueThe catalogue’s name, the first half of every key: up to 64 of a-z, 0-9, ., _ and -.
row_typeThe name of the row class. It is compared with the class you pass and never imported.
aboutWhat the document is, how you read it and what units it prints.
provenanceThe document: its kind, its title in document, its version (or null when it prints none) and the day you consulted it, as YYYY-MM-DD, are required. The kind is one of io.PROVENANCE_KINDS: datasheet, declaration_of_performance, test_report, measurement, calculation, publication or other.
basisOptional: what the document says every value is, unless a row says otherwise.
attributed_toOptional: who the whole table is credited to, as one text. Every row holds it as its "table" credit, and no row credits the table again.
conventionsOptional: the notes and legends the document prints for the whole table.
phonometry_versionOptional: the version of the library that wrote the file, which write_catalogue fills in. It is never read.
$schemaOptional: where an editor finds the document’s JSON Schema (see Completion in your editor). It is never read.
rowsThe rows, each with a key of its own and a name.

Here is a fictitious data sheet with two rows. The first is the airflow resistivity the sheet declares under its CE marking, as a lower bound; the second is the laboratory characterisation page 3 quotes, from its own report.

panel_40 = """{
"schema": "phonometry-catalogue",
"schema_version": 1,
"catalogue": "panel-40",
"row_type": "PorousMaterial",
"about": "Core of the Panel 40 absorber as its data sheet gives it. Page 2 declares the airflow resistivity class under CE marking; page 3 quotes the laboratory characterisation of a 40 mm specimen.",
"provenance": {
"kind": "datasheet",
"document": "Panel 40 technical data sheet",
"publisher": "Example Acoustics Ltd",
"version": "Rev. 4",
"issued": "2026-03",
"consulted": "2026-09-23",
"page": "2"
},
"basis": "declared",
"rows": [
{
"key": "core-declared",
"name": "Panel 40 core",
"variant": "as declared",
"thickness_mm": 40,
"frame_density_kg_m3": 40,
"ranges": {"flow_resistivity_kpa_s_m2": [5, null]},
"bounded_below": ["flow_resistivity_kpa_s_m2"],
"note": "the sheet prints the designation code AFr5"
},
{
"key": "core-lab",
"name": "Panel 40 core",
"variant": "40 mm specimen",
"provenance": {
"page": "3",
"printed_table": "Table 2",
"laboratory": "Example Lab",
"report": "26-014",
"test_date": "2025-11-04",
"test_standard": "ISO 9053-1:2018"
},
"basis": {"row": "measured", "poisson_ratio": "estimated"},
"flow_resistivity_pa_s_m2": 12500,
"uncertainty": {"flow_resistivity_pa_s_m2": 900},
"porosity": 0.97,
"tortuosity": 1.02,
"approximate": ["tortuosity"],
"viscous_length_um": 95,
"thermal_length_um": 190,
"frame_density_kg_m3": 40,
"thickness_mm": 40,
"youngs_modulus_pa": 140000,
"poisson_ratio": 0.0,
"unquantified": {"structural_loss_factor": "n.m."},
"x-product-code": "P40-C"
}
]
}"""
pathlib.Path("panel-40.json").write_text(panel_40, encoding="utf-8")
mine = io.read_catalogue("panel-40.json", row_type=materials.PorousMaterial)
print(list(mine)) # ['panel-40/core-declared', 'panel-40/core-lab']
declared = mine["panel-40/core-declared"]
lab = mine["panel-40/core-lab"]
print(lab.source)
# Panel 40 technical data sheet (Example Acoustics Ltd), Rev. 4, p. 3, Table 2;
# report 26-014 (Example Lab); consulted 2026-09-23
print(lab.basis_of("porosity"), lab.basis_of("poisson_ratio")) # measured estimated
print(declared.basis_of("frame_density_kg_m3")) # declared
print(declared.ranges, declared.converted)
# {'flow_resistivity_pa_s_m2': (5000.0, None)}
# {'flow_resistivity_pa_s_m2': ('5', 'kPa s/m2')}
print(mine.extras["panel-40/core-lab"]["x-product-code"]) # P40-C

Every key is "<catalogue>/<key>", the form every published catalogue uses, and every row’s table is the catalogue’s name. A catalogue’s name can never take the form of a packaged table’s, a four-digit year followed by a word (allard-2009-table-13-1), so a key of yours and a key of the library never meet, today or after an update adds a table: acme-2026-rev4 is refused and acme-rev4-2026 is not.

A row may narrow the document’s provenance to its own page, table, laboratory, accreditation, report, test date and standard, as core-lab does, and a standard given for one field (field_test_standards) is added to the document’s. A different document is a different file: a row cannot change the document, its kind or its version. For every kind but a publication, which is cited as its document is, the row’s source names the document, its publisher and its version, the page and the table, the report and the laboratory, and the day it was consulted; the accreditation, the test date and the standards stay in row.provenance. A refusal names the document by its kind: “the datasheet”, “the declaration of performance”, “the test report”, “the measurement record” for a measurement, “the calculation note” for a calculation, and “the source” for a publication or other.

Columns of your own go under a name that starts with x-: they are kept, as text, in Catalogue.extras and take part in no calculation. A row never writes derived, table, source or estimated: the first three are the library’s to write, and an estimate is a basis.

Every problem of form in a file is reported at once, in one io.CatalogueError, each problem an io.CatalogueIssue with the file, its place as a JSON pointer, the row and the field. Here the porosity is written with a decimal comma and the thickness in inches, a unit the reader does not convert:

broken = panel_40.replace('"porosity": 0.97', '"porosity": "0,97"')
broken = broken.replace('"thickness_mm": 40,\n "youngs', '"thickness_inch": 1.6,\n "youngs')
try:
io.parse_catalogue(broken, row_type=materials.PorousMaterial, label="panel-40.json")
except io.CatalogueError as error:
for issue in error.issues:
print(issue)
# panel-40.json: /rows/1/porosity (row 'core-lab'): expected a number, got the
# text '0,97'; JSON numbers use a decimal point
# panel-40.json: /rows/1/thickness_inch (row 'core-lab'): 'inch' is not a unit
# this reader converts; write thickness_mm (mm), thickness_cm (cm),
# thickness_m (m) or thickness_um (µm)

parse_catalogue reads the same from text or from a dictionary already in memory, with label naming the document in every issue. A misspelt field is answered with the field it is most like; a row that is written correctly but breaks the contract every row keeps (a negative density, a porosity above 1, a value beside a word that says there is none) is reported with the first rule it breaks. The reader refuses a NaN or an Infinity, a name written twice in one object, a control character or a mark that reorders text, anything but a regular file at the name of a catalogue or of its header (a pipe, a device, a socket or a directory, refused before it is opened), a file over 16 MiB (checked before a byte of it is read), the header of a CSV file over 64 KiB, more than 50 000 rows and nesting past six levels.

What is only worth a second look is not an error. A value whose basis is measured with neither a report nor a laboratory named for it, or a word in place of a number long enough to be a sentence, is kept as the file writes it, listed in Catalogue.notes, and announced once with an io.CatalogueWarning. A note never changes a row.

A row read from a file is the same class, built the same way, as a published one: the same methods take it, and the same functions accept what they return.

printed() gives one number the document prints, or refuses with what the document has there instead. That is the way to hand a cell to any function that takes a float. PorousMaterial.medium() reads its five parameters the same way, so the laboratory row goes into the transfer-matrix model and the declared row is refused in the sheet’s own terms:

import numpy as np
print(lab.printed("flow_resistivity_pa_s_m2")) # 12500.0
frequencies = np.array([250.0, 500.0, 1000.0, 2000.0])
core = materials.PorousLayer(thickness=0.040, medium=lab.medium(frequencies))
print(materials.layered_absorber(frequencies, [core]).absorption.round(2))
# [0.15 0.4 0.73 0.98]
try:
declared.medium(frequencies)
except ValueError as error:
print(error)
# 'Panel 40 core' has no flow_resistivity_pa_s_m2, which 'johnson_champoux_allard'
# needs: the datasheet prints a lower bound of 5 kPa s/m2 (5000 Pa s/m2) and no
# value (Panel 40 technical data sheet (Example Acoustics Ltd), Rev. 4, p. 2;
# consulted 2026-09-23).

The refusal quotes the figure and the unit the sheet prints, then the value the row holds: a lower bound of AFr5 is not a flow resistivity of 5000 Pa·s/m², and nothing here pretends it is.

A row that prints one value per band reads at the frequencies a model asks for with values_at(), which refuses a band the document does not print rather than reading it as zero. The functions that take their coefficients by position, such as room.sabine_reverberation_time, get an array whose every entry was printed:

from phonometry import room
tiles = io.parse_catalogue(
{
"schema": "phonometry-catalogue",
"schema_version": 1,
"catalogue": "ceiling-tiles",
"row_type": "AbsorptionSpectrum",
"about": "Octave-band Sabine coefficients a fictitious data sheet prints.",
"provenance": {
"kind": "datasheet",
"document": "Example tile data sheet",
"publisher": "Example Acoustics Ltd",
"version": "Rev. 1",
"consulted": "2026-09-25",
"laboratory": "Example Lab",
"report": "26-031",
"test_standard": "ASTM C423-17",
},
"basis": "measured",
"rows": [
{
"key": "tile-e400",
"name": "Example ceiling tile",
"mounting": "E400",
"absorption_coefficient_125": 0.45,
"absorption_coefficient_250": 0.62,
"absorption_coefficient_500": 0.78,
"absorption_coefficient_1000": 0.90,
"absorption_coefficient_2000": 0.94,
"absorption_coefficient_4000": 0.91,
}
],
},
row_type=materials.AbsorptionSpectrum,
)
tile = tiles["ceiling-tiles/tile-e400"]
bands = np.array([125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0])
t60 = room.sabine_reverberation_time(180.0, [(60.0, tile.values_at(bands)), (190.0, 0.04)])
print(t60.round(2)) # [0.84 0.65 0.53 0.47 0.45 0.47]
try:
tile.values_at([63.0, 125.0])
except ValueError as error:
print(error)
# 'Example ceiling tile' has no absorption_coefficient_63, which 'the 63 Hz band'
# needs: the datasheet does not give it, and it does not follow from the cells
# that it does (Example tile data sheet (Example Acoustics Ltd), Rev. 1;
# report 26-031 (Example Lab); consulted 2026-09-25).

building.plateau_transmission_loss takes a PlateauMaterial row as its material, one of the book’s or one of yours, and reads its three numbers through printed(). A number you pass explicitly replaces the row’s, which is then not read at all.

from phonometry import building, solids
boards = io.parse_catalogue(
{
"schema": "phonometry-catalogue",
"schema_version": 1,
"catalogue": "boards",
"row_type": "PlateauMaterial",
"about": "Plateau-method constants of a fictitious board, as its data sheet prints them.",
"provenance": {
"kind": "datasheet",
"document": "Example board data sheet",
"version": "Rev. 2",
"consulted": "2026-09-25",
},
"rows": [
{
"key": "board",
"name": "Example board",
"surface_density_per_mm_kg_m2": 0.9,
"coincidence_height_db": 28,
"plateau_frequency_ratio": 7,
}
],
},
row_type=solids.PlateauMaterial,
)
board = boards["boards/board"]
tl = building.plateau_transmission_loss(bands, material=board, thickness_mm=12.5)
print(round(tl.plateau_start), round(tl.plateau_end)) # 522 3656
print(tl.transmission_loss.round(1)) # [15.6 21.6 27.6 28. 28. 29.3]

A resilient layer’s data sheet prints one of two different quantities. The dynamic stiffness of the installed layer is what the natural frequency of a floating floor takes; the apparent dynamic stiffness is what EN 29052-1 measures on a test specimen, and it becomes only through the lateral airflow resistivity of clause 8.2. ResilientLayer keeps them in two fields, dynamic_stiffness_n_m3 and apparent_dynamic_stiffness_n_m3, and a declared “s’ ≤ 9 MN/m³” is a bound, not a stiffness:

layers = io.parse_catalogue(
{
"schema": "phonometry-catalogue",
"schema_version": 1,
"catalogue": "underlays",
"row_type": "ResilientLayer",
"about": "Two underlays of a fictitious data sheet, as it prints them.",
"provenance": {
"kind": "datasheet",
"document": "Example underlay data sheet",
"publisher": "Example Acoustics Ltd",
"version": None,
"consulted": "2026-09-25",
},
"basis": "declared",
"rows": [
{
"key": "declared",
"name": "Underlay 8",
"thickness_mm": 8,
"ranges": {"dynamic_stiffness_mn_m3": [None, 9]},
"bounded_above": ["dynamic_stiffness_mn_m3"],
},
{
"key": "tested",
"name": "Underlay 20",
"thickness_mm": 20,
"apparent_dynamic_stiffness_mn_m3": 7,
"basis": {"row": "measured"},
"provenance": {"laboratory": "Example Lab", "report": "26-044"},
},
],
},
row_type=materials.ResilientLayer,
)
try:
layers["underlays/declared"].natural_frequency(mass_per_area_kg_m2=100.0)
except ValueError as error:
print(error)
# 'Underlay 8' has no dynamic_stiffness_n_m3, which 'the natural frequency of
# EN 29052-1 Formula 2' needs: the datasheet prints an upper bound of 9 MN/m3
# (9000000 N/m3) and no value (Example underlay data sheet (Example Acoustics
# Ltd), no version printed; consulted 2026-09-25).
tested = layers["underlays/tested"]
f0 = tested.natural_frequency(mass_per_area_kg_m2=100.0, airflow_resistivity_pa_s_m2=150_000.0)
print(round(float(f0), 1)) # 42.1

The tested layer gives only , so its natural frequency needs the lateral airflow resistivity of clause 8.2: at or above 100 kPa·s/m², is , and below it the call needs the enclosed-gas stiffness of Formula 7 as well, which gas_stiffness_n_m3 passes. Without the call is refused with that explanation rather than taking one quantity for the other.

The elastic simulation takes a solid’s row wherever it takes a Material: simulation.scholte_speed and ElasticFDTD2D.from_regions read the three numbers the solver integrates, the bulk longitudinal speed, the transverse speed and the density. A sheet that prints a modulus, a Poisson ratio and a density has printed those speeds too, and the row holds them marked as derived. The plate and bar speeds a page prints beside them are never read: the solver integrates neither, and a plate speed taken for the bulk one is some ten per cent wrong.

from phonometry import simulation, solids
alloys = io.parse_catalogue(
{
"schema": "phonometry-catalogue",
"schema_version": 1,
"catalogue": "alloys",
"row_type": "SolidMaterial",
"about": "Elastic constants of two fictitious alloys, as their data sheet prints them.",
"provenance": {
"kind": "datasheet",
"document": "Example alloy data sheet",
"version": "Rev. 1",
"consulted": "2026-09-25",
},
"rows": [
{
"key": "plate",
"name": "Example alloy",
"youngs_modulus_gpa": 70,
"poisson_ratio": 0.33,
"density_kg_m3": 2700,
},
{
"key": "sheet",
"name": "Example sheet",
"plate_longitudinal_speed_m_s": 5400,
"density_kg_m3": 2700,
},
],
},
row_type=solids.SolidMaterial,
)
alloy = alloys["alloys/plate"]
print(round(alloy.bulk_longitudinal_speed_m_s), round(alloy.transverse_speed_m_s))
# 6198 3122
print(round(simulation.scholte_speed(simulation.WATER, alloy), 1)) # 1476.6
try:
simulation.scholte_speed(simulation.WATER, alloys["alloys/sheet"])
except ValueError as error:
print(error)
# 'Example sheet' has no bulk_longitudinal_speed_m_s, which 'the elastic FDTD'
# needs: the datasheet does not give it, and it does not follow from the cells
# that it does (Example alloy data sheet, Rev. 1; consulted 2026-09-25).

A GroundSurface row, one of the published grounds or one of yours, goes into the outdoor models through medium(): the porous half-space, by the Delany and Bazley or the Miki model, that environment.ground_effect takes as impedance, barrier_insertion_loss as ground_impedance and atmospheric_parabolic_equation as impedance. The resistivity is read through printed(), so a range of grassland is refused rather than averaged. An effective flow resistivity is the parameter of the model it was fitted with, so a row the page marks as fitted with one model goes into that model only:

from phonometry import environment
site = io.parse_catalogue(
{
"schema": "phonometry-catalogue",
"schema_version": 1,
"catalogue": "site-survey",
"row_type": "GroundSurface",
"about": "An effective flow resistivity fitted to a site's own level differences.",
"provenance": {
"kind": "measurement",
"document": "Site survey 12",
"version": None,
"consulted": "2026-09-25",
"laboratory": "Example Lab",
"report": "26-051",
},
"basis": "measured",
"rows": [{"key": "pasture", "name": "Pasture", "flow_resistivity_kpa_s_m2": 180}],
},
row_type=environment.GroundSurface,
)
pasture = site["site-survey/pasture"]
octaves = np.array([125.0, 250.0, 500.0, 1000.0])
ground = pasture.medium(octaves, model="miki")
over_pasture = environment.ground_effect(octaves, 1.0, 1.5, 50.0, impedance=ground)
print(over_pasture.excess_attenuation.round(1)) # [ 3.6 -4.2 -12.8 -2.8]
fitted = environment.PUBLISHED_GROUND["cox-2017-table-6-7/sports_field_semi_phenomenological"]
try:
fitted.medium(octaves)
except ValueError as error:
print(error)
# 'Sports field' is the row the page marks “Fitted using semi-phenomenological
# model” (Cox & D'Antonio 3e Table 6.7, PDF pages 257-258 (printed pp.
# 200-201)): its effective flow resistivity is a parameter of that model and
# not of 'delany_bazley'; neither 'delany_bazley' nor 'miki' is that model, or
# take row.printed('flow_resistivity_pa_s_m2') into another model yourself.

An outdoor ground is far more resistive than the fibrous materials the two models were fitted to, so every octave here lies below their published fit range, and the call raises a PorousAbsorberWarning saying the regression is an extrapolation there; Choosing the ground impedance explains what that costs and why Miki is the model to prefer below the range.

building.HomogeneousElement.from_solid builds the element of ISO 12354-1 a layer of a solid makes, from the row’s density and plate speed, which is the quasi-longitudinal phase velocity of the standard’s Table B.3, and the thickness you give: and . The internal loss factor is yours to give and never read from the row, which may hold four (flexural, longitudinal, in situ and one the page does not qualify), because only you know which of them, if any, is the of the standard:

from phonometry import building
blocks = io.parse_catalogue(
{
"schema": "phonometry-catalogue",
"schema_version": 1,
"catalogue": "blocks",
"row_type": "SolidMaterial",
"about": "A fictitious calcium-silicate block, as its data sheet prints it.",
"provenance": {
"kind": "datasheet",
"document": "Example block data sheet",
"version": "Rev. 3",
"consulted": "2026-09-25",
},
"basis": "declared",
"rows": [
{
"key": "block",
"name": "Example block",
"density_kg_m3": 1800,
"plate_longitudinal_speed_m_s": 2500,
"flexural_loss_factor": 0.012,
}
],
},
row_type=solids.SolidMaterial,
)
wall = building.HomogeneousElement.from_solid(
blocks["blocks/block"],
thickness_m=0.2,
internal_loss_factor=0.01,
area_m2=13.75,
length1_m=5.0,
length2_m=2.75,
)
print(round(wall.mass_per_area, 1), round(wall.critical_frequency, 1)) # 360.0 128.4
print(wall.internal_loss_factor) # 0.01, not the row's flexural 0.012

These are the numbers of the internal walls of the worked building of ISO 12354-1 Annex L, 200 mm of calcium-silicate blocks at 360 kg/m² and 128.4 Hz, and the element goes into building.in_situ_element as one built by hand does.

Searching your catalogue and the published ones

Section titled “Searching your catalogue and the published ones”

Every *_named lookup takes catalogue=, the rows to search in place of its own PUBLISHED_*. PUBLISHED_POROUS | mine joins the two into one read-only mapping that refuses a key both hold, so one search answers from both:

both = materials.PUBLISHED_POROUS | mine
for row in materials.porous_materials_named("panel 40 core", catalogue=both):
print(row.table, row.variant)
# panel-40 as declared
# panel-40 40 mm specimen
print(len(materials.porous_materials_named("Foam", catalogue=both))) # 5

Each lookup keeps its own way of matching a name, answers every match in the order the catalogue holds them and never picks one. It matches without regard to case or to how an accented letter is stored: a name pasted from a document that stores “é” as “e” followed by a combining accent finds the row typed with “é”, and the other way round. A row of another class in the catalogue is refused with TypeError, naming its key.

A document prints more than numbers, and a file writes each thing as what it is, in the same hedges the published tables use. The subject of each sentence below is the document’s kind: “the datasheet”, “the test report”.

The document printsWritten in the file aswhy_missing answers
a numberthe field, in its unit or another of the same kindnothing: the value is there
nothingthe field left out, or null“the datasheet does not give it, and it does not follow from the cells that it does”
~0.85the value, and the field in "approximate"nothing
≤ 30 or < 30"ranges": {"f": [null, 30]} and "bounded_above": ["f"]“the datasheet prints an upper bound of 30 and no value”
≥ 5 or > 5"ranges": {"f": [5, null]} and "bounded_below": ["f"]”… a lower bound of 5 and no value”
0.30 to 0.50"ranges": {"f": [0.30, 0.50]}“the datasheet prints 0.3 to 0.5 and no value”
several readings and no single value"reported": {"f": [0.30, 0.35, [0.30, 0.40]]}“the datasheet lists 0.3, 0.35, 0.3 to 0.4 and no single value”
0.85 ± 0.05the value, and "uncertainty": {"f": 0.05}nothing
a word or a code where the number goes"unquantified": {"f": "AFr5"}“the datasheet prints “AFr5” where the number would be”
a value you know is wrong"misprinted": {"f": "why"}, with no valueyour reason, as you wrote it
a value the arithmetic reaches but should not"not_derivable": {"f": "why"}your reason
a blank cell the document gives by reference to another rowthe value, and "carried": {"f": "from the row above"}nothing
a figure in a unit no family holdsthe value in the field’s own unit, and "converted": {"f": ["3e5", "psi"]}nothing, and a bound quotes the figure first
a credit to a laboratory or an author"attributed_to": {"f": "..."}, or "row", or "table"; or the table’s once, as the document’s own attributed_tonot asked
a yes or a notrue or false, in a field that holds onenot asked

A bound declared under a CE marking, AFr5 for an airflow resistivity of at least 5 kPa·s/m², is written as the bound, with the code in note, as core-declared does. Written as the number 5, it would claim a measurement the sheet does not make.

A cell may be written in the unit its field is named for, or in another unit of the same kind, under the same name with the other unit’s suffix: thickness_cm for thickness_mm. The figure is converted on the digits the file writes, with the exact factor, and rounded once, so 0.067 GPa is 67 000 000 Pa, which the product of two floats is not. converted keeps the figure and the unit exactly as the file writes them.

KindSuffixes
Length_um, _mm, _cm, _m
Mass per area_g_m2, _kg_m2
Density_g_cm3, _kg_m3
Pressure and modulus_pa, _kpa, _mpa (megapascal), _n_mm2, _gpa
Stiffness per area_n_m3, _mn_m3
Flow resistivity_pa_s_m2, _kpa_s_m2
Temperature_c, _k, moved by its offset in a value, a range and a reading, and by the factor alone in a plus-or-minus
Absorption area_m2, _ft2, _ft2_per_1000_ft3, only in AbsorptionAreaSpectrum, and the last only with per beside it

The unit is read by the longest match against every unit the library’s fields use, so a compound unit is never mistaken for a length: a specific flow resistance in Pa·s/m or a rate per metre takes no other unit, and a name two fields of one kind could take is refused as ambiguous. A unit the table does not hold is refused with the spellings the field takes. Every number of one cell is written in one unit.

A data sheet is more often typed into a spreadsheet than into a JSON file, and a spreadsheet saves CSV. A catalogue can be one: the rows go in the CSV file, each starting on a line of its own under a first line that names the columns, and the document without its rows goes in a JSON header beside it, named as the CSV file with .phonometry.json after it. The header declares how the file writes its cells, "csv": {"delimiter": ";", "decimal": ","} for a spreadsheet set up for a decimal comma, and nothing about the dialect is guessed from the file. write_catalogue writes the pair when the name ends in .csv, so a published table is again the quickest template, here the absorption coefficients of Tabla 6.1 of Arau-Puchades (1999):

arau = {
key: row
for key, row in materials.PUBLISHED_ABSORPTION.items()
if row.table == "arau-1999-table-6-1"
}
written = io.write_catalogue(
arau, "template.csv", catalogue="template-arau", delimiter=";", decimal=","
)
print([path.name for path in written])
# ['template.csv', 'template.csv.phonometry.json']
lines = pathlib.Path("template.csv").read_text(encoding="utf-8-sig").splitlines()
print(lines[0].split(";")[:5])
# ['key', 'name', 'variant', 'note', 'absorption_coefficient_125']
print(lines[1].split(";")[:5])
# ['01_pared_de_ladrillo', 'Pared de ladrillo', '', '', '0,025']
header = json.loads(pathlib.Path("template.csv.phonometry.json").read_text("utf-8"))
print(header["csv"]) # {'delimiter': ';', 'decimal': ','}

Open the file in a spreadsheet, edit it, and save it as “CSV UTF-8”. The reader takes UTF-8 with or without the byte order mark a spreadsheet writes there, and refuses any other encoding with that advice. Keep the header beside the file, with the delimiter and the decimal mark the spreadsheet saved with. The writer puts an apostrophe before a text a spreadsheet would run as a formula (one that starts with =, +, -, @, a tab or a carriage return), and the reader takes it off again. A CSV file and its header are two files: each is written whole, but not the two as one.

Each numeric cell holds one thing, in a closed grammar that writes the same hedges as the JSON document:

The cellWhat it says
emptythe document prints nothing there
0,85a value, with the decimal mark the header declares
~0,85an approximate value
<=30, <30 or ≤30an upper bound and no value
>=5, >5 or ≥5a lower bound and no value
0,30..0,50a range and no value; ~0,30..0,50 if it is approximate
0,85±0,05 or 0,85+/-0,05a value and its plus-or-minus
[AFr5]what the document prints where the number would be
true or falsea yes or a no, in a column that holds one

A number never carries a thousands separator, and its minus sign may be the typographic one. The columns are key, the fields of the row class (in the unit each is named for, or in another of the same kind, as flow_resistivity_kpa_s_m2), basis for the row as a whole (an empty cell takes the document’s), the page, the table, the laboratory, the accreditation, the report, the test date and the standard of the row as provenance.page, provenance.printed_table, provenance.laboratory, provenance.accreditation, provenance.report, provenance.test_date and provenance.test_standard, the credit of the row as a whole as attributed_to.row, and columns of your own named x-.... The credit of the whole table goes once in the header, as "attributed_to": "...", as in a JSON document. A fictitious tile range typed into a sheet:

header = {
"schema": "phonometry-catalogue",
"schema_version": 1,
"catalogue": "tile-range",
"row_type": "AbsorptionSpectrum",
"about": "Octave-band Sabine coefficients of a fictitious tile range, typed from its data sheet.",
"provenance": {
"kind": "datasheet",
"document": "Example tile range data sheet",
"publisher": "Example Acoustics Ltd",
"version": "Rev. 2",
"consulted": "2026-09-25",
"laboratory": "Example Lab",
},
"basis": "measured",
"csv": {"delimiter": ";", "decimal": ","},
}
pathlib.Path("tiles.csv.phonometry.json").write_text(json.dumps(header), encoding="utf-8")
pathlib.Path("tiles.csv").write_text(
"key;name;mounting;absorption_coefficient_125;absorption_coefficient_250;"
"absorption_coefficient_500;absorption_coefficient_1000;"
"absorption_coefficient_2000;absorption_coefficient_4000;provenance.report;x-code\n"
"e400;Tile 15;E400;0,45;~0,62;0,78±0,03;0,90;0,94;0,91;26-031;T15\n"
"a;Tile 15;A;[n.m.];0,25..0,30;0,55;0,80;>=0,85;0,80;26-032;T15\n",
encoding="utf-8",
)
sheet = io.read_catalogue("tiles.csv", row_type=materials.AbsorptionSpectrum)
e400, mounted_a = sheet["tile-range/e400"], sheet["tile-range/a"]
print(e400.absorption_coefficient_1000, e400.approximate, e400.uncertainty)
# 0.9 frozenset({'absorption_coefficient_250'}) {'absorption_coefficient_500': 0.03}
print(mounted_a.ranges)
# {'absorption_coefficient_250': (0.25, 0.3), 'absorption_coefficient_2000': (0.85, None)}
print(mounted_a.why_missing("absorption_coefficient_2000"))
# the datasheet prints a lower bound of 0.85 and no value
print(mounted_a.source)
# Example tile range data sheet (Example Acoustics Ltd), Rev. 2; report 26-032
# (Example Lab); consulted 2026-09-25
print(sheet.extras["tile-range/a"]["x-code"]) # T15

The rows go through the same checks as a JSON document’s, and every problem is reported at once at its line and its column, lettered as a spreadsheet letters them. A column the reader does not know is answered with the name most like it, and a text where a number goes is never read as a word, a NaN or a zero:

pathlib.Path("tiles.csv").write_text(
"key;name;absorption_coefficent_500;absorption_coefficient_1000\n"
"e400;Tile 15;0,78;0.^G\n",
encoding="utf-8",
)
try:
io.read_catalogue("tiles.csv", row_type=materials.AbsorptionSpectrum)
except io.CatalogueError as error:
for issue in error.issues:
print(issue)
# tiles.csv, line 1, column C: no field 'absorption_coefficent_500' on
# AbsorptionSpectrum; did you mean 'absorption_coefficient_500'?
# tiles.csv, line 2, column D (absorption_coefficient_1000), row 'e400': '0.^G'
# is not a number; if the datasheet prints this text where the number would
# be, write it as [0.^G]

0.^G is what a PDF’s broken text layer can leave behind: if the page prints a word there, it goes between brackets; if it prints a number, type the number. A range typed with a dash, a plus-or-minus typed as +-, a unit typed after the number and a number grouped in thousands are each told how to write them. When every number that fails is written with the other decimal mark (a number grouped in thousands, which could be either, is not counted), or the first line splits into columns at another delimiter, the refusal also says which one to declare.

A credit travels in the file as well: write_catalogue puts the credit of a row in its attributed_to.row column, and the credit every row gives the whole table in the header. Table 6.2 of Bies credits its first row to Beranek and Hidaka (1998):

bies = {
key: row
for key, row in materials.PUBLISHED_ABSORPTION.items()
if row.table == "bies-2017-table-6-2"
}
io.write_catalogue(bies, "bies.csv", catalogue="bies")
lines = pathlib.Path("bies.csv").read_text(encoding="utf-8-sig").splitlines()
print(lines[0].split(",")[-1]) # attributed_to.row
back = io.read_catalogue("bies.csv", row_type=materials.AbsorptionSpectrum)
print(back["bies/unoccupied_heavily_upholstered_seats"].attributed_to)
# {'row': 'Beranek and Hidaka (1998)'}

What one cell cannot say is written in a JSON document instead: several readings with no single value, a misprint, a value the arithmetic reaches but should not, a cell carried from another row, a figure in a unit no family holds, a credit, a basis or a standard for a single cell, a bound or a range with a plus-or-minus, an approximate bound, an empty text where the field’s default says something, and a table whose rows credit it apart. write_catalogue refuses to write such a table as CSV, names every cell it cannot hold by the pointer the JSON document would write it at, and writes nothing. Table A2 of Hopkins prints the source of three cells of its steel row inside the cell itself, as “5270 (Fahy, 1985)” for the plate speed: a credit for one cell, so that row goes to JSON only:

from phonometry import solids
steel = "hopkins-2007-table-a2/steel"
try:
io.write_catalogue(
{steel: solids.PUBLISHED_SOLIDS[steel]}, "steel.csv", catalogue="hopkins"
)
except io.CatalogueError as error:
print(error.issues[0].location)
# /rows/0/attributed_to/plate_longitudinal_speed_m_s
print(pathlib.Path("steel.csv").exists()) # False

A data sheet and a book can print quantities that look alike and are not. The row classes keep them apart, and the file cannot keep you from writing one into another’s field, so read this before choosing a row_type.

  • AbsorptionSpectrum holds Sabine absorption coefficients in octave bands from 63 Hz to 8 kHz, as a book’s table prints them or as a data sheet prints the result of a reverberation-room test in octaves. The practical sound absorption coefficient of ISO 11654 is not one of them: it is a rating, rounded to steps of 0.05 and capped at 1.00, and a European data sheet’s octave row is usually that. PracticalAbsorptionSpectrum holds and ThirdOctaveAbsorptionSpectrum a test report’s one-third-octave , each with the rating the sheet prints; do not write either into AbsorptionSpectrum. Reading a material datasheet tells the three apart on the page.
  • TransmissionLossSpectrum holds the transmission loss of a construction as a book compiles it, in octave bands, from tests the book seldom names: Bies qualifies his values as field incidence, ASHRAE’s rows come from laboratory tests, and Rossing names no source. The sound reduction index of a test report under ISO 10140-2 is measured in one-third-octave bands on one product’s specimen, in a laboratory where the sound transmitted by flanking paths has been shown to be negligible, and it is rated with ISO 717-1. It is a different record: SoundReductionSpectrum holds it, with the single numbers the report prints, and ImpactImprovementSpectrum the reduction of impact level of a covering; do not write either into TransmissionLossSpectrum.
  • ResilientLayer holds in dynamic_stiffness_n_m3 and in apparent_dynamic_stiffness_n_m3. A test report under EN 29052-1 prints and, when it can, ; a data sheet often prints only “s’”. Write each in its own field.
  • PorousMaterial holds the parameters of a porous frame, PlateauMaterial the three numbers of the plateau method, SolidMaterial the elastic constants of a solid. The published catalogues page lists every class with its columns.

What a value is: basis, converted and carried

Section titled “What a value is: basis, converted and carried”

basis says what the document claims a value is, one of io.CATALOGUE_BASES, and basis_of(field) answers it for any cell:

WordMeaning
measureda test result the document gives
declareda value, a bound or a class declared under a product standard or a CE marking
calculateda figure the document worked out itself, by a standard’s model, a program or a formula
estimatedthe document’s own estimate
extendedthe result of an extended application of a test

A cell with no word is one the document does not qualify, which is the honest answer for most data sheets. The document’s basis is every row’s, a row’s "row" entry is every cell’s, and a cell’s own entry wins over both.

basis is what the document says; derived is what this library worked out, from the printed cells, when it built the row. They are independent: the shear modulus of core-lab is derived, and its derived text names the bases it rests on, a measured Young’s modulus and an estimated Poisson ratio. converted keeps the figure and the unit of a value converted from another unit, and carried says where a document gives a blank cell from. A file may write converted and carried beside a value; it never writes derived.

A row is frozen. To change a cell, take the cells the document prints with printed_fields(), change one and build the row again with from_printed, which works out again what follows from them. dataclasses.replace copies the derived values as they were, beside a cell they no longer follow from:

cells = lab.printed_fields()
cells["youngs_modulus_pa"] = 200_000.0
edited = materials.PorousMaterial.from_printed(**cells)
print(lab.shear_modulus_pa, edited.shear_modulus_pa) # 70000.0 100000.0

A quantity no class of this library holds can still live in a catalogue file, in a row class of your own: a frozen, keyword-only dataclass built on io.CatalogueRow, with every field annotated float | None, int | None, bool, str, frozenset[str] or a Mapping of those. The reader reads it like any other, and the other units of a kind apply to your fields too when the name leaves no doubt, except a temperature, which has to be written in the unit its field is named for:

import dataclasses
@dataclasses.dataclass(frozen=True, kw_only=True)
class Plasterboard(io.CatalogueRow):
surface_density_kg_m2: float | None = None
reaction_to_fire: str = ""
boards_of_mine = io.parse_catalogue(
{
"schema": "phonometry-catalogue",
"schema_version": 1,
"catalogue": "plasterboards",
"row_type": "Plasterboard",
"about": "One board of a fictitious data sheet.",
"provenance": {
"kind": "datasheet",
"document": "Example board range",
"version": None,
"consulted": "2026-09-25",
},
"rows": [
{
"key": "board-12",
"name": "Board 12.5",
"surface_density_g_m2": 8600,
"reaction_to_fire": "A2-s1,d0",
}
],
},
row_type=Plasterboard,
)
row = boards_of_mine["plasterboards/board-12"]
print(row.surface_density_kg_m2, row.converted)
# 8.6 {'surface_density_kg_m2': ('8600', 'g/m2')}

Leave slots=True out of such a class: on Python 3.12, and on the 3.13 releases that predate the fix, a method of it that calls super() without arguments fails, which is how a __post_init__ of your own reaches the row contract. A bare float or int annotation is refused with TypeError the first time the class is built, because every cell of a row may be missing. A class built on BandedRow is not supported: its bands are declared in private class variables.

Completion in your editor: the JSON Schema

Section titled “Completion in your editor: the JSON Schema”

A catalogue file is typed by hand, and an editor that knows its shape completes the name of a field as you type it, offers the words basis and kind take, and marks a text where a number goes before the reader ever sees the file. JSON Schema is how an editor learns a shape, and io.catalogue_schema writes one, in the 2020-12 dialect, from the row classes you give it: every field under its own name and under each other unit the reader converts, the fields each hedge may name, the provenance and its kinds, the limits a field’s unit sets (a density is never negative, a porosity runs from 0 to 1), and a CSV file’s header with its dialect. A row class of your own is described with its fields:

schema = io.catalogue_schema(materials.PorousMaterial, Plasterboard)
print(schema["$id"]) # urn:phonometry:schema:catalogue:1
print(schema["properties"]["row_type"]["enum"]) # ['Plasterboard', 'PorousMaterial']
board = schema["$defs"]["Plasterboard"]["properties"]
print(sorted(name for name in board if name.startswith("surface_density")))
# ['surface_density_g_m2', 'surface_density_kg_m2']
pathlib.Path("catalogue.schema.json").write_text(json.dumps(schema, indent=2), encoding="utf-8")
pointed = json.loads(panel_40)
pointed["$schema"] = "./catalogue.schema.json"
print(io.parse_catalogue(pointed, row_type=materials.PorousMaterial) == mine) # True

A document names its schema in a top-level $schema, which the reader takes and never reads: a file beside it, as above, or the schema of every row class the library publishes, which the documentation site serves at https://docs.jmrp.io/phonometry/schemas/phonometry-catalogue-1.json. An editor can also be told which files are catalogues, and keep them free of the key; in Visual Studio Code that is one setting:

{
"json.schemas": [
{
"fileMatch": ["*.catalogue.json", "*.csv.phonometry.json"],
"url": "https://docs.jmrp.io/phonometry/schemas/phonometry-catalogue-1.json"
}
]
}

The schema holds a file to its form: which keys a document, a provenance and a row may have, what kind of value each takes, the pattern of a name and a key. What depends on two cells at once, a value beside a word that says there is none or a bound on a field with no range, is the reader’s to refuse, so a file the editor accepts may still be refused, and the refusal says where. A file the reader reads is never marked by an editor whose validator reads regular expressions as JavaScript or Python does, Visual Studio Code’s among them. The schema’s name, urn:phonometry:schema:catalogue:1, follows the layout version, so a later layout is a new schema beside this one.

The reader and the rows hold what the document prints and nothing else.

  • No band a document does not print is extrapolated, interpolated or filled from its neighbours, and none is read as zero.
  • Nothing is clipped or rounded: a coefficient of 1.05 stays 1.05, and a rating is never moved to a grid.
  • No figure is guessed from its size, and no unit is read from inside a cell.
  • Nothing is uploaded, cached or copied, and no link a file names is opened.
  • Two rows under the same name are both kept and both answered, and no lookup chooses between them for you.
  • Nothing the file names is imported: only the JSON and CSV readers of the standard library read it, and the class name it writes is only compared with the class you pass.
  • No dialect is guessed: a CSV file’s delimiter and decimal mark are the ones its header declares, and a thousands separator is never read.
  • Covered

    Reading a catalogue of your own from a JSON file or from a CSV file with its JSON header (io.read_catalogue), or from text or a mapping (io.parse_catalogue), into the rows every published catalogue hands out, with its io.Provenance, its notes and every problem of form reported at once with a JSON pointer or a line and a column; writing rows, a published table among them, back to a JSON or CSV file (io.write_catalogue); searching yours and the published ones with every *_named lookup; handing the rows to the porous models, a room model, the plateau method, a floating floor, the elastic simulation, the outdoor ground models and the ISO 12354 detailed model; and the JSON Schema of a catalogue file, for an editor to complete it (io.catalogue_schema).

  • Not covered

    A spreadsheet’s own format (XLSX, ODS) is not read: save the sheet as “CSV UTF-8”, and write in JSON what one cell cannot say. A rating no function of this library works out (an NRC, an STC, an IIC) is held as printed and never checked. No manufacturer’s data ships with the library, and a table pasted into an issue or a pull request is never merged.