<!-- canonical: https://docs.jmrp.io/phonometry/start/upgrading/ -->
Source: https://docs.jmrp.io/phonometry/start/upgrading/

# Upgrading from 3.3 to 4.0

4.0 is a hard break with no compatibility layer. Almost nothing was deleted:
of the 885 names the root exported in 3.3.0, ten exist nowhere any more and
the rest simply live at a named address instead of a flat one. So most of the
work is an import sweep, and this page is mostly a map.

Read the first two sections and you can fix the imports. Read the third even
if you are in a hurry, because in three places the obvious fix is the wrong
one, and one of those three changes a number without raising anything.

## The one rule: imports move down one level

In 3.3.0 the root re-exported the whole library, so `from phonometry import
laeq` worked for almost every name. Today the root exports twenty-five things:
the twenty-one subpackages, the three classes that belong to no single domain
(`Signal`, `ReportMetadata`, `PhonometryWarning`) and `__version__`.

```python
import phonometry

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

from phonometry.signals import laeq     # this is the new spelling
```

Measured across the whole surface, 872 of those 885 root names are published
today by exactly one subpackage, none by two, and none needs a path deeper
than `phonometry.<subpackage>`. Three more are still at the root, where they
always were. So the swap is unambiguous: find the subpackage, import from it.

The attribute form was never removed, which is the smallest possible diff for
code that reached through the root:

```python
from phonometry import filters

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

### Finding the new home of a name

Rather than search the reference for each one, ask the installed package:

```python
import importlib
import pkgutil

import phonometry


def whereis(name):
    """Every public module that publishes `name`, shallowest first."""
    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
```

It walks the tree in about two seconds. When it returns several paths the
shallowest one is the canonical import: a subpackage re-exports what its own
modules publish, and that shallow path is the one the guides and the tests
use. The four names the root still publishes on its own, `Signal`,
`PhonometryWarning`, `ReportMetadata` and `__version__`, come back as
`phonometry` itself. An empty list means the name is in the last section of
this page.

## Modules that split rather than moved

Five of the thirty modules that changed path did not move to one new address.
They were divided, and a reader who follows only the first half of the split
gets an `ImportError` for the rest. These are worth checking one by one, and
the private renderer package is listed with them because the same thing
happened to it:

| 3.3.0 module | Where its contents are now |
| --- | --- |
| `phonometry.impedance_tube` | `materials.absorbers.impedance_tube` (two-microphone transfer function), `materials.absorbers.four_microphone` (`TransferMatrix`, `transfer_matrix_one_load`, `wave_decomposition`, `face_quantities`, `air_layer_transfer_matrix`), `materials.absorbers.standing_wave`, and `fluids` |
| `phonometry.scattering_diffusion` | `materials.diffusers.reverberation_room_scattering` for the reverberation-room part (`ScatteringResult`, `ScatteringUncertainty`, `BASE_PLATE_BANDS` and the rest), `materials.diffusers.scattering_diffusion` for the nine free-field names |
| `phonometry.materials.porous_absorber` | `materials.absorbers.porous` for the bulk-material models, `materials.absorbers.layered` for the whole multilayer API (`layered_absorber`, `AirLayer`, `MembraneLayer`, `PerforatedPlateLayer`, `MicroperforatedPlateLayer`, `LayeredAbsorberResult`) |
| `phonometry.metrology.spectra` | `signals.spectra`, plus `signals.multitaper` (`multitaper_psd`) and `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.<domain>`: the 82 renderers were split per domain, so `plot_airborne_insulation` is in `_plot.building` and `plot_age_threshold` in `_plot.hearing`. Private either way, and `.plot()` on the result is the supported route |

The other twenty-five moved whole, and the resolver above finds them without
reading this table. Each of the first three was reachable by two paths in
3.3.0, the flat one and the one under its package, and both are gone.

## Three where the obvious fix is the wrong one

Everything else on this page announces itself the moment you run it, and the
fix is the one it looks like. These three are not that.

### The impedance-tube helpers took kelvin

`air_density_iso` and `speed_of_sound_iso` documented and consumed their
temperature in **kelvin**. Their successors take degrees Celsius, like the
rest of the library. The names changed, so the import fails and you will
notice, but the argument you carry over is the problem: 293.15 is a
legitimate-looking number in either unit.

```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 against 477.13 m/s: a 39 % error, no exception, and every
absorption coefficient computed from it is wrong by a plausible-looking
amount. Convert the value, do not just rename the keyword.

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

With the conversion applied both return exactly what they returned in 3.3.0,
bit for bit.

### `scattering_diffusion.speed_of_sound` took Celsius, and its replacement answers differently

The ISO 17497-1 helper of the same name took degrees Celsius, so no conversion
is needed there. The catch is the replacement. `fluids.air()` uses a fuller
model of the medium than the ISO 17497-1 one-liner, and at 20 °C it answers
343.99 m/s where the old helper answered 343.20 m/s. That is small, and it is
not nothing if you are reproducing a measurement made with the old number.
ISO 17497-1 clause 8 prints the formula if you need that clause exactly.

### The `tl` field of an underwater result is `pl`

`transmission_loss` became `propagation_loss`, which the import catches, and
the field inside the result was renamed with it. That one does not raise: a
`PropagationLossResult` has no `.tl`, so the `AttributeError` arrives wherever
you read it, which may be far from the call.

```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, the number .tl used to hold
```

The same rename reaches `underwater.sonar_equation`, whose
`transmission_loss` parameter is now `propagation_loss`.

## The environment moved into a dataclass

Five entry points no longer take the ambient conditions as loose numbers.
Renaming the keyword is not enough there, because the parameter is gone
entirely:

| 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(...)` | the same, with `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(...)` | the same, with `RotorcraftAtmosphere` |
| `outdoor_propagation.predicted_receiver_level(..., humidity=70.0)` | `predicted_receiver_level(..., atmosphere=AtmosphericConditions(relative_humidity_percent=70.0))` |

The per-point rotorcraft overrides went the same way: `bank_angle` and
`path_angle` are now `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)
```

## A parameter that names a quantity carries its unit

Everywhere else, the ambient conditions kept their place and gained a suffix.
The rule is that a public parameter naming a dimensional quantity ends in the
unit the caller types, because a number is where a unit is lost and no guard
can catch it by magnitude: 101.325 and 101325 are both legitimate pressures
in this library.

| 3.3.0 | 4.0 |
| --- | --- |
| `temperature=` | `temperature_c=` |
| `pressure=`, `static_pressure=`, `barometric_pressure=`, `ambient_pressure=`, `atmospheric_pressure=` | `atmospheric_pressure_kpa=`, or `_pa=` where the clause is written in pascals |
| `humidity=`, `relative_humidity=` | `relative_humidity_percent=` |
| `diameter=` and its thirteen compounds | `diameter_m=` |
| `angle=`, `angles=`, `bank_angle=`, `path_angle=`, `grazing_angle=`, `launch_angles=` | `_deg=` or `_rad=`, whichever the function reads |
| `runway_gradient=` | `runway_gradient_ratio=` |
| `sound_speed_gradient=` | `sound_speed_gradient_per_s=` |

One case is worth naming because it was a trap rather than an inconvenience.
`critical_angle` was a field in radians on one result and a parameter in
degrees on the two functions one screen below it, which is the same word for
two things 57 times apart. It is `critical_angle_rad` on the result and
`critical_angle_deg` on the parameters now.

`transmission_loss=` became `propagation_loss=` only in the underwater sonar
and numerical-propagation results. `ReactiveSilencerResult` and
`SoundReductionResult` still call theirs `transmission_loss=`, correctly:
that is what the standards behind them call it.

The EN 29052-1 functions of `materials` follow the same rule, with the unit
of each quantity at the end of its name:

| 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=` by name only, `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=` |

The airflow resistivity is the one that earns the keyword. EN 29052-1
thresholds it in kPa·s/m², and every other flow resistivity in the library is
in Pa·s/m², a unit a thousand times smaller, so a Pa·s/m² figure passed by
position landed in the wrong branch of clause 8.2 without a word; it is
written by name now. And below 100 kPa·s/m², `installed_dynamic_stiffness` no
longer reads a missing gas term as zero: 3.3.0 returned $s'_\mathrm{t}$ from
`installed_dynamic_stiffness(20e6, 50.0)`, Formula 6 with its second term
dropped, and 4.0 raises and asks for $s'_\mathrm{a}$.

## Conditions and flags are written by name

Two rules made 37 signatures keyword-only. A condition or an option that
carries a default is written by name, and every public boolean flag is
keyword-only. Both exist because the call site is where the meaning is lost:
`air_attenuation(freqs, 20.0, 50.0, 101.325)` is a temperature, a humidity
and a pressure in an order nobody remembers, and `bank.filter(x, True)` says
nothing about which of five flags was thrown.

The failure is loud, which is the point:

```python
import numpy as np

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

That call is worth a second look. `leq` never had an `fs`, because an energy
average does not need one, so in 3.3.0 the 48000 silently bound to
`calibration_factor` and the answer came back 93.6 dB high with no complaint.
Now it is a `TypeError`, which is what it always should have been.

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

## One spelling per concept

The public names had drifted into several spellings of the same quantity, and
4.0 keeps the one the majority already used. The rule for the quantities
counted in the plural is the one most of the API followed: a field or a
parameter that holds an array is `frequencies`, `times`, `distances` or
`levels`, and one that holds a single value is singular. A vectorised argument
that takes either a number or an array keeps its name.

| 3.3.0 or 4.0.0rc1 | 4.0 |
| --- | --- |
| `sound_speed` (control valves, silencer measurement, Weston regimes, ambient noise, `FluidSeabed`, `SoundSpeedProfile`, `ShipSourceLevelResult`) | `speed_of_sound` |
| `c=` and `rho=` on `sound_intensity`, `c=` on the two phase-mismatch conversions, `phase_mismatch()`, `absorption_coefficient_uncertainty` and `monopole_source_level` | `speed_of_sound=`, `density=` |
| `speed_of_sound_m_s=` on `thickness_critical_frequency_product` | `speed_of_sound=` |
| `sample_rate` on the FDTD `SignalSource`, `STOIResult` and `impact_force_exposure_level` | `fs` |
| `freq=` on `linkwitz_riley` and `adaptation_term_kc` | `frequency=` and `frequencies=` |
| `.frequency` on the results that carry a frequency axis: `IntensityResult`, `FieldIndicators`, `IntensityInstrumentComplianceResult`, `RoomAcousticsResult`, the five auditorium results, `ImpedanceTubeResult`, `PorousMediumResult`, `LayeredAbsorberResult`, `DiffuseFieldAbsorptionResult`, `BiotWavesResult`, `SlitResonatorAbsorberResult`, `MetadiffuserResult`, `TransferMatrix`, the two valve-noise results, `AircraftBandAttenuation`, `AmbientNoiseResult`, `ShipTrafficSpectrum` | `.frequencies` |
| `frequency=` holding an array: the flanking-transmission functions, `internal_spectrum`, `pipe_transmission_loss`, `expander_noise`, `transfer_matrix_one_load`, `transfer_matrix_two_load`, `TransferMatrix.plot` | `frequencies=` |
| `.time` on `ZwickerLoudness`, `EcmaLoudness`, `MooreGlasbergTimeVaryingLoudness`, the ECMA-418-2 tonality, roughness and fluctuation-strength results, `DecayCurve`, `QuasiPeakResult` | `.times` |
| `.level` on `DecayCurve`, `NpdLevelResult`, `NoiseContourResult`, `RotorcraftNoiseContourResult`, `LowFrequencyResult`, `DuctPathStage`, `LateLateralResult`, `TransferStiffnessResult` | `.levels` |
| `.distance` on `NpdLevelResult` and `RotorcraftEventResult` | `.distances` |
| `level=`, `measured_level=`, `operational_level=` on `sti_from_impulse_response`, `stipa`, `sti_adjusted_for_levels` and `STIResult.adjusted_for_levels` | `levels=`, `measured_levels=`, `operational_levels=` |
| `.passed` on `AircraftSystemComplianceResult`, `QuasiPeakDynamicsResult`, `HeavyImpactSourceCheck`, `RigidMassCalibrationResult` | `.passes`, as on every other verifier |
| `MicrophoneNoise(weighting="CCIR")` | `MicrophoneNoise(weighting="468")`, the spelling `weighting_filter` and `weighted_thd` use for the ITU-R BS.468-4 curve |

`times, levels = decay_curve(ir, fs)` still unpacks the decay curve, in the
same order. The FDTD solvers keep `c` and `rho`, which name the maps their
equations are written in.

## Every `.plot()` takes the axes first

Every result's `.plot()` now reads `plot(ax=None, *, ...)`: the axes first,
and every other argument by name, `language` included. Three did not start
with the axes in 3.3.0, so `plot(ax)` on them handed the axes to the wrong
parameter:

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

Six results that are newer than 3.3.0 took `language` by position in
4.0.0rc1: the four hearing-protector ratings and the two low-frequency
intensity results. `plot(ax, "es")` on those is `plot(ax, language="es")`
now, as it is everywhere else.

## Verdicts are result objects, not dictionaries

The five `verify_*` functions returned `dict[str, Any]` and now return a
frozen dataclass, like every other result in the library. Two public
wrappers that existed only to package that dictionary went with the change,
since the verifier now returns the object itself.

| 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)`, which returns the same `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` is newer than 3.3.0, and in 4.0.0rc1 it returned a
bare `bool`, which threw away the printed interval and the measured time it
had judged. It now returns a `RunningRmsDecayVerification` that keeps both:
the verdict is `.passes`, beside `.measured_time_s`, `.printed_time_s`,
`.tolerance_s` and the interval as `.lower_time_s` and `.upper_time_s`. The
object refuses to be read as a truth value, so an
`if verify_running_rms_decay(...):` written against the release candidate
raises `TypeError` instead of passing every meter.

## The filter bank returns a result, not a tuple

`octave_filter()` and `OctaveFilterBank.filter()` returned two items normally
and three when `sigbands=True` asked for the band waveforms. The length of the
answer depended on a keyword, which is why the pair needed twelve `@overload`
declarations to be typed at all, and why `_, _, bands = bank.filter(...)` was
a line you had to count commas in.

They now return an `OctaveFilterResult` with named fields, like every other
computation in the library.

| 3.3.0 | 4.0 |
| --- | --- |
| `spl, freq = octave_filter(x, fs)` | `r = octave_filter(x, fs)`, then `r.levels` and `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
import numpy as np
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` is `None` unless the call asked for `sigbands=True`, and `levels` is
`None` for a call made with `calculate_level=False`. Code that needs either
one can say so with `require_bands()` or `require_levels()`, which return the
value or raise naming the argument that was missing, rather than handing back
a `None` that fails several lines later.

The result also draws itself, so the band spectrum is one call:

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

## Waveforms inside results come back as the `Signal` they came from

A transform handed a `Signal` has returned a `Signal` since the contract was
written, but five result objects still stored their waveform as a bare array
next to a loose `fs`. They now keep the type the input arrived as:

| Field | Is a `Signal` when the input was one |
| --- | --- |
| `envelope(...).signal` | at the input's rate |
| `time_synchronous_average(...).period_waveform` | at the input's rate |
| `resample_signal(...).signal` | at `fs_new`, which then has to be a whole number of hertz |
| `align_impulse_responses(...).aligned`, `.reference` | each one if its own input was |
| `underwater.pile_strike_metrics(...).pressure` | at the input's rate |

A bare array in still gives a bare array out, so code that passes arrays does
not notice. Code that passes a `Signal` and does arithmetic on one of these
fields goes through `np.asarray`, as it does for any `Signal`, and a
calibrated input comes back with `calibration_factor=1.0`, so the field feeds
the next function in pascals without the factor being applied again.

## A block stream yields `Signal` blocks

`io.read_blocks` is newer than 3.3.0, and in 4.0.0rc1 it yielded bare float64
arrays. It now yields a `Signal` per block, carrying the rate, the
calibration, the channel labels and the provenance `io.read` gives, and it
takes the same `calibration_factor=` and reads the same sidecar. Two things
change for a loop written against the release candidate. A block from a
calibrated file reaches the filters and the level functions in pascals, so a
`calibration_factor` applied by hand on top of it counts twice. And
arithmetic on a block, or on what a filter returns for one, goes through
`np.asarray`, as it does for any `Signal`.

## Published tables refuse writes

Every table the library publishes at module level is read-only in 4.0. In
3.3.0 published tables were plain dictionaries and published arrays took
in-place arithmetic, so a line such as `REFERENCE_CURVE[500] = 0.9` or
`BAND_IMPORTANCE *= 2` changed the number for every caller that ran after it
in the same process, and nothing raised. The dictionaries are now
`types.MappingProxyType`, including the ones nested inside another table, and
the arrays have their `writeable` flag cleared. Reading is unchanged; writing
raises `TypeError` on a table and `ValueError` on an array.

Code that adjusted a published table has to copy it first:

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

A table with tables inside it needs each level copied, as in
`{area: dict(rows) for area, rows in GUIDE_VALUES.items()}`. The same copy is
needed before a table is pickled, deep-copied or written out as JSON, because
a read-only mapping supports none of the three.

## A result keeps its own copy of the arrays it was given

In 3.3.0 and in 4.0.0rc1 a result kept the very array it was handed whenever
that array was already of the type the function reads, `float64` for levels
and frequencies, because `np.asarray` hands its argument back unchanged in
that case. Changing the caller's array afterwards, `levels[0] = 0.0` or
`frequencies *= 2`, changed a result already computed and perhaps already
printed. In 4.0 every array that a function or a method of the library
keeps from the arguments it was given is a copy of its own, made read-only
like the published tables, and the caller's array is left as it was. That
holds for the objects that keep arrays to hand out later as well: the
frequency grid of a `noise_control.SilencerChain`, the axes and flight paths
an `aircraft.AnpDatabase` is built from, and the medium maps of
`simulation.FDTD2D` and `simulation.ElasticFDTD2D`, whose `c`, `rho`, `c_p`
and `c_s` now stay the ones the time step was computed from. A `Signal`
keeps a copy of its own samples too, which stays writeable for processing in
place, as the samples of a file read with `io.read` always were. A result
record built by hand, by calling its class directly, makes the same copy:
every public record that can hold an array copies the arrays it is handed
when it is built, whoever builds it, also inside a tuple, a list or any
mapping. A record held inside another one is kept whole and answers for its
own arrays, and a `Signal` is no exception: the seven results that hand a
waveform back as a `Signal` (`filters.OctaveFilterResult`,
`signals.EnvelopeResult`, `signals.ResampledSignalResult`,
`signals.AlignedImpulseResponseResult`, `signals.SynchronousAverageResult`,
`environment.DirectSoundSubtraction` and `underwater.PileStrikeResult`) hold
one of their own when the library builds them, whose samples stay writeable
like any `Signal`'s, and hold the caller's own when built by hand around it.
Every other array a result holds refuses in-place writes, the ones it
computed as well as the ones it was given, where in 3.3.0 most computed
arrays took them. Writing into one now raises `ValueError`, so code that
edited one in place has to copy it first:

```python
import numpy as np

from phonometry import io, room

levels = np.array([60.0, 58.0, 55.0, 52.0, 50.0, 48.0, 45.0, 42.0, 40.0, 38.0])
nc = room.noise_criterion(levels)
levels[0] = 0.0
samples = np.zeros(4)
signal = io.Signal(samples, fs=48_000)
samples[0] = 1.0
measured = np.array([1.0, 0.5, 0.25, 0.125])
response = room.ImpulseResponseResult(ir=measured, fs=48_000, method="farina")
measured[0] = 0.0
edited = nc.levels.copy()
edited[0] = 62.0
print(nc.levels[0], nc.levels.flags.writeable, response.ir[0], response.ir.flags.writeable)
print(signal.data[0, 0], signal.data.flags.writeable)
# 60.0 False 1.0 False
# 0.0 True
```

## A catalogue row says what its source claims for each cell

The published catalogues of materials are new in 4.0: neither 3.3.0 nor
4.0.0rc1 had them. They changed shape once on the way to the final release,
and code written against the main branch in between needs the changes below.
Every row of every catalogue now shares one base, `io.CatalogueRow` (with
`io.BandedRow` for a row that prints one value per band), and the same three
questions have the same answer on any of them.

| Before | 4.0 |
| --- | --- |
| `SolidMaterial.estimated`, `OrthotropicWood.estimated` | `row.basis`, which maps each footnoted field to `"estimated"` |
| `SolidMaterial.is_estimate(field)`, `OrthotropicWood.is_estimated(field)` | `row.basis_of(field) == "estimated"` |
| `row.is_derived(field)` on a value the page gives in a unit the row does not hold (the °F and psi of Ver & Beranek Table 14.1, the sabins of Long Table 7.1) | `field in row.converted`; `row.converted[field]` is the page's figure and its unit, such as `("3e5", "psi")` |
| `row.is_derived(field)` on a value the page gives by reference to another of its rows: a cell left blank under a block (Ver & Beranek Table 8.7, ASHRAE Table 30) or a description that reads "Parecido al anterior" (Harris Chapter 32) | `field in row.carried`; `row.carried[field]` names the row |
| `OrthotropicWood(...)` and `PlateauMaterial(...)` with their own fields by position | every field by name, as on every other row |
| `from phonometry._internal.catalogue import CatalogueRow` | `from phonometry.io import CatalogueRow` |

`is_derived` now answers only for what the library computes and could compute
again from the row's own cells, such as a bar speed worked out from a modulus
and a density. A value the page gives in another unit is still the page's
number, and so is one it gives by reference to another of its rows, so neither
is derived any more. `basis_of` answers with the field's own entry, else the
row's, else an empty string, which means the source does not say how the
number was obtained; the five things a source can claim are in
`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
```

The resilient layers became catalogue rows in the same change. A
`ResilientLayer` is an `io.CatalogueRow` like every other row, keyed and
credited the way the others are:

| Before | 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"`, the `"<table>/<row>"` key of every catalogue |
| `layer.attributed_to`, a string | `layer.attributed_to["row"]`, a mapping as on every row |
| `ResilientLayer(...)` with the stiffness, the density and the thickness required | every quantity optional, and `apparent_dynamic_stiffness_n_m3` beside `dynamic_stiffness_n_m3` for a source that gives only the apparent $s'_\mathrm{t}$ |
| `layer.natural_frequency(mass_per_area=...)` | `layer.natural_frequency(mass_per_area_kg_m2=...)`; a layer that holds only $s'_\mathrm{t}$ also takes `airflow_resistivity_pa_s_m2=` and `gas_stiffness_n_m3=`, and raises `io.CatalogueError` without the first |

Hopkins Table A3 prints $s'$, the stiffness of the installed layer as the
book's own list of symbols defines it, so its fifteen rows still hold
`dynamic_stiffness_n_m3` and return the natural frequencies they returned
before. Four of them hold a density the page prints once for their block and
leaves blank on their own line, and `layer.carried["density_kg_m3"]` now
names the row that prints it; the density is the same.

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

## A catalogue row checks itself when it is built

Every catalogue row is checked in its constructor, whether the library builds
it from a packaged table or a caller builds it by hand, and a cell nothing
downstream could read raises `io.CatalogueError`, naming the row and the
field. Code written against the main branch in between that relied on any of
these being accepted has to change:

| Accepted before | 4.0 |
| --- | --- |
| `NaN`, an infinity, `True` or a text such as `"0,97"` in a numeric field | `CatalogueError`; a cell the page leaves empty is `None` |
| a fraction in a whole-number field (`year`), `1` in a flag field (`has_section_drawing`), a number in a text field | `CatalogueError` |
| an empty `name` or `source`, or a hedge whose text is empty, as in `misprinted={"porosity": ""}` | `CatalogueError`; `why_missing` answered `""` for that cell, which hid the hedge |
| a hedge keyed by a field the class does not have, as in `ranges={"no_such_field": ...}`, or a hedge on a number (`ranges`, `approximate`, `unquantified`, ...) keyed by a text field | `CatalogueError`, naming the hedge and the key |
| `bounded_above` or `bounded_below` on a field with no range; a range, or an interval among the `reported` readings, with an end that is not finite or with its low end above its high one; a `reported` list with nothing in it; an `uncertainty` below zero | `CatalogueError` |
| a value beside `misprinted`, `unquantified`, `not_derivable` or `reported` on the same field; a `converted` or `carried` entry on a field that holds nothing | `CatalogueError`: the first four say there is no value to serve, the last two have nothing to describe |
| a value below zero in a field whose name ends in `_kg_m3`, `_kg_m2`, `_kg`, `_kg_mol`, `_m_s`, `_pa`, `_pa_s_m2`, `_pa_s_m`, `_n_m3`, `_mm`, `_um`, `_m`, `_m2`, `_m_hz` or `_per_cm` (a density, a mass per area, a mass, a molar mass, a speed, a pressure or modulus, a flow resistivity, a specific flow resistance, a stiffness per area, a length, an area, a thickness-frequency product, a count per centimetre); a porosity outside 0 to 1; `shot_content_percent`, `binder_content_percent` or `adhered_area_percent` outside 0 to 100 | `CatalogueError`; a Celsius temperature, a decay rate per metre and a level in decibels stay signed |
| `approximate=[...]` kept as the list it was given, a mapping kept as the dict | a `frozenset`, and every mapping frozen all the way down, its pairs into tuples |
| a subclass field annotated with a type the contract has no check for, a bare `float` or `int` included | `TypeError` the first time the class is built; a field is `float \| None` (`Optional[float]` is the same), `int \| None`, `bool`, `str`, `frozenset[str]` or a `Mapping[str, ...]` of those, because every cell of a row may be missing |
| `PorousMaterial.frame_constants()` on a row without `structural_loss_factor`, read as a frame that dissipates nothing | `ValueError`, saying what the page had in that cell |
| `GroundSurface.porosity` holding 26.9 to 58.1 on six rows of Cox & D'Antonio Table 6.7, which the page prints in per cent in a column of fractions that states no unit | `None`, with `misprinted["porosity"]` quoting the figure the page prints; `why_missing("porosity")` and `printed("porosity")` say so |

Every other packaged row already met the contract, so those six porosities are
the only published cells that change, and every published porous row with
both moduli prints its loss factor.

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

## A filter bank's class reads every requirement of IEC 61260-1

`verify_filter_class` graded the Table 1 mask alone. For the 2014 edition it
now also grades the effective bandwidth deviation of 5.12 and the summation of
output signals of 5.16, computed as IEC 61260-2:2016 computes them, and a
band's `class` and the bank's `overall_class` are the strictest class met on
all three. For the 1995 edition it grades the filter integrated response of
4.5.3 and the summation of output signals of 4.9 beside Table 1, as that
edition's clause 5 tests them. In both editions every band is graded at the
bank's input rate up to half of it, so a decimated band is graded on the alias
images its decimation folds onto it, and the bank's decimator keeps them more
than 125 dB down.

| Before | 4.0 |
| --- | --- |
| each band of a decimated bank decimated until its processing Nyquist frequency is 1.25 times its upper band edge | decimated only as far as leaves it sixteen times that edge, so the bilinear transform no longer bends the band: a band level moves by up to 0.07 dB on white noise, most in the lowest bands, and the octave bank, which would sum its outputs up to +0.94 dB and read class 2 on 5.16, is class 1 on every requirement |
| a band's `class` read off `margin_class{c}_db` | `class` over every requirement; `margin_class{c}_db` is still the Table 1 margin, beside `bandwidth_margin_class{c}_db` and `summation_margin_class{c}_db`, which is `None` on the two end bands |
| one requirement to ask about | `requirements`, `requirement_class(name)` and `binding_margin_db(name, cls)` read each one on its own, and `plot(requirement="summation")` draws it |
| an `edition="1995"` verdict on Table 1 alone | Table 1, `"effective_bandwidth"` (the filter integrated response of 4.5.3) and `"summation"` (4.9, run from the lowest to the highest mid-band frequency, so the end bands carry it too), both at the S that 5.3.3 raises in steps of 12 until the integrated response settles, which the result's `points_per_bandwidth` reports (36 for the default octave bank) |
| a decimated band graded up to the Nyquist frequency of its decimated rate, `checked_to_omega` stopping there | graded at the input rate up to half of it, alias images included; `checked_to_omega` is half the input rate over the mid-band frequency, and `range_limited` marks a Table 1 mask running on past it |
| the anti-aliasing filter `scipy.signal.resample_poly` designs by default, which left the images of the octave bank at 8, 16 and 32 kHz 68.9 dB down, under the 70 dB of class 1 | a filter of the same length sized for a 120 dB stopband, its images at least 125.4 dB down; a band level moves by under 0.01 dB |

The octave and one-third-octave banks keep class 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"))
# 1 1
```

## A verdict reads its printed limit from the standard

A verdict used to carry the limit it was judged against as a constructor
field. In 3.3.0 three did: `ToneAssessment.criterion_db`,
`ExposureAssessment.action_value` and `limit_value`, and
`RigidMassCalibrationResult.tolerance`. Six more that are newer than 3.3.0
did in 4.0.0rc1, and the rest of the table arrived after 4.0.0rc1. Anything
that built one by hand, or rewrote one with `dataclasses.replace`, could
judge a measurement against any number and still print the standard's name
over the result. In 4.0 every limit a standard prints is a read-only
property, read from the fields that select it: the bands, the condition, the
source, the class, the room. A verdict that was stored beside its limit is
read from the measured fields too, or checked against them when the result
is built, so it cannot contradict the limit. The functions that return these
verdicts take the same arguments and give the same answers, and every name
below still reads the same value; only code that constructs a verdict
directly, or replaces one of these fields, has to change. The last column
says where each verdict first shipped.

| Verdict | No longer constructor fields | Fields it keeps or gains | Since |
| --- | --- | --- | --- |
| `building.PositionSpreadCheck` | `limit_db`, `stage`, `spread_db`, `action`, `next_positions`, `corner_standard_deviation_db` | `levels_db`, which all of them are read from | after 4.0.0rc1 |
| `building.HeavyImpactSourceCheck` | `frequencies`, `nominal`, `tolerance`, `deviation`, `within_tolerance`, `passes` | `source`, `measured` | 4.0.0rc1 |
| `noise_control.BandFlatnessCheck` | `limit_db`, `satisfied` | `octave_centres_hz`, `spread_db` | after 4.0.0rc1 |
| `noise_control.SourcePositionCheck` | `required_positions`, `satisfied`, `exceeds_maximum` | `positions_used`, `max_octave_spread_db` | after 4.0.0rc1 |
| `noise_control.TestEnvironmentApplicability` | `environmental_correction_limit_db`, `background_margin_limit_db`, `required_area_ratio`, `applicable` | `base_standard`, `actual_area_ratio`, `mean_absorption_coefficient` | after 4.0.0rc1 |
| `environment.ReflectionGridCheck` | `nominal_m`, `deviations_m`, `within` | `check`, `path_differences_m` | after 4.0.0rc1 |
| `emission.HardWalledRoomCheck` | `limit_db`, `minimum_volume_m3`, `box_dimension_limit_m`, `minimum_microphone_distance_m` | the bands, the room and the box | after 4.0.0rc1 |
| `emission.ReferenceSoundSourceVerdict` | every `*_limit_db`, `adjacent_step_db`, `core_range_db`, `extended_range_db`, `frequency_range_met`, `not_judged` | the bands, the levels and the measured figures | after 4.0.0rc1 |
| `emission.ReferenceSourceDriftResult`, `emission.SpecialRoomSuitabilityCheck` | `limit_db` | the bands | after 4.0.0rc1 |
| `emission.SpecialRoomReverberationCheck` | `lower_limit`, `upper_limit` | the bands | after 4.0.0rc1 |
| `emission.IntensityInstrumentComplianceResult` | `limit_class1`, `limit_class2`, `spacing_offset_db` | `device`, `spacing`; the band rows are read from them, see the next section | 4.0.0rc1 |
| `emission.InverseSquareLawResult`, `emission.SourceDirectionalityResult` | `tolerance_db` | `room` selects it; the qualified radii must be the ones the levels reach | after 4.0.0rc1 |
| `hearing.AmbientNoiseCheck` | `limits_db`, `table_bands_hz` | gains `earphone_attenuation_db` and `allowed_threshold_shift_db` | after 4.0.0rc1 |
| `hearing.FixtureIsolationCheck` | `required_db` | the bands | after 4.0.0rc1 |
| `hearing.ReatSoundFieldCheck` | `allowable_variation_db` | gains `free_field_rejection_db` | after 4.0.0rc1 |
| `hearing.DiffuseSoundFieldCheck` | `allowable_variation_db` | gains `front_to_random_index_db` | after 4.0.0rc1 |
| `hearing.FreeSoundFieldCheck` | `axis_offset_m`, `inverse_distance_difference_db` | `field` and `loudspeaker_distance_m` select them | after 4.0.0rc1 |
| `electroacoustics.ProgrammeSignalCheck` | `offset_db`, `relative_levels_db`, `tolerance_plus_db`, `tolerance_minus_db` | `frequencies_hz`, `band_levels_db` | after 4.0.0rc1 |
| `electroacoustics.NeckLoopVerification` | `limits` | `neck_loop_type` selects it | after 4.0.0rc1 |
| `psychoacoustics.ToneAssessment` | `criterion_db`, `prominent` | gains `method`, `"tone_to_noise_ratio"` or `"prominence_ratio"` | 3.3.0 |
| `underwater.WeightedExposureResult` | `criteria` | gains `impulsive` | 4.0.0rc1 |
| `vibration.ExposureAssessment` | `action_value`, `limit_value`, `exceeds_action`, `exceeds_limit`, `zone` | `value`, `kind`, `metric` | 3.3.0 |
| `vibration.PhaseVerification`, `vibration.WeightingVerification` | `tolerance_deg` (phase), `within_tolerance` | the weighting and the frequencies | 4.0.0rc1 |
| `vibration.RunningRmsDecayVerification` | `printed_time_s`, `tolerance_s` | `method`, `integration_time_s`, `measured_time_s` | after 4.0.0rc1 |
| `vibration.SignalBurstVerification` | `printed`, `deviation_percent`, `tolerance_percent`, `within_tolerance` | the table row, the columns and `measured` | 4.0.0rc1 |
| `vibration.VibrationMeterVerification` | `lower_percent`, `upper_percent`, `within_tolerance` | gains `measured_response` | after 4.0.0rc1 |
| `vibration.AssessmentVelocity` | `guide_value_mm_s` | `building_class` selects it | after 4.0.0rc1 |
| `vibration.MountingCheck` | `acceptable`, `frequency_limit_hz`, `peak_acceleration_limit_m_s2`, `device` | gains `peak_acceleration_m_s2` and `upper_frequency_hz` | after 4.0.0rc1 |
| `vibration.DamageAssessment` | `guideline_mm_s` | gains `massive_structure`, by name only | 4.0.0rc1 |
| `vibration.RigidMassCalibrationResult` | `expected`, `deviation`, `within_tolerance`, `passes`, `tolerance` | `frequencies`, `measured`, `mass`, `quantity` | 3.3.0 |
| `vibration.LevelDifferenceCheck` | `limit_db` | `condition` selects it | after 4.0.0rc1 |
| `vibration.OutputMassCheck` | `mass_limit_kg` | gains `output_apparent_mass_kg`, the measured output force over output acceleration | after 4.0.0rc1 |

Two calls change too. `rigid_mass_calibration_check` no longer takes
`tolerance`: ISO 7626-2:2015 7.5.2 says the block "shall agree within ±5 %",
so a tighter in-house acceptance is read from `.deviation` instead. And
`HeavyImpactSourceCheck` and `RigidMassCalibrationResult`, whose `passes` was a
stored field, refuse to be read as a truth value like every other verdict, so
`if check:` raises `TypeError` and the verdict is `.passes`.

```python
import dataclasses

from phonometry import vibration

check = vibration.check_blocked_output([100.0, 200.0], [90.0, 95.0], [60.0, 70.0])
print(check.limit_db, check.passes)
# 20.0 True
try:
    dataclasses.replace(check, limit_db=10.0)
except TypeError as error:
    print(type(error).__name__)
# TypeError
calibration = vibration.rigid_mass_calibration_check([0.104, 0.093], [50.0, 500.0], 10.0)
print(calibration.tolerance, calibration.within_tolerance.tolist())
# 0.05 [True, False]
```

The limits a standard leaves to the user stay fields: the "default" roughness
and decay-rate limits of ISO 3095, the tolerance ISO/DIS 16032 7.6 does not
print for "approximately 30 s", the curing period a product specification
sets under ISO 10140-1 G.4, the threshold shift ISO 8253-1 lets the tester
accept, and the specification limits, test-case limits and limit rows the
caller passes to the conformance, ISO 17534-1 and RD 1367/2007 checks. The
requirement records of ISO 8297 and IEC 60118-4, `PlantRequirement` and
`LoopRequirement`, also keep each row's limit as a field: one record type
holds requirements whose limits mix printed figures with the plant's geometry,
the specified field strength or the noise measured with the loop off.

## A verdict is read from the values it holds

The section above took the printed limits out of the constructors. The verdict
flags stayed in: `passes`, `complies`, `qualified`, every `*_met` and `*_ok`,
the advisories, and the class of each band of a filter, weighting or intensity
instrument verdict. The function that built a result worked them out and
stored them beside the readings they came from, so a result built by hand, or
rewritten with `dataclasses.replace`, could hold readings that fail and a flag
that says they pass. In 4.0 every one of them is a read-only property, read
from the values the result holds and the limit the standard prints. A result
keeps what its verdict is read from: the readings, the margins, the positions,
the measured figures. A figure that only fed the verdict, such as the rounded
level of an RD 1367/2007 period or the share of raindrops inside their
tolerance, is a property too. The functions that return these results take the
same arguments and return the same verdicts, and every name in the table still
reads the same value; only code that builds one of these results directly, or
replaces one of the listed fields, has to change. "By name only" marks a field
that is keyword-only.

| Result | No longer constructor fields | Read from | Since |
| --- | --- | --- | --- |
| `building.JointTestElementCheck` | `length_ok`, `width_ok`, `passes` | `joint_length_m`, `joint_width_mm`, `window_or_door_gap` | after 4.0.0rc1 |
| `building.GapWidthCheck` | `gap_width_mm`, `spread_mm`, `enough_positions`, `uniform`, `passes` | `readings_mm` | after 4.0.0rc1 |
| `building.JointGapSeriesCheck` | `nominal_measured`, `minimum_measured`, `working_range_measured`, `passes` | `gap_widths_mm`, `nominal_gap_mm`, `minimum_gap_mm` | after 4.0.0rc1 |
| `building.LiningCuringCheck` | `cured`, `lag_within_third`, `passes` | `curing_time_days`, `time_lag_days`, `required_curing_days` | after 4.0.0rc1 |
| `building.RainGeneratorVerification` | `rate_deviation_mm_h`, `rate_ok`, `drop_share`, `drops_ok`, `velocity_share`, `velocities_ok`, `passes` | `rain_type`, `rainfall_rate_mm_h`, `drop_diameters_mm`, `fall_velocities_m_s` | after 4.0.0rc1 |
| `building.ServiceEquipmentPositionCheck` | `separation_m`, `surface_distance_m`, `source_distance_m`, `heights_m`, `corner_height_m`, `corner_wall_distances_m` and the six `*_ok` | the room, the corner and the positions | after 4.0.0rc1 |
| `building.DbHrCheck` | `reported`, `margin`, `complies` | `requirement`, `value` | 4.0.0rc1 |
| `building.LowFrequencyIntensityResult`, `building.LowFrequencyElementResult` | `qualified` | `surface_pressure_intensity_indicator`, against the 6.4.2 limit `absorbing_specimen_surface` selects | 4.0.0rc1 |
| `electroacoustics.BackgroundNoiseAssessment` | `reference_signal_to_noise_ratio_db`, `category`, `report_required` | `noise_levels_db`, `noise_is_tonal` | after 4.0.0rc1 |
| `electroacoustics.FeedbackStabilityResult` | `is_stable` | `loop_gain`, `stability_margin` | 4.0.0rc1 |
| `emission.FreeFieldCheck` | `room`, `frequencies_hz`, `band_radius_m`, `maximum_qualified_radius_m`, every `*_met`, `full_frequency_range`, `conforming_range_hz`, `conforming_radius_m`, `not_judged` | the arguments of `check_free_field`: gains `source_directionality`, `reflecting_plane_absorption_coefficient`, `reflecting_plane_margin_m`, `paths_in_working_area`, `speed_of_sound` | after 4.0.0rc1 |
| `emission.IntensityInstrumentComplianceResult` | `bands`, `overall_class`, `range_limited` | `frequencies`, `residual_index`, `device`, `spacing` | 4.0.0rc1 |
| `emission.ReferenceSourceCalibration` | `sound_power_level_db`, `intensity_bands`, `intensity_agreement`, `room_qualified` | gains `intensity_sound_power_level_db` and `room_qualification` | after 4.0.0rc1 |
| `emission.HardWalledSoundPowerResult` | `background_requirement_met`, `upper_bound` | gains `background_margin_db`, `background_margin_ref_db` | after 4.0.0rc1 |
| `emission.InSituSoundPowerResult` | `background_requirement_met`, `upper_bound`, `grade`, `sigma_r0`, `sigma_tot`, `expanded_uncertainty` | gains `background_margin_db`, `background_margin_ref_db`, the smallest over the positions, and `excess_levels_db`, `directivity_range_db`, the two indicators Table 2 grades by | 4.0.0rc1 |
| `emission.SpecialRoomSoundPowerResult` | `background_requirement_met`, `background_requirement_met_a` | gains `background_margin_db`, `background_margin_ref_db`, `background_margin_a_db` | after 4.0.0rc1 |
| `emission.EmissionPressureResult` | `upper_bound` | gains `background_margin_db`, by name only | 4.0.0rc1 |
| `emission.PrecisionCriteria` | `criterion_1` to `criterion_5`, `qualified` | gains the readings: `indicators`, `scan_intensity_level_1`, `scan_intensity_level_2`, `frequencies`, `repeatability_limit_db`, `pressure_residual_index_db`, `field_nonuniformity_1`, `field_nonuniformity_2` | 3.3.0 |
| `emission.DiscretePointIntensityResult` | everything but the indicators and the grade: `partial_power`, `sound_power`, `sound_power_level`, `achieved_grade`, both criteria, the A-weighted figures and the rest | gains `normal_intensity`, `segment_areas_m2`, `pressure_residual_index_db`, `band_type` | 4.0.0rc1 |
| `emission.TurbineTestEnvironmentCheck` | `correction_ok`, `wind_ok`, `windscreen_advised` | `environmental_correction_db`, `ratio`, `wind_speed_m_s` | after 4.0.0rc1 |
| `environment.ImpulseOnset` | `level_difference`, `prominence`, `qualifies` | `level_start`, `level_end`, `onset_rate` | 3.3.0 |
| `environment.ImpulseProminenceResult` | `per_impulse`, `qualifies`, `prominence`, `adjustment` | `onset_rates`, `level_differences`, `assessment_period_min` | 3.3.0 |
| `environment.ResidualCorrectionResult` | `reliable` | `margin` | 3.3.0 |
| `environment.PeriodAssessment` | `reported_level`, `reported_long_term`, `max_phase_level`, `phase_pass`, `daily_pass`, `long_term_pass` | `phases`, the two levels and `limit`; gains `new_activity`, by name only | 4.0.0rc1 |
| `environment.ModulationPeriod` | `modulation_depths_db`, `fundamental_frequencies_hz`, `valid_blocks`, `rated`, `rating_db`, `mean_modulation_frequency_hz`, `mode_modulation_frequency_hz` | `blocks` | after 4.0.0rc1 |
| `environment.SoundRelevantTurbines` | `relevant`, `total_level_db`, `relevant_level_db` | `predicted_levels_db` | after 4.0.0rc1 |
| `environment.WindShearProfile` | `typical` | `shear_exponent` | after 4.0.0rc1 |
| `environment.WindTurbineTonalityResult` | `is_audible` | `tonal_audibility`, `has_identified_tone`, itself read-only (next section) | 3.3.0 |
| `filters.FilterComplianceResult` | `bands`, `overall_class`, `range_limited` | gains `band_margins`, the rows without their class | 3.3.0 |
| `filters.WeightingComplianceResult` | `bands`, `overall_class`, `range_limited` | gains `band_margins`, the rows without their class | 4.0.0rc1 |
| `hearing.AscendingThresholdResult` | `threshold_db`, `determined`, `series_exhausted` | `ascent_levels_db`, `shortened` | after 4.0.0rc1 |
| `hearing.TaskContribution` | `spread_advisory` | `n_samples`, `sample_range_db` | 3.3.0 |
| `hearing.ExposureResult` | `sampling_advisory` | gains `sample_range_db`, `n_workers`, `sample_duration_hours`, by name only | 3.3.0 |
| `hearing.AttenuationDifferenceResult` | `difference_db`, `criterion_db`, `significant` | the two means and their expanded uncertainties | after 4.0.0rc1 |
| `materials.CeilingSpecimenCheck` | `area_error_m2`, `deflection_ok`, `substructure_ok`, `fixture_ok`, `supports_ok`, `humidity_ok`, `ce_marking_depth`, `satisfied` | gains the measured figures: `deflection_mm`, `substructure_width_mm`, `substructure_height_mm`, `fixture_density_kg_m2`, `support_width_mm`, `support_height_mm`, `support_centre_distance_m`, `lowest_relative_humidity_percent` | after 4.0.0rc1 |
| `metrology.TrendTestResult` | `trend_free`, `bounds` | `statistic`, against the region `values`, `method`, `alpha` and `median` give | 3.3.0 |
| `metrology.StationarityTestResult` | `stationary`, `bounds` | `count`, against the region `segment_values`, `method` and `alpha` give | 3.3.0 |
| `metrology.CouplerCheck` | `ratio_recommended`, `approximation_valid`, `full_solution_advised`, `conditions_valid` | gains `heat_conduction_method`, `temperature_c`, `static_pressure_pa`, `relative_humidity_percent`, `in_air`, by name only | after 4.0.0rc1 |
| `metrology.FreeFieldArrangementCheck` | `distances_ok`, `support_ok`, `annex_a_range`, `attenuation_accuracy` | gains `air_attenuation` | after 4.0.0rc1 |
| `noise_control.CabinUncertainty` | `ratio_satisfied`, `stateable`, `stated_band_range_hz`, `increased_uncertainty_band_range_hz`, `excess_standard_deviation_db` | `method`, `volume_ratio` | after 4.0.0rc1 |
| `room.RoomAcousticsResult` | `edt_valid`, `t20_valid`, `t30_valid` | `dynamic_range` and the decay times | 3.3.0 |
| `room.BackgroundMarginCheck` | `needs_correction`, `unusable`, `satisfied` | `margins_db` | after 4.0.0rc1 |
| `room.DetailVerdict` | `satisfied`, `room_ok`, `fittings_ok`, `sources_ok` | gains `room_detail`, `fitting_detail`, `source_detail` | after 4.0.0rc1 |
| `underwater.WeightedExposureResult` | `exceeds_injury`, `exceeds_tts` | `sel_margin`, `peak_margin`, `tts_margin`, `tts_peak_margin` | 4.0.0rc1 |
| `vibration.PeopleAssessment` | `complies`, `criterion`, `within_uncertainty` | `kb_fmax`, `kb_ftr`, `guide`, `source`; gains `rare_short_events`, by name only | after 4.0.0rc1 |
| `vibration.RailwayChange` | `complies`, `kb_fmax_met`, `kb_ftr_met`, `kb_fmax_increase_percent`, `kb_ftr_increase_percent` | gains `kb_fmax_before`, `kb_fmax_after`, `kb_ftr_before`, `kb_ftr_after` | after 4.0.0rc1 |
| `vibration.TransferStiffnessResult` | `valid` | gains `transmissibility`, \|T\| of the indirect method | 3.3.0 |
| `vibration.DrivingPointStiffnessResult` | `adequate` | gains `blocked_output_difference_db`, `unwanted_input_difference_db` | after 4.0.0rc1 |
| `aircraft.AircraftSystemComplianceResult` | `passes`, `checks` | gains `directional`, `frequency_response`, `linearity`, `resolution`, the arguments of `verify_aircraft_noise_system` | 4.0.0rc1 |
| `broadcast.QuasiPeakDynamicsResult` | `passes`, `worst_margin_db`, `worst_deviation_db`, `stimuli` | gains `readings_percent`, the eleven readings in the order of Tables 2 and 3 | 4.0.0rc1 |
| `building.FloorCoveringImprovementResult` | `limited` | gains `background_margin_db`, the smallest margin over the positions and both floors | 3.1.0 |
| `building.VibrationReductionResult` | `single_number`, `bracketed` | `frequencies`, `k_ij`; gains `modal_overlap`, `band_type` | 3.1.0 |
| `building.ServiceEquipmentBackgroundResult` | `difference_db`, `correction_db`, `corrected_db`, `regime` | `measured_db`, `background_db` | after 4.0.0rc1 |
| `building.ServiceEquipmentResult` | `standardizable` | `frequencies_hz`, `band` | after 4.0.0rc1 |
| `emission.SoundPowerIntensityResult` | `partial_power_level`, `sound_power`, `sound_power_level`, `negative_band`, `negative_partial_power_index`, `dynamic_capability_index`, `achieved_grade`, `a_weighting_omitted_bands`, `sound_power_level_a` | `partial_power` and the indicators; gains `pressure_residual_index_db`, `band_type`, `repeatability_limit_db` | 3.1.0 |
| `emission.PrecisionIntensityResult` | `sound_power`, `sound_power_level`, `sound_power_level_normalized`, `not_applicable_band`, `sound_power_level_a` | `partial_power`; gains `temperature_c`, `barometric_pressure_pa`, by name only | 3.1.0 |
| `emission.InDuctSoundPowerResult` | `information_only_band` | `frequencies`, `flow_velocity` | 4.0.0rc1 |
| `emission.SourceLocationPlan` | `spectral_character`, `a_weighted_spectral_character` | `standard`, `standard_deviation_db`, `a_weighted_standard_deviation_db` | after 4.0.0rc1 |
| `environment.ImpulsiveSoundResult` | `prominence`, `adjustment`, `category`, `adjusted_laeq` | `onsets`, `laeq` | 3.3.0 |
| `environment.ModulationBlock` | `status` | `prominence`; gains `excluded`, by name only | after 4.0.0rc1 |
| `environment.ReferenceTrackCheck` | `conditions` | the roughness, the decay rates and the limits; gains `curve_radius_m`, `track_gradient_ratio`, by name only | after 4.0.0rc1 |

`FilterComplianceResult` and `WeightingComplianceResult` hold their per-band
rows as `band_margins`: the rows `bands` held, without the `"class"` key. A
row that states a class is refused, and the `bands` property adds to each row
the class its margins earn, so `result.bands[i]["class"]` reads as before.
`IntensityInstrumentComplianceResult` takes no band rows at all: it holds the
frequencies and the measured pressure-residual intensity index, and reads the
rows, the class and `range_limited` from them, the device and the microphone
separation.

The rows a verdict is read from are read-only. `band_margins` and
`between_nominals` are copied into read-only mappings when the result is
built, so a write into one raises `TypeError` and a later write into the
dictionaries you built it from does not move the class; copy a row with
`dict(row)` to change it, since `copy.deepcopy` cannot copy a read-only
mapping. The rows of `AircraftSystemComplianceResult.checks`,
`QuasiPeakDynamicsResult.stimuli` and `ReferenceTrackCheck.conditions` are
built from what the result holds and the tables of the standard each time
they are read.

```python
import dataclasses

from phonometry import building, filters

gap = building.check_gap_width([5.0, 5.1, 5.2, 5.1])
print(gap.passes, round(gap.spread_mm, 1))
# True 0.2
try:
    dataclasses.replace(gap, passes=False)
except TypeError as error:
    print(type(error).__name__)
# TypeError
wider = dataclasses.replace(gap, readings_mm=(5.0, 5.1, 5.6, 5.1))
print(wider.uniform, wider.passes)
# False False
bank = filters.OctaveFilterBank(fs=48000, fraction=1, order=6, limits=[125, 4000])
result = filters.verify_filter_class(bank)
print(result.overall_class, result.bands[0]["class"], "class" in result.band_margins[0])
# 1 1 False
```

A flag stays a field where it is no verdict against a limit and nothing the
result holds decides it: an option the caller sets, such as `small_room` or
`noise_is_tonal`; a branch the caller chose by what they gave, such as
`InverseSquareLawResult.origin_fitted`; a fact of an input the result does not
keep, such as `TimeWeightedEnvelope.calibrated`; a property of the file read;
whether an iterative solver converged; and a column of a published table row.
`CriticalCouplingResult.converged` keeps the outcome of its solve, which the
result cannot run again, but it can no longer claim convergence beside an
absorption that does not exceed 0.999, the third condition of the flag.
A regime the result could read from its own values is a verdict, and the next
section lists the ones that moved. `PlantRequirement` and `LoopRequirement` do not change: their rows
keep each limit as a field for the reason the section above gives, and
`PlantRequirement.holds` stays beside it. `TrackCondition` keeps its `holds`
as a row of the report, but `ReferenceTrackCheck` builds its conditions from
the track it holds, so no verdict reads one built elsewhere. The yes/no
`checks` of `SoundLevelMeterPeriodicRequirement` stay as well: they are what
the tester saw, an overload indicated or an indicator latched, not a limit
judged.

## A result reads its regime from the values it holds

Some results stored a regime or a classification beside the values it was
read from: the regime of a valve, whether a hammer rebounds, whether terrain
screens a path, whether a tone was found, whether a synchronous average had to
interpolate. The function compared values the result holds, or could hold,
with a constant or a threshold its source prints, and stored the answer, so a
result built by hand could hold the stiffness of an under-critical hammer and
say it is over-critical. In 4.0 each of them is a read-only property, read
from the values the result keeps, and a result that did not keep what the test
reads now keeps it, with the unit in the name. "By name only" marks a field
that is keyword-only.

A value exactly on a boundary falls where the source puts it. Hopkins prints
the over-critical case as K m ≥ 4 Zdp², so critical damping is over-critical,
and IEC TS 61400-11-2 asks for a total "at least 3 dB" above the background
before the logarithmic subtraction, so a difference of 3 dB takes it.
IEC 60534-8-4 does not settle its cavitation threshold one way: the test of
5.1 leaves the threshold itself to neither side, the region of Equation (9)
and Equation (19b) put it with the cavitating points, and 4.1, Equation (18b)
and 6.3 with the turbulent ones. The library reads it as turbulent, where
Equation (9) returns zero and the two branches meet without a step.

The functions take the same arguments and return the same numbers. Code that
builds one of these results directly, or replaces one of the fields its regime
is read from, has to change, and so does code that reads `band_type` from
`vibration_reduction_index` (see below the table).

| Result | No longer a constructor field | Read from | Since |
| --- | --- | --- | --- |
| `building.TappingForceResult` | `over_critical`, K m ≥ 4 Zdp² (Hopkins Eq. 3.95) | `contact_stiffness`, `impedance`; gains `mass_kg`, by name only | 4.0.0rc1 |
| `noise_control.HydrodynamicValveNoise` | `regime`, cavitating when Δp exceeds x_Fzp1 (p1 - pv) (IEC 60534-8-4 5.1) | `differential`, `corrected_ratio`; gains `inlet_pressure_pa`, `vapour_pressure_pa` | 4.0.0rc1 |
| `noise_control.AerodynamicValveNoise` | `regime`, the five intervals of IEC 60534-8-3 5.2 | `pressure_ratio`, `boundaries`; gains `specific_heat_ratio`, `pressure_recovery`, `efficiency_correction`, by name only | 4.0.0rc1 |
| `aircraft.TerrainScreeningResult` | `screened`, terrain strictly above the line of sight | `source`, `receiver`, `distances`, `heights` | 3.3.0 |
| `hearing.AutomaticThresholdResult` | `is_peak`, `retained` (ISO 8253-1 6.3.5 a)) | `reversal_levels_db` | after 4.0.0rc1 |
| `hearing.SweepThresholdResult` | `is_peak` | `reversal_levels_db` | after 4.0.0rc1 |
| `metrology.WaveMotionCorrection` | `interpolated` | `frequencies_hz`, `speed_of_sound_ratio` | after 4.0.0rc1 |
| `noise_control.CabinInsulationResult` | `apparent` (ISO 11957 3.6) | `method` | after 4.0.0rc1 |
| `environment.WindTurbineTonalityResult` | `has_identified_tone` (IEC 61400-11 9.5.2 to 9.5.4) | `levels`, `frequencies`; gains `candidate_frequency_hz`, by name only | 3.3.0 |
| `building.LabJointInsulationResult` | `regime` (ISO 10140-1 J.1) | `r_s_measured_db`, `r_s_max_db`; gains `limit_at_maximum`, by name only | after 4.0.0rc1 |
| `environment.TurbineSoundLevels` | `regimes` (IEC TS 61400-11-2 11.7) | `total_levels_db`, `background_levels_db` | after 4.0.0rc1 |
| `underwater.WestonPropagationResult` | `regime` | `range_m`, `boundaries` | 4.0.0rc1 |
| `signals.SynchronousAverageResult` | `interpolated`, f_s T not a whole number of samples | `fs`, `period_s` | 4.0.0rc1 |

`building.VibrationReductionResult.band_type` stays a field, as the band set
the caller states: `octave_bands()` states `"octave"`, since a single octave
band cannot be told from a one-third-octave band by its spacing.
`vibration_reduction_index` no longer fills it with the band set it read from
the frequencies; it leaves `None`, and the result reads the spacing each time
it takes the Annex A mean, which gives the same mean as before.

The numbers a regime selects are held to it, so a result built by hand can no
longer carry one regime beside the values of another. When it is built, each
of these results refuses:

- `TappingForceResult`: a cut-off frequency, a limiting frequency, a force
  spectrum or a power input that is not what K, Zdp and m give in their
  regime, and limits of Eqs. (3.99) and (3.100) that are not a factor of 2
  apart.
- `HydrodynamicValveNoise`: a vapour pressure at or above the inlet pressure;
  an x_F or an x_Fzp1 that is not what Equation (1) or (3c) gives from the
  fields beside it; a point at or past flashing, x_F = 1; cavitation fields on
  a turbulent point, or none on a cavitating one.
- `AerodynamicValveNoise`: boundaries that are not those of its gas and its
  trim, and a Mach number or an acoustical efficiency of another regime.
- `TerrainScreeningResult`: a source or a receiver that is not a finite point,
  a source at or past the receiver, a section that does not run from one to
  the other, and a path difference or diffracting edges that are not those
  of the rubber band over that section.
- `AutomaticThresholdResult` and `SweepThresholdResult`: reversals that do not
  alternate, a tracing that keeps no peak or no valley, and a mean, a
  threshold or a running threshold that is not what the reversals give.
- `WaveMotionCorrection`: a frequency beyond the last row of Table C.3, and a
  correction the table does not give at its frequency.
- `CabinInsulationResult`: an A-weighted insulation under any method but the
  actual noise.
- `WindTurbineTonalityResult`: a candidate that is not a line of the spectrum
  at or above 20 Hz, and a Formula 30 to 34 chain that is not the one the
  spectrum gives about it.
- `LabJointInsulationResult`: a corrected index of another rule of J.1.
- `TurbineSoundLevels`: a level difference other than the total less the
  background, a turbine level of another rule of 11.7, and an uncertainty on
  a bin whose level cannot be determined.
- `WestonPropagationResult`: a composite loss that is not the law of the
  regime in force.
- `SynchronousAverageResult`: a period grid other than `round(fs * period_s)`
  samples.

```python
import dataclasses

import numpy as np

from phonometry import building

bands = np.array([100.0, 200.0, 400.0, 800.0])
force = building.tapping_force_spectrum(bands, 1.0e6, 3.0e4)
print(force.over_critical)
# False
print(building.tapping_force_spectrum(bands, 1.0e6, 100.0).over_critical)
# True
try:
    dataclasses.replace(force, impedance=100.0)
except ValueError as error:
    print("cut_off_frequency" in str(error))
# True
```

## The ten names that are gone

Everything else moved. These ten exist in no public module:

| Gone | What to write instead |
| --- | --- |
| `octavefilter`, `getansifrequencies`, `normalizedfreq` | `filters.octave_filter`, `filters.nominal_frequencies`, `filters.normalized_frequencies`. These were the original PyOctaveBand spellings, renamed in 3.1 and kept as aliases until 4.0 |
| `calculate_sensitivity` | `metrology.sensitivity`, the same 3.1 rename |
| `filter_class_compliance` | `filters.verify_filter_class` |
| `air_density_iso`, `speed_of_sound_iso` | `materials.air_density_iso10534`, `materials.speed_of_sound_iso10534`, and read the kelvin warning above |
| `speed_of_sound` | `fluids.air(temperature_c=...).speed_of_sound` for the medium. It uses a fuller model than the ISO 17497-1 one-liner did, so the number moves: 343.99 m/s against 343.20 m/s at 20 °C |
| `depth_to_pressure` | `fluids.depth_to_gauge_pressure_mpa(depth_m=...)`, which returns the same number, or `fluids.depth_to_absolute_pressure_pa` for the absolute one. Both are keyword-only and both take an optional `latitude_deg` |
| `TransmissionLossResult` | `PropagationLossResult`, with `.tl` renamed to `.pl` |

## What did not change

Numbers. The conformance report is the same 995 checks against the same
standards with the same expected values it had before the reorganisation, and
every rename above was checked to return what its predecessor returned. If a
result moves after you finish the upgrade, that is a defect and it is worth
[reporting](https://github.com/jmrplens/phonometry/issues).

One verdict is the exception, and the section on filter classes above says
how: `verify_filter_class` grades the effective bandwidth of 5.12 and the
summation of 5.16 as well as the Table 1 mask for the 2014 edition, and the
filter integrated response of 4.5.3 and the summation of 4.9 for
`edition="1995"`, so a bank's class is the strictest it meets on all three.
Every band is graded at the bank's input rate up to half of it, so the Table 1
margins of a decimated band take in the alias images its decimation folds onto
it.

The decimated filter banks are the other: each band now keeps its processing
Nyquist frequency sixteen times its upper edge and decimates through an
anti-aliasing filter sized for a 120 dB stopband, so a band level moves by up
to 0.07 dB on white noise, as the same section says.

What each `.plot()` draws is untouched, and so is what each `.report()`
prints but one label: the microphone fiche writes dB(468) where it wrote
dB(CCIR). The `Signal` contract is the one it was, now kept in two more
places, the blocks of `read_blocks` and the waveforms inside five results.
Every formula is untouched too. 4.0 rearranged the furniture and put the unit
on the label; apart from those two, it did not recompute anything.
