Added suggested SLM down sampling factor, improved quite a lot of documentation. Measurement object can now work with old, as well as new measurement files.

This commit is contained in:
Anne de Jong 2022-10-01 19:59:35 +02:00
parent 59085b091f
commit bb26fc6bcc
15 changed files with 214 additions and 102 deletions

View File

@ -11,7 +11,7 @@ __version__ = lasp_cpp.__version__
# from .lasp_imptube import * # TwoMicImpedanceTube
from .lasp_measurement import * # Measurement, scaleBlockSens
from .lasp_octavefilter import *
# from .lasp_slm import * # SLM, Dummy
from .lasp_slm import * # SLM, Dummy
from .lasp_record import * # RecordStatus, Recording
from .lasp_daqconfigs import *
# from .lasp_siggen import * # SignalType, NoiseType, SiggenMessage, SiggenData, Siggen

View File

@ -200,6 +200,19 @@ public:
* output.
*/
double digitalHighPassCutOn = -1;
/**
* @brief Compare two channels to eachother. They are equal when the name,
* sensitivity and quantity are the same.
*
* @param other The DaqChannel to compare with
*
* @return true if equal
*/
bool operator==(const DaqChannel& other) const {
return other.name == name && other.sensitivity == sensitivity &&
other.qty == qty;
}
};
/**

View File

@ -7,6 +7,10 @@
#include <optional>
/** \defgroup dsp Digital Signal Processing utilities
* These are classes and functions used for processing raw signal data, to
* obtain statistics.
*
*
* @{
*/

View File

@ -2,7 +2,7 @@
#include "lasp_filter.h"
/**
* \ingroup dsp
* \addtogroup dsp
* @{
*/
@ -53,7 +53,8 @@ public:
/**
* @brief Initialize biquadbank.
*
* @param filters Filters for each filter in the bank
* @param filters Filters for each filter in the bank. First axis isis the
* coefficient index, second axis is the filter index.
* @param gains Gain values. Given as pointer, if not given (nulltpr), gains
* are initialized with unity gain.
*/

View File

@ -9,7 +9,7 @@
using std::cerr;
using std::endl;
using std::runtime_error;
using rte = std::runtime_error;
using std::unique_ptr;
SLM::SLM(const d fs, const d Lref, const us downsampling_fac, const d tau,
@ -40,13 +40,13 @@ SLM::SLM(const d fs, const d Lref, const us downsampling_fac, const d tau,
getPool();
if (Lref <= 0) {
throw runtime_error("Invalid reference level");
throw rte("Invalid reference level");
}
if (tau <= 0) {
throw runtime_error("Invalid time constant for Single pole lowpass filter");
throw rte("Invalid time constant for Single pole lowpass filter");
}
if (fs <= 0) {
throw runtime_error("Invalid sampling frequency");
throw rte("Invalid sampling frequency");
}
}
SLM::~SLM() {}
@ -66,6 +66,21 @@ std::vector<unique_ptr<Filter>> createBandPass(const dmat &coefs) {
}
return bf;
}
us SLM::suggestedDownSamplingFac(const d fs,const d tau) {
if(tau<0) throw rte("Invalid time weightin time constant");
if(fs<=0) throw rte("Invalid sampling frequency");
// A reasonable 'framerate' for the sound level meter, based on the
// filtering time constant.
if (tau > 0) {
d fs_slm = 10 / tau;
if(fs_slm < 30) {
fs_slm = 30;
}
return std::max((us) 1, static_cast<us>(fs / fs_slm));
} else {
return 1;
}
}
SLM SLM::fromBiquads(const d fs, const d Lref, const us downsampling_fac,
const d tau, const vd &pre_filter_coefs,

View File

@ -98,7 +98,8 @@ public:
* @param downsampling_fac Every 1/downsampling_fac value is returned from
* compute()
* @param tau Time consant of level meter
* @param bandpass_coefs Biquad filter coeffiecients for bandpass filter
* @param bandpass_coefs Biquad filter coefficients for bandpass filter. First axis isis the coefficient index, second axis is the filter index.
*
* @return Sound Level Meter object
*/
@ -149,6 +150,20 @@ public:
*/
vd Lmax() const { return 10 * arma::log10(Pmax / Lrefsq); };
/**
* @brief Comput a 'suggested' downsampling factor, i.e. a lower frame rate
* at which sound level meter values are returned from the computation. This
* is possible since the signal power is low-pas filtered with a single pole
* low pass filter. It can remove computational burden, especially for
* plotting, to have a value > 10.
*
* @param fs Sampling frequency of signal [Hz]
* @param tw Time weighting of SLM low pass filter
*
* @return Suggested downsampling factor, no unit. [-]
*/
static us suggestedDownSamplingFac(const d fs,const d tw);
private:
vd run_single(vd input, const us filter_no);
};

View File

@ -460,7 +460,15 @@ class OctaveBankDesigner(FilterBankDesigner):
class ThirdOctaveBankDesigner(FilterBankDesigner):
def __init__(self, fs):
def __init__(self, fs: float):
"""
Initialize ThirdOctaveBankDesigner, a filter bank designer for
one-third octave bands
Args:
fs: Sampling frequency in [Hz]
"""
super().__init__(fs)
self.xs = list(range(-16, 14))
# Text corresponding to the nominal frequency

View File

@ -84,7 +84,6 @@ class Qty:
@unique
class SIQtys(Enum):
N = Qty(name='Number',
@ -113,26 +112,20 @@ class SIQtys(Enum):
cpp_enum = DaqChannel.Qty.Voltage
)
@staticmethod
def fillComboBox(cb):
"""
Fill to a combobox
Args:
cb: QComboBox to fill
"""
cb.clear()
for ty in SIQtys:
cb.addItem(f'{ty.value.unit_name}')
cb.setCurrentIndex(0)
@staticmethod
def default():
return SIQtys.N.value
@staticmethod
def getCurrent(cb):
return list(SIQtys)[cb.currentIndex()]
def fromCppEnum(enum):
"""
Convert enumeration index from - say - a measurement file back into
physical quantity information.
"""
for qty in SIQtys:
if qty.value.cpp_enum.value == enum:
return qty.value
raise RuntimeError(f'Qty corresponding to enum {enum} not found')
@dataclass

View File

@ -16,9 +16,13 @@ is assumed to be acoustic data.
'sensitivity': (Optionally) the stored sensitivity of the record channels.
This can be a single value, or a list of sensitivities for
each channel. Both representations are allowed.
For measurement files of LASP < v1.0
'qtys' : (Optionally): list of quantities that is recorded for each channel',
if this array is not found. Quantities are defaulted to 'Number / Full scale'
For measurement files of LASP >= 1.0
- Datasets:
'audio': 3-dimensional array of blocks of audio data. The first axis is the
@ -46,7 +50,7 @@ from .lasp_config import LASP_NUMPY_FLOAT_TYPE
from scipy.io import wavfile
import os, time, wave, logging
from .lasp_common import SIQtys, Qty, getFreq
from .lasp_cpp import Window, DaqChannel
from .lasp_cpp import Window, DaqChannel, LASP_VERSION_MAJOR
def getSampWidth(dtype):
@ -208,6 +212,13 @@ class Measurement:
self.N = (self.nblocks * self.blocksize)
self.T = self.N / self.samplerate
try:
self.version_major = f.attrs['LASP_VERSION_MAJOR']
self.version_minor = f.attrs['LASP_VERSION_MINOR']
except KeyError:
self.version_major = 0
self.version_minor = 1
# Due to a previous bug, the channel names were not stored
# consistently, i.e. as 'channel_names' and later camelcase.
try:
@ -239,6 +250,7 @@ class Measurement:
self._time = f.attrs['time']
self._qtys = None
try:
qtys_json = f.attrs['qtys']
# Load quantity data
@ -246,9 +258,20 @@ class Measurement:
except KeyError:
# If quantity data is not available, this is an 'old'
# measurement file.
logging.debug(f'Physical quantity data not available in measurement file. Assuming {SIQtys.default}')
self._qtys = [SIQtys.default() for i in range(self.nchannels)]
pass
try:
qtys_enum_idx = f.attrs['qtys_enum_idx']
self._qtys = [SIQtys.fromCppEnum(idx) for idx in qtys_enum_idx]
except KeyError:
pass
if self._qtys is None:
self._qtys = [SIQtys.default() for i in range(self.nchannels)]
logging.debug(f'Physical quantity data not available in measurement file. Assuming {SIQtys.default}')
logging.debug(f'Physical quantity data not available in measurement file. Assuming {SIQtys.default}')
def setAttribute(self, atrname, value):
"""
Set an attribute in the measurement file, and keep a local copy in
@ -276,14 +299,15 @@ class Measurement:
@property
def channelConfig(self):
chcfg = [DaqChannel(channel_enabled=True,
channel_name=chname,
sensitivity=sens,)
for chname, sens in zip(
self.channelNames,
self.sensitivity)]
for i, qty in enumerate(self.qtys):
chcfg[i].qty = qty
chcfg = []
for chname, sens, qty in zip(self.channelNames, self.sensitivity,
self.qtys):
ch = DaqChannel()
ch.enabled = True
ch.name = chname
ch.sensitivity = sens
ch.qty = qty.cpp_enum
chcfg.append(ch)
return chcfg
@channelConfig.setter
@ -292,9 +316,9 @@ class Measurement:
sens = []
qtys = []
for ch in chcfg:
chname.append(ch.channel_name)
chname.append(ch.name)
sens.append(ch.sensitivity)
qtys.append(ch.qty)
qtys.append(SIQtys.fromCppEnum(ch.qty))
self.channelNames = chname
self.sensitivity = sens

View File

@ -9,7 +9,8 @@ import os
import time
import h5py
import numpy as np
from .lasp_cpp import InDataHandler, StreamMgr
from .lasp_cpp import (InDataHandler, StreamMgr, LASP_VERSION_MAJOR,
LASP_VERSION_MINOR)
from .lasp_atomic import Atomic
@ -119,12 +120,16 @@ class Recording:
f = self.f
f.attrs['LASP_VERSION_MAJOR'] = LASP_VERSION_MAJOR
f.attrs['LASP_VERSION_MINOR'] = LASP_VERSION_MINOR
# Set the bunch of attributes
f.attrs['samplerate'] = daq.samplerate()
f.attrs['nchannels'] = daq.neninchannels()
f.attrs['blocksize'] = blocksize
f.attrs['sensitivity'] = [ch.sensitivity for ch in in_ch]
f.attrs['channelNames'] = [ch.name for ch in in_ch]
# f.attrs['
# Add the start delay here, as firstFrames() is called right after the
# constructor is called.

View File

@ -4,7 +4,7 @@
Sound level meter implementation
@author: J.A. de Jong - ASCEE
"""
from .lasp_cpp import SLM as cppSLM
from .lasp_cpp import cppSLM
import numpy as np
from .lasp_common import (TimeWeighting, FreqWeighting, P_REF)
from .filter import SPLFilterDesigner
@ -33,8 +33,8 @@ class SLM:
def __init__(self,
fs,
fbdesigner=None,
tw=TimeWeighting.fast,
fw=FreqWeighting.A,
tw: TimeWeighting =TimeWeighting.fast,
fw: FreqWeighting =FreqWeighting.A,
xmin = None,
xmax = None,
include_overall=True,
@ -50,7 +50,7 @@ class SLM:
fs: Sampling frequency [Hz]
tw: Time Weighting to apply
fw: Frequency weighting to apply
xmin: Filter designator of lowest band
xmin: Filter designator of lowest band.
xmax: Filter designator of highest band
include_overall: If true, a non-functioning filter is added which
is used to compute the overall level.
@ -96,36 +96,42 @@ class SLM:
assert fbdesigner.fs == fs
sos_firstx = fbdesigner.createSOSFilter(self.xs[0]).flatten()
self.nom_txt.append(fbdesigner.nominal_txt(self.xs[0]))
sos = np.empty((nfilters, sos_firstx.size), dtype=float, order='C')
sos[0, :] = sos_firstx
sos = np.empty((sos_firstx.size, nfilters), dtype=float, order='C')
sos[:, 0] = sos_firstx
for i, x in enumerate(self.xs[1:]):
sos[i+1, :] = fbdesigner.createSOSFilter(x).flatten()
sos[:, i+1] = fbdesigner.createSOSFilter(x).flatten()
self.nom_txt.append(fbdesigner.nominal_txt(x))
if include_overall:
# Create a unit impulse response filter, every third index equals
# 1, so b0 = 1 and a0 is 1 (by definition)
# a0 = 1, b0 = 1, rest is zero
sos[-1,:] = sos_overall
sos[:,-1] = sos_overall
self.nom_txt.append('overall')
else:
# No filterbank, means we do only compute the overall values. This
# means that in case of include_overall, it creates two overall
# channels. That would be confusing, so we do not allow it.
sos = sos_overall[np.newaxis,:]
sos = sos_overall[:,np.newaxis]
self.nom_txt.append('overall')
self.slm = cppSLM(fs, prefilter, sos,
fs, tw[0], level_ref_value)
# Downsampling factor, determine from single pole low pass filter time
# constant, such that aliasing is ~ allowed at 20 dB lower value
# and
dsfac = cppSLM.suggestedDownSamplingFac(fs, tw[0])
dsfac = self.slm.downsampling_fac
if dsfac > 0:
# Not unfiltered data
self.fs_slm = fs / self.slm.downsampling_fac
if prefilter is not None:
self.slm = cppSLM.fromBiquads(fs, level_ref_value, dsfac,
tw[0],
prefilter, sos)
else:
self.fs_slm = fs
self.slm = cppSLM.fromBiquads(fs, level_ref_value, dsfac,
tw[0],
sos)
self.fs_slm = fs / dsfac
# Initialize counter to 0
self.N = 0
@ -189,7 +195,6 @@ class SLM:
output['mid'] = self.fbdesigner.fm(list(self.xs))
logging.debug(list(self.xs))
logging.debug(output['mid'])
# Indiced at 0, as pyxSLM always returns 2D arrays.
if self.include_overall and self.fbdesigner is not None:
output['overall'] = dat[-1,0]

View File

@ -69,6 +69,8 @@ void init_daqconfiguration(py::module &m) {
.value("UserDefined", DaqChannel::Qty::UserDefined);
daqchannel.def_readwrite("qty", &DaqChannel::qty);
daqchannel.def("__eq__", [](const DaqChannel& a, const DaqChannel& b) { return a==b;});
/// DaqConfiguration
daqconfig.def(py::init<>());
daqconfig.def(py::init<const DeviceInfo &>());

View File

@ -69,15 +69,16 @@ void init_dsp(py::module &m) {
/// AvPowerSpectra
py::class_<AvPowerSpectra> aps(m, "AvPowerSpectra");
aps.def(py::init<const us, const Window::WindowType, const d, const int>(),
py::arg("nfft"), py::arg("WindowType"), py::arg("overlap_percentage"),
py::arg("time_constant"));
py::arg("nfft") = 2048, py::arg("windowType") =Window::WindowType::Hann, py::arg("overlap_percentage") = 50.0,
py::arg("time_constant") = -1);
aps.def("compute", [](AvPowerSpectra &aps, const dmat &timedata) {
std::optional<arma::cx_cube> res = aps.compute(timedata);
return res.value_or(arma::cx_cube(0,0,0));
}
);
py::class_<SLM> slm(m, "SLM");
py::class_<SLM> slm(m, "cppSLM");
slm.def_static(
"fromBiquads",
@ -94,5 +95,6 @@ void init_dsp(py::module &m) {
slm.def("Lpeak", &SLM::Lpeak);
slm.def("Leq", &SLM::Leq);
slm.def("Lmax", &SLM::Lmax);
slm.def_static("suggestedDownSamplingFac", &SLM::suggestedDownSamplingFac);
}
/** @} */

View File

@ -2,6 +2,7 @@
#include <pybind11/numpy.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <functional>
#include <stdint.h>
using std::cerr;
@ -31,4 +32,11 @@ void init_streammgr(py::module &m) {
smgr.def("isStreamRunningOK", &StreamMgr::isStreamRunningOK);
smgr.def("isStreamRunning", &StreamMgr::isStreamRunning);
smgr.def("getDaq", &StreamMgr::getDaq, py::return_value_policy::reference);
smgr.def("rescanDAQDevices", [](StreamMgr& smgr, bool background) {
// A pure C++ callback is the second argument to rescanDAQDevices, which
// cannot be wrapped to Pybind11. Only the one without callback is
// forwarded here to Python code.
smgr.rescanDAQDevices(background);
},
py::arg("background")=false);
}

View File

@ -1,6 +1,6 @@
#!/usr/bin/python3
import numpy as np
from lasp import SLM
from lasp import cppSLM
from lasp.filter import SPLFilterDesigner
import matplotlib.pyplot as plt
@ -11,7 +11,7 @@ def test_cppslm1():
fs = 48000
omg = 2*np.pi*1000
slm = SLM.fromBiquads(fs, 2e-5, 1, 0.125, [1.,0,0,1,0,0])
slm = cppSLM.fromBiquads(fs, 2e-5, 1, 0.125, [1.,0,0,1,0,0])
t = np.linspace(0, 10, 10*fs, endpoint=False)
@ -40,7 +40,7 @@ def test_cppslm2():
omg = 2*np.pi*1000
filt = SPLFilterDesigner(fs).A_Sos_design()
slm = SLM.fromBiquads(fs, 2e-5, 0, 0.125, filt.flatten(), [1.,0,0,1,0,0])
slm = cppSLM.fromBiquads(fs, 2e-5, 0, 0.125, filt.flatten(), [1.,0,0,1,0,0])
t = np.linspace(0, 10, 10*fs, endpoint=False)
@ -60,16 +60,33 @@ def test_cppslm2():
# (Fast, Slow etc)
assert np.isclose(out[-1,0], level, atol=1e-2)
def test_cppslm3():
fs = 48000
omg = 2*np.pi*1000
filt = SPLFilterDesigner(fs).A_Sos_design()
slm = cppSLM.fromBiquads(fs, 2e-5, 0, 0.125, filt.flatten(), [1.,0,0,1,0,0])
t = np.linspace(0, 10, 10*fs, endpoint=False)
in_ = 10*np.sin(omg*t) * np.sqrt(2)+np.random.randn()
# Compute overall RMS
rms = np.sqrt(np.sum(in_**2)/in_.size)
# Compute overall level
level = 20*np.log10(rms/2e-5)
# Output of SLM
out = slm.run(in_)
Lpeak = 20*np.log10(np.max(np.abs(in_)/2e-5))
Lpeak
slm.Lpeak()
assert np.isclose(out[-1,0], slm.Leq()[0][0], atol=1e-2)
assert np.isclose(Lpeak, slm.Lpeak()[0][0], atol=1e0)
if __name__ == '__main__':
test_cppslm1()
test_cppslm2()
# plt.plot(t,out[:,0])
# plt.close('all')
# from scipy.signal import sosfreqz
# omg, H = sosfreqz(filt)
# freq = omg / (2*np.pi)*fs
# plt.figure()
# plt.semilogx(freq[1:],20*np.log10(np.abs(H[1:])))
test_cppslm3()