<!-- canonical: https://jmrplens.github.io/phonometry/es/start/upgrading/ -->
Source: https://jmrplens.github.io/phonometry/es/start/upgrading/

La 4.0 rompe duro y no lleva capa de compatibilidad. Casi nada se ha borrado:
de los 885 nombres que la raíz exportaba en la 3.3.0, diez no existen ya en
ningún sitio y el resto simplemente vive en una dirección con nombre en vez de
en una plana. Así que el trabajo es sobre todo un barrido de imports, y esta
página es sobre todo un mapa.

Con las dos primeras secciones arreglas los imports. La tercera léela aunque
tengas prisa, porque en tres sitios el arreglo evidente es el equivocado, y uno
de esos tres cambia un número sin levantar nada.

## La regla única: los imports bajan un nivel

En la 3.3.0 la raíz reexportaba la biblioteca entera, así que `from phonometry

veinticinco cosas: los veintiún subpaquetes, las tres clases que no pertenecen a
ningún dominio concreto (`Signal`, `ReportMetadata`, `PhonometryWarning`) y
`__version__`.

```python

print(len(phonometry.__all__))          # 25
print("laeq" in phonometry.__all__)     # False

from phonometry.signals import laeq     # esta es la grafía nueva
```

Medido sobre la superficie entera, 872 de aquellos 885 nombres de la raíz los
publica hoy exactamente un subpaquete, ninguno dos, y ninguno necesita una ruta
más profunda que `phonometry.<subpaquete>`. Otros tres siguen en la raíz, donde
siempre estuvieron. Así que el cambio no tiene ambigüedad: busca el subpaquete e
importa de él.

La forma de atributo no se retiró nunca, que es el cambio más pequeño posible
para el código que tiraba de la raíz:

```python
from phonometry import filters

_, _, _, labels = filters.nominal_frequencies(1)
print(labels)
```

### Encontrar la casa nueva de un nombre

En vez de buscar cada uno en la referencia, pregúntaselo al paquete instalado:

```python

def whereis(name):
    """Cada módulo público que publica `name`, del menos profundo al que más."""
    found = ["phonometry"] if name in phonometry.__all__ else []
    for attribute in phonometry.__all__:
        package = getattr(phonometry, attribute)
        if not hasattr(package, "__path__"):
            continue
        modules = [package.__name__]
        modules += [m.name for m in pkgutil.walk_packages(package.__path__, package.__name__ + ".")]
        for module_name in modules:
            if any(part.startswith("_") for part in module_name.split(".")):
                continue
            try:
                module = importlib.import_module(module_name)
            except ImportError:
                continue
            if name in getattr(module, "__all__", ()):
                found.append(module_name)
    return sorted(found, key=lambda path: path.count("."))

print(whereis("laeq")[0])                  # phonometry.signals
print(whereis("reverberation_time")[0])    # phonometry.room
print(whereis("sensitivity")[0])           # phonometry.metrology
print(whereis("Signal")[0])                # phonometry
```

Recorre el árbol en unos dos segundos. Cuando devuelve varias rutas, la menos
profunda es el import canónico: un subpaquete reexporta lo que publican sus
propios módulos, y esa ruta corta es la que usan las guías y los tests. Los
cuatro nombres que la raíz sigue publicando por su cuenta, `Signal`,
`PhonometryWarning`, `ReportMetadata` y `__version__`, vuelven como
`phonometry` a secas. Una lista vacía significa que el nombre está en la
última sección de esta página.

## Módulos que se partieron en vez de mudarse

Cinco de los treinta módulos que cambiaron de ruta no se mudaron a una
dirección nueva. Se dividieron, y quien siga sólo la primera mitad de la
partición se lleva un `ImportError` por el resto. Merece la pena revisarlos uno
a uno, y el paquete privado de renderizado va con ellos porque le pasó lo
mismo:

| Módulo en 3.3.0 | Dónde está hoy su contenido |
| --- | --- |
| `phonometry.impedance_tube` | `materials.absorbers.impedance_tube` (función de transferencia de dos micrófonos), `materials.absorbers.four_microphone` (`TransferMatrix`, `transfer_matrix_one_load`, `wave_decomposition`, `face_quantities`, `air_layer_transfer_matrix`), `materials.absorbers.standing_wave` y `fluids` |
| `phonometry.scattering_diffusion` | `materials.diffusers.reverberation_room_scattering` para la parte de cámara reverberante (`ScatteringResult`, `ScatteringUncertainty`, `BASE_PLATE_BANDS` y el resto), `materials.diffusers.scattering_diffusion` para los nueve nombres de campo libre |
| `phonometry.materials.porous_absorber` | `materials.absorbers.porous` para los modelos de material en masa, `materials.absorbers.layered` para toda la API multicapa (`layered_absorber`, `AirLayer`, `MembraneLayer`, `PerforatedPlateLayer`, `MicroperforatedPlateLayer`, `LayeredAbsorberResult`) |
| `phonometry.metrology.spectra` | `signals.spectra`, más `signals.multitaper` (`multitaper_psd`) y `signals.windows` (`window_metrics`) |
| `phonometry.compliance` | `filters.compliance` (`FilterComplianceResult`, `class_limits`, `verify_filter_class`), `filters.weighting_compliance` (`verify_weighting_class`, `weighting_class_limits`), `aircraft.measurement_system` (`verify_aircraft_noise_system`) |
| `phonometry._plotting` | `phonometry._plot.<dominio>`: los 82 renderizadores se repartieron por dominio, así que `plot_airborne_insulation` está en `_plot.building` y `plot_age_threshold` en `_plot.hearing`. Privado en ambos casos, y la vía soportada es el `.plot()` del resultado |

Los otros veinticinco se mudaron enteros, y el resolutor de arriba los
encuentra sin leer esta tabla. Cada uno de los tres primeros era alcanzable por
dos rutas en la 3.3.0, la plana y la de dentro de su paquete, y las dos han
desaparecido.

## Tres sitios donde el arreglo evidente es el equivocado

Todo lo demás de esta página se anuncia en cuanto lo ejecutas, y el arreglo es
el que parece. Estos tres no son así.

### Los auxiliares del tubo de impedancia tomaban kelvin

`air_density_iso` y `speed_of_sound_iso` documentaban y consumían su
temperatura en **kelvin**. Sus sucesores toman grados Celsius, como el resto de
la biblioteca. El nombre cambió, así que el import falla y te enteras, pero el
problema es el argumento que arrastras: 293,15 es un número de aspecto
legítimo en cualquiera de las dos unidades.

```python
from phonometry.materials import speed_of_sound_iso10534

print(round(float(speed_of_sound_iso10534(temperature_c=20.0)), 5))      # 343.28784
print(round(float(speed_of_sound_iso10534(temperature_c=293.15)), 5))    # 477.13003
```

343,29 m/s frente a 477,13 m/s: un 39 % de error, sin excepción ninguna, y
cada coeficiente de absorción calculado a partir de ahí sale mal en una
cantidad de aspecto plausible. Convierte el valor, no te limites a renombrar la
palabra clave.

| 3.3.0 | 4.0 |
| --- | --- |
| `impedance_tube.air_density_iso(T_kelvin, p_kPa)` | `materials.air_density_iso10534(temperature_c=T_kelvin - 273.15, atmospheric_pressure_kpa=p_kPa)` |
| `impedance_tube.speed_of_sound_iso(T_kelvin)` | `materials.speed_of_sound_iso10534(temperature_c=T_kelvin - 273.15)` |

Con la conversión aplicada, los dos devuelven exactamente lo que devolvían en
la 3.3.0, bit a bit.

### `scattering_diffusion.speed_of_sound` tomaba Celsius, y su sustituto responde distinto

El auxiliar de ISO 17497-1 que se llamaba igual tomaba grados Celsius, así que
ahí no hay conversión que hacer. La trampa es el sustituto. `fluids.air()` usa
un modelo del medio más completo que aquella fórmula de una línea de la
ISO 17497-1, y a 20 °C responde 343,99 m/s donde el auxiliar viejo respondía
343,20 m/s. Es poco, y no es nada despreciable si estás reproduciendo una
medición hecha con el número antiguo. El apartado 8 de la ISO 17497-1 imprime
la fórmula si necesitas ese apartado exacto.

### El campo `tl` de un resultado submarino es `pl`

`transmission_loss` pasó a `propagation_loss`, cosa que el import detecta, y
con él se renombró el campo de dentro del resultado. Ese no levanta nada: un
`PropagationLossResult` no tiene `.tl`, así que el `AttributeError` aparece
allí donde lo leas, que puede quedar lejos de la llamada.

```python
from phonometry.underwater.propagation.closed_form import propagation_loss

result = propagation_loss([1000.0], 1000.0)
print(round(float(result.pl[0]), 5))    # 60.06013, el número que guardaba .tl
```

El mismo renombrado llega a `underwater.sonar_equation`, cuyo parámetro
`transmission_loss` es ahora `propagation_loss`.

## El entorno se metió en una dataclase

Cinco puntos de entrada ya no toman las condiciones ambientales como números
sueltos. Ahí no basta con renombrar la palabra clave, porque el parámetro ha
desaparecido del todo:

| 3.3.0 | 4.0 |
| --- | --- |
| `airport_noise.event_level(..., temperature=15.0, pressure=101.325)` | `event_level(..., atmosphere=AerodromeAtmosphere(temperature_c=15.0, atmospheric_pressure_kpa=101.325))` |
| `airport_noise.noise_contour(...)` | lo mismo, con `AerodromeAtmosphere` |
| `rotorcraft_noise.rotorcraft_event_level(..., temperature=15.0, relative_humidity=70.0)` | `rotorcraft_event_level(..., atmosphere=RotorcraftAtmosphere(temperature_c=15.0, relative_humidity_percent=70.0))` |
| `rotorcraft_noise.rotorcraft_noise_contour(...)` | lo mismo, con `RotorcraftAtmosphere` |
| `outdoor_propagation.predicted_receiver_level(..., humidity=70.0)` | `predicted_receiver_level(..., atmosphere=AtmosphericConditions(relative_humidity_percent=70.0))` |

Las sustituciones punto a punto del rotorcraft fueron por el mismo camino:
`bank_angle` y `path_angle` son ahora
`RotorcraftTrackState(bank_angle_deg=..., path_angle_deg=...)`.

```python
from phonometry.aircraft.airport_noise import AerodromeAtmosphere

atmosphere = AerodromeAtmosphere(temperature_c=15.0, atmospheric_pressure_kpa=101.325)
print(atmosphere.temperature_c, atmosphere.atmospheric_pressure_kpa)
```

## Un parámetro que nombra una magnitud lleva su unidad

En todo lo demás, las condiciones ambientales conservaron su sitio y ganaron un
sufijo. La regla es que un parámetro público que nombra una magnitud dimensional
termina en la unidad que teclea quien llama, porque el número es donde se pierde
la unidad y ninguna guardia puede cazarlo por magnitud: 101,325 y 101325 son
las dos presiones legítimas en esta biblioteca.

| 3.3.0 | 4.0 |
| --- | --- |
| `temperature=` | `temperature_c=` |
| `pressure=`, `static_pressure=`, `barometric_pressure=`, `ambient_pressure=`, `atmospheric_pressure=` | `atmospheric_pressure_kpa=`, o `_pa=` donde el apartado esté escrito en pascales |
| `humidity=`, `relative_humidity=` | `relative_humidity_percent=` |
| `diameter=` y sus trece compuestos | `diameter_m=` |
| `angle=`, `angles=`, `bank_angle=`, `path_angle=`, `grazing_angle=`, `launch_angles=` | `_deg=` o `_rad=`, según lo que lea la función |
| `runway_gradient=` | `runway_gradient_ratio=` |
| `sound_speed_gradient=` | `sound_speed_gradient_per_s=` |

Un caso merece mención propia porque era una trampa y no una incomodidad.
`critical_angle` era un campo en radianes en un resultado y un parámetro en
grados en las dos funciones que estaban una pantalla más abajo, o sea la misma
palabra para dos cosas separadas por un factor 57. Ahora es
`critical_angle_rad` en el resultado y `critical_angle_deg` en los parámetros.

`transmission_loss=` pasó a `propagation_loss=` sólo en los resultados de sónar
y de propagación numérica submarina. `ReactiveSilencerResult` y
`SoundReductionResult` siguen llamando al suyo `transmission_loss=`, y con
razón: es como lo llaman las normas que hay detrás.

Las funciones de la EN 29052-1 de `materials` siguen la misma regla, con la
unidad de cada magnitud al final de su nombre:

| 3.3.0 | 4.0 |
| --- | --- |
| `apparent_dynamic_stiffness(resonant_frequency=, total_mass_per_area=)` | `resonant_frequency_hz=`, `total_mass_per_area_kg_m2=` |
| `enclosed_gas_stiffness(thickness=)` | `thickness_m=` |
| `installed_dynamic_stiffness(apparent_stiffness, airflow_resistivity, gas_stiffness=)` | `apparent_stiffness_n_m3`, `airflow_resistivity_kpa_s_m2=` sólo por nombre, `gas_stiffness_n_m3=` |
| `natural_frequency(dynamic_stiffness=, mass_per_area=)` | `dynamic_stiffness_n_m3=`, `mass_per_area_kg_m2=` |
| `floating_floor_resonance(resonant_frequency=, total_mass_per_area=, floor_mass_per_area=, airflow_resistivity=, thickness=)` | `resonant_frequency_hz=`, `total_mass_per_area_kg_m2=`, `floor_mass_per_area_kg_m2=`, `airflow_resistivity_kpa_s_m2=`, `thickness_m=` |

La resistividad al flujo es la que se gana la palabra clave. La EN 29052-1 pone
sus umbrales en kPa·s/m², y todas las demás resistividades al flujo de la
biblioteca van en Pa·s/m², una unidad mil veces menor, así que una cifra en
Pa·s/m² pasada por posición caía sin aviso en la rama equivocada del
apartado 8.2; ahora se escribe por su nombre. Y por debajo de 100 kPa·s/m²,
`installed_dynamic_stiffness` ya no toma como cero un término de gas ausente:
la 3.3.0 devolvía $s'_\mathrm{t}$ con `installed_dynamic_stiffness(20e6, 50.0)`,
la Fórmula 6 sin su segundo término, y la 4.0 lanza un error y pide
$s'_\mathrm{a}$.

## Las condiciones y las banderas se escriben por su nombre

Dos reglas dejaron 37 firmas keyword-only. Una condición o una opción que lleva
valor por defecto se escribe por su nombre, y toda bandera booleana pública es
keyword-only. Las dos existen porque el sitio donde se pierde el significado es
la llamada: `air_attenuation(freqs, 20.0, 50.0, 101.325)` son una temperatura,
una humedad y una presión en un orden que no recuerda nadie, y
`bank.filter(x, True)` no dice cuál de cinco banderas se ha activado.

El fallo es ruidoso, que es de lo que se trata:

```python

from phonometry.signals import leq

x = np.random.default_rng(1).standard_normal(4800) * 0.01

try:
    leq(x, 48000)
except TypeError as error:
    print(error)      # leq() takes 1 positional argument but 2 were given
```

Esa llamada merece una segunda mirada. `leq` nunca tuvo `fs`, porque una media
energética no lo necesita, así que en la 3.3.0 el 48000 se ligaba en silencio a
`calibration_factor` y la respuesta salía 93,6 dB alta sin una queja. Ahora es
un `TypeError`, que es lo que siempre debió ser.

```python
print(round(float(leq(x, dbfs=True)), 3))
```

## Una grafía por concepto

Los nombres públicos se habían ido repartiendo entre varias grafías de la
misma magnitud, y la 4.0 se queda con la que ya usaba la mayoría. La regla de
las magnitudes que se cuentan en plural es la que seguía casi toda la API: un
campo o un parámetro que guarda un array es `frequencies`, `times`,
`distances` o `levels`, y uno que guarda un solo valor va en singular. Un
argumento vectorizado que admite un número o un array conserva su nombre.

| 3.3.0 o 4.0.0rc1 | 4.0 |
| --- | --- |
| `sound_speed` (válvulas de control, medición de silenciadores, regímenes de Weston, ruido ambiente, `FluidSeabed`, `SoundSpeedProfile`, `ShipSourceLevelResult`) | `speed_of_sound` |
| `c=` y `rho=` en `sound_intensity`, `c=` en las dos conversiones de desajuste de fase, `phase_mismatch()`, `absorption_coefficient_uncertainty` y `monopole_source_level` | `speed_of_sound=`, `density=` |
| `speed_of_sound_m_s=` en `thickness_critical_frequency_product` | `speed_of_sound=` |
| `sample_rate` en el `SignalSource` de FDTD, `STOIResult` e `impact_force_exposure_level` | `fs` |
| `freq=` en `linkwitz_riley` y `adaptation_term_kc` | `frequency=` y `frequencies=` |
| `.frequency` en los resultados que llevan un eje de frecuencias: `IntensityResult`, `FieldIndicators`, `IntensityInstrumentComplianceResult`, `RoomAcousticsResult`, los cinco resultados de auditorio, `ImpedanceTubeResult`, `PorousMediumResult`, `LayeredAbsorberResult`, `DiffuseFieldAbsorptionResult`, `BiotWavesResult`, `SlitResonatorAbsorberResult`, `MetadiffuserResult`, `TransferMatrix`, los dos resultados de ruido de válvulas, `AircraftBandAttenuation`, `AmbientNoiseResult`, `ShipTrafficSpectrum` | `.frequencies` |
| `frequency=` con un array: las funciones de transmisión por flancos, `internal_spectrum`, `pipe_transmission_loss`, `expander_noise`, `transfer_matrix_one_load`, `transfer_matrix_two_load`, `TransferMatrix.plot` | `frequencies=` |
| `.time` en `ZwickerLoudness`, `EcmaLoudness`, `MooreGlasbergTimeVaryingLoudness`, los resultados de tonalidad, aspereza y fuerza de fluctuación de ECMA-418-2, `DecayCurve`, `QuasiPeakResult` | `.times` |
| `.level` en `DecayCurve`, `NpdLevelResult`, `NoiseContourResult`, `RotorcraftNoiseContourResult`, `LowFrequencyResult`, `DuctPathStage`, `LateLateralResult`, `TransferStiffnessResult` | `.levels` |
| `.distance` en `NpdLevelResult` y `RotorcraftEventResult` | `.distances` |
| `level=`, `measured_level=`, `operational_level=` en `sti_from_impulse_response`, `stipa`, `sti_adjusted_for_levels` y `STIResult.adjusted_for_levels` | `levels=`, `measured_levels=`, `operational_levels=` |
| `.passed` en `AircraftSystemComplianceResult`, `QuasiPeakDynamicsResult`, `HeavyImpactSourceCheck`, `RigidMassCalibrationResult` | `.passes`, como en todos los demás verificadores |
| `MicrophoneNoise(weighting="CCIR")` | `MicrophoneNoise(weighting="468")`, la grafía que usan `weighting_filter` y `weighted_thd` para la curva UIT-R BS.468-4 |

`times, levels = decay_curve(ir, fs)` sigue desempaquetando la curva de
caída, en el mismo orden. Los solvers FDTD conservan `c` y `rho`, que nombran
los mapas en los que están escritas sus ecuaciones.

## Todo `.plot()` toma primero los ejes

El `.plot()` de cada resultado se lee ahora `plot(ax=None, *, ...)`: primero
los ejes, y todo lo demás por su nombre, `language` incluido. Tres no empezaban
por los ejes en la 3.3.0, así que `plot(ax)` les entregaba los ejes al
parámetro equivocado:

| 3.3.0 | 4.0 |
| --- | --- |
| `LoudspeakerCharacteristics.plot("impedance", ax)` | `.plot(ax, quantity="impedance")` |
| `MicrophoneCharacteristics.plot("noise", ax)` | `.plot(ax, quantity="noise")` |
| `TransferMatrix.plot(f, rho_c, ax)` | `.plot(ax, frequencies=f, characteristic_impedance=rho_c)` |

Seis resultados posteriores a la 3.3.0 tomaban `language` por posición en la
4.0.0rc1: las cuatro valoraciones de protectores auditivos y los dos resultados
de intensimetría en baja frecuencia. `plot(ax, "es")` sobre ellos es ahora
`plot(ax, language="es")`, como en todos los demás.

## Los veredictos son objetos de resultado, no diccionarios

Las cinco funciones `verify_*` devolvían `dict[str, Any]` y ahora devuelven una
dataclase congelada, como cualquier otro resultado de la biblioteca. Dos
envoltorios públicos que existían sólo para empaquetar ese diccionario se
fueron con el cambio, ya que el verificador devuelve ahora el objeto él mismo.

| 3.3.0 | 4.0 |
| --- | --- |
| `verify_filter_class(bank)["overall_class"]` | `verify_filter_class(bank).overall_class` |
| `verify_weighting_class(wf)["overall_class"]` | `verify_weighting_class(wf).overall_class` |
| `verify_intensity_class(...)["bands"]` | `verify_intensity_class(...).bands` |
| `verify_quasi_peak_dynamics()["passed"]` | `verify_quasi_peak_dynamics().passes` |
| `verify_aircraft_noise_system(...)["passed"]` | `verify_aircraft_noise_system(...).passes` |
| `filter_class_compliance(bank)` | `verify_filter_class(bank)`, que devuelve el mismo `FilterComplianceResult` |

```python
from phonometry.filters import OctaveFilterBank, verify_filter_class

verdict = verify_filter_class(OctaveFilterBank(48000, fraction=1, limits=[125, 4000]))
print(verdict.overall_class, len(verdict.bands))
```

`verify_running_rms_decay` es posterior a la 3.3.0, y en la 4.0.0rc1 devolvía
un `bool` desnudo, que tiraba el intervalo impreso y el tiempo medido que había
juzgado. Ahora devuelve un `RunningRmsDecayVerification` que conserva los dos:
el veredicto es `.passes`, junto a `.measured_time_s`, `.printed_time_s`,
`.tolerance_s` y el intervalo como `.lower_time_s` y `.upper_time_s`. El objeto
se niega a leerse como valor de verdad, así que un
`if verify_running_rms_decay(...):` escrito contra la versión candidata levanta
`TypeError` en vez de dar por buenos todos los vibrómetros.

## El banco de filtros devuelve un resultado, no una tupla

`octave_filter()` y `OctaveFilterBank.filter()` devolvían dos elementos
normalmente y tres cuando `sigbands=True` pedía las señales por banda. La
longitud de la respuesta dependía de una palabra clave, que es la razón por la
que ese par necesitaba doce declaraciones `@overload` para poder tiparse, y por
la que `_, _, bands = bank.filter(...)` era una línea en la que había que contar
comas.

Ahora devuelven un `OctaveFilterResult` con campos con nombre, como cualquier
otro cálculo de la biblioteca.

| 3.3.0 | 4.0 |
| --- | --- |
| `spl, freq = octave_filter(x, fs)` | `r = octave_filter(x, fs)`, y luego `r.levels` y `r.frequencies` |
| `spl, _ = octave_filter(x, fs)` | `octave_filter(x, fs).levels` |
| `_, freq = octave_filter(x, fs)` | `octave_filter(x, fs).frequencies` |
| `_, _, bands = bank.filter(x, sigbands=True)` | `bank.filter(x, sigbands=True).require_bands()` |

```python

from phonometry import filters

result = filters.octave_filter(np.zeros(8000) + 1e-6, 8000, fraction=3)
print(len(result.frequencies), result.bands)
# 25 None
```

`bands` es `None` salvo que la llamada haya pedido `sigbands=True`, y `levels`
es `None` en una llamada hecha con `calculate_level=False`. El código que
necesita cualquiera de los dos puede decirlo con `require_bands()` o
`require_levels()`, que devuelven el valor o fallan nombrando el argumento que
faltaba, en vez de entregar un `None` que revienta unas líneas más adelante.

El resultado también se dibuja solo, así que el espectro de bandas es una
llamada:

```python
ax = result.plot()
```

## Las formas de onda dentro de los resultados vuelven como el `Signal` del que salieron

Una transformación a la que se le entrega un `Signal` devuelve un `Signal`
desde que se escribió el contrato, pero cinco objetos de resultado todavía
guardaban su forma de onda como un array desnudo junto a un `fs` suelto. Ahora
conservan el tipo con el que llegó la entrada:

| Campo | Es un `Signal` cuando la entrada lo era |
| --- | --- |
| `envelope(...).signal` | a la frecuencia de muestreo de la entrada |
| `time_synchronous_average(...).period_waveform` | a la frecuencia de muestreo de la entrada |
| `resample_signal(...).signal` | a `fs_new`, que entonces tiene que ser un número entero de hercios |
| `align_impulse_responses(...).aligned`, `.reference` | cada uno si su propia entrada lo era |
| `underwater.pile_strike_metrics(...).pressure` | a la frecuencia de muestreo de la entrada |

Un array desnudo sigue devolviendo un array desnudo, así que el código que
pasa arrays no lo nota. El código que pasa un `Signal` y hace aritmética con
uno de estos campos pasa por `np.asarray`, como con cualquier `Signal`, y una
entrada calibrada vuelve con `calibration_factor=1.0`, así que el campo
alimenta a la función siguiente en pascales sin que el factor se aplique otra
vez.

## Un flujo por bloques entrega bloques `Signal`

`io.read_blocks` es posterior a la 3.3.0, y en la 4.0.0rc1 entregaba arrays
float64 desnudos. Ahora entrega un `Signal` por bloque, con la frecuencia de
muestreo, la calibración, las etiquetas de canal y la procedencia que da
`io.read`, y toma el mismo `calibration_factor=` y lee el mismo sidecar. Para
un bucle escrito contra la versión candidata cambian dos cosas. Un bloque de
un archivo calibrado llega a los filtros y a las funciones de nivel en
pascales, así que un `calibration_factor` aplicado a mano encima cuenta dos
veces. Y la aritmética sobre un bloque, o sobre lo que un filtro devuelve para
él, pasa por `np.asarray`, como con cualquier `Signal`.

## Las tablas publicadas no admiten escritura

Toda tabla que la biblioteca publica a nivel de módulo es de sólo lectura en
la 4.0. En la 3.3.0 las tablas publicadas eran diccionarios corrientes y los
arrays publicados admitían aritmética sobre el propio array, así que una
línea como `REFERENCE_CURVE[500] = 0.9` o `BAND_IMPORTANCE *= 2` cambiaba el
número para todo lo que se ejecutara después en el mismo proceso, sin que
nada lo avisara. Los diccionarios son ahora `types.MappingProxyType`, también
los que van anidados dentro de otra tabla, y los arrays tienen desactivada la
bandera `writeable`. Leer no cambia; escribir lanza `TypeError` en una tabla y
`ValueError` en un array.

El código que retocaba una tabla publicada tiene que copiarla antes:

```python
from phonometry.materials.absorbers import REFERENCE_CURVE
from phonometry.speech.sii import BAND_IMPORTANCE

curve = dict(REFERENCE_CURVE)
curve[4000] = 1.0
weights = BAND_IMPORTANCE.copy()
weights[0] = 0.0
print(REFERENCE_CURVE[4000], curve[4000], weights[0], BAND_IMPORTANCE[0])
# 0.9 1.0 0.0 0.0083
```

Una tabla que lleva tablas dentro necesita copiar cada nivel, como en
`{area: dict(rows) for area, rows in GUIDE_VALUES.items()}`. La misma copia
hace falta antes de serializar una tabla con pickle, copiarla en profundidad o
escribirla como JSON, porque un mapeo de sólo lectura no admite ninguna de las
tres cosas.

## Una fila de catálogo dice lo que su fuente afirma de cada celda

Los catálogos publicados de materiales son nuevos en la 4.0: ni la 3.3.0 ni
la 4.0.0rc1 los tenían. Cambiaron de forma una vez de camino a la versión
final, y el código escrito contra la rama principal entretanto necesita los
cambios de abajo. Todas las filas de todos los catálogos comparten ahora una
base, `io.CatalogueRow` (con `io.BandedRow` para una fila que imprime un valor
por banda), y las mismas tres preguntas tienen la misma respuesta en
cualquiera de ellas.

| Antes | 4.0 |
| --- | --- |
| `SolidMaterial.estimated`, `OrthotropicWood.estimated` | `row.basis`, que asigna `"estimated"` a cada campo con la nota al pie |
| `SolidMaterial.is_estimate(field)`, `OrthotropicWood.is_estimated(field)` | `row.basis_of(field) == "estimated"` |
| `row.is_derived(field)` en un valor que la página da en una unidad que la fila no guarda (los °F y psi de la Tabla 14.1 de Ver & Beranek, los sabines de la Tabla 7.1 de Long) | `field in row.converted`; `row.converted[field]` es la cifra de la página y su unidad, como `("3e5", "psi")` |
| `row.is_derived(field)` en un valor que la página da por referencia a otra de sus filas: una celda en blanco bajo un bloque (Tabla 8.7 de Ver & Beranek, Tabla 30 de ASHRAE) o una descripción que dice «Parecido al anterior» (capítulo 32 de Harris) | `field in row.carried`; `row.carried[field]` nombra la fila |
| `OrthotropicWood(...)` y `PlateauMaterial(...)` con sus propios campos por posición | todos los campos por nombre, como en cualquier otra fila |
| `from phonometry._internal.catalogue import CatalogueRow` | `from phonometry.io import CatalogueRow` |

`is_derived` responde ahora sólo por lo que calcula la biblioteca y podría
volver a calcular con las celdas de la propia fila, como una velocidad de
barra obtenida de un módulo y una densidad. Un valor que la página da en otra
unidad sigue siendo el número de la página, y también lo es uno que da por
referencia a otra de sus filas, así que ninguno de los dos es ya derivado. `basis_of` responde con la entrada del propio campo, si no
con la de la fila, y si no con una cadena vacía, que quiere decir que la
fuente no dice cómo se obtuvo el número; las cinco cosas que puede afirmar una
fuente están en `io.CATALOGUE_BASES`.

```python
from phonometry import solids

board = solids.PUBLISHED_SOLIDS["hopkins-2007-table-a2/plasterboard_natural_gypsum"]
ear = solids.damping_named("EAR C-1002")[0]
print(board.basis_of("poisson_ratio"))
# estimated
print(ear.converted["youngs_modulus_max_pa"], ear.is_derived("youngs_modulus_max_pa"))
# ('3e5', 'psi') False
```

Las capas elásticas pasaron a ser filas de catálogo en el mismo cambio. Una
`ResilientLayer` es una `io.CatalogueRow` como cualquier otra fila, con la
clave y la atribución de las demás:

| Antes | 4.0 |
| --- | --- |
| `PUBLISHED_RESILIENT_LAYERS["mineral_wool_rock_60_30"]`, `resilient_layer("mineral_wool_rock_60_30")` | `"hopkins-2007-table-a3/mineral_wool_rock_60_30"`, la clave `"<tabla>/<fila>"` de todos los catálogos |
| `layer.attributed_to`, una cadena | `layer.attributed_to["row"]`, un mapeo como en todas las filas |
| `ResilientLayer(...)` con la rigidez, la densidad y el espesor obligatorios | todas las magnitudes opcionales, y `apparent_dynamic_stiffness_n_m3` junto a `dynamic_stiffness_n_m3` para una fuente que sólo da la aparente $s'_\mathrm{t}$ |
| `layer.natural_frequency(mass_per_area=...)` | `layer.natural_frequency(mass_per_area_kg_m2=...)`; una capa que sólo guarda $s'_\mathrm{t}$ toma además `airflow_resistivity_pa_s_m2=` y `gas_stiffness_n_m3=`, y sin el primero lanza `io.CatalogueError` |

La Tabla A3 de Hopkins imprime $s'$, la rigidez de la capa instalada tal como
la define la lista de símbolos del propio libro, así que sus quince filas
siguen guardando `dynamic_stiffness_n_m3` y devuelven las frecuencias
naturales que devolvían. Cuatro de ellas guardan una densidad que la página
imprime una sola vez para su bloque y deja en blanco en su propia línea, y
`layer.carried["density_kg_m3"]` nombra ahora la fila que la imprime; la
densidad es la misma.

```python
from phonometry import materials

rebond = materials.resilient_layer("hopkins-2007-table-a3/rebond_foam_64_20")
print(rebond.attributed_to["row"], round(rebond.natural_frequency(mass_per_area_kg_m2=120.0), 1))
# Hopkins and Hall (2006) 43.6
```

## Una fila de catálogo se comprueba al construirse

Toda fila de catálogo se comprueba en su constructor, la construya la
biblioteca a partir de una tabla empaquetada o quien llama a mano, y una celda
que nada de lo que viene después podría leer lanza `io.CatalogueError`,
nombrando la fila y el campo. El código escrito entretanto contra la rama
principal que contara con que se aceptara algo de lo siguiente tiene que
cambiar:

| Antes se aceptaba | 4.0 |
| --- | --- |
| `NaN`, un infinito, `True` o un texto como `"0,97"` en un campo numérico | `CatalogueError`; una celda que la página deja vacía es `None` |
| una fracción en un campo entero (`year`), `1` en un campo booleano (`has_section_drawing`), un número en un campo de texto | `CatalogueError` |
| un `name` o un `source` vacíos, o un matiz cuyo texto está vacío, como `misprinted={"porosity": ""}` | `CatalogueError`; `why_missing` respondía `""` para esa celda, lo que escondía el matiz |
| un matiz con la clave de un campo que la clase no tiene, como en `ranges={"no_such_field": ...}`, o un matiz sobre un número (`ranges`, `approximate`, `unquantified`, ...) con la clave de un campo de texto | `CatalogueError`, que nombra el matiz y la clave |
| `bounded_above` o `bounded_below` sobre un campo sin intervalo; un intervalo, o uno de los intervalos de las lecturas de `reported`, con un extremo que no es finito o con el extremo bajo por encima del alto; una lista de `reported` sin nada dentro; una `uncertainty` negativa | `CatalogueError` |
| un valor junto a `misprinted`, `unquantified`, `not_derivable` o `reported` en el mismo campo; una entrada de `converted` o `carried` en un campo que no guarda nada | `CatalogueError`: los cuatro primeros dicen que no hay valor que servir, y los dos últimos no tienen nada que describir |
| un valor negativo en un campo cuyo nombre acaba en `_kg_m3`, `_kg_m2`, `_kg`, `_kg_mol`, `_m_s`, `_pa`, `_pa_s_m2`, `_pa_s_m`, `_n_m3`, `_mm`, `_um`, `_m`, `_m2`, `_m_hz` o `_per_cm` (una densidad, una masa por unidad de superficie, una masa, una masa molar, una velocidad, una presión o un módulo, una resistividad al flujo, una resistencia al flujo específica, una rigidez por unidad de superficie, una longitud, un área, un producto espesor por frecuencia, una cuenta por centímetro); una porosidad fuera de 0 a 1; `shot_content_percent`, `binder_content_percent` o `adhered_area_percent` fuera de 0 a 100 | `CatalogueError`; una temperatura en grados Celsius, una tasa de decaimiento por metro y un nivel en decibelios siguen pudiendo ser negativos |
| `approximate=[...]` guardado como la lista que se le dio, un mapeo guardado como el diccionario | un `frozenset`, y todo mapeo congelado hasta el fondo, con sus pares en tuplas |
| un campo de una subclase anotado con un tipo para el que el contrato no tiene comprobación, un `float` o un `int` a secas incluidos | `TypeError` la primera vez que se construye la clase; un campo es `float \| None` (`Optional[float]` es lo mismo), `int \| None`, `bool`, `str`, `frozenset[str]` o un `Mapping[str, ...]` de ellos, porque cualquier celda de una fila puede faltar |
| `PorousMaterial.frame_constants()` sobre una fila sin `structural_loss_factor`, leída como un esqueleto que no disipa nada | `ValueError`, que dice qué tenía la página en esa celda |
| `GroundSurface.porosity` con valores de 26,9 a 58,1 en seis filas de la Tabla 6.7 de Cox y D'Antonio, que la página imprime en tanto por ciento en una columna de fracciones que no dice su unidad | `None`, con `misprinted["porosity"]` citando la cifra que imprime la página; `why_missing("porosity")` y `printed("porosity")` lo dicen |

Todas las demás filas empaquetadas cumplían ya el contrato, así que esas seis
porosidades son las únicas celdas publicadas que cambian, y todas las filas
porosas publicadas con los dos módulos imprimen su factor de pérdidas.

```python
from phonometry import io, materials

try:
    materials.PorousMaterial(name="Panel core", source="a datasheet", porosity=97.0)
except io.CatalogueError as error:
    print(error)
# 'Panel core': porosity is 97.0, and a porosity is a fraction from 0 to 1

carpet = materials.Carpet(
    name="Loop pile", source="a datasheet", pile_height_mm=6.0, approximate=["pile_height_mm"]
)
print(carpet.approximate)
# frozenset({'pile_height_mm'})
```

## La clase de un banco de filtros lee todos los requisitos de IEC 61260-1

`verify_filter_class` calificaba solo la máscara de la Tabla 1. Para la edición
de 2014 califica ahora también la desviación del ancho de banda efectivo del
5.12 y la suma de las señales de salida del 5.16, calculadas como las calcula
IEC 61260-2:2016, y la `class` de una banda y la `overall_class` del banco son
la clase más estricta que se cumple en los tres. Un veredicto con
`edition="1995"` no cambia.

| Antes | 4.0 |
| --- | --- |
| cada banda de un banco diezmado se diezmaba hasta que su frecuencia de Nyquist de proceso era 1,25 veces su borde superior de banda | se diezma sólo hasta dejarla en dieciséis veces ese borde, así que la transformación bilineal ya no deforma la banda: el nivel de una banda se mueve hasta 0,07 dB con ruido blanco, sobre todo en las bandas más bajas, y el banco de octavas, que sumaría sus salidas hasta +0,94 dB y leería clase 2 en el 5.16, es clase 1 en todos los requisitos |
| la `class` de una banda leída de `margin_class{c}_db` | `class` sobre todos los requisitos; `margin_class{c}_db` sigue siendo el margen de la Tabla 1, junto a `bandwidth_margin_class{c}_db` y `summation_margin_class{c}_db`, que vale `None` en las dos bandas de los extremos |
| un solo requisito por el que preguntar | `requirements`, `requirement_class(name)` y `binding_margin_db(name, cls)` leen cada uno por separado, y `plot(requirement="summation")` lo dibuja |

Los bancos de octavas y de tercios de octava siguen siendo clase 1.

```python
from phonometry import filters

octave = filters.verify_filter_class(
    filters.OctaveFilterBank(48000, fraction=1, limits=[125, 4000]))
print(octave.overall_class, octave.requirement_class("relative_attenuation"))
# 2 1
```

## Los diez nombres que ya no están

Todo lo demás se movió. Estos diez no existen en ningún módulo público:

| Ya no está | Qué escribir en su lugar |
| --- | --- |
| `octavefilter`, `getansifrequencies`, `normalizedfreq` | `filters.octave_filter`, `filters.nominal_frequencies`, `filters.normalized_frequencies`. Eran las grafías originales de PyOctaveBand, renombradas en la 3.1 y conservadas como alias hasta la 4.0 |
| `calculate_sensitivity` | `metrology.sensitivity`, el mismo renombrado de la 3.1 |
| `filter_class_compliance` | `filters.verify_filter_class` |
| `air_density_iso`, `speed_of_sound_iso` | `materials.air_density_iso10534`, `materials.speed_of_sound_iso10534`, y lee el aviso de los kelvin de más arriba |
| `speed_of_sound` | `fluids.air(temperature_c=...).speed_of_sound` para el medio. Usa un modelo más completo que aquella fórmula de una línea de la ISO 17497-1, así que el número se mueve: 343,99 m/s frente a 343,20 m/s a 20 °C |
| `depth_to_pressure` | `fluids.depth_to_gauge_pressure_mpa(depth_m=...)`, que devuelve el mismo número, o `fluids.depth_to_absolute_pressure_pa` para la absoluta. Las dos son keyword-only y las dos toman un `latitude_deg` opcional |
| `TransmissionLossResult` | `PropagationLossResult`, con `.tl` renombrado a `.pl` |

## Lo que no ha cambiado

Los números. El informe de conformidad son las mismas 995 comprobaciones contra
las mismas normas con los mismos valores esperados que tenía antes de la
reorganización, y de cada renombrado de esta página se comprobó que devuelve lo
que devolvía su predecesor. Si un resultado se mueve después de que termines de
actualizar, eso es un defecto y merece la pena
[avisar](https://github.com/jmrplens/phonometry/issues).

Un veredicto es la excepción, y la sección sobre las clases de filtro de más
arriba explica cómo: para la edición de 2014, `verify_filter_class` califica el
ancho de banda efectivo del 5.12 y la suma de salidas del 5.16 además de la
máscara de la Tabla 1, así que la clase de un banco es la más estricta que
cumple en los tres. Sus márgenes de la Tabla 1 y un veredicto con
`edition="1995"` son los números que eran.

Los bancos de filtros diezmados son la otra: cada banda conserva ahora su
frecuencia de Nyquist de proceso en dieciséis veces su borde superior, así que
el nivel de una banda se mueve hasta 0,07 dB con ruido blanco, como dice la
misma sección.

Lo que dibuja cada `.plot()` está intacto, y también lo que imprime cada
`.report()` salvo una etiqueta: la ficha del micrófono escribe dB(468) donde
escribía dB(CCIR). El contrato de `Signal` es el que era, ahora cumplido en dos
sitios más, los bloques de `read_blocks` y las formas de onda dentro de cinco
resultados. Cada fórmula está intacta también. La 4.0 recolocó los muebles y
puso la unidad en la etiqueta; salvo esas dos, no recalculó nada.
