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
Section titled “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__.
import phonometry
print(len(phonometry.__all__)) # 25print("laeq" in phonometry.__all__) # False
from phonometry.signals import laeq # this is the new spellingMeasured 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:
from phonometry import filters
_, _, _, labels = filters.nominal_frequencies(1)print(labels)Finding the new home of a name
Section titled “Finding the new home of a name”Rather than search the reference for each one, ask the installed package:
import importlibimport 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.signalsprint(whereis("reverberation_time")[0]) # phonometry.roomprint(whereis("sensitivity")[0]) # phonometry.metrologyprint(whereis("Signal")[0]) # phonometryIt 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
Section titled “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
Section titled “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
Section titled “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.
from phonometry.materials import speed_of_sound_iso10534
print(round(float(speed_of_sound_iso10534(temperature_c=20.0)), 5)) # 343.28784print(round(float(speed_of_sound_iso10534(temperature_c=293.15)), 5)) # 477.13003343.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
Section titled “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
Section titled “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.
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 holdThe same rename reaches underwater.sonar_equation, whose
transmission_loss parameter is now propagation_loss.
The environment moved into a dataclass
Section titled “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=...).
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
Section titled “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 from
installed_dynamic_stiffness(20e6, 50.0), Formula 6 with its second term
dropped, and 4.0 raises and asks for .
Conditions and flags are written by name
Section titled “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:
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 givenThat 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.
print(round(float(leq(x, dbfs=True)), 3))One spelling per concept
Section titled “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
Section titled “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
Section titled “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 |
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
Section titled “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() |
import numpy as npfrom phonometry import filters
result = filters.octave_filter(np.zeros(8000) + 1e-6, 8000, fraction=3)print(len(result.frequencies), result.bands)# 25 Nonebands 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:
ax = result.plot()Waveforms inside results come back as the Signal they came from
Section titled “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
Section titled “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
Section titled “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:
from phonometry.materials.absorbers import REFERENCE_CURVEfrom phonometry.speech.sii import BAND_IMPORTANCE
curve = dict(REFERENCE_CURVE)curve[4000] = 1.0weights = BAND_IMPORTANCE.copy()weights[0] = 0.0print(REFERENCE_CURVE[4000], curve[4000], weights[0], BAND_IMPORTANCE[0])# 0.9 1.0 0.0 0.0083A 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 catalogue row says what its source claims for each cell
Section titled “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.
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"))# estimatedprint(ear.converted["youngs_modulus_max_pa"], ear.is_derived("youngs_modulus_max_pa"))# ('3e5', 'psi') FalseThe 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 |
layer.natural_frequency(mass_per_area=...) | layer.natural_frequency(mass_per_area_kg_m2=...); a layer that holds only also takes airflow_resistivity_pa_s_m2= and gas_stiffness_n_m3=, and raises io.CatalogueError without the first |
Hopkins Table A3 prints , 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.
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.6A catalogue row checks itself when it is built
Section titled “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.
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
Section titled “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. A verdict for edition="1995" is unchanged.
| 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 |
The octave and one-third-octave banks keep class 1.
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 1The ten names that are gone
Section titled “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
Section titled “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.
One verdict is the exception, and the section on filter classes above says
how: for the 2014 edition verify_filter_class grades the effective bandwidth
of 5.12 and the summation of 5.16 as well as the Table 1 mask, so a bank’s
class is the strictest it meets on all three. Its Table 1 margins and a
verdict for edition="1995" are the numbers they were.
The decimated filter banks are the other: each band now keeps its processing Nyquist frequency sixteen times its upper edge, 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.