<!-- canonical: https://docs.jmrp.io/phonometry/es/io/material-catalogues/ -->
Source: https://docs.jmrp.io/phonometry/es/io/material-catalogues/

La biblioteca publica las tablas de sus libros y de sus normas, y nada de lo
que imprime un fabricante. Una hoja de características se revisa sin aviso
bajo el mismo título, sus condiciones de uso rara vez permiten copiarla, y el
valor que necesita un proyecto es el de la hoja con la que se especificó, no
el de la revisión de hoy. Así que las hojas de características, las
declaraciones de prestaciones, los informes de ensayo y las medidas tuyas
viven en un archivo de catálogo tuyo, y `phonometry.io` lee ese archivo en
filas de las mismas clases que entregan todos los catálogos `PUBLISHED_*`. El
núcleo poroso de tu hoja entra en los modelos de porosos como entra un
material poroso de un libro, un espectro de tu informe de ensayo entra en un
modelo de sala, y todas las búsquedas por nombre buscan en tu catálogo, en el
publicado o en los dos a la vez.

Las filas son tuyas y se quedan en tus objetos: la biblioteca no guarda
ninguna copia, no abre ningún enlace que nombre un archivo y no envía nada a
ninguna parte. Por la misma razón, una tabla de un fabricante pegada en una
issue o en una pull request nunca se incorpora a la biblioteca.

## Empezar por una tabla publicada

La forma más rápida de ver cómo es un archivo es escribir uno.
`write_catalogue` escribe cualquier tabla publicada en la forma que lee
`read_catalogue`, como plantilla para copiar y editar: aquí la espuma de la
Tabla 13.1 de Allard & Atalla.

```python

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
```

Una tabla de filas de una misma clase sale entera, con el libro como
procedencia y el día de la exportación como día de consulta. Lo que la
biblioteca calcula a partir de las celdas impresas (aquí el módulo de
cizalla, a partir del módulo de Young y del coeficiente de Poisson) no se
escribe nunca: se vuelve a calcular al leer el archivo. Si ya hay un archivo
en esa ruta, se rechaza con `FileExistsError` salvo que pases
`overwrite=True`.

## El documento

Un archivo de catálogo es un único objeto JSON, en UTF-8, con un conjunto
cerrado de claves en el primer nivel:

| Clave | Qué guarda |
|---|---|
| `schema`, `schema_version` | `"phonometry-catalogue"` y `1`. Una versión posterior se rechaza con «upgrade phonometry». |
| `catalogue` | El nombre del catálogo, la primera mitad de cada clave: hasta 64 caracteres entre `a-z`, `0-9`, `.`, `_` y `-`. |
| `row_type` | El nombre de la clase de fila. Se compara con la clase que pasas y nunca se importa. |
| `about` | Qué es el documento, cómo lo leíste y en qué unidades imprime. |
| `provenance` | El documento: son obligatorios su tipo en `kind`, su título en `document`, su versión en `version` (o `null` si no imprime ninguna) y el día en que lo consultaste en `consulted`, como `AAAA-MM-DD`. El tipo es uno de `io.PROVENANCE_KINDS`: `datasheet`, `declaration_of_performance`, `test_report`, `measurement`, `calculation`, `publication` u `other`. |
| `basis` | Opcional: qué dice el documento que es cada valor, salvo que una fila diga otra cosa. |
| `attributed_to` | Opcional: a quién se atribuye la tabla entera, como un solo texto. Cada fila lo guarda como su crédito `"table"`, y ninguna vuelve a atribuir la tabla. |
| `conventions` | Opcional: las notas y leyendas que el documento imprime para toda la tabla. |
| `phonometry_version` | Opcional: la versión de la biblioteca que escribió el archivo, que rellena `write_catalogue`. No se lee nunca. |
| `$schema` | Opcional: dónde encuentra un editor el esquema JSON del documento (véase [Autocompletado en tu editor](#autocompletado-en-tu-editor-el-esquema-json)). No se lee nunca. |
| `rows` | Las filas, cada una con una clave propia en `key` y un nombre en `name`. |

Aquí va una hoja de características ficticia con dos filas. La primera es la
resistividad al flujo que la hoja declara bajo su marcado CE, como cota
inferior; la segunda es la caracterización de laboratorio que cita la
página 3, con su propio informe.

```python
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
```

Cada clave es `"<catalogue>/<key>"`, la forma que usan todos los catálogos
publicados, y el `table` de cada fila es el nombre del catálogo. El nombre de
un catálogo no puede tener nunca la forma del de una tabla empaquetada, un año
de cuatro cifras seguido de una palabra (`allard-2009-table-13-1`), así que una
clave tuya y una clave de la biblioteca no coinciden nunca, ni hoy ni después
de que una actualización añada una tabla: `acme-2026-rev4` se rechaza y
`acme-rev4-2026` no.

Una fila puede acotar la procedencia del documento a su propia página, tabla,
laboratorio, acreditación, informe, fecha de ensayo y norma, como hace
`core-lab`, y una norma dada para un solo campo (`field_test_standards`) se
suma a las del documento. Un documento distinto es otro archivo: una fila no
puede cambiar el documento, su tipo ni su versión. Para todo tipo salvo
`publication`, que se cita como se cita su documento, el `source` de la fila
nombra el documento, su editor y su versión, la página y la tabla, el informe
y el laboratorio, y el día de la consulta; la acreditación, la fecha de
ensayo y las normas se quedan en `row.provenance`. Un rechazo nombra el
documento por su tipo: «the datasheet», «the declaration of performance»,
«the test report», «the measurement record» para `measurement`, «the
calculation note» para `calculation`, y «the source» para `publication` u
`other`.

Las columnas propias van bajo un nombre que empieza por `x-`: se guardan, como
texto, en `Catalogue.extras` y no intervienen en ningún cálculo. Una fila
nunca escribe `derived`, `table`, `source` ni `estimated`: las tres primeras
las escribe la biblioteca, y una estimación es una `basis`.

## Cuando el archivo está mal

Todos los problemas de forma de un archivo se comunican a la vez, en un solo
`io.CatalogueError`, cada uno un `io.CatalogueIssue` con el archivo, su lugar
como puntero JSON, la fila y el campo. Aquí la porosidad está escrita con coma
decimal y el espesor en pulgadas, una unidad que el lector no convierte:

```python
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` lee lo mismo de un texto o de un diccionario que ya está en
memoria, y `label` nombra el documento en cada problema. Un campo mal escrito
se contesta con el campo al que más se parece; una fila bien escrita que
incumple el contrato que cumple toda fila (una densidad negativa, una
porosidad mayor que 1, un valor junto a una palabra que dice que no lo hay) se
comunica con la primera regla que incumple. El lector rechaza un `NaN` o un
`Infinity`, un nombre escrito dos veces en un mismo objeto, un carácter de
control o una marca que reordena el texto, cualquier cosa que no sea un
archivo regular con el nombre de un catálogo o de su cabecera (una tubería, un
dispositivo, un socket o un directorio, rechazados antes de abrirlos), un
archivo de más de 16 MiB (comprobado antes de leer un solo byte), una cabecera
de archivo CSV de más de 64 KiB, más de 50 000 filas y un anidamiento de más de
seis niveles.

Lo que solo merece una segunda mirada no es un error. Un valor cuya base es
`measured` sin informe ni laboratorio que lo respalden, o una palabra en lugar
de un número tan larga que es una frase, se guarda tal como lo escribe el
archivo, se anota en `Catalogue.notes` y se anuncia una sola vez con un
`io.CatalogueWarning`. Una nota nunca cambia una fila.

## Usar las filas

Una fila leída de un archivo es de la misma clase, y se construye de la misma
manera, que una publicada: la aceptan los mismos métodos, y lo que devuelven
lo aceptan las mismas funciones.

### En los modelos

`printed()` da un número que imprime el documento, o lo rechaza con lo que el
documento tiene en su lugar. Es la manera de pasar una celda a cualquier
función que reciba un `float`. `PorousMaterial.medium()` lee sus cinco
parámetros del mismo modo, así que la fila de laboratorio entra en el modelo
de matriz de transferencia y la fila declarada se rechaza en los términos de
la propia hoja:

```python

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).
```

El rechazo cita primero la cifra y la unidad que imprime la hoja, y después el
valor que guarda la fila: una cota inferior AFr5 no es una resistividad al
flujo de 5000 Pa·s/m², y aquí nada finge que lo sea.

### Banda a banda, hacia un modelo de sala

Una fila que imprime un valor por banda se lee en las frecuencias que pide un
modelo con `values_at()`, que rechaza una banda que el documento no imprime en
lugar de leerla como cero. Las funciones que reciben sus coeficientes por
posición, como `room.sabine_reverberation_time`, reciben un array en el que
cada valor está impreso:

```python
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).
```

### El método de la meseta

`building.plateau_transmission_loss` admite una fila `PlateauMaterial` como
`material`, una del libro o una tuya, y lee sus tres números a través de
`printed()`. Un número que pases explícitamente sustituye al de la fila, que
entonces ni siquiera se lee.

```python
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]
```

### Un suelo flotante: s' y s't

La hoja de características de una capa resiliente imprime una de dos magnitudes
distintas. La rigidez dinámica $s'$ de la capa instalada es la que usa la
frecuencia natural de un suelo flotante; la rigidez dinámica aparente
$s'_\mathrm{t}$ es la que mide la EN 29052-1 sobre una probeta, y solo se
convierte en $s'$ a través de la resistividad al flujo lateral del
apartado 8.2. `ResilientLayer` las guarda en dos campos,
`dynamic_stiffness_n_m3` y `apparent_dynamic_stiffness_n_m3`, y un «s' ≤ 9 MN/m³»
declarado es una cota, no una rigidez:

```python
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
```

La capa ensayada solo da $s'_\mathrm{t}$, así que su frecuencia natural
necesita la resistividad al flujo lateral $r$ del apartado 8.2: a partir de
100 kPa·s/m², $s'$ es $s'_\mathrm{t}$, y por debajo la llamada necesita además
la rigidez del gas encerrado $s'_\mathrm{a}$ de la Fórmula 7, que se pasa con
`gas_stiffness_n_m3`. Sin $r$ la llamada se rechaza con esa explicación en
lugar de tomar una magnitud por la otra.

### Un sólido en la simulación elástica

La simulación elástica acepta la fila de un sólido allí donde acepta un
`Material`: `simulation.scholte_speed` y `ElasticFDTD2D.from_regions` leen
los tres números que integra el FDTD elástico: la velocidad longitudinal de
un sólido extenso, la velocidad transversal y la densidad. Una hoja que
imprime un módulo, un coeficiente de Poisson y una densidad ha impreso
también esas velocidades, y la fila las guarda marcadas como derivadas. Las
velocidades de placa y de barra que una página imprime a su lado no se leen
nunca: el FDTD elástico no integra ninguna de las dos, y una velocidad de
placa tomada por la de un sólido extenso se equivoca en torno a un diez por
ciento.

```python
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).
```

### Un suelo en los modelos de exteriores

Una fila `GroundSurface`, de los suelos publicados o de los tuyos, entra en
los modelos de exteriores a través de `medium()`: el semiespacio poroso, por
el modelo de Delany y Bazley o por el de Miki, que
`environment.ground_effect` recibe como `impedance`,
`barrier_insertion_loss` como `ground_impedance` y
`atmospheric_parabolic_equation` como `impedance`. La resistividad se lee a
través de `printed()`, así que el intervalo de un pastizal se rechaza en
lugar de promediarse. Una resistividad al flujo efectiva es un parámetro del
modelo con el que se ajustó, así que una fila que la página marca como
ajustada con un modelo solo entra en ese modelo:

```python
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.
```

Un suelo exterior es mucho más resistivo que los materiales fibrosos con los
que se ajustaron los dos modelos, así que todas las octavas de aquí quedan por
debajo de su rango de ajuste publicado, y la llamada emite un
`PorousAbsorberWarning` que dice que la regresión es ahí una extrapolación;
[Elección de la impedancia del suelo](/phonometry/es/environment/propagation/ground-barriers/#elección-de-la-impedancia-del-suelo)
explica lo que eso cuesta y por qué Miki es el modelo que conviene preferir por
debajo del rango.

### Una pared en el modelo detallado de edificación

`building.HomogeneousElement.from_solid` construye el elemento de la
ISO 12354-1 que forma una capa de un sólido, a partir de la densidad y la
velocidad de placa de la fila, que es la velocidad de fase cuasilongitudinal
$c_\mathrm{L}$ de la Tabla B.3 de la norma, y del espesor que le das:
$m' = \rho t$ y $f_\mathrm{c} = c_\mathrm{o}^2 / (1{,}8\, c_\mathrm{L} t)$.
El factor de pérdidas interno lo das tú y nunca se lee de la fila, que puede
guardar cuatro (a flexión, longitudinal, in situ y uno que la página no
precisa), porque solo tú sabes cuál de ellos, si alguno, es el
$\eta_\mathrm{int}$ de la norma:

```python
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, no el 0.012 a flexión de la fila
```

Son los números de las paredes interiores del edificio resuelto del
Anexo L de la ISO 12354-1, 200 mm de bloques de silicato cálcico con
360 kg/m² y 128,4 Hz, y el elemento entra en `building.in_situ_element` como
uno construido a mano.

### Buscar en tu catálogo y en los publicados

Todas las búsquedas `*_named` admiten `catalogue=`, las filas en las que
buscar en lugar de su propio `PUBLISHED_*`. `PUBLISHED_POROUS | mine` une los
dos en un único mapping de solo lectura que rechaza una clave que tengan los
dos, así que una sola búsqueda contesta desde ambos:

```python
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
```

Cada búsqueda conserva su propia forma de casar un nombre, contesta con todas
las coincidencias en el orden en que las guarda el catálogo y nunca elige una.
Casa sin distinguir mayúsculas ni la forma en que se guarda una letra
acentuada: un nombre pegado de un documento que guarda «é» como «e» seguida
de un acento combinante encuentra la fila escrita con «é», y al revés. Una
fila de otra clase dentro del catálogo se rechaza con `TypeError`, que nombra
su clave.

## Escribir lo que imprime el documento

Un documento imprime más que números, y un archivo escribe cada cosa como lo
que es, con los mismos matices que usan las tablas publicadas. El sujeto de
cada frase de abajo es el tipo de documento: «the datasheet», «the test
report».

| El documento imprime | Se escribe en el archivo como | `why_missing` contesta |
|---|---|---|
| un número | el campo, en su unidad o en otra de la misma magnitud | nada: el valor está |
| nada | el campo omitido, o `null` | «the datasheet does not give it, and it does not follow from the cells that it does» |
| `~0,85` | el valor, y el campo en `"approximate"` | nada |
| `≤ 30` o `< 30` | `"ranges": {"f": [null, 30]}` y `"bounded_above": ["f"]` | «the datasheet prints an upper bound of 30 and no value» |
| `≥ 5` o `> 5` | `"ranges": {"f": [5, null]}` y `"bounded_below": ["f"]` | «... a lower bound of 5 and no value» |
| `0,30 a 0,50` | `"ranges": {"f": [0.30, 0.50]}` | «the datasheet prints 0.3 to 0.5 and no value» |
| varias lecturas y ningún valor único | `"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,05` | el valor, y `"uncertainty": {"f": 0.05}` | nada |
| una palabra o un código donde iría el número | `"unquantified": {"f": "AFr5"}` | «the datasheet prints “AFr5” where the number would be» |
| un valor que sabes que está mal | `"misprinted": {"f": "por qué"}`, sin valor | tu motivo, tal como lo escribiste |
| un valor al que llega la aritmética pero que no debe calcularse | `"not_derivable": {"f": "por qué"}` | tu motivo |
| una celda en blanco que el documento da por referencia a otra fila | el valor, y `"carried": {"f": "de la fila de arriba"}` | nada |
| una cifra en una unidad que no está en ninguna familia | el valor en la unidad del propio campo, y `"converted": {"f": ["3e5", "psi"]}` | nada, y una cota cita primero la cifra |
| el crédito a un laboratorio o a un autor | `"attributed_to": {"f": "..."}`, o `"row"`, o `"table"`; o el de la tabla una sola vez, como `attributed_to` del propio documento | no se pregunta |
| un sí o un no | `true` o `false`, en un campo que guarde uno | no se pregunta |

Una cota declarada bajo un marcado CE, `AFr5` para una resistividad al flujo
de al menos 5 kPa·s/m², se escribe como la cota, con el código en `note`, como
hace `core-declared`. Escrita como el número 5, afirmaría una medida que la
hoja no hace.

## Unidades

Una celda puede escribirse en la unidad que lleva el nombre de su campo, o en
otra unidad de la misma magnitud, con el mismo nombre y el sufijo de la otra
unidad: `thickness_cm` en lugar de `thickness_mm`. La cifra se convierte sobre
los dígitos que escribe el archivo, con el factor exacto, y se redondea una
sola vez, así que 0,067 GPa son 67 000 000 Pa, cosa que no da el producto de
dos flotantes. `converted` guarda la cifra y la unidad exactamente como las
escribe el archivo.

| Magnitud | Sufijos |
|---|---|
| Longitud | `_um`, `_mm`, `_cm`, `_m` |
| Masa por unidad de superficie | `_g_m2`, `_kg_m2` |
| Densidad | `_g_cm3`, `_kg_m3` |
| Presión y módulo | `_pa`, `_kpa`, `_mpa` (megapascales), `_n_mm2`, `_gpa` |
| Rigidez por unidad de superficie | `_n_m3`, `_mn_m3` |
| Resistividad al flujo | `_pa_s_m2`, `_kpa_s_m2` |
| Temperatura | `_c`, `_k`, desplazada por su origen en un valor, un intervalo y una lectura, y solo por el factor en un más-menos |
| Área de absorción | `_m2`, `_ft2`, `_ft2_per_1000_ft3`, solo en `AbsorptionAreaSpectrum`, y la última solo con `per` al lado |

La unidad se reconoce por la coincidencia más larga con todas las unidades que
usan los campos de la biblioteca, así que una unidad compuesta nunca se toma
por una longitud: una resistencia específica al flujo en Pa·s/m o una tasa por
metro no admiten otra unidad, y un nombre que podrían tomar dos campos de la
misma magnitud se rechaza por ambiguo. Una unidad que no está en la tabla se
rechaza con las grafías que admite el campo. Todos los números de una misma
celda se escriben en una sola unidad.

## Desde una hoja de cálculo: un archivo CSV

Una hoja de características se teclea más a menudo en una hoja de cálculo que
en un archivo JSON, y una hoja de cálculo guarda CSV. Un catálogo puede ser
uno: las filas van en el archivo CSV, cada una empezando en una línea propia
bajo una primera línea que nombra las columnas, y el documento sin sus filas
va en una cabecera JSON al lado, con el nombre del archivo CSV seguido de
`.phonometry.json`. La cabecera declara cómo escribe el archivo sus celdas,
`"csv": {"delimiter": ";", "decimal": ","}` para una hoja de cálculo
configurada con coma decimal, y nada del dialecto se adivina a partir del
archivo. `write_catalogue` escribe el par cuando el nombre termina en `.csv`,
así que una tabla publicada vuelve a ser la plantilla más rápida, aquí los
coeficientes de absorción de la Tabla 6.1 de Arau-Puchades (1999):

```python
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': ','}
```

Abre el archivo en una hoja de cálculo, edítalo y guárdalo como «CSV UTF-8».
El lector admite UTF-8 con o sin la marca de orden de bytes que escribe ahí la
hoja de cálculo, y rechaza cualquier otra codificación con ese consejo.
Conserva la cabecera al lado del archivo, con el delimitador y la marca
decimal con los que guardó la hoja de cálculo. El escritor pone un apóstrofo
delante de un texto que una hoja de cálculo ejecutaría como fórmula (el que
empieza por `=`, `+`, `-`, `@`, un tabulador o un retorno de carro), y el
lector lo vuelve a quitar. Un archivo CSV y su cabecera son dos archivos: cada
uno se escribe entero, pero no los dos como uno solo.

Cada celda numérica guarda una sola cosa, con una gramática cerrada que
escribe los mismos matices que el documento JSON:

| La celda | Lo que dice |
|---|---|
| vacía | el documento no imprime nada ahí |
| `0,85` | un valor, con la marca decimal que declara la cabecera |
| `~0,85` | un valor aproximado |
| `<=30`, `<30` o `≤30` | una cota superior y ningún valor |
| `>=5`, `>5` o `≥5` | una cota inferior y ningún valor |
| `0,30..0,50` | un intervalo y ningún valor; `~0,30..0,50` si es aproximado |
| `0,85±0,05` o `0,85+/-0,05` | un valor y su más-menos |
| `[AFr5]` | lo que imprime el documento donde iría el número |
| `true` o `false` | un sí o un no, en una columna que guarde uno |

Un número nunca lleva separador de miles, y su signo menos puede ser el
tipográfico. Las columnas son `key`, los campos de la clase de fila (en la
unidad que lleva el nombre de cada uno, o en otra de la misma magnitud, como
`flow_resistivity_kpa_s_m2`), `basis` para la fila entera (una celda vacía
toma la del documento), la página, la tabla, el laboratorio, la acreditación,
el informe, la fecha de ensayo y la norma de la fila como `provenance.page`,
`provenance.printed_table`, `provenance.laboratory`,
`provenance.accreditation`, `provenance.report`, `provenance.test_date` y
`provenance.test_standard`, el crédito de la fila entera como
`attributed_to.row`, y columnas propias con nombre `x-...`. El crédito de la
tabla entera va una sola vez en la cabecera, como `"attributed_to": "..."`,
igual que en un documento JSON. Una gama ficticia de placas tecleada en una
hoja:

```python
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
```

Las filas pasan las mismas comprobaciones que las de un documento JSON, y
todos los problemas se comunican a la vez en su línea y su columna, con las
letras con que las nombra una hoja de cálculo. Una columna que el lector no
conoce se contesta con el nombre más parecido, y un texto donde va un número
nunca se lee como una palabra, un `NaN` o un cero:

```python
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` es lo que puede dejar la capa de texto rota de un PDF: si la página
imprime ahí una palabra, va entre corchetes; si imprime un número, teclea el
número. Un intervalo tecleado con un guion, un más-menos tecleado como `+-`,
una unidad tecleada detrás del número y un número agrupado en miles reciben
cada uno la forma de escribirlos. Cuando todos los números que fallan están
escritos con la otra marca decimal (un número agrupado en miles, que podría
ser cualquiera de las dos, no cuenta), o la primera línea se parte en
columnas con otro delimitador, el rechazo dice además cuál hay que
declarar.

El crédito también viaja en el archivo: `write_catalogue` escribe el
crédito de una fila en su columna `attributed_to.row`, y el que todas las
filas dan a la tabla entera en la cabecera. La Tabla 6.2 de Bies atribuye
su primera fila a Beranek e Hidaka (1998):

```python
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)'}
```

Lo que no cabe en una celda se escribe en un documento JSON: varias lecturas
sin un valor único, una errata, un valor al que llega la aritmética pero que
no debe calcularse, una celda arrastrada de otra fila, una cifra en una unidad
que no está en ninguna familia, un crédito, una base o una norma para una sola
celda, una cota o un intervalo con más-menos, una cota aproximada, un texto
vacío donde el valor por defecto del campo dice algo y una tabla cuyas filas
la atribuyen por separado. `write_catalogue` se niega a escribir una tabla
así como CSV, nombra cada celda que no puede guardar con el puntero con que
la escribiría el documento JSON y no escribe nada. La Tabla A2 de Hopkins
imprime la fuente de tres celdas de su fila del acero dentro de la propia
celda, como «5270 (Fahy, 1985)» para la velocidad de placa: un crédito para
una sola celda, así que esa fila solo va a JSON:

```python
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
```

## Qué clase de fila guarda cada magnitud

Una hoja de características y un libro pueden imprimir magnitudes que se
parecen y no son la misma. Las clases de fila las separan, y el archivo no
puede impedir que escribas una en el campo de otra, así que lee esto antes de
elegir un `row_type`.

- `AbsorptionSpectrum` guarda coeficientes de absorción de Sabine por octavas
  de 63 Hz a 8 kHz, tal como los imprime la tabla de un libro o tal como una
  hoja de características imprime por octavas el resultado de un ensayo en
  cámara reverberante. El coeficiente práctico de absorción sonora
  $\alpha_\mathrm{p}$ de la ISO 11654 no es uno de ellos: es una valoración,
  redondeada a pasos de 0,05 y limitada a 1,00, y la fila por octavas de una
  hoja de características europea suele ser eso.
  `PracticalAbsorptionSpectrum` guarda $\alpha_\mathrm{p}$ y
  `ThirdOctaveAbsorptionSpectrum` el $\alpha_\mathrm{s}$ por tercios de
  octava de un informe de ensayo, cada uno con la valoración que imprime la
  hoja; no escribas ninguno de los dos en `AbsorptionSpectrum`.
  [Cómo leer la hoja de características de un material](/phonometry/es/materials/reading-a-datasheet/)
  distingue los tres en la página.
- `TransmissionLossSpectrum` guarda la pérdida por transmisión de una
  construcción tal como la recopila un libro, por bandas de octava, a partir
  de ensayos que el libro rara vez nombra: Bies califica sus valores de
  incidencia de campo, las filas de ASHRAE salen de ensayos de laboratorio y
  Rossing no nombra ninguna fuente. El índice de reducción acústica $R$ de un
  informe de ensayo según la ISO 10140-2 se mide por tercios de octava sobre
  una probeta de un producto, en un laboratorio donde se ha comprobado que el
  sonido transmitido por flancos es despreciable, y se valora con la
  ISO 717-1. Es otro registro: lo guarda `SoundReductionSpectrum`, con los
  índices globales que imprime el informe, e `ImpactImprovementSpectrum`
  guarda la reducción del nivel de impactos de un revestimiento; no escribas
  ninguno de los dos en `TransmissionLossSpectrum`.
- `ResilientLayer` guarda $s'$ en `dynamic_stiffness_n_m3` y
  $s'_\mathrm{t}$ en `apparent_dynamic_stiffness_n_m3`. Un informe de ensayo
  según la EN 29052-1 imprime $s'_\mathrm{t}$ y, cuando puede, $s'$; una hoja
  de características a menudo solo imprime «s'». Escribe cada una en su campo.
- `PorousMaterial` guarda los parámetros de un esqueleto poroso,
  `PlateauMaterial` los tres números del método de la meseta y
  `SolidMaterial` las constantes elásticas de un sólido. La página de
  catálogos publicados muestra cada clase con sus columnas.

## Qué es un valor: basis, converted y carried

`basis` dice qué afirma el documento que es un valor, uno de
`io.CATALOGUE_BASES`, y `basis_of(field)` lo contesta para cualquier celda:

| Palabra | Significado |
|---|---|
| `measured` | un resultado de ensayo que da el documento |
| `declared` | un valor, una cota o una clase declarados bajo una norma de producto o un marcado CE |
| `calculated` | una cifra que calculó el propio documento, con el modelo de una norma, un programa o una fórmula |
| `estimated` | una estimación del propio documento |
| `extended` | el resultado de una aplicación extendida de un ensayo |

Una celda sin palabra es una que el documento no califica, que es la respuesta
honesta en la mayoría de las hojas de características. La `basis` del
documento es la de todas sus filas, la entrada `"row"` de una fila es la de
todas sus celdas, y la entrada propia de una celda manda sobre las dos.

`basis` es lo que dice el documento; `derived` es lo que calculó esta
biblioteca, a partir de las celdas impresas, al construir la fila. Son
independientes: el módulo de cizalla de `core-lab` es derivado, y su texto
en `derived` nombra las bases en las que se apoya, un módulo de Young medido y
un coeficiente de Poisson estimado. `converted` guarda la cifra y la unidad de
un valor convertido desde otra unidad, y `carried` dice de dónde da el
documento una celda en blanco. Un archivo puede escribir `converted` y
`carried` junto a un valor; `derived`, nunca.

## Editar una fila

Una fila es inmutable. Para cambiar una celda, toma las celdas que imprime el
documento con `printed_fields()`, cambia una y vuelve a construir la fila con
`from_printed`, que vuelve a calcular lo que se sigue de ellas.
`dataclasses.replace` copia los valores derivados tal como estaban, junto a
una celda de la que ya no se siguen:

```python
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
```

## Una clase de fila propia

Una magnitud que no guarda ninguna clase de la biblioteca puede vivir igual en
un archivo de catálogo, en una clase de fila propia: una dataclass inmutable y
keyword-only construida sobre `io.CatalogueRow`, con cada campo anotado como
`float | None`, `int | None`, `bool`, `str`, `frozenset[str]` o un `Mapping`
de ellos. El lector la lee como cualquier otra, y las demás unidades de una
magnitud se aplican también a tus campos cuando el nombre no deja lugar a
dudas, salvo una temperatura, que se escribe en la unidad que nombra su
campo:

```python

@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')}
```

No pongas `slots=True` en una clase así: en Python 3.12, y en las versiones de
3.13 anteriores a la corrección, falla un método suyo que llame a `super()` sin
argumentos, que es como un `__post_init__` tuyo
llega al contrato de fila. Una anotación `float` o `int` a secas se rechaza
con `TypeError` la primera vez que se construye la clase, porque cualquier
celda de una fila puede faltar. Una clase construida sobre `BandedRow` no está
soportada: sus bandas se declaran en variables de clase privadas.

## Autocompletado en tu editor: el esquema JSON

Un archivo de catálogo se escribe a mano, y un editor que conoce su forma
completa el nombre de un campo mientras lo escribes, ofrece las palabras que
admiten `basis` y `kind` y marca un texto donde va un número antes de que el
lector vea el archivo. JSON Schema es la manera en que un editor aprende una
forma, y `io.catalogue_schema` escribe una, en el dialecto 2020-12, a partir
de las clases de fila que le das: cada campo con su propio nombre y con cada
otra unidad que convierte el lector, los campos que puede nombrar cada
matiz, la procedencia y sus tipos, los límites que impone la unidad de un
campo (una densidad nunca es negativa, una porosidad va de 0 a 1) y la
cabecera de un archivo CSV con su dialecto. Una clase de fila propia se
describe con sus campos:

```python
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
```

Un documento nombra su esquema en una clave `$schema` de primer nivel, que
el lector admite y no lee nunca: un archivo a su lado, como arriba, o el
esquema de todas las clases de fila que publica la biblioteca, que sirve el
sitio de la documentación en
`https://docs.jmrp.io/phonometry/schemas/phonometry-catalogue-1.json`.
También se le puede decir a un editor qué archivos son catálogos, y dejarlos
sin esa clave; en Visual Studio Code basta un ajuste:

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

El esquema exige a un archivo su forma: qué claves pueden tener un
documento, una procedencia y una fila, qué clase de valor admite cada una,
el patrón de un nombre y de una clave. Lo que depende de dos celdas a la
vez, un valor junto a una palabra que dice que no lo hay o una cota en un
campo sin intervalo, lo rechaza el lector, así que un archivo que el editor
acepta aún puede rechazarse, y el rechazo dice dónde. Un archivo que el
lector lee nunca aparece marcado en un editor cuyo validador lee las
expresiones regulares como JavaScript o Python, como hace el de Visual
Studio Code. El nombre del esquema, `urn:phonometry:schema:catalogue:1`,
sigue a la versión del formato, así que un formato posterior será un esquema
nuevo al lado de este.

## Lo que no pasa nunca

El lector y las filas guardan lo que imprime el documento y nada más.

- Ninguna banda que no imprima el documento se extrapola, se interpola o se
  rellena con sus vecinas, y ninguna se lee como cero.
- Nada se recorta ni se redondea: un coeficiente de 1,05 sigue siendo 1,05, y
  una valoración nunca se lleva a una rejilla.
- Ninguna cifra se adivina por su tamaño, y ninguna unidad se lee de dentro
  de una celda.
- Nada se sube, se guarda en caché ni se copia, y no se abre ningún enlace que
  nombre un archivo.
- Dos filas con el mismo nombre se guardan las dos y se contestan las dos, y
  ninguna búsqueda elige entre ellas por ti.
- Nada de lo que nombra el archivo se importa: solo lo leen los lectores de
  JSON y de CSV de la biblioteca estándar, y el nombre de clase que escribe
  solo se compara con la clase que pasas.
- Ningún dialecto se adivina: el delimitador y la marca decimal de un archivo
  CSV son los que declara su cabecera, y nunca se lee un separador de miles.

## Qué cubre esta guía

Leer un catálogo propio de un archivo JSON o de un archivo CSV con su cabecera
JSON (`io.read_catalogue`), o de un texto o un mapping (`io.parse_catalogue`),
en las filas que entregan todos los catálogos publicados, con su
`io.Provenance`, sus notas y todos los problemas de forma comunicados a la vez
con un puntero JSON o con una línea y una columna; escribir filas, entre ellas
una tabla publicada, de vuelta a un archivo JSON o CSV (`io.write_catalogue`);
buscar en el tuyo y en los publicados con todas las búsquedas `*_named`;
pasar las filas a los modelos de porosos, a un modelo de sala, al método de la
meseta, a un suelo flotante, a la simulación elástica, a los modelos de suelo
de exteriores y al modelo detallado de la ISO 12354; y el esquema JSON de un
archivo de catálogo, para que un editor lo complete (`io.catalogue_schema`).

El formato propio de una hoja de cálculo (XLSX, ODS) no se lee: guarda la hoja
como «CSV UTF-8», y escribe en JSON lo que no cabe en una celda. Una
valoración que ninguna función de esta biblioteca calcula (un NRC, un STC, un
IIC) se guarda tal como se imprime y no se comprueba nunca.
La biblioteca no trae datos de ningún fabricante, y una tabla pegada en una
issue o en una pull request nunca se incorpora.

## Véase también

- [Archivos](/phonometry/es/io/): qué lee y escribe la capa de archivos de la biblioteca, y por qué.
- [Catálogos publicados](/phonometry/es/reference/catalogues/): todas las tablas que la biblioteca ha leído de una página, las mismas clases de fila con las celdas del libro.
- [Cómo leer la hoja de características de un material](/phonometry/es/materials/reading-a-datasheet/): qué es cada número de una hoja de características y qué clase de fila lo guarda.
- [Absorbentes porosos y multicapa](/phonometry/es/materials/absorbers/porous-absorbers/): los modelos en los que entra una fila `PorousMaterial`.
- [Rigidez dinámica de materiales resilientes](/phonometry/es/materials/resilient/dynamic-stiffness/): $s'$, $s'_\mathrm{t}$ y el apartado 8.2 al completo.
- [Predicción del aislamiento de paneles](/phonometry/es/buildings/design/panel-sound-insulation/): el método de la meseta que dibuja una fila `PlateauMaterial`.
- [Ondas elásticas y acoplamiento fluido-sólido](/phonometry/es/simulation/elastic-waves/): el FDTD elástico en el que la fila de un sólido es un medio.
- [Efecto suelo esférico y barreras avanzadas](/phonometry/es/environment/propagation/ground-barriers/): los modelos en los que entra el `medium()` de una fila de suelo.
- [Predicción detallada por bandas (ISO 12354)](/phonometry/es/buildings/design/detailed-prediction/): la cadena de la ISO 12354 en la que entra un elemento de `from_solid`.
- Referencia de la API: [`phonometry.io`](/phonometry/es/reference/api/io/io/).
