From f014dcce80bae0ae694e761ab1768815a88e67c7 Mon Sep 17 00:00:00 2001 From: Andrey Parfenov Date: Mon, 5 Oct 2026 02:09:47 +0200 Subject: [PATCH 1/4] improve bandpower calculations and cleanup data handling Signed-off-by: Andrey Parfenov --- .github/workflows/run_unix.yml | 35 +- .github/workflows/run_windows.yml | 19 +- .gitignore | 12 + .../examples/signal_processing/CMakeLists.txt | 8 +- .../examples/signal_processing/src/ica.cpp | 73 +- cpp_package/src/data_filter.cpp | 204 +- cpp_package/src/inc/data_filter.h | 36 +- .../brainflow/brainflow/data_filter.cs | 93 +- .../brainflow/data_handler_library.cs | 16 + .../brainflow/brainflow/ml_model.cs | 21 +- csharp_package/brainflow/examples/ica/ica.cs | 38 +- docs/UserAPI.rst | 122 ++ .../src/main/java/brainflow/DataFilter.java | 155 +- .../src/main/java/brainflow/MLModel.java | 26 +- .../src/main/java/brainflow/examples/ICA.java | 36 +- julia_package/brainflow/src/data_filter.jl | 137 +- julia_package/brainflow/src/ml_model.jl | 26 +- .../brainflow/test/band_power_all.jl | 3 +- julia_package/brainflow/test/ica.jl | 29 +- julia_package/brainflow/test/runtests.jl | 1 + julia_package/brainflow/test/spo2.jl | 27 +- matlab_package/brainflow/DataFilter.m | 171 +- matlab_package/brainflow/MLModel.m | 37 +- matlab_package/brainflow/examples/ICA.m | 23 +- matlab_package/brainflow/examples/Spo2.m | 32 +- nodejs_package/brainflow/data_filter.ts | 171 +- nodejs_package/brainflow/functions.types.ts | 8 +- nodejs_package/tests/bandpower_all.ts | 3 +- nodejs_package/tests/ica.ts | 37 +- python_package/brainflow/data_filter.py | 408 +++- python_package/brainflow/ml_model.py | 36 +- python_package/examples/tests/ica.py | 25 +- r_package/examples/ICA.R | 21 +- rust_package/brainflow/examples/ica.rs | 42 +- rust_package/brainflow/src/data_filter.rs | 338 ++- .../brainflow/src/ffi/data_handler.rs | 12 + src/data_handler/build.cmake | 3 + src/data_handler/data_handler.cpp | 1818 ++--------------- src/data_handler/fastica.cpp | 262 ++- .../inc/common_data_handler_helpers.h | 59 +- src/data_handler/inc/data_handler.h | 33 + src/data_handler/inc/fastica.h | 15 +- src/ml/generated/mindfulness_model.cpp | 6 +- src/ml/inc/brainflow_model_params.h | 4 +- src/ml/mindfulness_classifier.cpp | 21 +- src/ml/ml_module.cpp | 20 +- src/ml/onnx/onnx_classifier.cpp | 111 +- src/ml/train/logreg_mindfulness.onnx | Bin 449 -> 564 bytes src/ml/train/requirements.txt | 16 +- src/ml/train/train_classifiers.py | 723 +++++-- src/utils/inc/brainflow_array.h | 61 +- .../Sources/BrainFlow/DataFilter.swift | 153 +- .../Tests/BrainFlowTests/BrainFlowTests.swift | 32 + swift_package/examples/tests/ica/ica.swift | 21 +- 54 files changed, 3111 insertions(+), 2728 deletions(-) diff --git a/.github/workflows/run_unix.yml b/.github/workflows/run_unix.yml index ae236a7c7..ff6e3cd7d 100644 --- a/.github/workflows/run_unix.yml +++ b/.github/workflows/run_unix.yml @@ -24,7 +24,9 @@ jobs: - name: Install dotnet uses: actions/setup-dotnet@v3 with: - dotnet-version: '7.0.x' + dotnet-version: | + 7.0.x + 8.0.x - name: Install Python 3.10 uses: actions/setup-python@v5 with: @@ -378,6 +380,27 @@ jobs: run: sudo -H python3 $GITHUB_WORKSPACE/python_package/examples/tests/band_power.py - name: BandPowerAll Python run: sudo -H python3 $GITHUB_WORKSPACE/python_package/examples/tests/band_power_all.py + - name: Band Power Regression Python + run: sudo -H python3 $GITHUB_WORKSPACE/python_package/examples/tests/band_power_regression.py + - name: Signal Processing Regression Python + run: | + sudo -H python3 $GITHUB_WORKSPACE/python_package/examples/tests/signal_processing_regression.py + sudo -H python3 $GITHUB_WORKSPACE/python_package/examples/tests/decomposition_regression.py + sudo -H python3 $GITHUB_WORKSPACE/python_package/examples/tests/signal_metrics_regression.py + sudo -H python3 $GITHUB_WORKSPACE/python_package/examples/tests/binding_validation_regression.py + - name: Signal Processing Regression Cpp + run: $GITHUB_WORKSPACE/cpp_package/examples/signal_processing/build/signal_processing_regression + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/installed/lib + - name: Signal Processing Regression Csharp + run: dotnet run --project $GITHUB_WORKSPACE/csharp_package/brainflow/examples/signal_processing_regression + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/installed/lib + - name: Signal Processing Regression Rust + run: cargo test --manifest-path $GITHUB_WORKSPACE/rust_package/brainflow/Cargo.toml data_filter::tests + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/installed/lib + DYLD_LIBRARY_PATH: ${{ github.workspace }}/installed/lib - name: Activity Index Python run: sudo -H python3 $GITHUB_WORKSPACE/python_package/examples/tests/activity_index.py - name: Denoising Cpp @@ -456,6 +479,12 @@ jobs: mvn exec:java -Dexec.mainClass="brainflow.examples.BandPower" env: LD_LIBRARY_PATH: ${{ github.workspace }}/installed/lib + - name: Binding Validation Java + run: | + cd $GITHUB_WORKSPACE/java_package/brainflow + mvn test-compile exec:java -Dexec.mainClass="brainflow.BindingValidationRegression" -Dexec.classpathScope=test + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/installed/lib - name: EEG Metrics Python run: sudo -H python3 $GITHUB_WORKSPACE/python_package/examples/tests/eeg_metrics.py --board-id -1 - name: EEG Metrics Custom Python @@ -485,6 +514,10 @@ jobs: run: | cd $GITHUB_WORKSPACE/nodejs_package/tests npm run signal_filtering + - name: Binding Validation Node + run: | + cd $GITHUB_WORKSPACE/nodejs_package/tests + npx ts-node binding_validation_regression.ts - name: Serialization Node run: | cd $GITHUB_WORKSPACE/nodejs_package/tests diff --git a/.github/workflows/run_windows.yml b/.github/workflows/run_windows.yml index 27ae71b84..62e884606 100644 --- a/.github/workflows/run_windows.yml +++ b/.github/workflows/run_windows.yml @@ -20,7 +20,9 @@ jobs: - name: Install dotnet uses: actions/setup-dotnet@v3 with: - dotnet-version: '7.0.x' + dotnet-version: | + 7.0.x + 8.0.x - name: Setup Python uses: actions/setup-python@v4 with: @@ -241,6 +243,21 @@ jobs: - name: PSD Welch Nyquist Overlap Python Test run: python %GITHUB_WORKSPACE%\python_package\examples\tests\psd_welch_nyquist_overlap.py shell: cmd + - name: Signal Processing Regression Python + run: | + python python_package\examples\tests\band_power_regression.py + python python_package\examples\tests\signal_processing_regression.py + python python_package\examples\tests\decomposition_regression.py + python python_package\examples\tests\signal_metrics_regression.py + python python_package\examples\tests\binding_validation_regression.py + shell: cmd + - name: Signal Processing Regression Csharp + run: dotnet run --project csharp_package\brainflow\examples\signal_processing_regression + - name: Signal Processing Regression Rust + run: cargo test --manifest-path rust_package\brainflow\Cargo.toml data_filter::tests + - name: Signal Processing Regression Cpp + run: .\cpp_package\examples\signal_processing\build\Release\signal_processing_regression.exe + shell: cmd - name: Wavelet Buffer Size Python Test run: python %GITHUB_WORKSPACE%\python_package\examples\tests\wavelet_buffer_size.py shell: cmd diff --git a/.gitignore b/.gitignore index cce2a8d9d..c78b3039e 100644 --- a/.gitignore +++ b/.gitignore @@ -343,6 +343,18 @@ ASALocalRun/ *.pickle *.onnx +# Reviewed classifier release artifacts (raw recordings and work caches stay ignored). +!src/ml/train/logreg_mindfulness.onnx +!src/ml/train/svm_mindfulness.onnx +!src/ml/train/forest_mindfulness.onnx +!src/ml/train/knn_mindfulness.onnx +!src/ml/train/mlp_mindfulness.onnx +!src/ml/train/stacking_mindfulness.onnx +!src/ml/train/extra_trees_mindfulness.onnx +!src/ml/train/hist_gradient_boosting_mindfulness.onnx +!src/ml/train/polynomial_logreg_mindfulness.onnx +!src/ml/train/gradient_boosting_mindfulness.onnx + #Environment file .env .vscode/ diff --git a/cpp_package/examples/signal_processing/CMakeLists.txt b/cpp_package/examples/signal_processing/CMakeLists.txt index 9f7474a46..5e2039def 100644 --- a/cpp_package/examples/signal_processing/CMakeLists.txt +++ b/cpp_package/examples/signal_processing/CMakeLists.txt @@ -50,6 +50,12 @@ find_package ( brainflow CONFIG REQUIRED ) +add_executable (signal_processing_regression src/signal_processing_regression.cpp) +target_include_directories (signal_processing_regression PUBLIC ${brainflow_INCLUDE_DIRS}) +target_link_libraries (signal_processing_regression PUBLIC ${BrainflowPath} ${DataHandlerPath} ${BoardControllerPath}) +enable_testing () +add_test (NAME signal_processing_regression COMMAND signal_processing_regression) + ############################## ## Demo for read write file ## ############################## @@ -290,4 +296,4 @@ target_link_libraries ( ${MLModulePath} ${DataHandlerPath} ${BoardControllerPath} -) \ No newline at end of file +) diff --git a/cpp_package/examples/signal_processing/src/ica.cpp b/cpp_package/examples/signal_processing/src/ica.cpp index aa84894e6..41b3542df 100644 --- a/cpp_package/examples/signal_processing/src/ica.cpp +++ b/cpp_package/examples/signal_processing/src/ica.cpp @@ -1,64 +1,33 @@ +#include #include -#include -#include -#ifdef _WIN32 -#include -#else -#include -#endif - -#include "board_shim.h" #include "data_filter.h" -using namespace std; - - -int main (int argc, char *argv[]) +int main () { - BoardShim::enable_dev_board_logger (); - - struct BrainFlowInputParams params; - int res = 0; - int board_id = (int)BoardIds::SYNTHETIC_BOARD; - std::vector eeg_channels = BoardShim::get_eeg_channels (board_id); - int channel_to_use = eeg_channels[4]; - // use synthetic board for demo - BoardShim *board = new BoardShim (board_id, params); - + // Two simultaneous mixtures of non-Gaussian sources: rows are channels, columns are samples. + const int samples = 1024; + const double pi = std::acos (-1.0); + BrainFlowArray data (2, samples); + for (int i = 0; i < samples; i++) + { + double t = i / 256.0; + double first = std::sin (2.0 * pi * 7.0 * t); + double second = std::pow (std::sin (2.0 * pi * 13.0 * t), 3); + data.at (0, i) = first + 0.3 * second; + data.at (1, i) = 0.2 * first + second; + } try { - board->prepare_session (); - board->start_stream (); - -#ifdef _WIN32 - Sleep (10000); -#else - sleep (10); -#endif - - board->stop_stream (); - BrainFlowArray data = - board->get_board_data (500, (int)BrainFlowPresets::DEFAULT_PRESET); - board->release_session (); - - BrainFlowArray data_reshaped (data.get_address (channel_to_use), 5, 100); - std::tuple, BrainFlowArray, BrainFlowArray, - BrainFlowArray> - returned_matrixes = DataFilter::perform_ica (data_reshaped, 2); - std::cout << std::get<3> (returned_matrixes) << std::endl; + auto result = DataFilter::perform_ica (data, 2); + // Component order and sign are arbitrary. + std::cout << "Recovered " << std::get<3> (result).get_size (0) << " sources from " + << samples << " samples" << std::endl; } catch (const BrainFlowException &err) { - BoardShim::log_message ((int)LogLevels::LEVEL_ERROR, err.what ()); - res = err.exit_code; - if (board->is_prepared ()) - { - board->release_session (); - } + std::cerr << err.what () << std::endl; + return err.exit_code; } - - delete board; - - return res; + return 0; } diff --git a/cpp_package/src/data_filter.cpp b/cpp_package/src/data_filter.cpp index ecc571bb6..aa673269a 100644 --- a/cpp_package/src/data_filter.cpp +++ b/cpp_package/src/data_filter.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include #include @@ -6,6 +8,37 @@ #include "data_filter.h" #include "data_handler.h" +namespace +{ + int wavelet_capacity (int data_len, int decomposition_level) + { + if (data_len <= 0 || decomposition_level <= 0 || decomposition_level > 100 || + data_len > std::numeric_limits::max () - 82 * decomposition_level) + { + throw BrainFlowException ( + "invalid wavelet dimensions", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } + return data_len + 82 * decomposition_level; + } + + void validate_channels (const BrainFlowArray &data, const std::vector &channels) + { + if (data.empty () || channels.empty () || + channels.size () > (size_t)std::numeric_limits::max () / data.get_size (1)) + { + throw BrainFlowException ( + "invalid channel dimensions", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } + for (int channel : channels) + { + if (channel < 0 || channel >= data.get_size (0)) + { + throw BrainFlowException ( + "invalid channel index", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } + } + } +} double DataFilter::get_oxygen_level (double *ppg_ir, double *ppg_red, int data_len, int sampling_rate, double coef1, double coef2, double coef3) @@ -118,7 +151,8 @@ void DataFilter::perform_rolling_filter (double *data, int data_len, int period, double *DataFilter::perform_downsampling ( double *data, int data_len, int period, int agg_operation, int *filtered_size) { - if ((data == NULL) || (data_len == 0) || (period == 0) || (data_len / period == 0)) + if ((data == NULL) || (data_len <= 0) || (period <= 0) || (data_len / period == 0) || + (filtered_size == NULL)) { throw BrainFlowException ( "invalid input params", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); @@ -137,30 +171,28 @@ double *DataFilter::perform_downsampling ( std::pair DataFilter::perform_wavelet_transform ( double *data, int data_len, int wavelet, int decomposition_level, int extension_type) { - if (data_len <= 0) + if (data == NULL) { throw BrainFlowException ( "invalid input params", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); } - double *wavelet_output = new double[data_len + - 2 * decomposition_level * (40 + 1)]; // I get this formula from wavelib sources - int *decomposition_lengths = new int[decomposition_level + 1]; + std::unique_ptr wavelet_output (new double[wavelet_capacity (data_len, decomposition_level)]); + std::unique_ptr decomposition_lengths (new int[decomposition_level + 1]); int res = ::perform_wavelet_transform (data, data_len, wavelet, decomposition_level, - extension_type, wavelet_output, decomposition_lengths); + extension_type, wavelet_output.get (), decomposition_lengths.get ()); if (res != (int)BrainFlowExitCodes::STATUS_OK) { - delete[] wavelet_output; - delete[] decomposition_lengths; throw BrainFlowException ("failed to perform wavelet", res); } - return std::make_pair (wavelet_output, decomposition_lengths); + return std::make_pair (wavelet_output.release (), decomposition_lengths.release ()); } double *DataFilter::perform_inverse_wavelet_transform (std::pair wavelet_output, int original_data_len, int wavelet, int decomposition_level, int extension_type) { - if (original_data_len <= 0) + wavelet_capacity (original_data_len, decomposition_level); + if (wavelet_output.first == NULL || wavelet_output.second == NULL) { throw BrainFlowException ( "invalid input params", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); @@ -177,6 +209,41 @@ double *DataFilter::perform_inverse_wavelet_transform (std::pair DataFilter::perform_inverse_wavelet_transform ( + const std::vector &coefficients, const std::vector &lengths, + int original_data_len, int wavelet, int decomposition_level, int extension_type) +{ + wavelet_capacity (original_data_len, decomposition_level); + if (lengths.size () != (size_t)decomposition_level + 1 || + (size_t)original_data_len > coefficients.size () || + coefficients.size () > (size_t)std::numeric_limits::max ()) + { + throw BrainFlowException ("invalid wavelet buffers", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } + size_t total = 0; + for (int length : lengths) + { + if (length <= 0 || (size_t)length > coefficients.size () - total) + { + throw BrainFlowException ("invalid wavelet lengths", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } + total += length; + } + if (total != coefficients.size ()) + { + throw BrainFlowException ("invalid coefficient count", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } + std::vector output (original_data_len); + int res = ::perform_inverse_wavelet_transform_checked (const_cast (coefficients.data ()), + (int)coefficients.size (), original_data_len, wavelet, decomposition_level, extension_type, + const_cast (lengths.data ()), (int)lengths.size (), output.data (), original_data_len); + if (res != (int)BrainFlowExitCodes::STATUS_OK) + { + throw BrainFlowException ("failed to perform inverse wavelet", res); + } + return output; +} + void DataFilter::perform_wavelet_denoising (double *data, int data_len, int wavelet, int decomposition_level, int wavelet_denoising, int threshold, int extenstion_type, int noise_level) @@ -192,7 +259,7 @@ void DataFilter::perform_wavelet_denoising (double *data, int data_len, int wave std::pair, BrainFlowArray> DataFilter::get_csp ( const BrainFlowArray &data, const BrainFlowArray &labels) { - if ((data.empty ()) || (labels.empty ())) + if ((data.empty ()) || (labels.empty ()) || labels.get_size (0) != data.get_size (0)) { throw BrainFlowException ( "Invalid params", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); @@ -214,6 +281,10 @@ std::pair, BrainFlowArray> DataFilter::get_ double *DataFilter::get_window (int window_function, int window_len) { + if (window_len <= 0) + { + throw BrainFlowException ("invalid window length", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } double *window_data = new double[window_len]; int res = ::get_window (window_function, window_len, window_data); if (res != (int)BrainFlowExitCodes::STATUS_OK) @@ -226,7 +297,7 @@ double *DataFilter::get_window (int window_function, int window_len) std::complex *DataFilter::perform_fft (double *data, int data_len, int window, int *fft_len) { - if ((data_len % 2 == 1) || (data_len <= 0)) + if ((data_len % 2 == 1) || (data_len <= 0) || (fft_len == NULL)) { throw BrainFlowException ( "data len must be even", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); @@ -268,7 +339,7 @@ void DataFilter::detrend (double *data, int data_len, int detrend_operation) std::pair DataFilter::get_psd ( double *data, int data_len, int sampling_rate, int window, int *psd_len) { - if ((data_len % 2 == 1) || (data_len <= 0)) + if ((data_len % 2 == 1) || (data_len <= 0) || (psd_len == NULL)) { throw BrainFlowException ( "data len must be even", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); @@ -289,7 +360,8 @@ std::pair DataFilter::get_psd ( std::pair DataFilter::get_psd_welch ( double *data, int data_len, int nfft, int overlap, int sampling_rate, int window, int *psd_len) { - if ((nfft % 2 == 1) || (data_len <= 0)) + if ((nfft % 2 != 0) || (nfft <= 0) || (data_len < nfft) || (overlap < 0) || + (overlap >= nfft) || (psd_len == NULL)) { throw BrainFlowException ( "nfft must be even", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); @@ -324,45 +396,36 @@ std::pair DataFilter::get_custom_band_powers ( const BrainFlowArray &data, std::vector> bands, std::vector channels, int sampling_rate, bool apply_filters) { - if ((data.empty ()) || (channels.empty ()) || (bands.empty ())) + validate_channels (data, channels); + if (bands.empty () || bands.size () > (size_t)std::numeric_limits::max ()) { - throw BrainFlowException ( - "Invalid params", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + throw BrainFlowException ("invalid bands", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); } int cols = data.get_size (1); int channels_len = (int)channels.size (); - double *data_1d = new double[cols * channels_len]; - double *avg_bands = new double[bands.size ()]; - double *stddev_bands = new double[bands.size ()]; - double *start_freqs = new double[bands.size ()]; - double *stop_freqs = new double[bands.size ()]; - for (int i = 0; i < (int)bands.size (); i++) - { - start_freqs[i] = std::get<0> (bands[i]); - stop_freqs[i] = std::get<1> (bands[i]); - avg_bands[i] = 0.0; - stddev_bands[i] = 0.0; + std::vector data_1d ((size_t)cols * channels_len); + std::unique_ptr avg_bands (new double[bands.size ()] ()); + std::unique_ptr stddev_bands (new double[bands.size ()] ()); + std::vector start_freqs (bands.size ()); + std::vector stop_freqs (bands.size ()); + for (size_t i = 0; i < bands.size (); i++) + { + start_freqs[i] = bands[i].first; + stop_freqs[i] = bands[i].second; } for (int i = 0; i < channels_len; i++) { - for (int j = 0; j < cols; j++) - { - data_1d[j + cols * i] = data.at (channels[i], j); - } + std::copy (data.get_address (channels[i]), data.get_address (channels[i]) + cols, + data_1d.begin () + (size_t)cols * i); } - int res = ::get_custom_band_powers (data_1d, channels_len, cols, start_freqs, stop_freqs, - (int)bands.size (), sampling_rate, (int)apply_filters, avg_bands, stddev_bands); - delete[] start_freqs; - delete[] stop_freqs; + int res = ::get_custom_band_powers (data_1d.data (), channels_len, cols, start_freqs.data (), + stop_freqs.data (), (int)bands.size (), sampling_rate, (int)apply_filters, + avg_bands.get (), stddev_bands.get ()); if (res != (int)BrainFlowExitCodes::STATUS_OK) { - delete[] avg_bands; - delete[] stddev_bands; - delete[] data_1d; - throw BrainFlowException ("failed to get_avg_band_powers", res); + throw BrainFlowException ("failed to get custom band powers", res); } - delete[] data_1d; - return std::make_pair (avg_bands, stddev_bands); + return std::make_pair (avg_bands.release (), stddev_bands.release ()); } double DataFilter::get_band_power ( @@ -379,7 +442,7 @@ double DataFilter::get_band_power ( double *DataFilter::perform_ifft (std::complex *fft_data, int fft_len, int *data_len) { - if ((fft_len <= 0) || (fft_data == NULL) || (data_len == NULL)) + if ((fft_len < 2) || (fft_len > std::numeric_limits::max () / 2 + 1) || (fft_data == NULL) || (data_len == NULL)) { throw BrainFlowException ("invalid args", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); } @@ -520,47 +583,30 @@ std::tuple, BrainFlowArray, BrainFlowArray< DataFilter::perform_ica ( const BrainFlowArray &data, int num_components, std::vector channels) { - if ((data.empty ()) || (channels.empty ()) || (num_components < 1)) - { - throw BrainFlowException ( - "Invalid params", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); - } - + validate_channels (data, channels); int cols = data.get_size (1); int channels_len = (int)channels.size (); - double *data_1d = new double[cols * channels_len]; - double *w = new double[num_components * num_components]; - double *k = new double[channels_len * num_components]; - double *a = new double[num_components * channels_len]; - double *s = new double[cols * num_components]; - + if (num_components < 2 || num_components > channels_len || cols < 2) + { + throw BrainFlowException ("invalid ICA dimensions", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } + BrainFlowArray selected (channels_len, cols); + BrainFlowArray w (num_components, num_components); + BrainFlowArray k (num_components, channels_len); + BrainFlowArray a (channels_len, num_components); + BrainFlowArray s (num_components, cols); for (int i = 0; i < channels_len; i++) { - for (int j = 0; j < cols; j++) - { - data_1d[j + cols * i] = data.at (channels[i], j); - } + std::copy (data.get_address (channels[i]), data.get_address (channels[i]) + cols, + selected.get_address (i)); + } + int res = ::perform_ica (selected.get_raw_ptr (), channels_len, cols, num_components, + w.get_raw_ptr (), k.get_raw_ptr (), a.get_raw_ptr (), s.get_raw_ptr ()); + if (res != (int)BrainFlowExitCodes::STATUS_OK) + { + throw BrainFlowException ("failed to perform ICA", res); } - int res = ::perform_ica (data_1d, channels_len, cols, num_components, w, k, a, s); - if (res != (int)BrainFlowExitCodes::STATUS_OK) - { - delete[] data_1d; - delete[] w; - delete[] k; - delete[] a; - delete[] s; - throw BrainFlowException ("failed to perform_ica", res); - } - BrainFlowArray w_mat (w, num_components, num_components); - BrainFlowArray k_mat (k, num_components, channels_len); - BrainFlowArray a_mat (a, channels_len, num_components); - BrainFlowArray s_mat (s, num_components, cols); - delete[] data_1d; - delete[] w; - delete[] k; - delete[] a; - delete[] s; - return std::make_tuple (w_mat, k_mat, a_mat, s_mat); + return std::make_tuple (std::move (w), std::move (k), std::move (a), std::move (s)); } std::tuple, BrainFlowArray, BrainFlowArray, diff --git a/cpp_package/src/inc/data_filter.h b/cpp_package/src/inc/data_filter.h index ceaa2eafa..654cf4a61 100644 --- a/cpp_package/src/inc/data_filter.h +++ b/cpp_package/src/inc/data_filter.h @@ -63,10 +63,16 @@ class DataFilter static std::pair perform_wavelet_transform ( double *data, int data_len, int wavelet, int decomposition_level, int extension_type = (int)WaveletExtensionTypes::SYMMETRIC); // clang-format on - /// performs inverse wavelet transform + /// Performs inverse wavelet transform. The legacy pointer API requires callers to supply + /// coefficient and length buffers matching the original transform; capacities cannot be checked. static double *perform_inverse_wavelet_transform (std::pair wavelet_output, int original_data_len, int wavelet, int decomposition_level, int extension_type = (int)WaveletExtensionTypes::SYMMETRIC); + /// Inverse wavelet transform with checked buffer lengths and transform metadata. + static std::vector perform_inverse_wavelet_transform ( + const std::vector &coefficients, const std::vector &lengths, + int original_data_len, int wavelet, int decomposition_level, + int extension_type = (int)WaveletExtensionTypes::SYMMETRIC); // clanf-format off /** * perform wavelet denoising @@ -161,25 +167,37 @@ class DataFilter static double get_band_power ( std::pair psd, int data_len, double freq_start, double freq_end); /** - * calculate avg and stddev of BandPowers across all channels + * Calculate normalized mean powers for bands 2-4, 4-8, 8-13, 13-30, 30-45 Hz. + * Uses get_custom_band_powers preprocessing and minimum retained data length. * @param data input 2d array - * @param cols number of cols in 2d array - number of datapoints * @param channels vector of rows - eeg channels which should be used * @param sampling_rate sampling rate - * @param apply_filters set to true to apply filters before band power calculations - * @return pair of double arrays of size 5, first of them - avg band powers, second stddev + * @param apply_filters preprocess and discard edge margins to reduce filter transients + * @return pair of double arrays of size 5: normalized means and coefficients of variation */ static std::pair get_avg_band_powers (const BrainFlowArray &data, std::vector channels, int sampling_rate, bool apply_filters); /** - * calculate avg and stddev of BandPowers across all channels + * Calculate normalized mean band powers and variation across selected channels. + * Filtering removes DC and applies padded, initialized zero-phase 48-52 and 58-62 Hz + * notches only when their upper edges are below 0.9 * Nyquist. Preprocessing is independent + * of the requested output bands; no automatic passband is applied. Margins estimated from + * the filter cascade's impulse tail are excluded from both ends before Welch estimation. + * Mains notches attenuate overlapping custom bands; use external preprocessing and + * disable filtering to customize this behavior. + * Supply extra surrounding data; excluding newest samples adds delay in live use. + * Without filtering, no preprocessing or trimming is performed. + * At least max(8, 2 * get_nearest_power_of_two(sampling_rate)) samples must remain. + * Data and band edges must be finite; bands must satisfy 0 <= start < stop <= Nyquist. + * Means are absolute channel-mean powers divided by their sum across requested bands. + * The second output is population stddev / mean of absolute powers across channels. + * Zero-power bands return zero variation; a zero total returns zero normalized means. * @param data input 2d array * @param bands input bands - * @param cols number of cols in 2d array - number of datapoints * @param channels vector of rows - eeg channels which should be used * @param sampling_rate sampling rate - * @param apply_filters set to true to apply filters before band power calculations - * @return pair of float arrays with the same size as bands argument + * @param apply_filters preprocess and discard edge margins to reduce filter transients + * @return pair of double arrays matching bands: normalized means and coefficients of variation */ static std::pair get_custom_band_powers ( const BrainFlowArray &data, std::vector> bands, diff --git a/csharp_package/brainflow/brainflow/data_filter.cs b/csharp_package/brainflow/brainflow/data_filter.cs index 54ad41d90..7b8a419cb 100644 --- a/csharp_package/brainflow/brainflow/data_filter.cs +++ b/csharp_package/brainflow/brainflow/data_filter.cs @@ -11,6 +11,23 @@ namespace brainflow /// public class DataFilter { + private static void validate_span (int length, int start_pos, int end_pos) + { + if (start_pos < 0 || end_pos > length || start_pos >= end_pos) + { + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); + } + } + + private static int wavelet_capacity (int data_len, int decomposition_level) + { + if (data_len <= 0 || decomposition_level <= 0 || decomposition_level > 100 || + (long)data_len + 82L * decomposition_level > int.MaxValue) + { + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); + } + return data_len + 82 * decomposition_level; + } /// @@ -273,6 +290,9 @@ public static double[] detect_peaks_z_score (double[] data, int lag, double thre /// stddev public static double calc_stddev (double[] data, int start_pos, int end_pos) { + if (data == null) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); + validate_span (data.Length, start_pos, end_pos); double[] output = new double[1]; int res = DataHandlerLibrary.calc_stddev (data, start_pos, end_pos, output); if (res != (int)BrainFlowExitCodes.STATUS_OK) @@ -351,7 +371,9 @@ public static double get_heart_rate (double[] ppg_ir, double[] ppg_red, int samp /// tuple of wavelet coeffs in format [A(J) D(J) D(J-1) ..... D(1)] where J is decomposition level, A - app coeffs, D - detailed coeffs, and array with lengths for each block public static Tuple perform_wavelet_transform (double[] data, int wavelet, int decomposition_level, int extension) { - double[] wavelet_coeffs = new double[data.Length + 2 * decomposition_level * (40 + 1)]; + if (data == null) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); + double[] wavelet_coeffs = new double[wavelet_capacity (data.Length, decomposition_level)]; int[] lengths = new int[decomposition_level + 1]; int res = DataHandlerLibrary.perform_wavelet_transform (data, data.Length, wavelet, decomposition_level, extension, wavelet_coeffs, lengths); if (res != (int)BrainFlowExitCodes.STATUS_OK) @@ -383,9 +405,24 @@ public static Tuple perform_wavelet_transform (double[] data, i /// restored data public static double[] perform_inverse_wavelet_transform (Tuple wavelet_data, int original_data_len, int wavelet, int decomposition_level, int extension) { + wavelet_capacity (original_data_len, decomposition_level); + if (wavelet_data == null || wavelet_data.Item1 == null || wavelet_data.Item2 == null || + original_data_len > wavelet_data.Item1.Length || + wavelet_data.Item2.Length != decomposition_level + 1) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); + long total = 0; + foreach (int length in wavelet_data.Item2) + { + if (length <= 0) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); + total += length; + } + if (total != wavelet_data.Item1.Length) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); double[] original_data = new double[original_data_len]; - int res = DataHandlerLibrary.perform_inverse_wavelet_transform (wavelet_data.Item1, original_data_len, wavelet, decomposition_level, extension, - wavelet_data.Item2, original_data); + int res = DataHandlerLibrary.perform_inverse_wavelet_transform_checked (wavelet_data.Item1, wavelet_data.Item1.Length, + original_data_len, wavelet, decomposition_level, extension, wavelet_data.Item2, wavelet_data.Item2.Length, + original_data, original_data.Length); if (res != (int)BrainFlowExitCodes.STATUS_OK) { throw new BrainFlowError (res); @@ -427,6 +464,8 @@ public static double[] perform_wavelet_denoising (double[] data, int wavelet, in /// Tuple of two arrays: [n_channels x n_channels] shaped array of filters and n_channels length array of eigenvalues public static Tuple get_csp (double[,,] data, double[] labels) { + if (data == null || labels == null || labels.Length != data.GetLength (0)) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); int n_epochs = data.GetLength (0); int n_channels = data.GetLength (1); int n_times = data.GetLength (2); @@ -649,14 +688,27 @@ public static int get_nearest_power_of_two (int value) } /// - /// calculate avg and stddev bandpowers across channels + /// Calculate normalized mean band powers and coefficients of variation across channels. + /// Filtering removes DC and applies padded, initialized zero-phase 48-52 and 58-62 Hz + /// notches only when their upper edges are below 0.9 * Nyquist. Preprocessing is independent + /// of the requested output bands; no automatic passband is applied. Margins estimated from + /// the filter cascade's impulse tail are excluded at both ends. + /// Mains notches attenuate overlapping bands; preprocess externally and disable filtering + /// to customize this behavior. + /// Supply surrounding samples; excluding newest samples adds delay in live analysis. + /// Without filtering, no preprocessing or trimming is performed. At least + /// max(8, 2 * get_nearest_power_of_two(sampling_rate)) samples must remain after trimming. + /// Data and edges must be finite; bands require 0 <= start < stop <= Nyquist, + /// and means are normalized by their sum. + /// The second array is population stddev / mean of absolute channel powers, with zero + /// for zero-power bands. A zero total returns zero normalized means. /// /// 2d array with values /// bands to calculate /// rows of data array which should be used for calculation /// sampling rate - /// apply bandpass and bandstop filters before calculation - /// Tuple of avgs and stddev arrays + /// preprocess and discard edge margins to reduce filter transients + /// Normalized mean band powers and coefficients of variation public static Tuple get_custom_band_powers (double[,] data, Tuple[] bands, int[] channels, int sampling_rate, bool apply_filters) { double[] data_1d = new double[data.GetRow (0).Length * channels.Length]; @@ -710,7 +762,10 @@ public static Tuple perform_ica (dou /// public static Tuple perform_ica (double[,] data, int num_components, int[] channels) { - if ((num_components < 1) || (data == null) || (channels == null)) + if ((num_components < 2) || (data == null) || (channels == null) || + num_components > channels.Length || data.GetLength (1) < 2 || + (long)channels.Length * data.GetLength (1) > int.MaxValue || + (long)channels.Length * num_components > int.MaxValue) { throw new BrainFlowError((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); } @@ -740,13 +795,14 @@ public static Tuple perform_ica (dou } /// - /// calculate avg and stddev bandpowers across channels + /// Calculate normalized mean powers for bands 2-4, 4-8, 8-13, 13-30, 30-45 Hz. + /// Uses get_custom_band_powers preprocessing and minimum retained data length. /// /// 2d array with values /// rows of data array which should be used for calculation /// sampling rate - /// apply bandpass and bandstop filters before calculation - /// Tuple of avgs and stddev arrays + /// preprocess and discard edge margins to reduce filter transients + /// Normalized mean band powers and coefficients of variation public static Tuple get_avg_band_powers (double[,] data, int[] channels, int sampling_rate, bool apply_filters) { Tuple[] bands = new Tuple[5]; @@ -754,7 +810,7 @@ public static Tuple get_avg_band_powers (double[,] data, int bands[1] = new Tuple (4.0, 8.0); bands[2] = new Tuple (8.0, 13.0); bands[3] = new Tuple (13.0, 30.0); - bands[4] = new Tuple (30.0, 50.0); + bands[4] = new Tuple (30.0, 45.0); return get_custom_band_powers (data, bands, channels, sampling_rate, apply_filters); } @@ -804,7 +860,7 @@ public static Tuple get_psd (double[] data, int start_pos, i /// Tuple of ampls and freqs arrays public static Tuple get_psd_welch (double[] data, int nfft, int overlap, int sampling_rate, int window) { - if ((nfft & (nfft - 1)) != 0) + if (nfft <= 0 || (nfft & (nfft - 1)) != 0) { throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); } @@ -829,6 +885,8 @@ public static Tuple get_psd_welch (double[] data, int nfft, /// band power public static double get_band_power (Tuple psd, double start_freq, double stop_freq) { + if (psd == null || psd.Item1 == null || psd.Item2 == null || psd.Item1.Length != psd.Item2.Length) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); double[] band_power = new double[1]; int res = DataHandlerLibrary.get_band_power (psd.Item1, psd.Item2, psd.Item1.Length, start_freq, stop_freq, band_power); @@ -1108,6 +1166,9 @@ public static unsafe double[] detect_peaks_z_score (double[,] data, int row_num, /// stddev public static unsafe double calc_stddev (double[,] data, int row_num, int start_pos, int end_pos) { + if (data == null) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); + validate_span (data.GetLength (1), start_pos, end_pos); double[] output = new double[1]; int res = (int)BrainFlowExitCodes.STATUS_OK; if ((row_num < 0) || (row_num >= data.GetLength (0))) @@ -1162,7 +1223,7 @@ public static unsafe double get_railed_percentage (double[,] data, int row_num, /// tuple of wavelet coeffs in format [A(J) D(J) D(J-1) ..... D(1)] where J is decomposition level, A - app coeffs, D - detailed coeffs, and array with lengths for each block public static unsafe Tuple perform_wavelet_transform (double[,] data, int row_num, int wavelet, int decomposition_level, int extension) { - double[] wavelet_coeffs = new double[data.GetLength (1) + 2 * decomposition_level * (40 + 1)]; + double[] wavelet_coeffs = new double[wavelet_capacity (data.GetLength (1), decomposition_level)]; int[] lengths = new int[decomposition_level + 1]; int res = (int)BrainFlowExitCodes.STATUS_OK; if ((row_num < 0) || (row_num >= data.GetLength (0))) @@ -1233,7 +1294,7 @@ public static unsafe void perform_wavelet_denoising (double[,] data, int row_num /// complex array of size N / 2 + 1 of fft data public static unsafe Complex[] perform_fft (double[,] data, int row_num, int start_pos, int end_pos, int window) { - if ((start_pos < 0) || (end_pos > data.Length) || (start_pos >= end_pos)) + if (data == null || (start_pos < 0) || (end_pos > data.GetLength (1)) || (start_pos >= end_pos)) { throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); } @@ -1278,7 +1339,7 @@ public static unsafe Complex[] perform_fft (double[,] data, int row_num, int sta /// Tuple of ampls and freqs arrays of size N / 2 + 1 public static unsafe Tuple get_psd (double[,] data, int row_num, int start_pos, int end_pos, int sampling_rate, int window) { - if ((start_pos < 0) || (end_pos > data.Length) || (start_pos >= end_pos)) + if (data == null || (start_pos < 0) || (end_pos > data.GetLength (1)) || (start_pos >= end_pos)) { throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); } @@ -1318,7 +1379,7 @@ public static unsafe Tuple get_psd (double[,] data, int row_ /// Tuple of ampls and freqs arrays public static unsafe Tuple get_psd_welch (double[,] data, int row_num, int nfft, int overlap, int sampling_rate, int window) { - if ((nfft & (nfft - 1)) != 0) + if (nfft <= 0 || (nfft & (nfft - 1)) != 0) { throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); } diff --git a/csharp_package/brainflow/brainflow/data_handler_library.cs b/csharp_package/brainflow/brainflow/data_handler_library.cs index 2107f5386..134cd5fc8 100644 --- a/csharp_package/brainflow/brainflow/data_handler_library.cs +++ b/csharp_package/brainflow/brainflow/data_handler_library.cs @@ -144,6 +144,8 @@ class DataHandlerLibrary64 [DllImport ("DataHandler", SetLastError = true, CallingConvention = CallingConvention.Cdecl)] public static extern int perform_wavelet_transform (double[] data, int data_len, int wavelet, int decomposition_level, int extension, double[] output_data, int[] decomposition_lengths); [DllImport ("DataHandler", SetLastError = true, CallingConvention = CallingConvention.Cdecl)] + public static extern int perform_inverse_wavelet_transform_checked (double[] coeffs, int coeff_count, int original_data_len, int wavelet, int level, int extension, int[] lengths, int lengths_count, double[] output, int output_count); + [DllImport ("DataHandler", SetLastError = true, CallingConvention = CallingConvention.Cdecl)] public static extern int perform_inverse_wavelet_transform (double[] wavelet_coeffs, int original_data_len, int wavelet, int decomposition_level, int extension, int[] decomposition_lengths, double[] output_data); [DllImport ("DataHandler", SetLastError = true, CallingConvention = CallingConvention.Cdecl)] @@ -256,6 +258,8 @@ class DataHandlerLibrary32 [DllImport ("DataHandler32", SetLastError = true, CallingConvention = CallingConvention.Cdecl)] public static extern int perform_wavelet_transform (double[] data, int data_len, int wavelet, int decomposition_level, int extension, double[] output_data, int[] decomposition_lengths); [DllImport ("DataHandler32", SetLastError = true, CallingConvention = CallingConvention.Cdecl)] + public static extern int perform_inverse_wavelet_transform_checked (double[] coeffs, int coeff_count, int original_data_len, int wavelet, int level, int extension, int[] lengths, int lengths_count, double[] output, int output_count); + [DllImport ("DataHandler32", SetLastError = true, CallingConvention = CallingConvention.Cdecl)] public static extern int perform_inverse_wavelet_transform (double[] wavelet_coeffs, int original_data_len, int wavelet, int decomposition_level, int extension, int[] decomposition_lengths, double[] output_data); [DllImport ("DataHandler32", SetLastError = true, CallingConvention = CallingConvention.Cdecl)] @@ -589,6 +593,18 @@ public static int perform_wavelet_transform (double[] data, int data_len, int wa return (int)BrainFlowExitCodes.GENERAL_ERROR; } + public static int perform_inverse_wavelet_transform_checked (double[] coeffs, int coeff_count, int original_data_len, int wavelet, int level, int extension, int[] lengths, int lengths_count, double[] output, int output_count) + { + switch (PlatformHelper.get_library_environment ()) + { + case LibraryEnvironment.x64: + return DataHandlerLibrary64.perform_inverse_wavelet_transform_checked (coeffs, coeff_count, original_data_len, wavelet, level, extension, lengths, lengths_count, output, output_count); + case LibraryEnvironment.x86: + return DataHandlerLibrary32.perform_inverse_wavelet_transform_checked (coeffs, coeff_count, original_data_len, wavelet, level, extension, lengths, lengths_count, output, output_count); + } + return (int)BrainFlowExitCodes.GENERAL_ERROR; + } + public static int perform_inverse_wavelet_transform (double[] wavelet_coeffs, int original_data_len, int wavelet, int decomposition_level, int extension, int[] decomposition_lengths, double[] output_data) { diff --git a/csharp_package/brainflow/brainflow/ml_model.cs b/csharp_package/brainflow/brainflow/ml_model.cs index be8babb8c..fb620970d 100644 --- a/csharp_package/brainflow/brainflow/ml_model.cs +++ b/csharp_package/brainflow/brainflow/ml_model.cs @@ -10,7 +10,7 @@ namespace brainflow public class MLModel { private string input_json; - BrainFlowModelParams input_params; + private readonly int output_capacity; /// @@ -19,8 +19,19 @@ public class MLModel /// public MLModel (BrainFlowModelParams input_params) { - this.input_json = input_params.to_json (); - this.input_params = input_params; + if (input_params == null) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); + var snapshot = new BrainFlowModelParams (input_params.metric, input_params.classifier) + { + file = input_params.file, + other_info = input_params.other_info, + output_name = input_params.output_name, + max_array_size = input_params.max_array_size + }; + if (snapshot.max_array_size <= 0) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR); + this.input_json = snapshot.to_json (); + this.output_capacity = snapshot.max_array_size; } /// @@ -128,13 +139,15 @@ public void release () /// public double[] predict (double[] data) { - double[] val = new double[input_params.max_array_size]; + double[] val = new double[output_capacity]; int[] val_len = new int[1]; int res = MLModuleLibrary.predict (data, data.Length, val, val_len, input_json); if (res != (int)BrainFlowExitCodes.STATUS_OK) { throw new BrainFlowError (res); } + if (val_len[0] < 0 || val_len[0] > output_capacity) + throw new BrainFlowError ((int)BrainFlowExitCodes.INVALID_BUFFER_SIZE_ERROR); double[] result = new double[val_len[0]]; for (int i = 0; i < val_len[0]; i++) { diff --git a/csharp_package/brainflow/examples/ica/ica.cs b/csharp_package/brainflow/examples/ica/ica.cs index d649fda77..df5d1a41c 100644 --- a/csharp_package/brainflow/examples/ica/ica.cs +++ b/csharp_package/brainflow/examples/ica/ica.cs @@ -1,9 +1,5 @@ -using System; -using System.Runtime.Serialization; - +using System; using brainflow; -using brainflow.math; - namespace examples { @@ -11,24 +7,20 @@ class ICA { static void Main (string[] args) { - BoardShim.enable_dev_board_logger (); - - int board_id = (int)BoardIds.SYNTHETIC_BOARD; - BoardDescr board_descr = BoardShim.get_board_descr (board_id); - int[] eeg_channels = board_descr.eeg_channels; - int channel = eeg_channels[1]; - - BrainFlowInputParams input_params = new BrainFlowInputParams (); - BoardShim board_shim = new BoardShim (board_id, input_params); - board_shim.prepare_session (); - board_shim.start_stream (3600); - System.Threading.Thread.Sleep (10000); - board_shim.stop_stream (); - double[,] data = board_shim.get_board_data (500); - board_shim.release_session (); - - double[,] ica_data = data.GetRow (channel).Reshape(5, 100); - Tuple ica = DataFilter.perform_ica (ica_data, 2); + // Two simultaneous mixtures: rows are channels and columns are samples. + const int samples = 1024; + double[,] data = new double[2, samples]; + for (int i = 0; i < samples; i++) + { + double t = i / 256.0; + double first = Math.Sin (2.0 * Math.PI * 7.0 * t); + double second = Math.Pow (Math.Sin (2.0 * Math.PI * 13.0 * t), 3); + data[0, i] = first + 0.3 * second; + data[1, i] = 0.2 * first + second; + } + var ica = DataFilter.perform_ica (data, 2); + // Component order and sign are arbitrary. + Console.WriteLine ("Recovered " + ica.Item4.GetLength (0) + " sources from " + samples + " samples"); } } } diff --git a/docs/UserAPI.rst b/docs/UserAPI.rst index 735b6f4c1..16b02257f 100644 --- a/docs/UserAPI.rst +++ b/docs/UserAPI.rst @@ -14,6 +14,128 @@ This abstraction allows you to switch boards without any changes in code. In BoardShim, all board data is returned as a 2d array. Rows in this array may contain timestamps, EEG and EMG data and so on. To see instructions how to query specific kind of data check :ref:`data-format-label` and :ref:`code-samples-label`. +Band power preprocessing and data length +---------------------------------------- + +``get_avg_band_powers`` uses bands 2-4, 4-8, 8-13, 13-30, and 30-45 Hz and delegates +to ``get_custom_band_powers``. The following behavior is shared by all bindings. + +With filtering enabled, each selected channel is demeaned. Fourth-order Butterworth +48-52 and 58-62 Hz mains notches are applied when their upper edge is below 90 percent +of Nyquist, avoiding poorly conditioned near-Nyquist designs. These notches attenuate +custom bands that overlap their ranges. Preprocessing does not depend on the requested +integration bands, and no passband is inferred from those bands. Filtering uses forward +and backward passes with odd reflection padding and independently initialized states. + +After filtering, a guard of ``G`` real samples is discarded from each end before the +Welch PSD is calculated. The same ``G`` is used for padding. It is estimated from the +complete cascade's impulse response: the absolute tail beyond ``G`` contains at most +0.1 percent of its absolute sum, over a simulation horizon derived from the poles. +This estimates transient settling; it does not guarantee removal of all edge effects. +Provide extra surrounding samples to retain the desired analysis interval. In live use, +the most recent retained sample is ``G / sampling_rate`` seconds behind the newest input. + +Welch uses a Hann window, 80 percent overlap, and +``nfft = max(8, 2 * get_nearest_power_of_two(sampling_rate))``. Inputs must contain at least +``nfft + 2 * G`` samples per selected channel when filtering is enabled. Short inputs +raise an error instead of silently lowering the frequency resolution. With filtering +disabled, there is no demeaning, filtering, or trimming, and at least ``nfft`` samples +are required. The native and Python ``get_custom_band_powers_with_options`` APIs allow +explicit low/high cutoffs, mains selection, FFT size, and whole-record detrending. +Cutoff zero disables that passband edge. Mains values are -1 for automatic selection, +0 for none, 1 for 50 Hz, 2 for 60 Hz, and 3 for both. Explicit notches must fit below +Nyquist. Detrending in this API is independent of the filtering flag; select +``NO_DETREND`` to preserve DC. Welch does not detrend individual segments. + +Python ``get_band_power_info`` (native ``get_band_power_settings``) reports the FFT size +and edge margin before collection. Python also reports minimum samples, bin spacing, +margin in seconds, and optionally the retained half-open interval. Bin spacing is not +the same as the resolving bandwidth of a window. Other bindings may preprocess an +extended buffer externally and call their existing method with filtering disabled. + +Selected samples and band edges must be finite. Each band must satisfy +``0 <= start < stop <= sampling_rate / 2``. Power is integrated over the exact requested +frequency bounds, including partial intervals at band edges. + +The first output contains absolute band powers averaged across selected channels, then +normalized by the sum of those means over the requested bands. The second output is +historically named ``stddev`` but contains the coefficient of variation: population +standard deviation divided by mean absolute power across channels for each band. A +zero-power band has zero variation; an all-zero total returns zero normalized powers. +Overlapping bands contribute separately to the normalization sum. + +These preprocessing and integration changes affect extracted features. Classifiers trained +with earlier band power behavior should be revalidated before using the new features. + +Signal-processing conventions and validation +-------------------------------------------- + +PSD and Welch return one-sided power density in input-units squared per Hz, normalized +by ``sampling_rate * sum(window**2)``. DC and Nyquist are not doubled. This corrects +the historical underestimation with nonrectangular windows and integer overflow for +large FFTs. Absolute powers therefore change. Windows are periodic; the four-term +Blackman-Harris coefficients are 0.355768, 0.487396, 0.144232, and 0.012604. + +Public filters require positive finite cutoffs strictly below Nyquist, ascending +band edges, order 1-8, and positive finite Chebyshev ripple. Zero-phase methods +preserve length and use reflected padding, limited to the available samples, with +independent steady-state initialization in each direction. Short inputs can still +have substantial boundary effects. Causal calls start from zero state on each call. +Native/Python ``get_filter_settling_samples`` reports the estimated margin for a +specified design; exclude it at both ends for zero-phase analysis or at startup +for a causal filter. Filter kinds are 0 lowpass, 1 highpass, 2 bandpass, 3 bandstop; +``low_cutoff`` holds the single cutoff for either lowpass or highpass. +The native streaming-filter API and Python ``StreamingFilter`` retain causal SOS +states across chunks, with explicit reset and release/close operations. Chunked +processing therefore matches a single causal call over the concatenated samples. +Combined mains removal is equivalent to the separate 50 Hz and 60 Hz zero-phase calls. +Nonfinite input is rejected before in-place filtering, detrending, or aggregation. + +Linear detrending subtracts the least-squares line; a one-sample input becomes zero. +Standard deviations use the population convention. Rolling mean and median both +use the available prefix until the window fills. Downsampling remains a block +aggregator: it drops an incomplete trailing block and does not apply an anti-alias +filter. ``EACH`` selects the last sample in each complete block. +The separate native/Python ``perform_decimation`` applies a centered Hamming-windowed +sinc FIR before selecting samples. Its cutoff is 80 percent of the new Nyquist, +length is ``20 * factor + 1``, endpoints use reflection, and the output contains +``floor(input_length / factor)`` samples aligned to input indices ``0, factor, ...``. +It is an offline operation; endpoint effects remain possible. + +Inverse-wavelet bindings validate metadata and actual buffer sizes through the new +checked native entry point. The original C API and C++ pointer overload remain for +compatibility; callers of these interfaces must supply correctly sized allocations. +Use the checked C API or C++ vector overload when sizes are available. + +CSP requires finite data, exact binary labels, both classes, and full-rank composite +covariance. ICA rejects insufficient retained rank and reports nonconvergence as +``GENERAL_ERROR``. Its mixing matrix is channels by components and reconstructs +centered input as ``A @ S``. The options API exposes iteration limit, tolerance, and +random seed; successful return means convergence was reached. + +Heart-rate estimation detrends both channels before Welch and requires a shared +distinct peak between 35 and 230 BPM. Each channel's peak must exceed four times +the in-band median; its three-bin neighborhood must contain at least 45 percent +of in-band power, and channel peaks must agree within one bin. FFT size must be +a power of two, at least 1024, with bin spacing no greater than 0.25 Hz. Oxygen +estimation uses a second-order 0.5-4 Hz zero-phase bandpass, excludes response-based +guards at both ends, requires at least four seconds retained, and checks positive +DC/AC and a distinct shared pulse. Existing calibration coefficients remain explicit +arguments. Invalid or insufficient pulse signals raise ``INVALID_ARGUMENTS_ERROR``; +insufficient data for oxygen's retained interval raises ``INVALID_BUFFER_SIZE_ERROR``. +These are numerical quality checks, not physiological validation. + +``get_railed_percentage`` preserves the legacy 4.5 V, 24-bit ADC peak/full-scale +metric with microvolt input and gain, including 100 percent for flat signals; its +result is now bounded to 0-100. Native/Python ``get_clipping_percentage`` instead +counts samples at or beyond caller-provided lower/upper limits. +``get_flatline_percentage`` reports the percentage of adjacent pairs whose absolute +difference is no larger than the supplied tolerance (at least two samples required). + +Data files accept rectangular tab- or comma-separated finite numeric values, including +a final line without a newline. Invalid tokens and insufficient destination capacity +return errors. Writing uses enough decimal precision to round-trip doubles. + Python API Reference ---------------------- diff --git a/java_package/brainflow/src/main/java/brainflow/DataFilter.java b/java_package/brainflow/src/main/java/brainflow/DataFilter.java index 1fb538730..ffa6b227f 100644 --- a/java_package/brainflow/src/main/java/brainflow/DataFilter.java +++ b/java_package/brainflow/src/main/java/brainflow/DataFilter.java @@ -21,6 +21,38 @@ @SuppressWarnings ("deprecation") public class DataFilter { + private static int checked_product (int first, int second) throws BrainFlowError + { + long count = (long) first * second; + if (first < 0 || second < 0 || count > Integer.MAX_VALUE) + { + throw new BrainFlowError ("Array dimensions exceed the native integer range", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } + return (int) count; + } + + private static int selected_columns (double[][] data, int[] channels) throws BrainFlowError + { + if (data == null || channels == null || channels.length == 0) + { + throw new BrainFlowError ("Channels must select nonempty data", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } + int columns = -1; + for (int channel : channels) + { + if (channel < 0 || channel >= data.length || data[channel] == null || + data[channel].length == 0 || (columns >= 0 && data[channel].length != columns)) + { + throw new BrainFlowError ("Selected channels must have equal nonzero sample counts", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } + columns = data[channel].length; + } + checked_product (channels.length, columns); + return columns; + } private interface DllInterface extends Library { @@ -53,8 +85,9 @@ int detect_peaks_z_score (double[] data, int data_len, int lag, double threshold int perform_wavelet_transform (double[] data, int data_len, int wavelet, int decomposition_level, int extention, double[] output_data, int[] decomposition_lengths); - int perform_inverse_wavelet_transform (double[] wavelet_coeffs, int original_data_len, int wavelet, - int decomposition_level, int extension, int[] decomposition_lengths, double[] output_data); + int perform_inverse_wavelet_transform_checked (double[] wavelet_coeffs, int coeff_count, + int original_data_len, int wavelet, int decomposition_level, int extension, + int[] decomposition_lengths, int lengths_count, double[] output_data, int output_count); int perform_wavelet_denoising (double[] data, int data_len, int wavelet, int decomposition_level, int wavelet_denoising, int threshold, int extenstion_type, int noise_level); @@ -200,6 +233,11 @@ public static void set_log_file (String log_file) throws BrainFlowError */ public static double calc_stddev (double[] data, int start_pos, int end_pos) throws BrainFlowError { + if (data == null || start_pos < 0 || end_pos <= start_pos || end_pos > data.length) + { + throw new BrainFlowError ("Invalid standard deviation span", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } double[] output = new double[1]; int ec = instance.calc_stddev (data, start_pos, end_pos, output); if (ec != BrainFlowExitCode.STATUS_OK.get_code ()) @@ -559,7 +597,9 @@ public static void perform_wavelet_denoising (double[] data, WaveletTypes wavele public static Pair perform_wavelet_transform (double[] data, int wavelet, int decomposition_level, int extension) throws BrainFlowError { - if (decomposition_level <= 0) + if (data == null || data.length == 0 || decomposition_level <= 0 || + decomposition_level > 100 || + (long) data.length + 82L * decomposition_level > Integer.MAX_VALUE) { throw new BrainFlowError ("Invalid decomposition level", BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); @@ -598,14 +638,33 @@ public static Pair perform_wavelet_transform (double[] data, Wa public static double[] perform_inverse_wavelet_transform (Pair wavelet_output, int original_data_len, int wavelet, int decomposition_level, int extension) throws BrainFlowError { - if (decomposition_level <= 0) + if (decomposition_level <= 0 || decomposition_level > 100 || original_data_len <= 0 || + wavelet_output == null || wavelet_output.getLeft () == null || + wavelet_output.getRight () == null || wavelet_output.getRight ().length != decomposition_level + 1 || + original_data_len > wavelet_output.getLeft ().length) { - throw new BrainFlowError ("Invalid decomposition level", + throw new BrainFlowError ("Invalid wavelet metadata", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } + long coeff_count = 0; + for (int length : wavelet_output.getRight ()) + { + if (length <= 0) + { + throw new BrainFlowError ("Invalid wavelet block length", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } + coeff_count += length; + } + if (coeff_count != wavelet_output.getLeft ().length) + { + throw new BrainFlowError ("Wavelet coefficient lengths do not match", BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); } double[] output_array = new double[original_data_len]; - int ec = instance.perform_inverse_wavelet_transform (wavelet_output.getLeft (), original_data_len, wavelet, - decomposition_level, extension, wavelet_output.getRight (), output_array); + int ec = instance.perform_inverse_wavelet_transform_checked (wavelet_output.getLeft (), + wavelet_output.getLeft ().length, original_data_len, wavelet, decomposition_level, extension, + wavelet_output.getRight (), wavelet_output.getRight ().length, output_array, output_array.length); if (ec != BrainFlowExitCode.STATUS_OK.get_code ()) { throw new BrainFlowError ("Failed to perform inverse wavelet transform", ec); @@ -629,15 +688,31 @@ public static double[] perform_inverse_wavelet_transform (Pair */ public static Pair get_csp (double[][][] data, double[] labels) throws BrainFlowError { + if (data == null || data.length == 0 || labels == null || labels.length != data.length || + data[0] == null || data[0].length == 0 || data[0][0] == null || data[0][0].length == 0) + { + throw new BrainFlowError ("CSP requires one label per nonempty epoch", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } int n_epochs = data.length; int n_channels = data[0].length; int n_times = data[0][0].length; - double[] temp_data1d = new double[n_epochs * n_channels * n_times]; + double[] temp_data1d = new double[checked_product (checked_product (n_epochs, n_channels), n_times)]; for (int e = 0; e < n_epochs; e++) { + if (data[e] == null || data[e].length != n_channels) + { + throw new BrainFlowError ("CSP epochs must have equal channel counts", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } for (int c = 0; c < n_channels; c++) { + if (data[e][c] == null || data[e][c].length != n_times) + { + throw new BrainFlowError ("CSP channels must have equal sample counts", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } for (int t = 0; t < n_times; t++) { int idx = e * n_channels * n_times + c * n_times + t; @@ -646,7 +721,7 @@ public static Pair get_csp (double[][][] data, double[] la } } - double[] temp_filters = new double[n_channels * n_channels]; + double[] temp_filters = new double[checked_product (n_channels, n_channels)]; double[] output_eigenvalues = new double[n_channels]; int ec = instance.get_csp (temp_data1d, labels, n_epochs, n_channels, n_times, temp_filters, @@ -769,13 +844,14 @@ public static double[] perform_ifft (Complex[] data) throws BrainFlowError } /** - * calc average and stddev of band powers across all channels + * Calculate normalized mean powers for bands 2-4, 4-8, 8-13, 13-30, 30-45 Hz. + * Uses get_custom_band_powers preprocessing and minimum retained data length. * * @param data data to process * @param channels rows of data arrays which should be used in calculation * @param sampling_rate sampling rate - * @param apply_filters apply bandpass and bandstop filters before calculation - * @return pair of avgs and stddevs for bandpowers + * @param apply_filters preprocess and discard edge margins to reduce filter transients + * @return normalized mean band powers and coefficients of variation */ public static Pair get_avg_band_powers (double[][] data, int[] channels, int sampling_rate, boolean apply_filters) throws BrainFlowError @@ -790,14 +866,26 @@ public static Pair get_avg_band_powers (double[][] data, int } /** - * calc average and stddev of band powers across all channels + * Calculate normalized mean band powers and coefficients of variation across channels. + * Filtering demeans and applies padded, initialized zero-phase 48-52 and 58-62 Hz + * notches only when their upper edges are below 90% of Nyquist. No automatic passband + * is applied, so preprocessing is independent of the integration bands. + * Margins estimated from the complete cascade impulse-response tail are excluded + * at both ends; this estimate is not a guaranteed artifact bound. Mains notches + * attenuate overlapping bands. Supply surrounding samples; excluding the newest + * samples adds delay in live analysis. Without filtering, no preprocessing or trimming + * is performed. At least max(8, 2 * get_nearest_power_of_two(sampling_rate)) samples + * must remain. Data and edges must be finite; bands require + * 0 <= start < stop <= Nyquist. Means are normalized by their sum. + * The second array is population stddev / mean of absolute channel powers, with zero + * for zero-power bands. A zero total returns zero normalized means. * * @param data data to process * @param bands bands to calculate * @param channels rows of data arrays which should be used in calculation * @param sampling_rate sampling rate - * @param apply_filters apply bandpass and bandstop filters before calculation - * @return pair of avgs and stddevs for bandpowers + * @param apply_filters preprocess and discard edge margins to reduce filter transients + * @return normalized mean band powers and coefficients of variation */ public static Pair get_custom_band_powers (double[][] data, List> bands, int[] channels, int sampling_rate, boolean apply_filters) throws BrainFlowError @@ -807,13 +895,11 @@ public static Pair get_custom_band_powers (double[][] data, throw new BrainFlowError ("data or channels or bands are null", BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); } - double[] data_1d = new double[channels.length * data[channels[0]].length]; + int cols = selected_columns (data, channels); + double[] data_1d = new double[checked_product (channels.length, cols)]; for (int i = 0; i < channels.length; i++) { - for (int j = 0; j < data[channels[i]].length; j++) - { - data_1d[j + i * data[channels[i]].length] = data[channels[i]][j]; - } + System.arraycopy (data[channels[i]], 0, data_1d, i * cols, cols); } double[] avgs = new double[bands.size ()]; double[] stddevs = new double[bands.size ()]; @@ -825,7 +911,7 @@ public static Pair get_custom_band_powers (double[][] data, stop_freqs[i] = bands.get (i).getValue (); } int filters = (apply_filters) ? 1 : 0; - int ec = instance.get_custom_band_powers (data_1d, channels.length, data[channels[0]].length, start_freqs, + int ec = instance.get_custom_band_powers (data_1d, channels.length, cols, start_freqs, stop_freqs, bands.size (), sampling_rate, filters, avgs, stddevs); if (ec != BrainFlowExitCode.STATUS_OK.get_code ()) { @@ -868,27 +954,24 @@ public static List perform_ica (double[][] data, int num_components) public static List perform_ica (double[][] data, int num_components, int[] channels) throws BrainFlowError { - if ((data == null) || (channels == null) || (num_components < 1)) + int cols = selected_columns (data, channels); + if (num_components < 2 || num_components > Math.min (channels.length, cols - 1)) { throw new BrainFlowError ("invalid args for perform_ica", BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); } - double[] data_1d = new double[channels.length * data[channels[0]].length]; + double[] data_1d = new double[checked_product (channels.length, cols)]; for (int i = 0; i < channels.length; i++) { - for (int j = 0; j < data[channels[i]].length; j++) - { - data_1d[j + i * data[channels[i]].length] = data[channels[i]][j]; - } + System.arraycopy (data[channels[i]], 0, data_1d, i * cols, cols); } - int cols = data[0].length; int channels_len = channels.length; - double[] w = new double[num_components * num_components]; - double[] k = new double[channels_len * num_components]; - double[] a = new double[num_components * channels_len]; - double[] s = new double[cols * num_components]; + double[] w = new double[checked_product (num_components, num_components)]; + double[] k = new double[checked_product (channels_len, num_components)]; + double[] a = new double[checked_product (num_components, channels_len)]; + double[] s = new double[checked_product (cols, num_components)]; - int ec = instance.perform_ica (data_1d, channels.length, data[channels[0]].length, num_components, w, k, a, s); + int ec = instance.perform_ica (data_1d, channels.length, cols, num_components, w, k, a, s); if (ec != BrainFlowExitCode.STATUS_OK.get_code ()) { throw new BrainFlowError ("Failed to perform_ica", ec); @@ -1013,6 +1096,12 @@ public static Pair get_psd_welch (double[] data, int nfft, i public static double get_band_power (Pair psd, double freq_start, double freq_end) throws BrainFlowError { + if (psd == null || psd.getLeft () == null || psd.getRight () == null || + psd.getLeft ().length != psd.getRight ().length) + { + throw new BrainFlowError ("PSD amplitude and frequency lengths must match", + BrainFlowExitCode.INVALID_ARGUMENTS_ERROR.get_code ()); + } double[] res = new double[1]; int ec = instance.get_band_power (psd.getLeft (), psd.getRight (), psd.getLeft ().length, freq_start, freq_end, res); diff --git a/java_package/brainflow/src/main/java/brainflow/MLModel.java b/java_package/brainflow/src/main/java/brainflow/MLModel.java index f34a3fea1..6030ec302 100644 --- a/java_package/brainflow/src/main/java/brainflow/MLModel.java +++ b/java_package/brainflow/src/main/java/brainflow/MLModel.java @@ -91,15 +91,28 @@ private interface DllInterface extends Library private String input_params; - private BrainFlowModelParams params; + private final int output_capacity; /** * Create MLModel object */ public MLModel (BrainFlowModelParams params) { - input_params = params.to_json (); - this.params = params; + if (params == null) + { + throw new IllegalArgumentException ("Model params must not be null"); + } + BrainFlowModelParams snapshot = new BrainFlowModelParams (params.metric, params.classifier); + snapshot.file = params.file; + snapshot.other_info = params.other_info; + snapshot.output_name = params.output_name; + snapshot.max_array_size = params.max_array_size; + if (snapshot.max_array_size <= 0) + { + throw new IllegalArgumentException ("max_array_size must be positive"); + } + input_params = snapshot.to_json (); + output_capacity = snapshot.max_array_size; } /** @@ -233,13 +246,18 @@ public void release () throws BrainFlowError */ public double[] predict (double[] data) throws BrainFlowError { - double[] val = new double[params.max_array_size]; + double[] val = new double[output_capacity]; int[] val_len = new int[1]; int ec = instance.predict (data, data.length, val, val_len, input_params); if (ec != BrainFlowExitCode.STATUS_OK.get_code ()) { throw new BrainFlowError ("Error in predict", ec); } + if (val_len[0] < 0 || val_len[0] > output_capacity) + { + throw new BrainFlowError ("Invalid prediction output length", + BrainFlowExitCode.INVALID_BUFFER_SIZE_ERROR.get_code ()); + } return Arrays.copyOfRange (val, 0, val_len[0]); } } diff --git a/java_package/brainflow/src/main/java/brainflow/examples/ICA.java b/java_package/brainflow/src/main/java/brainflow/examples/ICA.java index 200372fee..6be928b54 100644 --- a/java_package/brainflow/src/main/java/brainflow/examples/ICA.java +++ b/java_package/brainflow/src/main/java/brainflow/examples/ICA.java @@ -1,33 +1,25 @@ package brainflow.examples; import java.util.List; - -import brainflow.BoardIds; -import brainflow.BoardShim; -import brainflow.BrainFlowInputParams; import brainflow.DataFilter; public class ICA { - public static void main (String[] args) throws Exception { - // use synthetic board for demo - BoardShim.enable_board_logger (); - BrainFlowInputParams params = new BrainFlowInputParams (); - BoardIds board_id = BoardIds.SYNTHETIC_BOARD; - BoardShim board_shim = new BoardShim (board_id, params); - board_shim.prepare_session (); - board_shim.start_stream (3600); - Thread.sleep (10000); - board_shim.stop_stream (); - double[][] data = board_shim.get_board_data (500); - board_shim.release_session (); - - int[] eeg_channels = BoardShim.get_eeg_channels (board_id); - int eeg_channel = eeg_channels[1]; - double[][] ica_data = DataFilter.reshape_data_to_2d (5, 100, data[eeg_channel]); - List ica = DataFilter.perform_ica (ica_data, 2); - System.out.println ("Completed"); + // Two simultaneous mixtures: rows are channels and columns are samples. + int samples = 1024; + double[][] data = new double[2][samples]; + for (int i = 0; i < samples; i++) + { + double t = i / 256.0; + double first = Math.sin (2.0 * Math.PI * 7.0 * t); + double second = Math.pow (Math.sin (2.0 * Math.PI * 13.0 * t), 3); + data[0][i] = first + 0.3 * second; + data[1][i] = 0.2 * first + second; + } + List ica = DataFilter.perform_ica (data, 2); + // Component order and sign are arbitrary. + System.out.println ("Recovered " + ica.get (3).length + " sources from " + samples + " samples"); } } diff --git a/julia_package/brainflow/src/data_filter.jl b/julia_package/brainflow/src/data_filter.jl index 8e2baf974..d2594c23e 100644 --- a/julia_package/brainflow/src/data_filter.jl +++ b/julia_package/brainflow/src/data_filter.jl @@ -140,9 +140,39 @@ end WaveletType = Union{WaveletTypes, Integer} +function _dsp_signal(data; inplace=false) + if !(data isa AbstractVector{<:Real}) || !(0 < length(data) <= typemax(Cint)) + throw(BrainFlowError("Expected a nonempty real signal vector", Integer(INVALID_ARGUMENTS_ERROR))) + end + if inplace + if !(data isa StridedVector{Float64}) || stride(data, 1) != 1 + throw(BrainFlowError("In-place processing requires contiguous Float64 samples", Integer(INVALID_ARGUMENTS_ERROR))) + end + return data + end + return Vector{Float64}(data) +end + +function _dsp_matrix(data) + if !(data isa AbstractMatrix{<:Real}) || !(0 < length(data) <= typemax(Cint)) + throw(BrainFlowError("Expected a nonempty real matrix", Integer(INVALID_ARGUMENTS_ERROR))) + end + return Matrix{Float64}(data) +end + +function _dsp_channels(data, channels) + if !(channels isa AbstractVector) || isempty(channels) || + any(c -> !(c isa Integer) || !(1 <= c <= size(data, 1)), channels) || + length(channels) > div(typemax(Cint), size(data, 2)) + throw(BrainFlowError("Invalid channel selection", Integer(INVALID_ARGUMENTS_ERROR))) + end + return Int[c for c in channels] +end + @brainflow_rethrow function perform_lowpass(data, sampling_rate::Integer, cutoff::Float64, order::Integer, filter_type::FiltType, ripple::Float64) + data = _dsp_signal(data; inplace=true) ccall((:perform_lowpass, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Float64, Cint, Cint, Float64), data, length(data), Int32(sampling_rate), Float64(cutoff), Int32(order), Int32(filter_type), Float64(ripple)) return @@ -151,6 +181,7 @@ end @brainflow_rethrow function perform_highpass(data, sampling_rate::Integer, cutoff::Float64, order::Integer, filter_type::FiltType, ripple::Float64 ) + data = _dsp_signal(data; inplace=true) ccall((:perform_highpass, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Float64, Cint, Cint, Float64), data, length(data), Int32(sampling_rate), Float64(cutoff), Int32(order), Int32(filter_type), Float64(ripple)) return @@ -158,6 +189,7 @@ end @brainflow_rethrow function perform_bandpass(data, sampling_rate::Integer, start_freq::Float64, stop_freq::Float64, order::Integer, filter_type::FiltType, ripple::Float64) + data = _dsp_signal(data; inplace=true) ccall((:perform_bandpass, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Float64, Float64, Cint, Cint, Float64), data, length(data), Int32(sampling_rate), Float64(start_freq), Float64(stop_freq), Int32(order), Int32(filter_type), Float64(ripple)) return @@ -165,24 +197,28 @@ end @brainflow_rethrow function perform_bandstop(data, sampling_rate::Integer, start_freq::Float64, stop_freq::Float64, order::Integer, filter_type::FiltType, ripple::Float64) + data = _dsp_signal(data; inplace=true) ccall((:perform_bandstop, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Float64, Float64, Cint, Cint, Float64), data, length(data), Int32(sampling_rate), Float64(start_freq), Float64(stop_freq), Int32(order), Int32(filter_type), Float64(ripple)) return end @brainflow_rethrow function remove_environmental_noise(data, sampling_rate::Integer, noise_type::EnvNoiseType) + data = _dsp_signal(data; inplace=true) ccall((:remove_environmental_noise, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Cint), data, length(data), Int32(sampling_rate), Int32(noise_type)) return end @brainflow_rethrow function perform_rolling_filter(data, period::Integer, operation::AggType) + data = _dsp_signal(data; inplace=true) ccall((:perform_rolling_filter, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Cint), data, length(data), Int32(period), Int32(operation)) return end @brainflow_rethrow function detrend(data, operation::DetType) + data = _dsp_signal(data; inplace=true) ccall((:detrend, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint), data, length(data), Int32(operation)) return @@ -191,6 +227,7 @@ end @brainflow_rethrow function restore_data_from_wavelet_detailed_coeffs(data, wavelet::WaveletType, decomposition_level::Integer, level_to_restore::Integer) + data = _dsp_signal(data) restored_data = Vector{Float64}(undef, length(data)) ccall((:restore_data_from_wavelet_detailed_coeffs, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Cint, Cint, Ptr{Float64}), data, length(data), Int32(wavelet), Int32(decomposition_level), Int32(level_to_restore), restored_data) @@ -200,6 +237,7 @@ end @brainflow_rethrow function detect_peaks_z_score(data, lag::Integer, threshold::Float64, influence::Float64) + data = _dsp_signal(data) peaks = Vector{Float64}(undef, length(data)) ccall((:detect_peaks_z_score, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Float64, Float64, Ptr{Float64}), data, length(data), Int32(lag), threshold, influence, peaks) @@ -211,6 +249,7 @@ end threshold::ThresholdType, extension::WaveletExtensionType, noise_level::NoiseEstimationLevelType) + data = _dsp_signal(data; inplace=true) ccall((:perform_wavelet_denoising, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Cint, Cint, Cint, Cint, Cint), data, length(data), Int32(wavelet), Int32(decomposition_level), Int32(wavelet_denoising), Int32(threshold), Int32(extension), Int32(noise_level)) @@ -218,7 +257,11 @@ end end @brainflow_rethrow function perform_downsampling(data, period::Integer, operation::AggType) - len = Integer(floor(length(data) / period)) + data = _dsp_signal(data) + if !(1 <= period <= typemax(Cint)) + throw(BrainFlowError("Invalid aggregation period", Integer(INVALID_ARGUMENTS_ERROR))) + end + len = div(length(data), period) downsampled_data = Vector{Float64}(undef, len) ccall((:perform_downsampling, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Cint, Ptr{Float64}), data, length(data), Int32(period), Int32(operation), downsampled_data) @@ -226,6 +269,7 @@ end end @brainflow_rethrow function write_file(data, file_name::String, file_mode::String) + data = _dsp_matrix(data) shape = size(data) flatten = transpose(vcat(data)) flaten = reshape(flatten, (1, shape[1] * shape[2])) @@ -243,6 +287,7 @@ end end @brainflow_rethrow function calc_stddev(data) + data = _dsp_signal(data) output = Vector{Float64}(undef, 1) ccall((:calc_stddev, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Ptr{Float64}), data, 0, length(data), output) @@ -250,6 +295,7 @@ end end @brainflow_rethrow function get_railed_percentage(data, gain::Integer) + data = _dsp_signal(data) output = Vector{Float64}(undef, 1) ccall((:get_railed_percentage, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Ptr{Float64}), data, length(data), gain, output) @@ -257,6 +303,7 @@ end end @brainflow_rethrow function get_oxygen_level(ppg_ir, ppg_red, sampling_rate::Integer, coef1=1.5958422, coef2=-34.6596622, coef3=112.6898759) + ppg_ir, ppg_red = _dsp_signal(ppg_ir), _dsp_signal(ppg_red) if length(ppg_ir) != length(ppg_red) throw(BrainFlowError(string("invalid size", INVALID_ARGUMENTS_ERROR), Integer(INVALID_ARGUMENTS_ERROR))) end @@ -268,6 +315,7 @@ end end @brainflow_rethrow function get_heart_rate(ppg_ir, ppg_red, sampling_rate::Integer, fft_size::Integer) + ppg_ir, ppg_red = _dsp_signal(ppg_ir), _dsp_signal(ppg_red) if length(ppg_ir) != length(ppg_red) throw(BrainFlowError(string("invalid size", INVALID_ARGUMENTS_ERROR), Integer(INVALID_ARGUMENTS_ERROR))) end @@ -297,6 +345,10 @@ end end @brainflow_rethrow function perform_wavelet_transform(data, wavelet::WaveletType, decomposition_level::Integer, extension::WaveletExtensionType) + data = _dsp_signal(data) + if !(1 <= decomposition_level <= 100) || length(data) > typemax(Cint) - 82 * decomposition_level + throw(BrainFlowError("Invalid decomposition level", Integer(INVALID_ARGUMENTS_ERROR))) + end wavelet_coeffs = Vector{Float64}(undef, length(data) + 2 * decomposition_level * (40 + 1)) lengths = Vector{Cint}(undef, decomposition_level + 1) ccall((:perform_wavelet_transform, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Cint, Cint, Ptr{Float64}, Ptr{Cint}), @@ -306,13 +358,34 @@ end @brainflow_rethrow function perform_inverse_wavelet_transform(wavelet_output, original_data_len::Integer, wavelet::WaveletType, decomposition_level::Integer, extension::WaveletExtensionType) + if !(0 < original_data_len <= typemax(Cint)) || !(1 <= decomposition_level <= 100) || length(wavelet_output) != 2 + throw(BrainFlowError("Invalid wavelet dimensions", Integer(INVALID_ARGUMENTS_ERROR))) + end + coeffs, block_lengths = wavelet_output + if original_data_len > length(coeffs) + throw(BrainFlowError("Original length exceeds coefficient count", Integer(INVALID_ARGUMENTS_ERROR))) + end + if ndims(coeffs) != 1 || ndims(block_lengths) != 1 || length(block_lengths) != decomposition_level + 1 || + any(x -> !(x isa Integer) || !(0 < x <= typemax(Cint)), block_lengths) || + sum(Int64(x) for x in block_lengths) != length(coeffs) + throw(BrainFlowError("Invalid wavelet coefficient lengths", Integer(INVALID_ARGUMENTS_ERROR))) + end + coeffs = _dsp_signal(coeffs) + block_lengths = Vector{Cint}(block_lengths) original_data = Vector{Float64}(undef, original_data_len) - ccall((:perform_inverse_wavelet_transform, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Cint, Cint, Cint, Cint, Ptr{Float64}, Ptr{Float64}), - wavelet_output[1], Int32(original_data_len), Int32(wavelet), Int32(decomposition_level), Int32(extension), wavelet_output[2], original_data) + ccall((:perform_inverse_wavelet_transform_checked, DATA_HANDLER_INTERFACE), Cint, + (Ptr{Float64}, Cint, Cint, Cint, Cint, Cint, Ptr{Cint}, Cint, Ptr{Float64}, Cint), + coeffs, length(coeffs), Int32(original_data_len), Int32(wavelet), Int32(decomposition_level), + Int32(extension), block_lengths, length(block_lengths), original_data, length(original_data)) return original_data end @brainflow_rethrow function get_csp(data, labels) + if ndims(data) != 3 || ndims(labels) != 1 || length(labels) != size(data, 1) || + !(0 < length(data) <= typemax(Cint)) || size(data, 2) > isqrt(typemax(Cint)) + throw(BrainFlowError("CSP requires one label per epoch", Integer(INVALID_ARGUMENTS_ERROR))) + end + labels = Vector{Float64}(labels) n_epochs = size(data, 1) n_channels = size(data, 2) n_times = size(data, 3) @@ -341,6 +414,9 @@ end end @brainflow_rethrow function get_window(window_function::WinType, window_len::Integer) + if !(0 < window_len <= typemax(Cint)) + throw(BrainFlowError("Invalid window length", Integer(INVALID_ARGUMENTS_ERROR))) + end window_data = Vector{Float64}(undef, Integer(window_len)) ccall((:get_window, DATA_HANDLER_INTERFACE), Cint, (Cint, Cint, Ptr{Float64}), Int32(window_function), Int32(window_len), window_data) @@ -348,6 +424,7 @@ end end @brainflow_rethrow function perform_fft(data, window::WinType) + data = _dsp_signal(data) if (length(data) % 2 == 1) throw(BrainFlowError(string("Data Len must be even ", INVALID_ARGUMENTS_ERROR), Integer(INVALID_ARGUMENTS_ERROR))) @@ -366,6 +443,10 @@ end end @brainflow_rethrow function perform_ifft(data) + if !(data isa AbstractVector{<:Number}) || !(2 <= length(data) <= div(typemax(Cint), 2) + 1) + throw(BrainFlowError("Invalid inverse FFT spectrum", Integer(INVALID_ARGUMENTS_ERROR))) + end + data = ComplexF64.(data) temp_re = Vector{Float64}(undef, length(data)) temp_im = Vector{Float64}(undef, length(data)) @@ -381,19 +462,46 @@ end return res end +""" + get_avg_band_powers(data, channels, sampling_rate, apply_filter) + +Calculate normalized mean powers for bands 2-4, 4-8, 8-13, 13-30, 30-45 Hz. +Uses `get_custom_band_powers` preprocessing, minimum retained length, and variation semantics. +""" function get_avg_band_powers(data, channels, sampling_rate::Integer, apply_filter::Bool) bands = [(2.0, 4.0), (4.0, 8.0), (8.0, 13.0), (13.0, 30.0), (30.0, 45.0)] return get_custom_band_powers(data, bands, channels, sampling_rate, apply_filter) end +""" + get_custom_band_powers(data, bands, channels, sampling_rate, apply_filter) + +Return normalized channel-mean band powers and coefficients of variation (population +stddev / mean of absolute channel powers). Zero-power bands have zero variation; +an all-zero total returns zero normalized powers. +Filtering demeans and applies padded, initialized zero-phase 48-52 and 58-62 Hz +notches only when their upper edges are below 90% of Nyquist. There is no automatic +passband, so preprocessing is independent of the requested integration bands. +Margins estimated from the complete cascade impulse-response tail are discarded at +both ends; this estimate is not a guaranteed artifact bound. Supply surrounding +samples and account for the resulting delay in live analysis. Without filtering +there is no preprocessing or trimming. At least max(8, 2 * get_nearest_power_of_two(sampling_rate)) +samples must remain. Data and edges must be finite; bands require +0 <= start < stop <= Nyquist. Mains notches also attenuate overlapping bands. +""" @brainflow_rethrow function get_custom_band_powers(data, bands, channels, sampling_rate::Integer, apply_filter::Bool) + data = _dsp_matrix(data) + channels = _dsp_channels(data, channels) shape = size(data) data_1d = reshape(transpose(data[channels,:]), (1, size(channels)[1] * shape[2])) data_1d = copy(data_1d) - start_freqs = [first(p) for p in bands] - stop_freqs = [last(p) for p in bands] + if isempty(bands) || any(p -> length(p) != 2, bands) + throw(BrainFlowError("Bands must contain frequency pairs", Integer(INVALID_ARGUMENTS_ERROR))) + end + start_freqs = Float64[first(p) for p in bands] + stop_freqs = Float64[last(p) for p in bands] temp_avgs = Vector{Float64}(undef, length(start_freqs)) temp_stddevs = Vector{Float64}(undef, length(start_freqs)) @@ -404,10 +512,15 @@ end end @brainflow_rethrow function perform_ica_select_channels(data, num_components::Integer, channels) + data = _dsp_matrix(data) + channels = _dsp_channels(data, channels) shape = size(data) data_1d = reshape(transpose(data[channels,:]), (1, size(channels)[1] * shape[2])) data_1d = copy(data_1d) + if !(2 <= num_components <= min(length(channels), shape[2] - 1)) + throw(BrainFlowError("Invalid component count", Integer(INVALID_ARGUMENTS_ERROR))) + end temp_w = Vector{Float64}(undef, num_components * num_components) temp_k = Vector{Float64}(undef, length(channels) * num_components) temp_a = Vector{Float64}(undef, num_components * length(channels)) @@ -428,6 +541,7 @@ function perform_ica(data, num_components::Integer) end @brainflow_rethrow function get_psd(data, sampling_rate::Integer, window::WinType) + data = _dsp_signal(data) if (length(data) % 2 == 1) throw(BrainFlowError(string("Data Len must be even ", INVALID_ARGUMENTS_ERROR), Integer(INVALID_ARGUMENTS_ERROR))) @@ -442,10 +556,12 @@ end end @brainflow_rethrow function get_psd_welch(data, nfft::Integer, overlap::Integer, sampling_rate::Integer, window::WinType) + data = _dsp_signal(data) - if (length(data) % 2 == 1) - throw(BrainFlowError(string("Data Len must be even ", INVALID_ARGUMENTS_ERROR), Integer(INVALID_ARGUMENTS_ERROR))) + if ndims(data) != 1 || !(2 <= nfft <= length(data)) || !ispow2(nfft) || !(0 <= overlap < nfft) + throw(BrainFlowError("Invalid Welch segment size or overlap", Integer(INVALID_ARGUMENTS_ERROR))) end + data = Vector{Float64}(data) temp_ampls = Vector{Float64}(undef, Integer(nfft / 2) + 1) temp_freqs = Vector{Float64}(undef, Integer(nfft / 2) + 1) @@ -456,13 +572,18 @@ end end @brainflow_rethrow function get_band_power(psd, freq_start::Float64, freq_end::Float64) + if length(psd) != 2 || ndims(psd[1]) != 1 || ndims(psd[2]) != 1 || length(psd[1]) != length(psd[2]) + throw(BrainFlowError("PSD amplitude and frequency lengths must match", Integer(INVALID_ARGUMENTS_ERROR))) + end + amplitudes, frequencies = _dsp_signal(psd[1]), _dsp_signal(psd[2]) band_power = Vector{Float64}(undef, 1) ccall((:get_band_power, DATA_HANDLER_INTERFACE), Cint, (Ptr{Float64}, Ptr{Float64}, Cint, Float64, Float64, Ptr{Float64}), - psd[1], psd[2], length(psd[1]), Float64(freq_start), Float64(freq_end), band_power) + amplitudes, frequencies, length(amplitudes), Float64(freq_start), Float64(freq_end), band_power) return band_power[1] end @brainflow_rethrow function get_activity_index(accel_x, accel_y, accel_z, sampling_rate::Integer, period::Integer=0, noise_var_x::Real=0.0, noise_var_y::Real=0.0, noise_var_z::Real=0.0) + accel_x, accel_y, accel_z = _dsp_signal(accel_x), _dsp_signal(accel_y), _dsp_signal(accel_z) if (length(accel_x) != length(accel_y)) || (length(accel_x) != length(accel_z)) throw(BrainFlowError(string("Arrays lengths must match ", INVALID_ARGUMENTS_ERROR), Integer(INVALID_ARGUMENTS_ERROR))) end diff --git a/julia_package/brainflow/src/ml_model.jl b/julia_package/brainflow/src/ml_model.jl index cc59e9849..d350aab7f 100644 --- a/julia_package/brainflow/src/ml_model.jl +++ b/julia_package/brainflow/src/ml_model.jl @@ -36,6 +36,9 @@ mutable struct BrainFlowModelParams end function JSON.json(params::BrainFlowModelParams) + if params.max_array_size < 1 + throw(BrainFlowError("max_array_size must be positive", Integer(INVALID_ARGUMENTS_ERROR))) + end d = Dict( "metric" => Integer(params.metric), "classifier" => Integer(params.classifier), @@ -60,15 +63,28 @@ end end @brainflow_rethrow function predict(data, params::BrainFlowModelParams) - input_json = JSON.json(params) - val = Vector{Float64}(undef, params.max_array_size) - val_len = Vector{Float64}(undef, 1) + # Keep the native lookup key and allocated capacity from the same snapshot. + snapshot = deepcopy(params) + input_json = JSON.json(snapshot) + capacity = Int(snapshot.max_array_size) + if !(data isa AbstractVector{<:Real}) || !(0 < length(data) <= typemax(Cint)) + throw(BrainFlowError("Expected a nonempty real feature vector", Integer(INVALID_ARGUMENTS_ERROR))) + end + data = Vector{Float64}(data) + if !all(isfinite, data) + throw(BrainFlowError("Feature values must be finite", Integer(INVALID_ARGUMENTS_ERROR))) + end + val = Vector{Float64}(undef, capacity) + val_len = Ref{Cint}(0) ccall((:predict, ML_MODULE_INTERFACE), Cint, (Ptr{Float64}, Cint, Ptr{Float64}, Ptr{Cint}, Ptr{UInt8}), data, length(data), val, val_len, input_json) - value = val[1:val_len[1]] + if !(0 <= val_len[] <= capacity) + throw(BrainFlowError("Native prediction length exceeds the output buffer", Integer(GENERAL_ERROR))) + end + value = val[1:Int(val_len[])] return value end @brainflow_rethrow function release_all() ccall((:release_all, ML_MODULE_INTERFACE), Cint, ()) -end \ No newline at end of file +end diff --git a/julia_package/brainflow/test/band_power_all.jl b/julia_package/brainflow/test/band_power_all.jl index 50d506d76..8b95e7f76 100644 --- a/julia_package/brainflow/test/band_power_all.jl +++ b/julia_package/brainflow/test/band_power_all.jl @@ -10,7 +10,8 @@ nfft = BrainFlow.get_nearest_power_of_two(sampling_rate) BrainFlow.prepare_session(board_shim) BrainFlow.start_stream(board_shim) -sleep(5) +# Include filter settling margins around the retained analysis interval. +sleep(10) BrainFlow.stop_stream(board_shim) data = BrainFlow.get_board_data(board_shim) BrainFlow.release_session(board_shim) diff --git a/julia_package/brainflow/test/ica.jl b/julia_package/brainflow/test/ica.jl index 89793c61e..3cefe9011 100644 --- a/julia_package/brainflow/test/ica.jl +++ b/julia_package/brainflow/test/ica.jl @@ -1,23 +1,10 @@ using BrainFlow -# enable logs -BrainFlow.enable_dev_logger(BrainFlow.BOARD_CONTROLLER) -BrainFlow.enable_dev_logger(BrainFlow.DATA_HANDLER) - -params = BrainFlowInputParams() -board_shim = BrainFlow.BoardShim(BrainFlow.SYNTHETIC_BOARD, params) -sampling_rate = BrainFlow.get_sampling_rate(BrainFlow.SYNTHETIC_BOARD) - -BrainFlow.prepare_session(board_shim) -BrainFlow.start_stream(board_shim) -sleep(10) -BrainFlow.stop_stream(board_shim) -data = BrainFlow.get_board_data(500, board_shim) -BrainFlow.release_session(board_shim) - -eeg_channels = BrainFlow.get_eeg_channels(BrainFlow.SYNTHETIC_BOARD) -data_first_channel = data[eeg_channels[1], :] - -my_data = transpose(reshape(data_first_channel, (100, 5))) - -ica = BrainFlow.perform_ica(my_data, 2) +# Two simultaneous mixtures: rows are channels and columns are samples. +sample_times = collect(0:1023) ./ 256.0 +source1 = sin.(2.0 .* pi .* 7.0 .* sample_times) +source2 = sin.(2.0 .* pi .* 13.0 .* sample_times) .^ 3 +data = permutedims(hcat(source1 .+ 0.3 .* source2, 0.2 .* source1 .+ source2)) +w, k, a, sources = BrainFlow.perform_ica(data, 2) +# Component order and sign are arbitrary. +println("Recovered $(size(sources, 1)) sources from $(size(sources, 2)) samples") diff --git a/julia_package/brainflow/test/runtests.jl b/julia_package/brainflow/test/runtests.jl index bd2813200..5982fa623 100644 --- a/julia_package/brainflow/test/runtests.jl +++ b/julia_package/brainflow/test/runtests.jl @@ -14,3 +14,4 @@ include("markers.jl") include("release_all.jl") include("spo2.jl") include("ica.jl") +include("binding_validation_regression.jl") diff --git a/julia_package/brainflow/test/spo2.jl b/julia_package/brainflow/test/spo2.jl index 6cca0f720..c46ba2b5c 100644 --- a/julia_package/brainflow/test/spo2.jl +++ b/julia_package/brainflow/test/spo2.jl @@ -1,20 +1,13 @@ using BrainFlow -params = BrainFlowInputParams() -board_shim = BrainFlow.BoardShim(BrainFlow.SYNTHETIC_BOARD, params) -board_descr = BrainFlow.get_board_descr(BrainFlow.SYNTHETIC_BOARD) -sampling_rate = board_descr["sampling_rate"] - -BrainFlow.prepare_session(board_shim) -BrainFlow.start_stream(board_shim) -sleep(5) -BrainFlow.stop_stream(board_shim) -data = BrainFlow.get_board_data(board_shim) -BrainFlow.release_session(board_shim) - -ppg_channels = board_descr["ppg_channels"] -data_ir = data[ppg_channels[1], :] -data_red = data[ppg_channels[2], :] - +# Demonstrate calibrated, shared red/IR pulsatility using 15 seconds of synthetic PPG. +# Keep enough surrounding samples for filtering plus at least four seconds of analysis. +sampling_rate = 128 +sample_times = collect(0:(15 * sampling_rate - 1)) ./ sampling_rate +pulse = sin.(2.0 .* pi .* 1.2 .* sample_times) .+ 0.15 .* sin.(2.0 .* pi .* 2.4 .* sample_times) +data_ir = 100000.0 .+ 1000.0 .* pulse +data_red = 80000.0 .+ 400.0 .* pulse + +# Real sensors require their own calibrated coefficients and good optical contact. spo2 = BrainFlow.get_oxygen_level(data_ir, data_red, sampling_rate) -println(spo2) +println("Synthetic SpO2: $spo2") diff --git a/matlab_package/brainflow/DataFilter.m b/matlab_package/brainflow/DataFilter.m index cf1c94f8f..4e8dc2345 100644 --- a/matlab_package/brainflow/DataFilter.m +++ b/matlab_package/brainflow/DataFilter.m @@ -1,5 +1,23 @@ classdef DataFilter % DataFilter class for signal processing + methods(Static, Access = private) + function data = as_vector(data, allow_complex) + if nargin < 2; allow_complex = false; end + if ~isnumeric(data) || ~isvector(data) || isempty(data) || (~allow_complex && ~isreal(data)) + error('Input must be a nonempty numeric vector'); + end + data = reshape(double(data), 1, []); + end + + function channels = checked_channels(data, channels) + channels = DataFilter.as_vector(channels); + if ~isnumeric(data) || ~ismatrix(data) || isempty(data) || ... + any(~isfinite(channels) | channels < 1 | channels > size(data, 1) | fix(channels) ~= channels) + error('Channel indices must refer to existing rows'); + end + end + end + methods(Static) function lib_name = load_lib() @@ -79,116 +97,133 @@ function disable_data_logger() end function filtered_data = perform_lowpass(data, sampling_rate, cutoff, order, filter_type, ripple) + data = DataFilter.as_vector(data); % perform lowpass filtering task_name = 'perform_lowpass'; temp = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp, size(data, 2), sampling_rate, cutoff, order, int32(filter_type), ripple); + exit_code = calllib(lib_name, task_name, temp, numel(data), sampling_rate, cutoff, order, int32(filter_type), ripple); DataFilter.check_ec(exit_code, task_name); filtered_data = temp.Value; end function filtered_data = perform_highpass(data, sampling_rate, cutoff, order, filter_type, ripple) + data = DataFilter.as_vector(data); % perform highpass filtering task_name = 'perform_highpass'; temp = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp, size(data, 2), sampling_rate, cutoff, order, int32(filter_type), ripple); + exit_code = calllib(lib_name, task_name, temp, numel(data), sampling_rate, cutoff, order, int32(filter_type), ripple); DataFilter.check_ec(exit_code, task_name); filtered_data = temp.Value; end function filtered_data = perform_bandpass(data, sampling_rate, start_freq, stop_freq, order, filter_type, ripple) + data = DataFilter.as_vector(data); % perform bandpass filtering task_name = 'perform_bandpass'; temp = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp, size(data, 2), sampling_rate, start_freq, stop_freq, order, int32(filter_type), ripple); + exit_code = calllib(lib_name, task_name, temp, numel(data), sampling_rate, start_freq, stop_freq, order, int32(filter_type), ripple); DataFilter.check_ec(exit_code, task_name); filtered_data = temp.Value; end function filtered_data = perform_bandstop(data, sampling_rate, start_freq, stop_freq, order, filter_type, ripple) + data = DataFilter.as_vector(data); % perform bandpass filtering task_name = 'perform_bandstop'; temp = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp, size(data, 2), sampling_rate, start_freq, stop_freq, order, int32(filter_type), ripple); + exit_code = calllib(lib_name, task_name, temp, numel(data), sampling_rate, start_freq, stop_freq, order, int32(filter_type), ripple); DataFilter.check_ec(exit_code, task_name); filtered_data = temp.Value; end function filtered_data = remove_environmental_noise(data, sampling_rate, noise_type) + data = DataFilter.as_vector(data); % perform noth filtering task_name = 'remove_environmental_noise'; temp = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp, size(data, 2), sampling_rate, int32(noise_type)); + exit_code = calllib(lib_name, task_name, temp, numel(data), sampling_rate, int32(noise_type)); DataFilter.check_ec(exit_code, task_name); filtered_data = temp.Value; end function filtered_data = perform_rolling_filter(data, period, operation) + data = DataFilter.as_vector(data); % apply rolling filter task_name = 'perform_rolling_filter'; temp = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp, size(data, 2), period, int32(operation)); + exit_code = calllib(lib_name, task_name, temp, numel(data), period, int32(operation)); DataFilter.check_ec(exit_code, task_name); filtered_data = temp.Value; end function new_data = detrend(data, operation) + data = DataFilter.as_vector(data); % remove trend from data task_name = 'detrend'; temp = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp, size(data, 2), int32(operation)); + exit_code = calllib(lib_name, task_name, temp, numel(data), int32(operation)); DataFilter.check_ec(exit_code, task_name); new_data = temp.Value; end function downsampled_data = perform_downsampling(data, period, operation) + data = DataFilter.as_vector(data); % downsample data + if ~isscalar(period) || ~isfinite(period) || period < 1 || fix(period) ~= period + error('Period must be a positive integer'); + end task_name = 'perform_downsampling'; temp_input = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - temp_output = libpointer('doublePtr', zeros(1, int32(size(data,2) / period))); - exit_code = calllib(lib_name, task_name, temp_input, size(data, 2), period, int32(operation), temp_output); + temp_output = libpointer('doublePtr', zeros(1, floor(numel(data) / period))); + exit_code = calllib(lib_name, task_name, temp_input, numel(data), period, int32(operation), temp_output); DataFilter.check_ec(exit_code, task_name); downsampled_data = temp_output.Value; end function restored_data = restore_data_from_wavelet_detailed_coeffs(data, wavelet, decomposition_level, level_to_restore) + data = DataFilter.as_vector(data); % restore data from a single wavelet level task_name = 'restore_data_from_wavelet_detailed_coeffs'; temp_input = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - temp_output = libpointer('doublePtr', zeros(1, int32(size(data,2)))); - exit_code = calllib(lib_name, task_name, temp_input, size(data, 2), int32(wavelet), decomposition_level, level_to_restore, temp_output); + temp_output = libpointer('doublePtr', zeros(1, int32(numel(data)))); + exit_code = calllib(lib_name, task_name, temp_input, numel(data), int32(wavelet), decomposition_level, level_to_restore, temp_output); DataFilter.check_ec(exit_code, task_name); restored_data = temp_output.Value; end function peaks = detect_peaks_z_score(data, lag, threshold, influence) + data = DataFilter.as_vector(data); % peaks detection using z score algorithm task_name = 'detect_peaks_z_score'; temp_input = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - temp_output = libpointer('doublePtr', zeros(1, int32(size(data,2)))); - exit_code = calllib(lib_name, task_name, temp_input, size(data, 2), int32(lag), threshold, influence, temp_output); + temp_output = libpointer('doublePtr', zeros(1, int32(numel(data)))); + exit_code = calllib(lib_name, task_name, temp_input, numel(data), int32(lag), threshold, influence, temp_output); DataFilter.check_ec(exit_code, task_name); peaks = temp_output.Value; end function [wavelet_data, wavelet_sizes] = perform_wavelet_transform(data, wavelet, decomposition_level, extension) + data = DataFilter.as_vector(data); % perform wavelet transform + if ~isscalar(decomposition_level) || decomposition_level < 1 || decomposition_level > 100 || fix(decomposition_level) ~= decomposition_level + error('Invalid decomposition level'); + end task_name = 'perform_wavelet_transform'; temp_input = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - temp_output = libpointer('doublePtr', zeros(1, int32(size(data, 2) + 2 * decomposition_level * (40 + 1)))); + temp_output = libpointer('doublePtr', zeros(1, int32(numel(data) + 2 * decomposition_level * (40 + 1)))); lenghts = libpointer('int32Ptr', zeros(1, decomposition_level + 1)); - exit_code = calllib(lib_name, task_name, temp_input, size(data, 2), wavelet, decomposition_level, extension, temp_output, lenghts); + exit_code = calllib(lib_name, task_name, temp_input, numel(data), wavelet, decomposition_level, extension, temp_output, lenghts); DataFilter.check_ec(exit_code, task_name); wavelet_data = temp_output.Value(1,1: sum(lenghts.Value)); wavelet_sizes = lenghts.Value; @@ -196,28 +231,42 @@ function disable_data_logger() function original_data = perform_inverse_wavelet_transform(wavelet_data, wavelet_sizes, original_data_len, wavelet, decomposition_level, extension) % perform inverse wavelet transform - task_name = 'perform_inverse_wavelet_transform'; + wavelet_data = DataFilter.as_vector(wavelet_data); + wavelet_sizes = DataFilter.as_vector(wavelet_sizes); + if ~isscalar(original_data_len) || original_data_len < 1 || original_data_len > intmax('int32') || original_data_len > numel(wavelet_data) || fix(original_data_len) ~= original_data_len || ... + ~isscalar(decomposition_level) || decomposition_level < 1 || decomposition_level > 100 || fix(decomposition_level) ~= decomposition_level || ... + numel(wavelet_sizes) ~= decomposition_level + 1 || any(wavelet_sizes < 1 | wavelet_sizes > intmax('int32') | fix(wavelet_sizes) ~= wavelet_sizes) || ... + sum(wavelet_sizes) ~= numel(wavelet_data) + error('Invalid wavelet metadata'); + end + wavelet_sizes = int32(wavelet_sizes); + task_name = 'perform_inverse_wavelet_transform_checked'; lib_name = DataFilter.load_lib(); wavelet_data_ptr = libpointer('doublePtr', wavelet_data); wavelet_sizes_ptr = libpointer('int32Ptr', wavelet_sizes); output_ptr = libpointer('doublePtr', zeros(1, original_data_len)); - exit_code = calllib(lib_name, task_name, wavelet_data_ptr, original_data_len, wavelet, decomposition_level, extension, wavelet_sizes_ptr, output_ptr); + exit_code = calllib(lib_name, task_name, wavelet_data_ptr, numel(wavelet_data), original_data_len, wavelet, decomposition_level, extension, wavelet_sizes_ptr, numel(wavelet_sizes), output_ptr, original_data_len); DataFilter.check_ec(exit_code, task_name); original_data = output_ptr.Value; end function denoised_data = perform_wavelet_denoising(data, wavelet, decomposition_level, denoising, threshold, extention, noise_level) + data = DataFilter.as_vector(data); % perform wavelet denoising task_name = 'perform_wavelet_denoising'; temp = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp, size(data, 2), wavelet, decomposition_level, denoising, threshold, extention, noise_level); + exit_code = calllib(lib_name, task_name, temp, numel(data), wavelet, decomposition_level, denoising, threshold, extention, noise_level); DataFilter.check_ec(exit_code, task_name); denoised_data = temp.Value; end function [filters, eigenvalues] = get_csp(data, labels) % get common spatial patterns + labels = DataFilter.as_vector(labels); + if isempty(data) || ndims(data) > 3 || numel(labels) ~= size(data, 1) + error('CSP requires one label per nonempty epoch'); + end task_name = 'get_csp'; n_epochs = size(data, 1); n_channels = size(data, 2); @@ -258,55 +307,68 @@ function disable_data_logger() end function stddev = calc_stddev(data) + data = DataFilter.as_vector(data); % calc stddev task_name = 'calc_stddev'; temp_input = libpointer('doublePtr', data); output = libpointer('doublePtr', 0); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp_input, 0, size(data, 2), output); + exit_code = calllib(lib_name, task_name, temp_input, 0, numel(data), output); DataFilter.check_ec(exit_code, task_name); stddev = output.Value; end function railed = get_railed_percentage(data, gain) + data = DataFilter.as_vector(data); % calc railed percentage task_name = 'get_railed_percentage'; temp_input = libpointer('doublePtr', data); output = libpointer('doublePtr', 0); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp_input, size(data, 2), gain, output); + exit_code = calllib(lib_name, task_name, temp_input, numel(data), gain, output); DataFilter.check_ec(exit_code, task_name); railed = output.Value; end function spo2 = get_oxygen_level(ppg_ir, ppg_red, sampling_rate, coef1, coef2, coef3) + ppg_ir = DataFilter.as_vector(ppg_ir); + ppg_red = DataFilter.as_vector(ppg_red); + if numel(ppg_ir) ~= numel(ppg_red) + error('PPG array lengths must match'); + end % calc oxygen level task_name = 'get_oxygen_level'; temp_input_ir = libpointer('doublePtr', ppg_ir); temp_input_red = libpointer('doublePtr', ppg_red); output = libpointer('doublePtr', 0); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp_input_ir, temp_input_red, size(ppg_ir, 2), sampling_rate, coef1, coef2, coef3, output); + exit_code = calllib(lib_name, task_name, temp_input_ir, temp_input_red, numel(ppg_ir), sampling_rate, coef1, coef2, coef3, output); DataFilter.check_ec(exit_code, task_name); spo2 = output.Value; end function rate = get_heart_rate(ppg_ir, ppg_red, sampling_rate, fft_size) + ppg_ir = DataFilter.as_vector(ppg_ir); + ppg_red = DataFilter.as_vector(ppg_red); + if numel(ppg_ir) ~= numel(ppg_red) + error('PPG array lengths must match'); + end % calc heart rate task_name = 'get_heart_rate'; temp_input_ir = libpointer('doublePtr', ppg_ir); temp_input_red = libpointer('doublePtr', ppg_red); output = libpointer('doublePtr', 0); lib_name = DataFilter.load_lib(); - exit_code = calllib(lib_name, task_name, temp_input_ir, temp_input_red, size(ppg_ir, 2), sampling_rate, fft_size, output); + exit_code = calllib(lib_name, task_name, temp_input_ir, temp_input_red, numel(ppg_ir), sampling_rate, fft_size, output); DataFilter.check_ec(exit_code, task_name); rate = output.Value; end function fft_data = perform_fft(data, window) + data = DataFilter.as_vector(data); % perform fft task_name = 'perform_fft'; - n = size(data, 2); + n = numel(data); temp_input = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); temp_re = libpointer('doublePtr', zeros(1, int32(n / 2 + 1))); @@ -319,11 +381,12 @@ function disable_data_logger() function data = perform_ifft(fft_data) % perform inverse fft task_name = 'perform_ifft'; + fft_data = DataFilter.as_vector(fft_data, true); real_data = real(fft_data); imag_data = imag(fft_data); real_input = libpointer('doublePtr', real_data); imag_input = libpointer('doublePtr', imag_data); - output_len = 2 *(size(fft_data, 2) - 1); + output_len = 2 *(numel(fft_data) - 1); output = libpointer('doublePtr', zeros(1, output_len)); lib_name = DataFilter.load_lib(); exit_code = calllib(lib_name, task_name, real_input, imag_input, output_len, output); @@ -332,24 +395,43 @@ function disable_data_logger() end function [avg_bands, stddev_bands] = get_avg_band_powers(data, channels, sampling_rate, apply_filters) + % Normalized mean powers for bands 2-4, 4-8, 8-13, 13-30, 30-45 Hz. + % Uses get_custom_band_powers preprocessing, minimum retained length, and variation semantics. start_freqs = [2.0, 4.0, 8.0, 13.0, 30.0]; stop_freqs = [4.0, 8.0, 13.0, 30.0, 45.0]; [avg_bands, stddev_bands] = DataFilter.get_custom_band_powers(data, start_freqs, stop_freqs, channels, sampling_rate, apply_filters); end function [avg_bands, stddev_bands] = get_custom_band_powers(data, start_freqs, stop_freqs, channels, sampling_rate, apply_filters) - % calculate average band powers + % Return normalized channel-mean powers and coefficients of variation (population + % stddev / mean of absolute channel powers). Zero-power bands have zero variation; + % an all-zero total returns zero normalized powers. + % Filtering demeans and applies padded, initialized zero-phase 48-52 and 58-62 Hz + % notches only when their upper edges are below 90% of Nyquist. There is no automatic + % passband, so preprocessing is independent of the requested integration bands. + % Margins estimated from the complete cascade impulse-response tail are discarded at + % both ends; this estimate is not a guaranteed artifact bound. Supply surrounding + % samples and account for the resulting delay in live analysis. Without filtering + % there is no preprocessing or trimming. At least max(8, 2 * get_nearest_power_of_two(sampling_rate)) + % samples must remain. Data and edges must be finite; bands require + % 0 <= start < stop <= Nyquist. Mains notches also attenuate overlapping bands. + start_freqs = DataFilter.as_vector(start_freqs); + stop_freqs = DataFilter.as_vector(stop_freqs); + channels = DataFilter.checked_channels(data, channels); + if numel(start_freqs) ~= numel(stop_freqs) + error('Band start and stop lengths must match'); + end task_name = 'get_custom_band_powers'; data_1d = data(channels, :); data_1d = transpose(data_1d); data_1d = data_1d(:); temp_input = libpointer('doublePtr', data_1d); lib_name = DataFilter.load_lib(); - temp_avgs = libpointer('doublePtr', zeros(1, size(start_freqs, 2))); - temp_stddevs = libpointer('doublePtr', zeros(1, size(start_freqs, 2))); + temp_avgs = libpointer('doublePtr', zeros(1, numel(start_freqs))); + temp_stddevs = libpointer('doublePtr', zeros(1, numel(start_freqs))); temp_start = libpointer('doublePtr', start_freqs); temp_stop = libpointer('doublePtr', stop_freqs); - exit_code = calllib(lib_name, task_name, temp_input, size(channels, 2), size(data,2), temp_start, temp_stop, size(start_freqs, 2), sampling_rate, int32(apply_filters), temp_avgs, temp_stddevs); + exit_code = calllib(lib_name, task_name, temp_input, numel(channels), size(data,2), temp_start, temp_stop, numel(start_freqs), sampling_rate, int32(apply_filters), temp_avgs, temp_stddevs); DataFilter.check_ec(exit_code, task_name); avg_bands = temp_avgs.Value; stddev_bands = temp_stddevs.Value; @@ -357,6 +439,7 @@ function disable_data_logger() function [w_mat, k_mat, a_mat, s_mat] = perform_ica_select_channels(data, num_components, channels) % calculate ica + channels = DataFilter.checked_channels(data, channels); task_name = 'perform_ica'; data_1d = data(channels, :); data_1d = transpose(data_1d); @@ -364,14 +447,14 @@ function disable_data_logger() temp_input = libpointer('doublePtr', data_1d); lib_name = DataFilter.load_lib(); temp_w = libpointer('doublePtr', zeros(1, num_components * num_components)); - temp_k = libpointer('doublePtr', zeros(1, size(channels, 2) * num_components)); - temp_a = libpointer('doublePtr', zeros(1, num_components * size(channels, 2))); + temp_k = libpointer('doublePtr', zeros(1, numel(channels) * num_components)); + temp_a = libpointer('doublePtr', zeros(1, num_components * numel(channels))); temp_s = libpointer('doublePtr', zeros(1, size(data, 2) * num_components)); - exit_code = calllib(lib_name, task_name, temp_input, size(channels, 2), size(data, 2), num_components, temp_w, temp_k, temp_a, temp_s); + exit_code = calllib(lib_name, task_name, temp_input, numel(channels), size(data, 2), num_components, temp_w, temp_k, temp_a, temp_s); DataFilter.check_ec(exit_code, task_name); w_mat = transpose(reshape(temp_w.Value, [num_components, num_components])); - k_mat = transpose(reshape(temp_k.Value, [size(channels,2), num_components])); - a_mat = transpose(reshape(temp_a.Value, [num_components, size(channels, 2)])); + k_mat = transpose(reshape(temp_k.Value, [numel(channels), num_components])); + a_mat = transpose(reshape(temp_a.Value, [num_components, numel(channels)])); s_mat = transpose(reshape(temp_s.Value, [size(data, 2), num_components])); end @@ -382,9 +465,10 @@ function disable_data_logger() end function [ampls, freqs] = get_psd(data, sampling_rate, window) + data = DataFilter.as_vector(data); % calculate PSD task_name = 'get_psd'; - n = size(data, 2); + n = numel(data); temp_input = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); temp_ampls = libpointer('doublePtr', zeros(1, int32(n / 2 + 1))); @@ -396,13 +480,14 @@ function disable_data_logger() end function [ampls, freqs] = get_psd_welch(data, nfft, overlap, sampling_rate, window) + data = DataFilter.as_vector(data); % calculate PSD using welch method task_name = 'get_psd_welch'; temp_input = libpointer('doublePtr', data); lib_name = DataFilter.load_lib(); temp_ampls = libpointer('doublePtr', zeros(1, int32(nfft / 2 + 1))); temp_freqs = libpointer('doublePtr', zeros(1, int32(nfft / 2 + 1))); - exit_code = calllib(lib_name, task_name, temp_input, size(data, 2), nfft, overlap, sampling_rate, window, temp_ampls, temp_freqs); + exit_code = calllib(lib_name, task_name, temp_input, numel(data), nfft, overlap, sampling_rate, window, temp_ampls, temp_freqs); DataFilter.check_ec(exit_code, task_name); ampls = temp_ampls.Value; freqs = temp_freqs.Value; @@ -410,12 +495,17 @@ function disable_data_logger() function band_power = get_band_power(ampls, freqs, freq_start, freq_end) % calculate band power + ampls = DataFilter.as_vector(ampls); + freqs = DataFilter.as_vector(freqs); + if numel(ampls) ~= numel(freqs) + error('PSD amplitude and frequency lengths must match'); + end task_name = 'get_band_power'; temp_input_ampl = libpointer('doublePtr', ampls); temp_input_freq = libpointer('doublePtr', freqs); lib_name = DataFilter.load_lib(); temp_band = libpointer('doublePtr', 0); - exit_code = calllib(lib_name, task_name, temp_input_ampl, temp_input_freq, size(ampls, 2), freq_start, freq_end, temp_band); + exit_code = calllib(lib_name, task_name, temp_input_ampl, temp_input_freq, numel(ampls), freq_start, freq_end, temp_band); DataFilter.check_ec(exit_code, task_name); band_power = temp_band.Value; end @@ -465,16 +555,19 @@ function write_file(data, file_name, file_mode) if nargin < 4 error('accel_x, accel_y, accel_z, and sampling_rate are required'); end + accel_x = DataFilter.as_vector(accel_x); + accel_y = DataFilter.as_vector(accel_y); + accel_z = DataFilter.as_vector(accel_z); if isempty(accel_x) || isempty(accel_y) || isempty(accel_z) error('Input arrays must not be empty'); end - if size(accel_x, 2) ~= size(accel_y, 2) || size(accel_x, 2) ~= size(accel_z, 2) + if numel(accel_x) ~= numel(accel_y) || numel(accel_x) ~= numel(accel_z) error('Length of accel_x, accel_y, and accel_z must match'); end if floor(sampling_rate) ~= sampling_rate || sampling_rate <= 0 error('Sampling rate must be a positive integer'); end - data_len = size(accel_x, 2); + data_len = numel(accel_x); if data_len < sampling_rate error('Data length must be at least one second'); end @@ -507,4 +600,4 @@ function write_file(data, file_name, file_mode) end -end \ No newline at end of file +end diff --git a/matlab_package/brainflow/MLModel.m b/matlab_package/brainflow/MLModel.m index e4c15582d..ac9fc87d5 100644 --- a/matlab_package/brainflow/MLModel.m +++ b/matlab_package/brainflow/MLModel.m @@ -5,6 +5,11 @@ input_params end + properties(Access = private) + model_json + output_capacity + end + methods(Static) function lib_name = load_lib() @@ -96,15 +101,25 @@ function disable_ml_logger() methods function obj = MLModel(params) + capacity = params.max_array_size; + if ~isnumeric(capacity) || ~isreal(capacity) || ~isscalar(capacity) || ... + ~isfinite(capacity) || capacity < 1 || capacity ~= fix(capacity) || ... + capacity > double(intmax('int32')) + error('max_array_size must be a positive int32 integer'); + end obj.input_json = params.to_json(); obj.input_params = params; + % Public parameter copies remain available for inspection. Native + % lookup and allocation always use the original matching snapshot. + obj.model_json = obj.input_json; + obj.output_capacity = double(capacity); end function prepare(obj) % prepare model task_name = 'prepare'; lib_name = MLModel.load_lib(); - exit_code = calllib(lib_name, task_name, obj.input_json); + exit_code = calllib(lib_name, task_name, obj.model_json); MLModel.check_ec(exit_code, task_name); end @@ -112,22 +127,32 @@ function release(obj) % release model task_name = 'release'; lib_name = MLModel.load_lib(); - exit_code = calllib(lib_name, task_name, obj.input_json); + exit_code = calllib(lib_name, task_name, obj.model_json); MLModel.check_ec(exit_code, task_name); end function score = predict(obj, input_data) % perform inference for input data + if ~isnumeric(input_data) || ~isreal(input_data) || ~isvector(input_data) || ... + isempty(input_data) || numel(input_data) > double(intmax('int32')) || ... + any(~isfinite(input_data(:))) + error('Prediction input must be a nonempty finite real vector'); + end + input_data = full(double(input_data(:).')); task_name = 'predict'; lib_name = MLModel.load_lib(); - score_temp = libpointer('doublePtr', obj.input_params.max_array_size); + score_temp = libpointer('doublePtr', zeros(1, obj.output_capacity, 'double')); len = libpointer('int32Ptr', 0); input_data_temp = libpointer('doublePtr', input_data); - exit_code = calllib(lib_name, task_name, input_data_temp, size(input_data, 2), score_temp, len, obj.input_json); + exit_code = calllib(lib_name, task_name, input_data_temp, numel(input_data), score_temp, len, obj.model_json); MLModel.check_ec(exit_code, task_name); - score = score_temp.Value(1,1:len.Value); + output_length = double(len.Value); + if output_length < 0 || output_length > obj.output_capacity + error('Native prediction length exceeds the output buffer'); + end + score = score_temp.Value(1, 1:output_length); end end -end \ No newline at end of file +end diff --git a/matlab_package/brainflow/examples/ICA.m b/matlab_package/brainflow/examples/ICA.m index 2791769f5..ae5915461 100644 --- a/matlab_package/brainflow/examples/ICA.m +++ b/matlab_package/brainflow/examples/ICA.m @@ -1,15 +1,8 @@ -params = BrainFlowInputParams(); -board_shim = BoardShim(int32(BoardIds.SYNTHETIC_BOARD), params); -preset = int32(BrainFlowPresets.DEFAULT_PRESET); -board_shim.prepare_session(); -board_shim.start_stream(45000, ''); -pause(10); -board_shim.stop_stream(); -data = board_shim.get_board_data(500, preset); -board_shim.release_session(); - -eeg_channels = BoardShim.get_eeg_channels(int32(BoardIds.SYNTHETIC_BOARD), preset); -selected_channel = eeg_channels(4); -original_data = data(selected_channel, :); -original_data = transpose(reshape(original_data, [100, 5])); -[w,k,a,s] = DataFilter.perform_ica(original_data, 2); \ No newline at end of file +% Two simultaneous mixtures: rows are channels and columns are samples. +time = (0:1023) / 256; +first = sin(2 * pi * 7 * time); +second = sin(2 * pi * 13 * time).^3; +data = [first + 0.3 * second; 0.2 * first + second]; +[w, k, a, s] = DataFilter.perform_ica(data, 2); +% Component order and sign are arbitrary. +fprintf('Recovered %d sources from %d samples\n', size(s, 1), size(s, 2)); diff --git a/matlab_package/brainflow/examples/Spo2.m b/matlab_package/brainflow/examples/Spo2.m index 8647186dd..3e95b057f 100644 --- a/matlab_package/brainflow/examples/Spo2.m +++ b/matlab_package/brainflow/examples/Spo2.m @@ -1,25 +1,11 @@ -BoardShim.enable_dev_board_logger(); -DataFilter.enable_dev_data_logger(); -DataFilter.set_log_file('data.log'); +% Fifteen seconds of shared red/IR pulsatility, including filter settling margins. +sampling_rate = int32(128); +time = (0:(15 * double(sampling_rate) - 1)) / double(sampling_rate); +pulse = sin(2 * pi * 1.2 * time) + 0.15 * sin(2 * pi * 2.4 * time); +ir_data = 100000 + 1000 * pulse; +red_data = 80000 + 400 * pulse; -params = BrainFlowInputParams(); -board_shim = BoardShim(int32(BoardIds.SYNTHETIC_BOARD), params); -board_id = int32(BoardIds.SYNTHETIC_BOARD); -preset = int32(BrainFlowPresets.DEFAULT_PRESET); -board_descr = BoardShim.get_board_descr(board_id, preset); -sampling_rate = int32(board_descr.sampling_rate); -board_shim.prepare_session(); -board_shim.start_stream(45000, ''); -pause(200); -board_shim.stop_stream(); -nfft = DataFilter.get_nearest_power_of_two(sampling_rate); -data = board_shim.get_board_data(board_shim.get_board_data_count(preset), preset); -board_shim.release_session(); - -ppg_channels = board_descr.ppg_channels; -ir_channel = ppg_channels(1); -red_channel = ppg_channels(2); -ir_data = data(ir_channel, :); -red_data = data(red_channel, :); +% Example calibration coefficients; real sensors require their own calibration. spo2 = DataFilter.get_oxygen_level(ir_data, red_data, sampling_rate, 0.0, -37.663, 114.91); -heart_rate = DataFilter.get_heart_rate(ir_data, red_data, sampling_rate, 1024); \ No newline at end of file +heart_rate = DataFilter.get_heart_rate(ir_data, red_data, sampling_rate, 1024); +fprintf('Synthetic SpO2: %.2f; heart rate: %.2f bpm\n', spo2, heart_rate); diff --git a/nodejs_package/brainflow/data_filter.ts b/nodejs_package/brainflow/data_filter.ts index 05660cece..a872308b5 100644 --- a/nodejs_package/brainflow/data_filter.ts +++ b/nodejs_package/brainflow/data_filter.ts @@ -48,8 +48,8 @@ class DataHandlerDLL extends DataHandlerFunctions this.getNumElementsInFile = this.lib.func(CLike.get_num_elements_in_file); this.performDownsampling = this.lib.func(CLike.perform_downsampling); this.performWaveletTransform = this.lib.func(CLike.perform_wavelet_transform); - this.performInverseWaveletTransform = - this.lib.func(CLike.perform_inverse_wavelet_transform); + this.performInverseWaveletTransformChecked = + this.lib.func(CLike.perform_inverse_wavelet_transform_checked); this.performWaveletDenoising = this.lib.func(CLike.perform_wavelet_denoising); this.getWindow = this.lib.func(CLike.get_window); this.performFft = this.lib.func(CLike.perform_fft); @@ -121,6 +121,69 @@ class DataHandlerDLL extends DataHandlerFunctions export class DataFilter { + private static checkMatrix(data: number[][], requireFinite = false): number + { + if (!Array.isArray(data) || data.length === 0 || !Array.isArray(data[0]) || data[0].length === 0) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Input matrix must be non-empty'); + } + const columns = data[0].length; + if (data.length * columns > 2147483647) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Input matrix must be rectangular and fit native dimensions'); + } + // Value iteration also rejects sparse arrays, whose holes flat() would omit. + for (const row of data) + { + if (!Array.isArray(row) || row.length !== columns) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Input matrix must be rectangular'); + } + if (requireFinite) + { + for (const value of row) + { + if (!Number.isFinite(value)) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Input matrix must contain finite numbers'); + } + } + } + } + return columns; + } + + private static checkChannels(data: number[][], channels: number[], columns: number): void + { + if (!Array.isArray(channels) || channels.length === 0 || channels.length * columns > 2147483647) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Invalid channel selection'); + } + for (const channel of channels) + { + if (!Number.isInteger(channel) || channel < 0 || channel >= data.length) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Invalid channel selection'); + } + for (const value of data[channel]) + { + if (!Number.isFinite(value)) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Selected channels must contain finite numbers'); + } + } + } + } + + private static checkPairedArrays(first: number[], second: number[]): void + { + if (!Array.isArray(first) || !Array.isArray(second) || first.length !== second.length) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, + 'Input array lengths must match'); + } + } + // logging methods public static setLogLevel(logLevel: LogLevels): void { @@ -231,13 +294,10 @@ export class DataFilter public static writeFile(data: number[][], file: string, mode: string) { - if (data.length == 0) - { - throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Empty data'); - } + const columns = DataFilter.checkMatrix(data, true); const flat = data.flat(); const res = - DataHandlerDLL.getInstance().writeFile(flat, data.length, data[0].length, file, mode); + DataHandlerDLL.getInstance().writeFile(flat, data.length, columns, file, mode); if (res !== BrainFlowExitCodes.STATUS_OK) { throw new BrainFlowError (res, 'Could not write file'); @@ -303,6 +363,7 @@ export class DataFilter public static getOxygenLevel(ppgIr: number[], ppgRed: number[], samplingRate: number, coef1 = 1.5958422, coef2 = -34.6596622, coef3 = 112.6898759): number { + this.checkPairedArrays(ppgIr, ppgRed); const output = [0]; const res = DataHandlerDLL.getInstance().getOxygenLevel( ppgIr, ppgRed, ppgIr.length, samplingRate, coef1, coef2, coef3, output); @@ -316,6 +377,7 @@ export class DataFilter public static getHeartRate( ppgIr: number[], ppgRed: number[], samplingRate: number, fftSize: number): number { + this.checkPairedArrays(ppgIr, ppgRed); const output = [0]; const res = DataHandlerDLL.getInstance().getHeartRate( ppgIr, ppgRed, ppgIr.length, samplingRate, fftSize, output); @@ -326,18 +388,41 @@ export class DataFilter return output[0]; } + /** + * Return normalized channel-mean band powers and coefficients of variation (population + * stddev / mean of absolute channel powers). Zero-power bands have zero variation; + * an all-zero total returns zero normalized powers. + * Filtering demeans and applies padded, initialized zero-phase 48-52 and 58-62 Hz + * notches only when their upper edges are below 90% of Nyquist. There is no automatic + * passband, so preprocessing is independent of the requested integration bands. + * Margins estimated from the complete cascade impulse-response tail are discarded at + * both ends; this estimate is not a guaranteed artifact bound. Supply surrounding + * samples and account for the resulting delay in live analysis. Without filtering + * there is no preprocessing or trimming. At least max(8, 2 * getNearestPowerOfTwo(samplingRate)) + * samples must remain. Data and edges must be finite; bands require + * 0 <= start < stop <= Nyquist. Mains notches also attenuate overlapping bands. + */ public static getCustomBandPowers(data: number[][], bands: number[][], channels: number[], samplingRate: number, applyFilters = true): [number[], number[]] { - if ((data.length == 0) || (bands.length == 0) || (channels.length == 0)) + const columns = DataFilter.checkMatrix(data); + DataFilter.checkChannels(data, channels, columns); + if (!Array.isArray(bands) || bands.length === 0 || bands.length > 2147483647) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Bands must contain finite start/stop pairs'); + } + for (const band of bands) { - throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Empty data'); + if (!Array.isArray(band) || band.length !== 2 || !Number.isFinite(band[0]) || !Number.isFinite(band[1])) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Bands must contain finite start/stop pairs'); + } } var i: number; var j: number; const avgBands = [...new Array (bands.length).fill(0)]; const stddevBands = [...new Array (bands.length).fill(0)]; - const data1D = [...new Array (data[0].length * channels.length).fill(0)]; + const data1D = [...new Array (columns * channels.length).fill(0)]; const startFreqs = [...new Array (bands.length).fill(0)]; const stopFreqs = [...new Array (bands.length).fill(0)]; for (i = 0; i < bands.length; i++) @@ -347,13 +432,13 @@ export class DataFilter } for (i = 0; i < channels.length; i++) { - for (j = 0; j < data[0].length; j++) + for (j = 0; j < columns; j++) { - data1D[j + data[0].length * i] = data[channels[i]][j]; + data1D[j + columns * i] = data[channels[i]][j]; } } const res = DataHandlerDLL.getInstance().getCustomBandPowers(data1D, channels.length, - data[0].length, startFreqs, stopFreqs, bands.length, samplingRate, + columns, startFreqs, stopFreqs, bands.length, samplingRate, Number (applyFilters), avgBands, stddevBands); if (res !== BrainFlowExitCodes.STATUS_OK) { @@ -362,6 +447,10 @@ export class DataFilter return [avgBands, stddevBands]; } + /** + * Calculate normalized mean powers for bands 2-4, 4-8, 8-13, 13-30, 30-45 Hz. + * Uses getCustomBandPowers preprocessing, minimum retained length, and variation semantics. + */ public static getAvgBandPowers(data: number[][], channels: number[], samplingRate: number, applyFilters = true): [number[], number[]] { @@ -459,12 +548,13 @@ export class DataFilter public static getPsdWelch(data: number[], nfft: number, overlap: number, samplingRate: number, windowType: WindowOperations): [number[], number[]] { - if (data.length % 2 != 0) + if (!Number.isInteger(nfft) || nfft < 2 || nfft > data.length || nfft % 2 !== 0 || + !Number.isInteger(overlap) || overlap < 0 || overlap >= nfft) { throw new BrainFlowError ( - BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, "invalid input length"); + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Invalid Welch segment size or overlap'); } - const outputLen = Math.trunc(data.length / 2 + 1); + const outputLen = nfft / 2 + 1; const ampls = [...new Array (outputLen).fill(0)]; const freqs = [...new Array (outputLen).fill(0)]; const res = DataHandlerDLL.getInstance().getPsdWelch( @@ -479,6 +569,11 @@ export class DataFilter public static getBandPower( psd: [number[], number[]], startFreq: number, stopFreq: number): number { + if (!Array.isArray(psd) || psd.length !== 2) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Invalid PSD pair'); + } + this.checkPairedArrays(psd[0], psd[1]); const output = [0]; const res = DataHandlerDLL.getInstance().getBandPower( psd[0], psd[1], psd[0].length, startFreq, stopFreq, output); @@ -492,6 +587,10 @@ export class DataFilter public static performWaveletTransform(data: number[], wavelet: WaveletTypes, decompositionLevel: number, extension: WaveletExtensionTypes): [number[], number[]] { + if (!Number.isInteger(decompositionLevel) || decompositionLevel < 1 || decompositionLevel > 100) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Invalid decomposition level'); + } const waveletCoeffs = [...new Array (data.length + 2 * decompositionLevel * (40 + 1)).fill(0)]; const lengths = [...new Array (decompositionLevel + 1).fill(0)]; const res = DataHandlerDLL.getInstance().performWaveletTransform( @@ -509,9 +608,21 @@ export class DataFilter originalLen: number, wavelet: WaveletTypes, decompositionLevel: number, extension: WaveletExtensionTypes): number[] { + if (!Number.isInteger(originalLen) || originalLen < 1 || originalLen > 2147483647 || + !Number.isInteger(decompositionLevel) || decompositionLevel < 1 || decompositionLevel > 100 || + !Array.isArray(waveletData) || waveletData.length !== 2 || + !Array.isArray(waveletData[0]) || !Array.isArray(waveletData[1]) || + originalLen > waveletData[0].length || waveletData[0].length > 2147483647 || + waveletData[1].length !== decompositionLevel + 1 || + waveletData[1].some(n => !Number.isInteger(n) || n < 1 || n > 2147483647) || + waveletData[1].reduce((a, b) => a + b, 0) !== waveletData[0].length) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Invalid wavelet metadata'); + } const output = [...new Array (originalLen).fill(0)]; - const res = DataHandlerDLL.getInstance().performInverseWaveletTransform(waveletData[0], - originalLen, wavelet, decompositionLevel, extension, waveletData[1], output); + const res = DataHandlerDLL.getInstance().performInverseWaveletTransformChecked(waveletData[0], + waveletData[0].length, originalLen, wavelet, decompositionLevel, extension, + waveletData[1], waveletData[1].length, output, output.length); if (res !== BrainFlowExitCodes.STATUS_OK) { throw new BrainFlowError (res, 'Could not calc inverse wavelet transform'); @@ -548,28 +659,31 @@ export class DataFilter public static performIca(data: number[][], numComponents: number, channels: number[]): [number[][], number[][], number[][], number[][]] { - if (data.length < 1) + const columns = DataFilter.checkMatrix(data); + DataFilter.checkChannels(data, channels, columns); + if (!Number.isInteger(numComponents) || numComponents < 2 || numComponents > channels.length || + columns < 2 || channels.length * numComponents > 2147483647) { - throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, "empty data"); + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Invalid ICA dimensions'); } var i: number; var j: number; - const data1D = [...new Array (data[0].length * channels.length).fill(0)]; + const data1D = [...new Array (columns * channels.length).fill(0)]; const w = [...new Array (numComponents * numComponents).fill(0)]; const k = [...new Array (channels.length * numComponents).fill(0)]; const a = [...new Array (numComponents * channels.length).fill(0)]; - const s = [...new Array (data[0].length * numComponents).fill(0)]; + const s = [...new Array (columns * numComponents).fill(0)]; for (i = 0; i < channels.length; i++) { - for (j = 0; j < data[0].length; j++) + for (j = 0; j < columns; j++) { - data1D[j + data[0].length * i] = data[channels[i]][j]; + data1D[j + columns * i] = data[channels[i]][j]; } } const res = DataHandlerDLL.getInstance().performIca( - data1D, channels.length, data[0].length, numComponents, w, k, a, s); + data1D, channels.length, columns, numComponents, w, k, a, s); if (res !== BrainFlowExitCodes.STATUS_OK) { throw new BrainFlowError (res, 'Could not perform ica'); @@ -585,7 +699,7 @@ export class DataFilter aOut.push(a.splice(0, numComponents)); const sOut: number[][] = []; while (s.length) - sOut.push(s.splice(0, data[0].length)); + sOut.push(s.splice(0, columns)); return [wOut, kOut, aOut, sOut]; } @@ -600,6 +714,11 @@ export class DataFilter public static calcStddev(data: number[], startPos: number, stopPos: number): number { + if (!Number.isInteger(startPos) || !Number.isInteger(stopPos) || + startPos < 0 || stopPos <= startPos || stopPos > data.length) + { + throw new BrainFlowError (BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR, 'Invalid standard deviation span'); + } const output = [0]; const res = DataHandlerDLL.getInstance().calcStddev(data, startPos, stopPos, output); if (res !== BrainFlowExitCodes.STATUS_OK) diff --git a/nodejs_package/brainflow/functions.types.ts b/nodejs_package/brainflow/functions.types.ts index f48742744..2a2c41d45 100644 --- a/nodejs_package/brainflow/functions.types.ts +++ b/nodejs_package/brainflow/functions.types.ts @@ -338,8 +338,8 @@ export enum DataHandlerCLikeFunctions { 'int perform_downsampling (double *data, int data_len, int period, int agg_operation, _Inout_ double *output_data)', perform_wavelet_transform = 'int perform_wavelet_transform (double *data, int data_len, int wavelet, int decomposition_level, int extension, _Inout_ double *output_data, _Inout_ int *decomposition_lengths)', - perform_inverse_wavelet_transform = - 'int perform_inverse_wavelet_transform (double *wavelet_coeffs, int original_data_len, int wavelet, int decomposition_level, int extension, int *decomposition_lengths, _Inout_ double *output_data)', + perform_inverse_wavelet_transform_checked = + 'int perform_inverse_wavelet_transform_checked (double *wavelet_coeffs, int coeff_count, int original_data_len, int wavelet, int decomposition_level, int extension, int *decomposition_lengths, int lengths_count, _Inout_ double *output_data, int output_count)', perform_wavelet_denoising = 'int perform_wavelet_denoising (_Inout_ double *data, int data_len, int wavelet, int decomposition_level, int wavelet_denoising, int threshold, int extenstion_type, int noise_level)', get_window = @@ -411,9 +411,9 @@ export class DataHandlerFunctions performWaveletTransform!: (data: number[], dataLen: number, wavelet: WaveletTypes, decompositionLevel: number, extension: WaveletExtensionTypes, outputData: number[], decompositionLengths: number[]) => BrainFlowExitCodes; - performInverseWaveletTransform!: (waveletCoeffs: number[], originalDataLen: number, + performInverseWaveletTransformChecked!: (waveletCoeffs: number[], coeffCount: number, originalDataLen: number, wavelet: WaveletTypes, decompositionLevel: number, extension: WaveletExtensionTypes, - decompositionLengths: number[], outputData: number[]) => BrainFlowExitCodes; + decompositionLengths: number[], lengthsCount: number, outputData: number[], outputCount: number) => BrainFlowExitCodes; performWaveletDenoising!: (data: number[], dataLen: number, wavelet: WaveletTypes, decompositionLevel: number, waveletDenoising: WaveletDenoisingTypes, threshold: ThresholdTypes, extenstionType: WaveletExtensionTypes, diff --git a/nodejs_package/tests/bandpower_all.ts b/nodejs_package/tests/bandpower_all.ts index b56172204..5c3d623f3 100644 --- a/nodejs_package/tests/bandpower_all.ts +++ b/nodejs_package/tests/bandpower_all.ts @@ -11,7 +11,8 @@ async function runExample (): Promise const board = new BoardShim (boardId, {}); board.prepareSession(); board.startStream(); - await sleep (4000); + // Include filter settling margins around the retained analysis interval. + await sleep (10000); board.stopStream(); const data = board.getBoardData(); board.releaseSession() diff --git a/nodejs_package/tests/ica.ts b/nodejs_package/tests/ica.ts index 5d6a376ed..731ae53a1 100644 --- a/nodejs_package/tests/ica.ts +++ b/nodejs_package/tests/ica.ts @@ -1,27 +1,16 @@ -import {BoardIds, BoardShim, DataFilter} from 'brainflow'; +import {DataFilter} from 'brainflow'; -function sleep (ms: number) +// Two simultaneous mixtures: rows are channels and columns are samples. +const samples = 1024; +const data: number[][] = [[], []]; +for (let i = 0; i < samples; i++) { - return new Promise ((resolve) => { setTimeout (resolve, ms); }); + const t = i / 256; + const first = Math.sin(2 * Math.PI * 7 * t); + const second = Math.pow(Math.sin(2 * Math.PI * 13 * t), 3); + data[0].push(first + 0.3 * second); + data[1].push(0.2 * first + second); } - -async function runExample (): Promise -{ - const boardId = BoardIds.SYNTHETIC_BOARD; - const board = new BoardShim (boardId, {}); - board.prepareSession(); - board.startStream(); - await sleep (10000); - board.stopStream(); - const data = board.getCurrentBoardData(500); - board.releaseSession(); - const eegChannels = BoardShim.getEegChannels(boardId); - const eegData = data[eegChannels[0]]; - const eeg2D: number[][] = []; - while (eegData.length) - eeg2D.push(eegData.splice(0, 100)); - const icaData = DataFilter.performIca(eeg2D, 2, [0, 1, 2, 3, 4]); - console.info(icaData[3]); -} - -runExample (); +const ica = DataFilter.performIca(data, 2, [0, 1]); +// Component order and sign are arbitrary. +console.info(`Recovered ${ica[3].length} sources from ${samples} samples`); diff --git a/python_package/brainflow/data_filter.py b/python_package/brainflow/data_filter.py index 10d11fb5b..bd325c901 100644 --- a/python_package/brainflow/data_filter.py +++ b/python_package/brainflow/data_filter.py @@ -379,16 +379,19 @@ def __init__(self): ndpointer(ctypes.c_double) ] - self.perform_inverse_wavelet_transform = self.lib.perform_inverse_wavelet_transform - self.perform_inverse_wavelet_transform.restype = ctypes.c_int - self.perform_inverse_wavelet_transform.argtypes = [ - ndpointer(ctypes.c_double), + self.perform_inverse_wavelet_transform_checked = self.lib.perform_inverse_wavelet_transform_checked + self.perform_inverse_wavelet_transform_checked.restype = ctypes.c_int + self.perform_inverse_wavelet_transform_checked.argtypes = [ + ndpointer(ctypes.c_double, ndim=1, flags='C_CONTIGUOUS'), ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, - ndpointer(ctypes.c_int32), - ndpointer(ctypes.c_double) + ctypes.c_int, + ndpointer(ctypes.c_int32, ndim=1, flags='C_CONTIGUOUS'), + ctypes.c_int, + ndpointer(ctypes.c_double, ndim=1, flags=('C_CONTIGUOUS', 'WRITEABLE')), + ctypes.c_int ] self.get_csp = self.lib.get_csp @@ -478,6 +481,46 @@ def __init__(self): ndpointer(ctypes.c_double) ] + self.perform_ica_with_options = self.lib.perform_ica_with_options + self.perform_ica_with_options.restype = ctypes.c_int + self.perform_ica_with_options.argtypes = self.perform_ica.argtypes + [ + ctypes.c_int, ctypes.c_double, ctypes.c_int] + + self.create_streaming_filter = self.lib.create_streaming_filter + self.create_streaming_filter.restype = ctypes.c_int + self.create_streaming_filter.argtypes = [ + ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, + ctypes.c_int, ctypes.c_int, ctypes.c_double, ndpointer(ctypes.c_int32)] + self.get_filter_settling_samples = self.lib.get_filter_settling_samples + self.get_filter_settling_samples.restype = ctypes.c_int + self.get_filter_settling_samples.argtypes = self.create_streaming_filter.argtypes + self.perform_streaming_filter = self.lib.perform_streaming_filter + self.perform_streaming_filter.restype = ctypes.c_int + self.perform_streaming_filter.argtypes = [ctypes.c_int, + ndpointer(ctypes.c_double, ndim=1, flags=('C_CONTIGUOUS', 'WRITEABLE')), ctypes.c_int] + self.reset_streaming_filter = self.lib.reset_streaming_filter + self.reset_streaming_filter.restype = ctypes.c_int + self.reset_streaming_filter.argtypes = [ctypes.c_int] + self.release_streaming_filter = self.lib.release_streaming_filter + self.release_streaming_filter.restype = ctypes.c_int + self.release_streaming_filter.argtypes = [ctypes.c_int] + self.perform_decimation = self.lib.perform_decimation + self.perform_decimation.restype = ctypes.c_int + self.perform_decimation.argtypes = [ + ndpointer(ctypes.c_double, ndim=1, flags='C_CONTIGUOUS'), ctypes.c_int, + ctypes.c_int, ndpointer(ctypes.c_double)] + + self.get_clipping_percentage = self.lib.get_clipping_percentage + self.get_clipping_percentage.restype = ctypes.c_int + self.get_clipping_percentage.argtypes = [ + ndpointer(ctypes.c_double, ndim=1, flags='C_CONTIGUOUS'), ctypes.c_int, + ctypes.c_double, ctypes.c_double, ndpointer(ctypes.c_double)] + self.get_flatline_percentage = self.lib.get_flatline_percentage + self.get_flatline_percentage.restype = ctypes.c_int + self.get_flatline_percentage.argtypes = [ + ndpointer(ctypes.c_double, ndim=1, flags='C_CONTIGUOUS'), ctypes.c_int, + ctypes.c_double, ndpointer(ctypes.c_double)] + self.get_custom_band_powers = self.lib.get_custom_band_powers self.get_custom_band_powers.restype = ctypes.c_int self.get_custom_band_powers.argtypes = [ @@ -493,6 +536,19 @@ def __init__(self): ndpointer(ctypes.c_double), ] + self.get_band_power_settings = self.lib.get_band_power_settings + self.get_band_power_settings.restype = ctypes.c_int + self.get_band_power_settings.argtypes = [ + ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, + ctypes.c_int, ndpointer(ctypes.c_int32), ndpointer(ctypes.c_int32)] + self.get_custom_band_powers_with_options = self.lib.get_custom_band_powers_with_options + self.get_custom_band_powers_with_options.restype = ctypes.c_int + self.get_custom_band_powers_with_options.argtypes = [ + ndpointer(ctypes.c_double), ctypes.c_int, ctypes.c_int, + ndpointer(ctypes.c_double), ndpointer(ctypes.c_double), ctypes.c_int, + ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_double, ctypes.c_double, + ctypes.c_int, ctypes.c_int, ndpointer(ctypes.c_double), ndpointer(ctypes.c_double)] + self.get_psd = self.lib.get_psd self.get_psd.restype = ctypes.c_int self.get_psd.argtypes = [ @@ -560,9 +616,84 @@ def __init__(self): ] +class StreamingFilter: + """Stateful causal filter for contiguous chunks from one signal channel. + + Kind 0 is lowpass, 1 highpass, 2 bandpass and 3 bandstop. low_cutoff supplies + the single cutoff for kinds 0/1; kinds 2/3 use both cutoffs. Only causal + FilterTypes 0, 1 and 2 are supported. + process modifies each writable float64 chunk in place. Use a separate instance + for each channel. close releases the native state; a context manager is preferred. + """ + + def __init__(self, kind, sampling_rate, low_cutoff=0.0, high_cutoff=0.0, order=4, + filter_type=FilterTypes.BUTTERWORTH, ripple=1.0): + self._handle = None + for value in (kind, sampling_rate, order, filter_type): + if not isinstance(value, (int, numpy.integer)) or not -2147483648 <= value <= 2147483647: + raise BrainFlowError('invalid streaming filter integer parameter', + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + self._dll = DataHandlerDLL.get_instance() + handle = numpy.zeros(1, dtype=numpy.int32) + res = self._dll.create_streaming_filter(kind, sampling_rate, low_cutoff, high_cutoff, + order, filter_type, ripple, handle) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('unable to create streaming filter', res) + self._handle = int(handle[0]) + + def _require_open(self): + if self._handle is None: + raise BrainFlowError('streaming filter is closed', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + + def process(self, data): + """Filter one chunk in place, preserving state for the next chunk.""" + self._require_open() + DataFilter._check_inplace_array(data) + if not 0 < data.size <= 2147483647: + raise BrainFlowError('invalid chunk length', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + res = self._dll.perform_streaming_filter(self._handle, data, data.size) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('unable to process streaming filter chunk', res) + + def reset(self): + """Reset filter state to the initial state for a new independent signal.""" + self._require_open() + res = self._dll.reset_streaming_filter(self._handle) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('unable to reset streaming filter', res) + + def close(self): + """Release native state. Repeated calls are harmless.""" + if self._handle is not None: + res = self._dll.release_streaming_filter(self._handle) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('unable to release streaming filter', res) + self._handle = None + + def __enter__(self): + self._require_open() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def __del__(self): + try: + self.close() + except Exception: + pass + + class DataFilter(object): """DataFilter class contains methods for signal processig""" + @staticmethod + def _check_inplace_array(data): + check_memory_layout_row_major(data, 1) + if data.dtype != numpy.float64 or not data.flags.writeable: + raise BrainFlowError('in-place data must be a writable float64 array', + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + @classmethod def set_log_level(cls, log_level: int) -> None: """set BrainFlow log level, use it only if you want to write your own messages to BrainFlow logger, @@ -575,6 +706,42 @@ def set_log_level(cls, log_level: int) -> None: if res != BrainFlowExitCodes.STATUS_OK.value: raise BrainFlowError('unable to enable logger', res) + @classmethod + def get_filter_settling_samples(cls, kind, sampling_rate, low_cutoff=0.0, high_cutoff=0.0, + order=4, filter_type=FilterTypes.BUTTERWORTH_ZERO_PHASE, ripple=1.0): + """Estimated edge margin in samples; not a guaranteed artifact bound. + + Kind/cutoff meanings match StreamingFilter. Zero-phase filtering keeps its + full output length; callers can exclude this margin at both ends for analysis. + """ + for value in (kind, sampling_rate, order, filter_type): + if not isinstance(value, (int, numpy.integer)) or not -2147483648 <= value <= 2147483647: + raise BrainFlowError('invalid filter integer parameter', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + output = numpy.zeros(1, dtype=numpy.int32) + res = DataHandlerDLL.get_instance().get_filter_settling_samples( + kind, sampling_rate, low_cutoff, high_cutoff, order, filter_type, ripple, output) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('unable to estimate filter settling margin', res) + return int(output[0]) + + @classmethod + def perform_decimation(cls, data, factor: int): + """Apply an anti-alias FIR and return floor(len(data)/factor) samples. + + The first output is aligned with input[0]. Reflected endpoint extension is + used for this offline operation. Block aggregation remains perform_downsampling. + """ + check_memory_layout_row_major(data, 1) + if (not isinstance(factor, (int, numpy.integer)) or not 1 <= factor <= data.size or + data.size > 2147483647): + raise BrainFlowError('invalid decimation factor or input length', + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + output = numpy.zeros(data.size // factor, dtype=numpy.float64) + res = DataHandlerDLL.get_instance().perform_decimation(data, data.size, factor, output) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('unable to decimate data', res) + return output + @classmethod def enable_data_logger(cls) -> None: """enable Data Logger with level INFO, uses stderr for log messages by default""" @@ -623,7 +790,7 @@ def perform_lowpass(cls, data, sampling_rate: int, cutoff: float, order: int, fi :param ripple: ripple value for Chebyshev filter :type ripple: float """ - check_memory_layout_row_major(data, 1) + cls._check_inplace_array(data) if not isinstance(sampling_rate, int): raise BrainFlowError('wrong type for sampling rate', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) if not isinstance(filter_type, int): @@ -651,7 +818,7 @@ def perform_highpass(cls, data, sampling_rate: int, cutoff: float, order: int, f :param ripple: ripple value for Chebyshev filter :type ripple: float """ - check_memory_layout_row_major(data, 1) + cls._check_inplace_array(data) if not isinstance(sampling_rate, int): raise BrainFlowError('wrong type for sampling rate', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) if not isinstance(filter_type, int): @@ -681,7 +848,7 @@ def perform_bandpass(cls, data, sampling_rate: int, start_freq: float, :param ripple: ripple value for Chebyshev filter :type ripple: float """ - check_memory_layout_row_major(data, 1) + cls._check_inplace_array(data) if not isinstance(sampling_rate, int): raise BrainFlowError('wrong type for sampling rate', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) if not isinstance(filter_type, int): @@ -711,7 +878,7 @@ def perform_bandstop(cls, data, sampling_rate: int, start_freq: float, :param ripple: ripple value for Chebyshev filter :type ripple: float """ - check_memory_layout_row_major(data, 1) + cls._check_inplace_array(data) if not isinstance(sampling_rate, int): raise BrainFlowError('wrong type for sampling rate', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) if not isinstance(filter_type, int): @@ -732,7 +899,7 @@ def remove_environmental_noise(cls, data, sampling_rate: int, noise_type: float) :param noise_type: noise type :type noise_type: int """ - check_memory_layout_row_major(data, 1) + cls._check_inplace_array(data) if not isinstance(sampling_rate, int): raise BrainFlowError('wrong type for sampling rate', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) if not isinstance(noise_type, int): @@ -752,7 +919,7 @@ def perform_rolling_filter(cls, data, period: int, operation: int) -> None: :param operation: int value from AggOperation enum :type operation: int """ - check_memory_layout_row_major(data, 1) + cls._check_inplace_array(data) if not isinstance(period, int): raise BrainFlowError('wrong type for period', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) if not isinstance(operation, int): @@ -795,6 +962,27 @@ def get_railed_percentage(cls, data, gain: int): raise BrainFlowError('unable to get railed percentage', res) return output[0] + @classmethod + def get_clipping_percentage(cls, data, lower_bound: float, upper_bound: float): + """Percentage of samples at or outside the supplied ADC limits, in data units.""" + check_memory_layout_row_major(data, 1) + output = numpy.zeros(1, dtype=numpy.float64) + res = DataHandlerDLL.get_instance().get_clipping_percentage( + data, data.size, lower_bound, upper_bound, output) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('unable to calculate clipping percentage', res) + return output[0] + + @classmethod + def get_flatline_percentage(cls, data, tolerance: float = 0.0): + """Percentage of adjacent sample differences no greater than tolerance.""" + check_memory_layout_row_major(data, 1) + output = numpy.zeros(1, dtype=numpy.float64) + res = DataHandlerDLL.get_instance().get_flatline_percentage(data, data.size, tolerance, output) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('unable to calculate flatline percentage', res) + return output[0] + @classmethod def get_oxygen_level(cls, ppg_ir, ppg_red, sampling_rate: int, coef1=1.5958422, coef2=-34.6596622, coef3=112.6898759): @@ -893,6 +1081,9 @@ def perform_wavelet_transform(cls, data, wavelet: int, decomposition_level: int, """ check_memory_layout_row_major(data, 1) + if (not isinstance(decomposition_level, (int, numpy.integer)) or not 1 <= decomposition_level <= 100 or + data.size == 0 or data.size + 2 * decomposition_level * 41 > 2147483647): + raise BrainFlowError('invalid wavelet dimensions', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) wavelet_coeffs = numpy.zeros(data.shape[0] + 2 * decomposition_level * (40 + 1)).astype(numpy.float64) lengths = numpy.zeros(decomposition_level + 1).astype(numpy.int32) res = DataHandlerDLL.get_instance().perform_wavelet_transform(data, data.shape[0], wavelet, @@ -971,11 +1162,23 @@ def perform_inverse_wavelet_transform(cls, wavelet_output: Tuple, original_data_ :return: restored data :rtype: NDArray[Shape["*"], Float64] """ - original_data = numpy.zeros(original_data_len).astype(numpy.float64) - res = DataHandlerDLL.get_instance().perform_inverse_wavelet_transform(wavelet_output[0], original_data_len, - wavelet, - decomposition_level, extension_type, - wavelet_output[1], original_data) + if (not isinstance(original_data_len, (int, numpy.integer)) or + not 0 < original_data_len <= numpy.iinfo(numpy.int32).max or + not isinstance(decomposition_level, (int, numpy.integer)) or + not 0 < decomposition_level <= 100 or len(wavelet_output) != 2): + raise BrainFlowError('invalid wavelet dimensions', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + coeffs, lengths = wavelet_output + check_memory_layout_row_major(coeffs, 1) + check_memory_layout_row_major(lengths, 1) + if (coeffs.dtype != numpy.float64 or lengths.dtype != numpy.int32 or original_data_len > coeffs.size or + lengths.size != decomposition_level + 1 or numpy.any(lengths <= 0) or + sum(int(value) for value in lengths) != coeffs.size): + raise BrainFlowError('invalid wavelet coefficient lengths', + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + original_data = numpy.zeros(original_data_len, dtype=numpy.float64) + res = DataHandlerDLL.get_instance().perform_inverse_wavelet_transform_checked( + coeffs, coeffs.size, original_data_len, wavelet, decomposition_level, extension_type, + lengths, lengths.size, original_data, original_data.size) if res != BrainFlowExitCodes.STATUS_OK.value: raise BrainFlowError('unable to perform inverse wavelet transform', res) @@ -1004,7 +1207,7 @@ def perform_wavelet_denoising(cls, data, wavelet: int, decomposition_level: int, :param noise_level: use NoiseEstimationLevelTypes enum :type noise_level: int """ - check_memory_layout_row_major(data, 1) + cls._check_inplace_array(data) res = DataHandlerDLL.get_instance().perform_wavelet_denoising(data, data.shape[0], wavelet, decomposition_level, wavelet_denoising, threshold, @@ -1023,6 +1226,8 @@ def get_csp(cls, data, labels) -> Tuple: :return: [channels x channels]-shaped 2D array of filters and [channels]-length 1D array of the corresponding eigenvalues :rtype: Tuple """ + check_memory_layout_row_major(data, 3) + check_memory_layout_row_major(labels, 1) if not (len(labels.shape) == 1): raise BrainFlowError('Invalid shape of array ', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) if not (len(labels) == data.shape[0]): @@ -1151,7 +1356,7 @@ def detrend(cls, data, detrend_operation: int) -> None: :type detrend_operation: int """ - check_memory_layout_row_major(data, 1) + cls._check_inplace_array(data) res = DataHandlerDLL.get_instance().detrend(data, data.shape[0], detrend_operation) if res != BrainFlowExitCodes.STATUS_OK.value: raise BrainFlowError('unable to detrend data', res) @@ -1169,6 +1374,14 @@ def get_band_power(cls, psd: Tuple, freq_start: float, freq_end: float) -> float :return: band power :rtype: float """ + if len(psd) != 2: + raise BrainFlowError('PSD must contain amplitudes and frequencies', + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + check_memory_layout_row_major(psd[0], 1) + check_memory_layout_row_major(psd[1], 1) + if psd[0].size != psd[1].size: + raise BrainFlowError('PSD amplitude and frequency lengths must match', + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) band_power = numpy.zeros(1).astype(numpy.float64) res = DataHandlerDLL.get_instance().get_band_power(psd[0], psd[1], psd[0].shape[0], freq_start, freq_end, band_power) @@ -1179,7 +1392,10 @@ def get_band_power(cls, psd: Tuple, freq_start: float, freq_end: float) -> float @classmethod def get_avg_band_powers(cls, data, channels: List, sampling_rate: int, apply_filter: bool) -> Tuple: - """calculate avg and stddev of BandPowers across all channels, bands are 1-4,4-8,8-13,13-30,30-50 + """Calculate normalized mean powers for bands 2-4, 4-8, 8-13, 13-30, 30-45 Hz. + + Uses the preprocessing, minimum data length, and coefficient-of-variation + semantics of :meth:`get_custom_band_powers`. :param data: 2d array for calculation :type data: NDArray[Shape["*, *"], Float64] @@ -1187,9 +1403,9 @@ def get_avg_band_powers(cls, data, channels: List, sampling_rate: int, apply_fil :type channels: List :param sampling_rate: sampling rate :type sampling_rate: int - :param apply_filter: apply bandpass and bandstop filtrers or not + :param apply_filter: preprocess and discard edge margins to reduce filter transients :type apply_filter: bool - :return: avg and stddev arrays for bandpowers + :return: normalized mean band powers and coefficients of variation across channels :rtype: tuple """ @@ -1199,37 +1415,57 @@ def get_avg_band_powers(cls, data, channels: List, sampling_rate: int, apply_fil @classmethod def get_custom_band_powers(cls, data, bands: List, channels: List, sampling_rate: int, apply_filter: bool) -> Tuple: - """calculate avg and stddev of BandPowers across selected channels + """Calculate normalized mean band powers and variation across selected channels. + + With ``apply_filter=True``, demean and apply padded, initialized zero-phase + 48-52 and 58-62 Hz notches when their upper edges are below 90% of Nyquist. + No automatic passband is applied, so preprocessing does not change when + integration bands change. Samples from both ends are excluded using an + estimated guard from the complete filter cascade's impulse-response tail. + The guard is an estimate, not a guaranteed artifact bound. Supply extra + surrounding data; excluding the newest samples introduces a corresponding + delay in live analysis. With ``apply_filter=False``, no preprocessing or + trimming is performed. Mains notches also attenuate overlapping custom bands; + use external preprocessing and disable filtering to customize this behavior. + + At least ``max(8, 2 * get_nearest_power_of_two(sampling_rate))`` samples must remain + after trimming. Short inputs raise an error instead of reducing the FFT size. + Band edges must satisfy ``0 <= start < stop <= sampling_rate / 2``. + Data and edges must be finite. + + The first output contains channel-mean absolute band powers normalized by + their sum across the requested bands. The second output, historically named + stddev, contains population standard deviation divided by mean absolute + power for each band (coefficient of variation). Zero-power bands have zero + variation, and an all-zero total returns zero normalized powers. :param data: 2d array for calculation :type data: NDArray[Shape["*, *"], Float64] - :param bands: List of typles with bands to use. E.g [(1.5, 4.0), (4.0, 8.0), (8.0, 13.0), (13.0, 30.0), (30.0, 45.0)] + :param bands: list of (start, stop) frequency pairs in Hz :type bands: List :param channels: channels - rows of data array which should be used for calculation :type channels: List :param sampling_rate: sampling rate :type sampling_rate: int - :param apply_filter: apply bandpass and bandstop filtrers or not + :param apply_filter: preprocess and discard edge margins to reduce filter transients :type apply_filter: bool - :return: avg and stddev arrays for bandpowers + :return: normalized mean band powers and coefficients of variation across channels :rtype: tuple """ check_memory_layout_row_major(data, 2) - if (len(channels) == 0) or (len(bands) == 0): - raise BrainFlowError('wrong input for channels or bands', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) - num_bands = len(bands) + channels = numpy.asarray(channels) + bands = numpy.asarray(bands, dtype=numpy.float64) + if (channels.ndim != 1 or channels.size == 0 or channels.dtype.kind not in 'iu' or + numpy.any(channels < 0) or numpy.any(channels >= data.shape[0]) or + bands.ndim != 2 or bands.shape[0] == 0 or bands.shape[1] != 2): + raise BrainFlowError('invalid channels or bands', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + num_bands = bands.shape[0] avg_bands = numpy.zeros(num_bands).astype(numpy.float64) stddev_bands = numpy.zeros(num_bands).astype(numpy.float64) - data_1d = numpy.zeros(len(channels) * data.shape[1]) - start_freqs = numpy.zeros(num_bands) - stop_freqs = numpy.zeros(num_bands) - for i in range(num_bands): - start_freqs[i] = bands[i][0] - stop_freqs[i] = bands[i][1] - for i, channel in enumerate(channels): - for j in range(data.shape[1]): - data_1d[j + data.shape[1] * i] = data[channel][j] + data_1d = numpy.ascontiguousarray(data[channels], dtype=numpy.float64).ravel() + start_freqs = numpy.ascontiguousarray(bands[:, 0]) + stop_freqs = numpy.ascontiguousarray(bands[:, 1]) res = DataHandlerDLL.get_instance().get_custom_band_powers(data_1d, len(channels), data.shape[1], start_freqs, stop_freqs, num_bands, sampling_rate, int(apply_filter), avg_bands, @@ -1240,7 +1476,70 @@ def get_custom_band_powers(cls, data, bands: List, channels: List, sampling_rate return avg_bands, stddev_bands @classmethod - def perform_ica(cls, data, num_components: int, channels=None) -> Tuple: + def get_band_power_info(cls, sampling_rate: int, apply_filter=True, *, nfft=0, + low_cutoff=0.0, high_cutoff=0.0, mains=-1, data_len=None): + """Return FFT size, bin spacing, edge margin, and minimum required samples. + + Cutoff 0 disables that passband edge. Mains: -1 automatic, 0 disabled, + 1 50 Hz, 2 60 Hz, 3 both. Automatic notches must end below 90% of Nyquist. + The settling margin is an impulse-tail estimate, not an artifact guarantee. + If data_len is supplied, usable_start/usable_stop describe a half-open interval. + """ + effective = numpy.zeros(1, dtype=numpy.int32) + edge = numpy.zeros(1, dtype=numpy.int32) + res = DataHandlerDLL.get_instance().get_band_power_settings( + sampling_rate, int(apply_filter), nfft, low_cutoff, high_cutoff, mains, + effective, edge) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('invalid band-power settings', res) + size, margin = int(effective[0]), int(edge[0]) + info = dict(nfft=size, frequency_resolution=sampling_rate / size, + edge_samples=margin, minimum_samples=size + 2 * margin, + edge_seconds=margin / sampling_rate) + if data_len is not None: + if data_len < info['minimum_samples']: + raise BrainFlowError('insufficient samples for band powers', + BrainFlowExitCodes.INVALID_BUFFER_SIZE_ERROR.value) + info.update(usable_start=margin, usable_stop=data_len - margin) + return info + + @classmethod + def get_custom_band_powers_with_options(cls, data, bands, channels, sampling_rate, + apply_filter=True, *, nfft=0, low_cutoff=0.0, + high_cutoff=0.0, mains=-1, + detrend_operation=DetrendOperations.CONSTANT): + """Band powers with preprocessing configured independently of integration bands. + + Output semantics match get_custom_band_powers. Query get_band_power_info with + the same filter/FFT options before collecting data. Detrending applies even + when apply_filter is False; choose NO_DETREND to preserve raw DC. + No per-segment detrending is performed. No automatic FFT-size reduction occurs. + """ + check_memory_layout_row_major(data, 2) + selected = numpy.asarray(channels) + edges = numpy.asarray(bands, dtype=numpy.float64) + if (selected.ndim != 1 or selected.size == 0 or selected.dtype.kind not in 'iu' or + numpy.any(selected < 0) or numpy.any(selected >= data.shape[0]) or + edges.ndim != 2 or edges.shape[0] == 0 or edges.shape[1] != 2 or + data.shape[1] == 0 or max(data.shape[1], selected.size, edges.shape[0]) > 2147483647): + raise BrainFlowError('invalid channels, bands, or data dimensions', + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + gathered = numpy.ascontiguousarray(data[selected], dtype=numpy.float64).ravel() + starts = numpy.ascontiguousarray(edges[:, 0]) + stops = numpy.ascontiguousarray(edges[:, 1]) + averages = numpy.zeros(len(edges), dtype=numpy.float64) + variation = numpy.zeros(len(edges), dtype=numpy.float64) + res = DataHandlerDLL.get_instance().get_custom_band_powers_with_options( + gathered, len(selected), data.shape[1], starts, stops, len(edges), sampling_rate, + int(apply_filter), nfft, low_cutoff, high_cutoff, mains, int(detrend_operation), + averages, variation) + if res != BrainFlowExitCodes.STATUS_OK.value: + raise BrainFlowError('unable to calculate band powers', res) + return averages, variation + + @classmethod + def perform_ica(cls, data, num_components: int, channels=None, *, max_iterations=1000, + tolerance=1e-4, seed=None) -> Tuple: """perform ICA :param data: 2d array for calculation @@ -1249,33 +1548,34 @@ def perform_ica(cls, data, num_components: int, channels=None) -> Tuple: :type num_components: int :param channels: channels - rows of data array which should be used for calculation, if None use all :type channels: List + :param max_iterations: positive maximum number of FastICA iterations + :param tolerance: positive convergence tolerance + :param seed: nonnegative random seed for reproducibility, or None for random initialization :return: w, k, a, s matrixes as a tuple :rtype: tuple """ check_memory_layout_row_major(data, 2) if len(data.shape) != 2: raise BrainFlowError('wrong number of dimensions', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) - if num_components < 1: - raise BrainFlowError('wrong number of components', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) - - if not channels: - channels_to_use = range(data.shape[0]) - else: - channels_to_use = channels - - data_1d = numpy.zeros(len(channels_to_use) * data.shape[1]).astype(numpy.float64) + channels_to_use = numpy.arange(data.shape[0]) if channels is None else numpy.asarray(channels) + if (channels_to_use.ndim != 1 or channels_to_use.dtype.kind not in 'iu' or + numpy.any(channels_to_use < 0) or numpy.any(channels_to_use >= data.shape[0]) or + not isinstance(num_components, (int, numpy.integer)) or + not 2 <= num_components <= min(channels_to_use.size, data.shape[1]) or + not isinstance(max_iterations, (int, numpy.integer)) or not 0 < max_iterations <= 2147483647 or + not numpy.isfinite(tolerance) or tolerance <= 0 or + (seed is not None and (not isinstance(seed, (int, numpy.integer)) or not 0 <= seed <= 2147483647))): + raise BrainFlowError('invalid ICA dimensions or options', BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + data_1d = numpy.ascontiguousarray(data[channels_to_use], dtype=numpy.float64).ravel() w = numpy.zeros(num_components * num_components).astype(numpy.float64) k = numpy.zeros(len(channels_to_use) * num_components).astype(numpy.float64) a = numpy.zeros(num_components * len(channels_to_use)).astype(numpy.float64) s = numpy.zeros(data.shape[1] * num_components).astype(numpy.float64) - for i, channel in enumerate(channels_to_use): - for j in range(data.shape[1]): - data_1d[j + data.shape[1] * i] = data[channel][j] - - res = DataHandlerDLL.get_instance().perform_ica(data_1d, len(channels_to_use), data.shape[1], - num_components, w, k, a, s) + res = DataHandlerDLL.get_instance().perform_ica_with_options( + data_1d, len(channels_to_use), data.shape[1], num_components, w, k, a, s, + max_iterations, tolerance, -1 if seed is None else seed) if res != BrainFlowExitCodes.STATUS_OK.value: raise BrainFlowError('unable to calculate ICA', res) diff --git a/python_package/brainflow/ml_model.py b/python_package/brainflow/ml_model.py index ffcbb510e..0bb589459 100644 --- a/python_package/brainflow/ml_model.py +++ b/python_package/brainflow/ml_model.py @@ -1,3 +1,4 @@ +import copy import ctypes import enum import json @@ -169,11 +170,22 @@ class MLModel(object): """ def __init__(self, model_params: BrainFlowModelParams) -> None: + capacity = model_params.max_array_size + if (isinstance(capacity, (bool, numpy.bool_)) or + not isinstance(capacity, (int, numpy.integer)) or + capacity <= 0 or capacity > numpy.iinfo(numpy.int32).max): + raise BrainFlowError('max_array_size must be a positive int32 integer', + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) self.model_params = model_params + # Native model settings are serialized once. Keep allocation capacity tied + # to that same snapshot even when callers later reuse or mutate the params. + self._output_capacity = int(capacity) + snapshot = copy.copy(model_params) + snapshot.max_array_size = self._output_capacity try: - self.serialized_params = model_params.to_json().encode() + self.serialized_params = snapshot.to_json().encode() except BaseException: - self.serialized_params = model_params.to_json() + self.serialized_params = snapshot.to_json() @classmethod def set_log_level(cls, log_level: int) -> None: @@ -274,14 +286,28 @@ def release(self) -> None: def predict(self, data) -> List: """calculate metric from data - :param data: input array + :param data: finite one-dimensional input, converted to contiguous float64 :type data: NDArray[Shape["*"], Float64] :return: metric value :rtype: List """ - output = numpy.zeros(self.model_params.max_array_size).astype(numpy.float64) - output_len = numpy.zeros(1).astype(numpy.int32) + try: + data = numpy.asarray(data) + if (data.ndim != 1 or data.size == 0 or data.size > numpy.iinfo(numpy.int32).max or + numpy.iscomplexobj(data)): + raise ValueError('input must be a nonempty one-dimensional real array') + data = numpy.ascontiguousarray(data, dtype=numpy.float64) + if not numpy.isfinite(data).all(): + raise ValueError('input must contain only finite values') + except (TypeError, ValueError, OverflowError) as error: + raise BrainFlowError('invalid prediction input: %s' % error, + BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) from error + output = numpy.zeros(self._output_capacity, dtype=numpy.float64) + output_len = numpy.zeros(1, dtype=numpy.int32) res = MLModuleDLL.get_instance().predict(data, data.shape[0], output, output_len, self.serialized_params) if res != BrainFlowExitCodes.STATUS_OK.value: raise BrainFlowError('unable to calc metric', res) + if output_len[0] < 0 or output_len[0] > self._output_capacity: + raise BrainFlowError('invalid prediction output length', + BrainFlowExitCodes.INVALID_BUFFER_SIZE_ERROR.value) return output[0:output_len[0]] diff --git a/python_package/examples/tests/ica.py b/python_package/examples/tests/ica.py index 7523b857a..773dba7ec 100644 --- a/python_package/examples/tests/ica.py +++ b/python_package/examples/tests/ica.py @@ -1,32 +1,19 @@ -import time import numpy as np import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt -from brainflow.board_shim import BoardShim, BoardIds, BrainFlowInputParams from brainflow.data_filter import DataFilter def main(): - board_id = BoardIds.SYNTHETIC_BOARD - eeg_channels = BoardShim.get_eeg_channels(board_id) - - params = BrainFlowInputParams() - board = BoardShim(board_id, params) - board.prepare_session() - board.start_stream() - time.sleep(10) - data = board.get_board_data(500) - board.stop_stream() - board.release_session() - - channel_to_use = eeg_channels[4] - data = data[channel_to_use, :] - # provide 5 chunks of data for components selection - data = data.reshape(5, 100) - data = np.ascontiguousarray(data) + # Two simultaneous mixtures: rows are channels and columns are samples. + time = np.arange(1024, dtype=np.float64) / 256.0 + first = np.sin(2.0 * np.pi * 7.0 * time) + second = np.sin(2.0 * np.pi * 13.0 * time) ** 3 + data = np.vstack((first + 0.3 * second, 0.2 * first + second)) + # Component order and sign are arbitrary. w, k, a, s = DataFilter.perform_ica(data, 2) fig, axs = plt.subplots(2, 1) axs[0].plot(s[0, :]) diff --git a/r_package/examples/ICA.R b/r_package/examples/ICA.R index bcf7c0c2d..1f849d33c 100644 --- a/r_package/examples/ICA.R +++ b/r_package/examples/ICA.R @@ -1,15 +1,10 @@ library(brainflow) -params <- brainflow_python$BrainFlowInputParams() -board_shim <- brainflow_python$BoardShim(brainflow_python$BoardIds$SYNTHETIC_BOARD$value, params) -board_shim$prepare_session() -board_shim$start_stream() -Sys.sleep(time = 10) -board_shim$stop_stream() -data <- board_shim$get_board_data(as.integer(500)) -board_shim$release_session() -eeg_channels <- brainflow_python$BoardShim$get_eeg_channels(as.integer(-1)) -my_channel = eeg_channels[2] -numpy_data <- np$array(data[my_channel,]) -numpy_data <- numpy_data$reshape(as.integer(5),as.integer(100)) -ica <- brainflow_python$DataFilter$perform_ica(numpy_data, as.integer(2)) \ No newline at end of file +# Two simultaneous mixtures: rows are channels and columns are samples. +sample_times <- (0:1023) / 256 +source1 <- sin(2 * pi * 7 * sample_times) +source2 <- sin(2 * pi * 13 * sample_times)^3 +mixed_data <- rbind(source1 + 0.3 * source2, 0.2 * source1 + source2) +numpy_data <- np$array(mixed_data, dtype = np$float64, order = "C") +# Component order and sign are arbitrary. +ica <- brainflow_python$DataFilter$perform_ica(numpy_data, as.integer(2)) diff --git a/rust_package/brainflow/examples/ica.rs b/rust_package/brainflow/examples/ica.rs index a0a4e80ea..6b853283f 100644 --- a/rust_package/brainflow/examples/ica.rs +++ b/rust_package/brainflow/examples/ica.rs @@ -1,31 +1,17 @@ +use brainflow::data_filter; use ndarray::Array2; -use std::{thread, time::Duration}; +use std::f64::consts::PI; -use brainflow::{ - board_shim, brainflow_input_params::BrainFlowInputParamsBuilder, data_filter, BoardIds, BrainFlowPresets, -}; -use ndarray::s; - -fn main() { - brainflow::board_shim::enable_dev_board_logger().unwrap(); - - let board_id = BoardIds::SyntheticBoard; - let eeg_channels = board_shim::get_eeg_channels(board_id, BrainFlowPresets::DefaultPreset).unwrap(); - println!("{:?}", eeg_channels); - - let params = BrainFlowInputParamsBuilder::default().build(); - let board = board_shim::BoardShim::new(board_id, params).unwrap(); - - board.prepare_session().unwrap(); - board.start_stream(45000, "").unwrap(); - thread::sleep(Duration::from_secs(10)); - board.stop_stream().unwrap(); - let mut data = board.get_board_data(Some(500), BrainFlowPresets::DefaultPreset).unwrap(); - board.release_session().unwrap(); - - let my_channel = eeg_channels[4]; - let mut my_data = data.slice_mut(s![my_channel, ..]); - let data_my_channel = my_data.as_slice_mut().unwrap(); - let ica_data = Array2::from_shape_vec((5, 100), data_my_channel.to_vec()).unwrap(); - let _ica = data_filter::perform_ica(ica_data, 2).unwrap(); +fn main() -> Result<(), Box> { + // Two simultaneous mixtures: rows are channels and columns are samples. + let data = Array2::from_shape_fn((2, 1024), |(row, col)| { + let t = col as f64 / 256.0; + let first = (2.0 * PI * 7.0 * t).sin(); + let second = (2.0 * PI * 13.0 * t).sin().powi(3); + if row == 0 { first + 0.3 * second } else { 0.2 * first + second } + }); + let (_, _, _, sources) = data_filter::perform_ica(data, 2)?; + // Component order and sign are arbitrary. + println!("Recovered 2 sources from {} samples", sources.len() / 2); + Ok(()) } diff --git a/rust_package/brainflow/src/data_filter.rs b/rust_package/brainflow/src/data_filter.rs index 09a14c3e0..6d24621c2 100644 --- a/rust_package/brainflow/src/data_filter.rs +++ b/rust_package/brainflow/src/data_filter.rs @@ -5,6 +5,7 @@ use num_complex::Complex64; use std::os::raw::c_int; use std::{ffi::CString, ffi::CStr, os::raw::c_double}; use std::os::raw::c_char; +use std::convert::TryFrom; use crate::error::{BrainFlowError, Error}; use crate::ffi::data_handler; @@ -13,6 +14,32 @@ use crate::{ NoiseTypes, Result, WindowOperations, WaveletTypes, WaveletExtensionTypes, WaveletDenoisingTypes, ThresholdTypes, NoiseEstimationLevelTypes, }; +fn invalid_arguments() -> Error { + Error::BrainFlowError(BrainFlowError::InvalidArgumentsError) +} + +fn native_int(value: usize) -> Result { + c_int::try_from(value).map_err(|_| invalid_arguments()) +} + +fn checked_product(left: usize, right: usize) -> Result { + let value = left.checked_mul(right).ok_or_else(invalid_arguments)?; + native_int(value)?; + Ok(value) +} + +fn selected_data(data: &Array2, channels: &[usize]) -> Result> { + if channels.is_empty() || data.ncols() == 0 || channels.iter().any(|&c| c >= data.nrows()) { + return Err(invalid_arguments()); + } + let mut selected = Vec::with_capacity(checked_product(channels.len(), data.ncols())?); + // Preserve the requested order and duplicate channels consistently with other bindings. + for &channel in channels { + selected.extend(data.row(channel).iter().copied()); + } + Ok(selected) +} + /// Set BrainFlow data logger log level. /// Use it only if you want to write your own messages to BrainFlow logger. @@ -69,10 +96,10 @@ pub fn perform_lowpass( let res = unsafe { data_handler::perform_lowpass( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - sampling_rate as c_int, + native_int(data.len())?, + native_int(sampling_rate)?, cutoff as c_double, - order as c_int, + native_int(order)?, filter_type as c_int, ripple as c_double, ) @@ -93,10 +120,10 @@ pub fn perform_highpass( let res = unsafe { data_handler::perform_highpass( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - sampling_rate as c_int, + native_int(data.len())?, + native_int(sampling_rate)?, cutoff as c_double, - order as c_int, + native_int(order)?, filter_type as c_int, ripple as c_double, ) @@ -118,11 +145,11 @@ pub fn perform_bandpass( let res = unsafe { data_handler::perform_bandpass( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - sampling_rate as c_int, + native_int(data.len())?, + native_int(sampling_rate)?, start_freq as c_double, stop_freq as c_double, - order as c_int, + native_int(order)?, filter_type as c_int, ripple as c_double, ) @@ -144,11 +171,11 @@ pub fn perform_bandstop( let res = unsafe { data_handler::perform_bandstop( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - sampling_rate as c_int, + native_int(data.len())?, + native_int(sampling_rate)?, start_freq as c_double, stop_freq as c_double, - order as c_int, + native_int(order)?, filter_type as c_int, ripple as c_double, ) @@ -166,8 +193,8 @@ pub fn remove_environmental_noise( let res = unsafe { data_handler::remove_environmental_noise( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - sampling_rate as c_int, + native_int(data.len())?, + native_int(sampling_rate)?, noise_type as c_int, ) }; @@ -184,8 +211,8 @@ pub fn perform_rolling_filter( let res = unsafe { data_handler::perform_rolling_filter( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - period as c_int, + native_int(data.len())?, + native_int(period)?, agg_operation as c_int, ) }; @@ -199,12 +226,15 @@ pub fn calc_stddev( start_pos: usize, end_pos: usize ) -> Result { + if start_pos >= end_pos || end_pos > data.len() { + return Err(invalid_arguments()); + } let mut output = 0.0 as f64; let res = unsafe { data_handler::calc_stddev( data.as_mut_ptr() as *mut c_double, - start_pos as c_int, - end_pos as c_int, + native_int(start_pos)?, + native_int(end_pos)?, &mut output, ) }; @@ -218,12 +248,15 @@ pub fn get_railed_percentage( data_size: usize, gain: usize ) -> Result { + if data_size == 0 || data_size > data.len() { + return Err(invalid_arguments()); + } let mut output = 0.0 as f64; let res = unsafe { data_handler::get_railed_percentage( data.as_mut_ptr() as *mut c_double, - data_size as c_int, - gain as c_int, + native_int(data_size)?, + native_int(gain)?, &mut output, ) }; @@ -240,13 +273,16 @@ pub fn get_oxygen_level( coef2: f64, coef3: f64, ) -> Result { + if ppg_ir.len() != ppg_red.len() { + return Err(invalid_arguments()); + } let mut output = 0.0 as f64; let res = unsafe { data_handler::get_oxygen_level( ppg_ir.as_mut_ptr() as *mut c_double, ppg_red.as_mut_ptr() as *mut c_double, - ppg_red.len() as c_int, - sampling_rate as c_int, + native_int(ppg_red.len())?, + native_int(sampling_rate)?, coef1 as c_double, coef2 as c_double, coef3 as c_double, @@ -264,14 +300,17 @@ pub fn get_heart_rate( sampling_rate: usize, fft_size: usize, ) -> Result { + if ppg_ir.len() != ppg_red.len() { + return Err(invalid_arguments()); + } let mut output = 0.0 as f64; let res = unsafe { data_handler::get_heart_rate( ppg_ir.as_mut_ptr() as *mut c_double, ppg_red.as_mut_ptr() as *mut c_double, - ppg_red.len() as c_int, - sampling_rate as c_int, - fft_size as c_int, + native_int(ppg_red.len())?, + native_int(sampling_rate)?, + native_int(fft_size)?, &mut output, ) }; @@ -293,8 +332,8 @@ pub fn perform_downsampling( let res = unsafe { data_handler::perform_downsampling( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - period as c_int, + native_int(data.len())?, + native_int(period)?, agg_operation as c_int, output.as_mut_ptr() as *mut c_double, ) @@ -362,7 +401,13 @@ pub fn perform_wavelet_transform( decomposition_level: usize, extension: WaveletExtensionTypes, ) -> Result { - let capacity = data.len() + 2 * decomposition_level * (40 + 1); + if decomposition_level == 0 || decomposition_level > 100 || data.is_empty() { + return Err(invalid_arguments()); + } + native_int(data.len())?; + let capacity = data.len().checked_add(checked_product(decomposition_level, 82)?) + .ok_or_else(invalid_arguments)?; + native_int(capacity)?; let mut wavelet_transform = WaveletTransform::new( capacity, decomposition_level, @@ -370,21 +415,33 @@ pub fn perform_wavelet_transform( extension, data.len(), ); + let mut lengths = vec![0 as c_int; decomposition_level + 1]; let res = unsafe { let output = wavelet_transform.coefficients.as_mut_ptr() as *mut c_double; - let decomposition_lengths = - wavelet_transform.decomposition_lengths.as_mut_ptr() as *mut c_int; + let decomposition_lengths = lengths.as_mut_ptr(); data_handler::perform_wavelet_transform( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, + native_int(data.len())?, wavelet as c_int, - decomposition_level as c_int, + native_int(decomposition_level)?, extension as c_int, output, decomposition_lengths, ) }; check_brainflow_exit_code(res)?; + let mut total = 0usize; + for &length in &lengths { + if length <= 0 { + return Err(invalid_arguments()); + } + total = total.checked_add(length as usize).ok_or_else(invalid_arguments)?; + } + if total > capacity { + return Err(invalid_arguments()); + } + unsafe { wavelet_transform.coefficients.set_len(total); } + wavelet_transform.decomposition_lengths = lengths.into_iter().map(|v| v as usize).collect(); Ok(wavelet_transform) } @@ -401,10 +458,10 @@ pub fn restore_data_from_wavelet_detailed_coeffs( let res = unsafe { data_handler::restore_data_from_wavelet_detailed_coeffs( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, + native_int(data.len())?, wavelet as c_int, - decomposition_level as c_int, - level_to_restore as c_int, + native_int(decomposition_level)?, + native_int(level_to_restore)?, output.as_mut_ptr() as *mut c_double, ) }; @@ -425,8 +482,8 @@ pub fn detect_peaks_z_score( let res = unsafe { data_handler::detect_peaks_z_score( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - lag as c_int, + native_int(data.len())?, + native_int(lag)?, threshold as c_double, influence as c_double, output.as_mut_ptr() as *mut c_double, @@ -440,16 +497,36 @@ pub fn detect_peaks_z_score( /// Perform inverse wavelet transform. pub fn perform_inverse_wavelet_transform(wavelet_transform: WaveletTransform) -> Result> { let mut wavelet_transform = wavelet_transform; + let level = wavelet_transform.decomposition_level; + if level == 0 || level > 100 || wavelet_transform.decomposition_lengths.len() != level + 1 + || wavelet_transform.original_data_len == 0 + || wavelet_transform.original_data_len > wavelet_transform.coefficients.len() { + return Err(invalid_arguments()); + } + let mut lengths = Vec::with_capacity(level + 1); + let mut total = 0usize; + for &length in &wavelet_transform.decomposition_lengths { + if length == 0 { return Err(invalid_arguments()); } + lengths.push(native_int(length)?); + total = total.checked_add(length).ok_or_else(invalid_arguments)?; + } + if total != wavelet_transform.coefficients.len() { + return Err(invalid_arguments()); + } + native_int(wavelet_transform.original_data_len)?; let mut output = Vec::::with_capacity(wavelet_transform.original_data_len); let res = unsafe { - data_handler::perform_inverse_wavelet_transform( + data_handler::perform_inverse_wavelet_transform_checked( wavelet_transform.coefficients.as_mut_ptr() as *mut c_double, - wavelet_transform.original_data_len as c_int, + native_int(wavelet_transform.coefficients.len())?, + native_int(wavelet_transform.original_data_len)?, wavelet_transform.wavelet as c_int, - wavelet_transform.decomposition_level as c_int, + native_int(wavelet_transform.decomposition_level)?, wavelet_transform.extension as c_int, - wavelet_transform.decomposition_lengths.as_ptr() as *mut c_int, + lengths.as_mut_ptr(), + native_int(lengths.len())?, output.as_mut_ptr() as *mut c_double, + native_int(wavelet_transform.original_data_len)?, ) }; check_brainflow_exit_code(res)?; @@ -470,9 +547,9 @@ pub fn perform_wavelet_denoising( let res = unsafe { data_handler::perform_wavelet_denoising( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, + native_int(data.len())?, wavelet as c_int, - decomposition_level as c_int, + native_int(decomposition_level)?, wavelet_denoising as c_int, wavelet_threshold as c_int, extension as c_int, @@ -484,7 +561,7 @@ pub fn perform_wavelet_denoising( } /// Calculate filters and the corresponding eigenvalues using the Common Spatial Patterns. -pub fn get_csp( +pub fn get_csp( data: &Array3, labels: &Array1, ) -> Result<(Array2, Array1)> { @@ -492,6 +569,11 @@ pub fn get_csp( let n_epochs = shape[0]; let n_channels = shape[1]; let n_times = shape[2]; + if labels.len() != n_epochs || n_epochs == 0 || n_channels == 0 || n_times == 0 { + return Err(invalid_arguments()); + } + checked_product(n_channels, n_channels)?; + native_int(data.len())?; let data: Vec = data.into_iter().cloned().collect(); let labels: Vec = labels.into_iter().cloned().collect(); @@ -503,9 +585,9 @@ pub fn get_csp( data_handler::get_csp( data.as_ptr() as *const c_double, labels.as_ptr() as *const c_double, - n_epochs as c_int, - n_channels as c_int, - n_times as c_int, + native_int(n_epochs)?, + native_int(n_channels)?, + native_int(n_times)?, output_filters.as_mut_ptr() as *mut c_double, output_eigenvalues.as_mut_ptr() as *mut c_double, ) @@ -523,11 +605,12 @@ pub fn get_csp( /// Perform data windowing. pub fn get_window(window_function: WindowOperations, window_len: usize) -> Result> { + native_int(window_len)?; let mut output = Vec::::with_capacity(window_len); let res = unsafe { data_handler::get_window( window_function as c_int, - window_len as c_int, + native_int(window_len)?, output.as_mut_ptr() as *mut c_double, ) }; @@ -544,7 +627,7 @@ pub fn perform_fft(data: &mut [f64], window_function: WindowOperations) -> Resul let res = unsafe { data_handler::perform_fft( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, + native_int(data.len())?, window_function as c_int, output_re.as_mut_ptr() as *mut c_double, output_im.as_mut_ptr() as *mut c_double, @@ -564,6 +647,10 @@ pub fn perform_fft(data: &mut [f64], window_function: WindowOperations) -> Resul /// Perform inverse FFT. pub fn perform_ifft(data: &[Complex64], original_data_len: usize) -> Result> { + native_int(original_data_len)?; + if data.len() < 2 || original_data_len != checked_product(data.len() - 1, 2)? { + return Err(invalid_arguments()); + } let mut restored_data = Vec::::with_capacity(original_data_len); let (mut input_re, mut input_im): (Vec, Vec) = data.iter().map(|d| (d.re, d.im)).unzip(); @@ -571,7 +658,7 @@ pub fn perform_ifft(data: &[Complex64], original_data_len: usize) -> Result Result let res = unsafe { data_handler::detrend( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, + native_int(data.len())?, detrend_operation as c_int, ) }; @@ -612,8 +699,8 @@ pub fn get_psd( let res = unsafe { data_handler::get_psd( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - sampling_rate as c_int, + native_int(data.len())?, + native_int(sampling_rate)?, window_function as c_int, amplitude.as_mut_ptr() as *mut c_double, frequency.as_mut_ptr() as *mut c_double, @@ -637,15 +724,19 @@ pub fn get_psd_welch( sampling_rate: usize, window_function: WindowOperations, ) -> Result { + native_int(nfft)?; + if nfft == 0 || nfft > data.len() || nfft % 2 != 0 || overlap >= nfft { + return Err(invalid_arguments()); + } let mut amplitude = Vec::::with_capacity(nfft / 2 + 1); let mut frequency = Vec::::with_capacity(nfft / 2 + 1); let res = unsafe { data_handler::get_psd_welch( data.as_mut_ptr() as *mut c_double, - data.len() as c_int, - nfft as c_int, - overlap as c_int, - sampling_rate as c_int, + native_int(data.len())?, + native_int(nfft)?, + native_int(overlap)?, + native_int(sampling_rate)?, window_function as c_int, amplitude.as_mut_ptr() as *mut c_double, frequency.as_mut_ptr() as *mut c_double, @@ -677,14 +768,13 @@ pub fn perform_ica_select_channels( ) -> Result<(Vec, Vec, Vec, Vec)> { let shape = data.shape(); let (rows, cols) = (channels.len(), shape[1]); - let mut raw_data = data - .outer_iter() - .enumerate() - .filter(|(i, _)| channels.contains(i)) - .map(|(_, x)| x) - .flatten() - .copied() - .collect::>(); + if num_components < 2 || num_components > rows || cols < 2 { + return Err(invalid_arguments()); + } + let mut raw_data = selected_data(&data, &channels)?; + checked_product(num_components, num_components)?; + checked_product(rows, num_components)?; + checked_product(cols, num_components)?; let mut temp_w = Vec::with_capacity(num_components * num_components); let mut temp_k = Vec::with_capacity(rows * num_components); @@ -694,9 +784,9 @@ pub fn perform_ica_select_channels( let res = unsafe { data_handler::perform_ica( raw_data.as_mut_ptr() as *mut c_double, - rows as c_int, - cols as c_int, - num_components as c_int, + native_int(rows)?, + native_int(cols)?, + native_int(num_components)?, temp_w.as_mut_ptr() as *mut c_double, temp_k.as_mut_ptr() as *mut c_double, temp_a.as_mut_ptr() as *mut c_double, @@ -704,11 +794,12 @@ pub fn perform_ica_select_channels( ) }; check_brainflow_exit_code(res)?; - //let w = Array2::from_shape_vec((num_components, num_components), temp_w); - //let k = Array2::from_shape_vec((num_components, rows), temp_k); - //let a = Array2::from_shape_vec((rows, num_components), temp_a); - //let s = Array2::from_shape_vec((num_components, cols), temp_s); - //Ok((w,k,a,s)) + unsafe { + temp_w.set_len(num_components * num_components); + temp_k.set_len(rows * num_components); + temp_a.set_len(rows * num_components); + temp_s.set_len(cols * num_components); + } Ok((temp_w, temp_k, temp_a, temp_s)) } @@ -722,7 +813,17 @@ pub fn perform_ica( perform_ica_select_channels(data, num_components, channels) } -/// Calculate avg and stddev of BandPowers across all channels, bands are 1-4,4-8,8-13,13-30,30-50. +/// Return normalized channel-mean band powers and coefficients of variation (population +/// stddev / mean of absolute channel powers). Zero-power bands have zero variation; +/// an all-zero total returns zero normalized powers. +/// Filtering removes DC and applies padded, initialized zero-phase 48-52 and 58-62 Hz +/// notches only when their upper edges are below 0.9 * Nyquist. Preprocessing is independent +/// of the requested output bands; no automatic passband is applied. Margins estimated from +/// the filter cascade's impulse tail are discarded at both ends; supply surrounding samples and account +/// for the resulting delay in live analysis. Without filtering there is no preprocessing +/// or trimming. At least max(8, 2 * get_nearest_power_of_two(sampling_rate)) samples must remain. +/// Data and edges must be finite. Bands require 0 <= start < stop <= Nyquist. +/// Mains notches also attenuate overlapping bands. pub fn get_custom_band_powers( data: Array2, bands: Vec, @@ -732,14 +833,7 @@ pub fn get_custom_band_powers( ) -> Result<(Vec, Vec)> { let shape = data.shape(); let (rows, cols) = (eeg_channels.len(), shape[1]); - let mut raw_data = data - .outer_iter() - .enumerate() - .filter(|(i, _)| eeg_channels.contains(i)) - .map(|(_, x)| x) - .flatten() - .copied() - .collect::>(); + let mut raw_data = selected_data(&data, &eeg_channels)?; let (mut x, mut y): (Vec<_>, Vec<_>) = bands.into_iter().map(|Band{freq_start, freq_stop}| (freq_start, freq_stop)).unzip(); @@ -749,12 +843,12 @@ pub fn get_custom_band_powers( let res = unsafe { data_handler::get_custom_band_powers( raw_data.as_mut_ptr() as *mut c_double, - rows as c_int, - cols as c_int, + native_int(rows)?, + native_int(cols)?, x.as_mut_ptr() as *mut c_double, y.as_mut_ptr() as *mut c_double, - x.len() as c_int, - sampling_rate as c_int, + native_int(x.len())?, + native_int(sampling_rate)?, apply_filters as c_int, avg_band_powers.as_mut_ptr() as *mut c_double, stddev_band_powers.as_mut_ptr() as *mut c_double, @@ -767,6 +861,8 @@ pub fn get_custom_band_powers( Ok((avg_band_powers, stddev_band_powers)) } +/// Calculate normalized mean powers for bands 2-4, 4-8, 8-13, 13-30, 30-45 Hz. +/// Uses get_custom_band_powers preprocessing, minimum retained length, and variation semantics. pub fn get_avg_band_powers( data: Array2, eeg_channels: Vec, @@ -790,7 +886,7 @@ pub fn get_band_power(psd: &mut Psd, band: Band) -> Result { data_handler::get_band_power( psd.amplitude.as_mut_ptr() as *mut c_double, psd.frequency.as_mut_ptr() as *mut c_double, - psd.amplitude.len() as c_int, + native_int(psd.amplitude.len())?, band.freq_start, band.freq_stop, &mut band_power, @@ -803,7 +899,7 @@ pub fn get_band_power(psd: &mut Psd, band: Band) -> Result { /// Calculate nearest power of two. pub fn get_nearest_power_of_two(value: usize) -> Result { let mut output = 0; - let res = unsafe { data_handler::get_nearest_power_of_two(value as c_int, &mut output) }; + let res = unsafe { data_handler::get_nearest_power_of_two(native_int(value)?, &mut output) }; check_brainflow_exit_code(res)?; Ok(output as usize) } @@ -830,9 +926,16 @@ pub fn read_file>(file_name: S) -> Result> { }; check_brainflow_exit_code(res)?; - unsafe { data.set_len(num_elements as usize) }; + if rows <= 0 || cols <= 0 { + return Err(invalid_arguments()); + } + let actual_len = checked_product(rows as usize, cols as usize)?; + if actual_len > num_elements as usize { + return Err(invalid_arguments()); + } + unsafe { data.set_len(actual_len) }; let data = ArrayBase::from_vec(data); - let data = data.into_shape((rows as usize, cols as usize)).unwrap(); + let data = data.into_shape((rows as usize, cols as usize))?; Ok(data) } @@ -850,8 +953,8 @@ where let res = unsafe { data_handler::write_file( data.as_mut_ptr() as *mut c_double, - rows as c_int, - cols as c_int, + native_int(rows)?, + native_int(cols)?, file_name.as_ptr(), file_mode.as_ptr(), ) @@ -893,9 +996,9 @@ pub fn get_activity_index( accel_x.as_ptr() as *const c_double, accel_y.as_ptr() as *const c_double, accel_z.as_ptr() as *const c_double, - data_len as c_int, - sampling_rate as c_int, - period as c_int, + native_int(data_len)?, + native_int(sampling_rate)?, + native_int(period)?, noise_var_x, noise_var_y, noise_var_z, @@ -953,13 +1056,56 @@ mod tests { println!("{:?}", data); let wavelet_data = perform_wavelet_transform(&mut data, WaveletTypes::Db3, 3, WaveletExtensionTypes::Periodic).unwrap(); - let restored_wavelet = perform_inverse_wavelet_transform(wavelet_data).unwrap(); + assert_eq!(wavelet_data.decomposition_lengths().len(), 4); + assert_eq!(wavelet_data.coefficients().len(), wavelet_data.decomposition_lengths().iter().sum::()); + let restored_wavelet = perform_inverse_wavelet_transform(wavelet_data.clone()).unwrap(); println!("{:?}", restored_wavelet); for (d, r) in data.iter().zip(restored_wavelet) { assert_relative_eq!(*d, r, max_relative = 1e-14); } } + #[test] + fn rejects_mismatched_buffers_before_native_access() { + let mut short = vec![1.0; 4]; + let mut long = vec![1.0; 8]; + assert!(calc_stddev(&mut short, 0, 8).is_err()); + assert!(get_railed_percentage(&mut short, 8, 24).is_err()); + assert!(get_heart_rate(&mut short, &mut long, 64, 1024).is_err()); + assert!(get_oxygen_level(&mut short, &mut long, 64, 1.0, 1.0, 1.0).is_err()); + assert!(perform_ifft(&[Complex64::new(1.0, 0.0); 3], 8).is_err()); + assert!(get_csp(&Array3::zeros((2, 2, 8)), &Array1::zeros(1)).is_err()); + assert!(native_int(c_int::MAX as usize + 1).is_err()); + let invalid = WaveletTransform::with_coefficients(vec![0.0; 4], 2, vec![2, 2], + WaveletTypes::Db3, WaveletExtensionTypes::Periodic, 8); + assert!(perform_inverse_wavelet_transform(invalid).is_err()); + for original_length in [4, c_int::MAX as usize / 2, c_int::MAX as usize] { + let oversized = WaveletTransform::with_coefficients(vec![0.0; 2], 1, vec![1, 1], + WaveletTypes::Haar, WaveletExtensionTypes::Periodic, original_length); + assert!(perform_inverse_wavelet_transform(oversized).is_err()); + } + } + + #[test] + fn channel_selection_preserves_order_and_duplicates() { + let data = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]; + assert_eq!(selected_data(&data, &[2, 0, 2]).unwrap(), vec![5.0, 6.0, 1.0, 2.0, 5.0, 6.0]); + assert!(selected_data(&data, &[0, 3]).is_err()); + } + + #[test] + fn ica_returns_initialized_output_vectors() { + let data = Array2::from_shape_fn((2, 1024), |(row, col)| { + let t = col as f64 / 256.0; + let first = (2.0 * PI * 7.0 * t).sin(); + let second = (2.0 * PI * 13.0 * t).sin().powi(3); + if row == 0 { first + 0.3 * second } else { 0.2 * first + second } + }); + let (w, k, a, s) = perform_ica_select_channels(data, 2, vec![1, 0]).unwrap(); + assert_eq!((w.len(), k.len(), a.len(), s.len()), (4, 4, 4, 2048)); + assert!(w.iter().chain(&k).chain(&a).chain(&s).all(|v| v.is_finite())); + } + #[test] fn read_written_data_is_same_as_input() { let data = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]; diff --git a/rust_package/brainflow/src/ffi/data_handler.rs b/rust_package/brainflow/src/ffi/data_handler.rs index d66ef000d..c62bec941 100644 --- a/rust_package/brainflow/src/ffi/data_handler.rs +++ b/rust_package/brainflow/src/ffi/data_handler.rs @@ -4,6 +4,18 @@ extern "C" { + pub fn perform_inverse_wavelet_transform_checked( + wavelet_coeffs: *mut ::std::os::raw::c_double, + coeff_count: ::std::os::raw::c_int, + original_data_len: ::std::os::raw::c_int, + wavelet: ::std::os::raw::c_int, + decomposition_level: ::std::os::raw::c_int, + extension: ::std::os::raw::c_int, + decomposition_lengths: *mut ::std::os::raw::c_int, + lengths_count: ::std::os::raw::c_int, + output_data: *mut ::std::os::raw::c_double, + output_count: ::std::os::raw::c_int, + ) -> ::std::os::raw::c_int; pub fn perform_lowpass( data: *mut f64, data_len: ::std::os::raw::c_int, diff --git a/src/data_handler/build.cmake b/src/data_handler/build.cmake index 7aa3d5b50..9addf51cd 100644 --- a/src/data_handler/build.cmake +++ b/src/data_handler/build.cmake @@ -28,6 +28,9 @@ endif (CMAKE_SIZEOF_VOID_P EQUAL 8) SET (DATA_HANDLER_SRC ${CMAKE_CURRENT_SOURCE_DIR}/src/data_handler/data_handler.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/data_handler/signal_processing.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/data_handler/signal_metrics.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/data_handler/decomposition.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/data_handler/fastica.cpp ) diff --git a/src/data_handler/data_handler.cpp b/src/data_handler/data_handler.cpp index c13fa7c53..35dfec88d 100644 --- a/src/data_handler/data_handler.cpp +++ b/src/data_handler/data_handler.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -15,25 +16,15 @@ #include "brainflow_version.h" #include "common_data_handler_helpers.h" #include "data_handler.h" -#include "downsample_operators.h" -#include "rolling_filter.h" -#include "wavelet_helpers.h" +#include "filter_helpers.h" + #include "window_functions.h" #include "DspFilters/Dsp.h" -#include "Eigen/Dense" - -#include "wauxlib.h" -#include "wavelib.h" - -#include "kiss_fftr.h" - #include "spdlog/sinks/null_sink.h" #include "spdlog/spdlog.h" -#include "fastica.h" - #ifdef _OPENMP #include #endif @@ -52,7 +43,6 @@ std::shared_ptr data_logger = spdlog::stderr_logger_mt (LOGGER_N // its only for logging methods, other methods can be executed simultaneously std::mutex data_mutex; - int log_message_data_handler (int log_level, char *log_message) { // its a method for loggging from high level @@ -124,1684 +114,318 @@ int set_log_level_data_handler (int level) } -int perform_lowpass (double *data, int data_len, int sampling_rate, double cutoff, int order, - int filter_type, double ripple) -{ - if ((order < 1) || (order > MAX_FILTER_ORDER) || (!data) || (cutoff < 0) || (sampling_rate < 1)) - { - data_logger->error ( - "Order must be from 1-8 and data cannot be empty. Order:{} , Data:{} , Cutoff:{}", - order, (data != NULL), cutoff); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - double *filter_data[1]; - filter_data[0] = data; - Dsp::Filter *f = NULL; - - switch (static_cast (filter_type)) - { - case FilterTypes::BUTTERWORTH: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BUTTERWORTH_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::CHEBYSHEV_TYPE_1: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BESSEL: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BESSEL_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - default: - data_logger->error ("Filter type {} is Invalid", filter_type); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - Dsp::Params params; - params[0] = sampling_rate; // sample rate - params[1] = order; // order - params[2] = cutoff; // cutoff - if ((filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1) || - (filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE)) - { - params[3] = ripple; // ripple - } - f->setParams (params); - f->process (data_len, filter_data); - if ((filter_type == (int)FilterTypes::BUTTERWORTH_ZERO_PHASE) || - (filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE) || - (filter_type == (int)FilterTypes::BESSEL_ZERO_PHASE)) - { - reverse_array (data, data_len); - f->process (data_len, filter_data); - reverse_array (data, data_len); - } - delete f; - - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int perform_highpass (double *data, int data_len, int sampling_rate, double cutoff, int order, - int filter_type, double ripple) -{ - if ((order < 1) || (order > MAX_FILTER_ORDER) || (!data) || (cutoff < 0) || (sampling_rate < 1)) - { - data_logger->error ( - "Order must be from 1-8 and data cannot be empty. Order:{} , Data:{} , Cutoff:{}", - order, (data != NULL), cutoff); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - Dsp::Filter *f = NULL; - double *filter_data[1]; - filter_data[0] = data; - - switch (static_cast (filter_type)) - { - case FilterTypes::BUTTERWORTH: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BUTTERWORTH_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::CHEBYSHEV_TYPE_1: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BESSEL: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BESSEL_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - default: - data_logger->error ("Filter type {} is Invalid", filter_type); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - Dsp::Params params; - params[0] = sampling_rate; // sample rate - params[1] = order; // order - params[2] = cutoff; // cutoff - if ((filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1) || - (filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE)) - { - params[3] = ripple; // ripple - } - f->setParams (params); - f->process (data_len, filter_data); - if ((filter_type == (int)FilterTypes::BUTTERWORTH_ZERO_PHASE) || - (filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE) || - (filter_type == (int)FilterTypes::BESSEL_ZERO_PHASE)) - { - reverse_array (data, data_len); - f->process (data_len, filter_data); - reverse_array (data, data_len); - } - delete f; - - return (int)BrainFlowExitCodes::STATUS_OK; -} +// inside wavelib inverse transform uses internal state from direct transform, dirty hack to restore +// it here -int perform_bandpass (double *data, int data_len, int sampling_rate, double start_freq, - double stop_freq, int order, int filter_type, double ripple) +int get_window (int window_function, int window_len, double *output_window) { - if ((order < 1) || (order > MAX_FILTER_ORDER) || (!data) || (stop_freq <= start_freq) || - (start_freq < 0) || (sampling_rate < 1)) + if ((window_len <= 0) || (window_function < 0) || (output_window == NULL)) { - data_logger->error ("Order must be from 1-8 and data cannot be empty. Order:{} , Data:{} , " - "Start Freq:{} , Stop Freq:{}", - order, (data != NULL), start_freq, stop_freq); + data_logger->error ("Please check the arguments: data_len must be > 0, window_function >= " + "0 and output_window cannot be empty."); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - - double center_freq = (start_freq + stop_freq) / 2.0; - double band_width = stop_freq - start_freq; - Dsp::Filter *f = NULL; - double *filter_data[1]; - filter_data[0] = data; - - switch (static_cast (filter_type)) + // from https://www.edn.com/windowing-functions-improve-fft-results-part-i/ + switch (static_cast (window_function)) { - case FilterTypes::BUTTERWORTH: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BUTTERWORTH_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::CHEBYSHEV_TYPE_1: - f = new Dsp::FilterDesign, 1> (); + case WindowOperations::NO_WINDOW: + no_window_function (window_len, output_window); break; - case FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); + case WindowOperations::HAMMING: + hamming_function (window_len, output_window); break; - case FilterTypes::BESSEL: - f = new Dsp::FilterDesign, 1> (); + case WindowOperations::HANNING: + hanning_function (window_len, output_window); break; - case FilterTypes::BESSEL_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); + case WindowOperations::BLACKMAN_HARRIS: + blackman_harris_function (window_len, output_window); break; default: - data_logger->error ("Filter type {} is Invalid. ", filter_type); + data_logger->error ("Invalid Window function. Window function:{}", window_function); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - - Dsp::Params params; - params.clear (); - params[0] = sampling_rate; // sample rate - params[1] = order; // order - params[2] = center_freq; // center freq - params[3] = band_width; // band width - if ((filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1) || - (filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE)) - { - // band pass and band stop chebyshev designs take 5 params and expect the ripple after - // the band width, unlike the low pass and high pass designs which take 4 - params[4] = ripple; // ripple - } - f->setParams (params); - f->process (data_len, filter_data); - if ((filter_type == (int)FilterTypes::BUTTERWORTH_ZERO_PHASE) || - (filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE) || - (filter_type == (int)FilterTypes::BESSEL_ZERO_PHASE)) - { - reverse_array (data, data_len); - f->process (data_len, filter_data); - reverse_array (data, data_len); - } - delete f; - return (int)BrainFlowExitCodes::STATUS_OK; } -int perform_bandstop (double *data, int data_len, int sampling_rate, double start_freq, - double stop_freq, int order, int filter_type, double ripple) -{ - if ((order < 1) || (order > MAX_FILTER_ORDER) || (!data) || (stop_freq <= start_freq) || - (start_freq < 0) || (sampling_rate < 1)) - { - data_logger->error ("Order must be from 1-8 and data cannot be empty. Order:{} , Data:{} , " - "Start Freq:{} , Stop Freq:{}", - order, (data != NULL), start_freq, stop_freq); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - double center_freq = (start_freq + stop_freq) / 2.0; - double band_width = stop_freq - start_freq; - Dsp::Filter *f = NULL; - double *filter_data[1]; - filter_data[0] = data; - - switch (static_cast (filter_type)) - { - case FilterTypes::BUTTERWORTH: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BUTTERWORTH_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::CHEBYSHEV_TYPE_1: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BESSEL: - f = new Dsp::FilterDesign, 1> (); - break; - case FilterTypes::BESSEL_ZERO_PHASE: - f = new Dsp::FilterDesign, 1> (); - break; - default: - data_logger->error ("Filter type {} is Invalid", filter_type); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - Dsp::Params params; - params.clear (); - params[0] = sampling_rate; // sample rate - params[1] = order; // order - params[2] = center_freq; // center freq - params[3] = band_width; // band width - if ((filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1) || - (filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE)) - { - // band pass and band stop chebyshev designs take 5 params and expect the ripple after - // the band width, unlike the low pass and high pass designs which take 4 - params[4] = ripple; // ripple - } - f->setParams (params); - f->process (data_len, filter_data); - if ((filter_type == (int)FilterTypes::BUTTERWORTH_ZERO_PHASE) || - (filter_type == (int)FilterTypes::CHEBYSHEV_TYPE_1_ZERO_PHASE) || - (filter_type == (int)FilterTypes::BESSEL_ZERO_PHASE)) - { - reverse_array (data, data_len); - f->process (data_len, filter_data); - reverse_array (data, data_len); - } - delete f; - - return (int)BrainFlowExitCodes::STATUS_OK; -} -int remove_environmental_noise (double *data, int data_len, int sampling_rate, int noise_type) +int get_band_power (double *ampl, double *freq, int data_len, double freq_start, double freq_end, + double *band_power) { - if ((data_len < 1) || (sampling_rate < 1) || (!data)) + if ((ampl == NULL) || (freq == NULL) || (band_power == NULL) || (data_len < 2) || + !std::isfinite (freq_start) || !std::isfinite (freq_end) || (freq_start < 0.0) || + (freq_start >= freq_end)) { + data_logger->error ("Band bounds must be finite and satisfy 0 <= start < end."); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - - int res = (int)BrainFlowExitCodes::STATUS_OK; - - switch (static_cast (noise_type)) + for (int i = 0; i < data_len; i++) { - case NoiseTypes::FIFTY: - res = perform_bandstop (data, data_len, sampling_rate, 48.0, 52.0, 4, - (int)FilterTypes::BUTTERWORTH_ZERO_PHASE, 0.0); - break; - case NoiseTypes::SIXTY: - res = perform_bandstop (data, data_len, sampling_rate, 58.0, 62.0, 4, - (int)FilterTypes::BUTTERWORTH_ZERO_PHASE, 0.0); - break; - case NoiseTypes::FIFTY_AND_SIXTY: - res = perform_bandstop (data, data_len, sampling_rate, 48.0, 52.0, 4, - (int)FilterTypes::BUTTERWORTH_ZERO_PHASE, 0.0); - if (res == (int)BrainFlowExitCodes::STATUS_OK) - { - res = perform_bandstop (data, data_len, sampling_rate, 58.0, 62.0, 4, - (int)FilterTypes::BUTTERWORTH, 0.0); - } - break; - default: - data_logger->error ("Invalid noise type"); + if (!std::isfinite (freq[i]) || !std::isfinite (ampl[i]) || (ampl[i] < 0.0) || + (freq[i] < 0.0) || ((i > 0) && (freq[i] <= freq[i - 1]))) + { + data_logger->error ("PSD must be finite and nonnegative with increasing frequencies."); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } } - - return res; -} - -int perform_rolling_filter (double *data, int data_len, int period, int agg_operation) -{ - if ((data == NULL) || (period <= 0)) + if ((freq_start < freq[0]) || (freq_end > freq[data_len - 1])) { - data_logger->error ("Period must be >= 0 and data cannot be empty. Data:{} , Period:{}", - period, (data != NULL)); + data_logger->error ("Band bounds must be within the PSD frequency range."); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - RollingFilter *filter = NULL; - switch (static_cast (agg_operation)) - { - case AggOperations::MEAN: - filter = new RollingAverage (period); - break; - case AggOperations::MEDIAN: - filter = new RollingMedian (period); - break; - case AggOperations::EACH: - return (int)BrainFlowExitCodes::STATUS_OK; - default: - data_logger->error ("Invalid aggregate opteration:{}", agg_operation); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - for (int i = 0; i < data_len; i++) + double res = 0.0; + for (int i = 0; (i < data_len - 1) && (freq[i] < freq_end); i++) { - filter->add_data (data[i]); - data[i] = filter->get_value (); + const double left = std::max (freq_start, freq[i]); + const double right = std::min (freq_end, freq[i + 1]); + if (right > left) + { + const double width = freq[i + 1] - freq[i]; + const double left_fraction = (left - freq[i]) / width; + const double right_fraction = (right - freq[i]) / width; + const double left_power = (1.0 - left_fraction) * ampl[i] + left_fraction * ampl[i + 1]; + const double right_power = + (1.0 - right_fraction) * ampl[i] + right_fraction * ampl[i + 1]; + res += (right - left) * (0.5 * left_power + 0.5 * right_power); + } } - delete filter; - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int perform_downsampling ( - double *data, int data_len, int period, int agg_operation, double *output_data) -{ - if ((data == NULL) || (data_len <= 0) || (period <= 0) || (output_data == NULL)) + if (!std::isfinite (res)) { - data_logger->error ("Period must be >= 0 and data and output_data cannot be NULL."); + data_logger->error ("Band power overflowed."); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - double (*downsampling_op) (double *, int); - switch (static_cast (agg_operation)) - { - case AggOperations::MEAN: - downsampling_op = downsample_mean; - break; - case AggOperations::MEDIAN: - downsampling_op = downsample_median; - break; - case AggOperations::EACH: - downsampling_op = downsample_each; - break; - default: - data_logger->error ( - "Invalid aggregate opteration:{}. Must be mean,median, or each", agg_operation); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - int num_values = data_len / period; - for (int i = 0; i < num_values; i++) - { - output_data[i] = downsampling_op (data + i * period, period); - } + *band_power = res; return (int)BrainFlowExitCodes::STATUS_OK; } -// https://github.com/rafat/wavelib/wiki/DWT-Example-Code -int perform_wavelet_transform (double *data, int data_len, int wavelet, int decomposition_level, - int extension, double *output_data, int *decomposition_lengths) +namespace { - std::string wavelet_str = get_wavelet_name (wavelet); - std::string extension_str = get_extension_type (extension); - if ((data == NULL) || (data_len <= 0) || (wavelet_str.empty ()) || (output_data == NULL) || - (extension_str.empty ()) || (decomposition_lengths == NULL) || (decomposition_level <= 0)) - { - data_logger->error ("Please review arguments."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - wave_object obj = NULL; - wt_object wt = NULL; - - try + bool prepare_band_power (int fs, int apply, int &nfft, double low, double high, int mains, + std::vector §ions, int &guard) { - obj = wave_init (wavelet_str.c_str ()); - wt = wt_init (obj, "dwt", data_len, decomposition_level); - setDWTExtension (wt, extension_str.c_str ()); - setWTConv (wt, "direct"); - dwt (wt, data); - for (int i = 0; i < wt->outlength; i++) + if (fs < 1 || fs > std::numeric_limits::max () / 4 || apply < 0 || apply > 1 || + !std::isfinite (low) || !std::isfinite (high) || low < 0 || high < 0 || + low >= fs / 2.0 || high >= fs / 2.0 || (low > 0 && high > 0 && low >= high) || + mains < -1 || mains > 3) + return false; + if (nfft == 0) { - output_data[i] = wt->output[i]; + if (get_nearest_power_of_two (fs, &nfft)) + return false; + nfft = std::max (8, 2 * nfft); } - for (int i = 0; i < decomposition_level + 1; i++) + if (nfft < 2 || (nfft & (nfft - 1))) + return false; + guard = 0; + if (!apply) + return true; + double radius = 0.0; + for (int i = 0; i < 2; i++) { - decomposition_lengths[i] = wt->length[i]; + const double center = i == 0 ? 50.0 : 60.0; + // Automatic mains removal excludes the poorly conditioned region next to Nyquist. + const bool enabled = mains == -1 ? center + 2 < 0.9 * fs / 2.0 : (mains & (1 << i)); + if (enabled && + !filter_helpers::design (3, fs, center - 2, center + 2, 4, 0, 0, sections, radius)) + return false; } - wave_free (obj); - obj = NULL; - wt_free (wt); - wt = NULL; - } - catch (const std::exception &e) - { - if (obj) + if (low > 0 && high > 0) { - wave_free (obj); - obj = NULL; + if (!filter_helpers::design (2, fs, low, high, 4, 0, 0, sections, radius)) + return false; } - if (wt) + else if (low > 0 || high > 0) { - wt_free (wt); - wt = NULL; + if (!filter_helpers::design ( + low > 0 ? 1 : 0, fs, low > 0 ? low : high, 0, 4, 0, 0, sections, radius)) + return false; } - // more likely exception here occured because input buffer is to small to perform wavelet - // transform - data_logger->error ("Exception in wavelib: {}", e.what ()); - return (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; + return filter_helpers::settling_samples (sections, radius, guard); } - return (int)BrainFlowExitCodes::STATUS_OK; } -// inside wavelib inverse transform uses internal state from direct transform, dirty hack to restore -// it here -int perform_inverse_wavelet_transform (double *wavelet_coeffs, int original_data_len, int wavelet, - int decomposition_level, int extension, int *decomposition_lengths, double *output_data) +int get_band_power_settings (int fs, int apply, int nfft, double low, double high, int mains, + int *effective_nfft, int *edge_samples) +try { - std::string wavelet_str = get_wavelet_name (wavelet); - std::string extension_str = get_extension_type (extension); - if ((wavelet_coeffs == NULL) || (decomposition_level <= 0) || (original_data_len <= 0) || - (output_data == NULL) || (wavelet_str.empty ()) || (extension_str.empty ()) || - (decomposition_lengths == NULL)) - { - data_logger->error ("Please review arguments."); + if (!effective_nfft || !edge_samples) return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - wave_object obj = NULL; - wt_object wt = NULL; - - try - { - obj = wave_init (wavelet_str.c_str ()); - wt = wt_init (obj, "dwt", original_data_len, decomposition_level); - setDWTExtension (wt, extension_str.c_str ()); - setWTConv (wt, "direct"); - int total_len = 0; - for (int i = 0; i < decomposition_level + 1; i++) - { - wt->length[i] = decomposition_lengths[i]; - total_len += decomposition_lengths[i]; - } - for (int i = 0; i < total_len; i++) - { - wt->output[i] = wavelet_coeffs[i]; - } - idwt (wt, output_data); - wave_free (obj); - obj = NULL; - wt_free (wt); - wt = NULL; - } - catch (const std::exception &e) - { - if (obj) - { - wave_free (obj); - obj = NULL; - } - if (wt) - { - wt_free (wt); - wt = NULL; - } - data_logger->error ("Exception in wavelib: {}", e.what ()); - // more likely exception here occured because input buffer is to small to perform wavelet - // transform - return (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; - } + std::vector sections; + int guard; + if (!prepare_band_power (fs, apply, nfft, low, high, mains, sections, guard) || + (int64_t)nfft + 2LL * guard > std::numeric_limits::max ()) + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + *effective_nfft = nfft; + *edge_samples = guard; return (int)BrainFlowExitCodes::STATUS_OK; } +catch (...) +{ + return (int)BrainFlowExitCodes::GENERAL_ERROR; +} + +int get_custom_band_powers (double *raw_data, int rows, int cols, double *start_freqs, + double *stop_freqs, int num_bands, int sampling_rate, int apply_filters, + double *avg_band_powers, double *stddev_band_powers) +{ + return get_custom_band_powers_with_options (raw_data, rows, cols, start_freqs, stop_freqs, + num_bands, sampling_rate, apply_filters, 0, 0, 0, -1, apply_filters ? 1 : 0, + avg_band_powers, stddev_band_powers); +} -int perform_wavelet_denoising (double *data, int data_len, int wavelet, int decomposition_level, - int wavelet_denoising, int threshold, int extenstion_type, int noise_level) +int get_custom_band_powers_with_options (double *raw_data, int rows, int cols, double *start_freqs, + double *stop_freqs, int num_bands, int sampling_rate, int apply_filters, int nfft, + double low_cutoff, double high_cutoff, int mains, int detrend_operation, + double *avg_band_powers, double *stddev_band_powers) +try { - std::string wavelet_str = get_wavelet_name (wavelet); - std::string denoising_str = get_wavelet_denoising_type (wavelet_denoising); - std::string threshold_str = get_threshold_type (threshold); - std::string extension_str = get_extension_type (extenstion_type); - std::string noise_str = get_noise_estimation_type (noise_level); - if ((data == NULL) || (data_len <= 0) || (decomposition_level <= 0) || (wavelet_str.empty ()) || - (denoising_str.empty ()) || (threshold_str.empty ()) || (extension_str.empty ()) || - (noise_str.empty ())) + if ((sampling_rate < 1) || (sampling_rate > std::numeric_limits::max () / 4) || + (raw_data == NULL) || (rows < 1) || (cols < 1) || (avg_band_powers == NULL) || + (stddev_band_powers == NULL) || (start_freqs == NULL) || (stop_freqs == NULL) || + (num_bands < 1) || detrend_operation < 0 || detrend_operation > 2 || + (int64_t)rows * cols > std::numeric_limits::max () || + (int64_t)rows * num_bands > std::numeric_limits::max ()) { - data_logger->error ("Please review arguments."); + data_logger->error ("Please review your arguments."); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - denoise_object obj = NULL; - double *temp = new double[data_len]; - try + const double nyquist = sampling_rate / 2.0; + for (int i = 0; i < num_bands; i++) { - obj = denoise_init (data_len, decomposition_level, wavelet_str.c_str ()); - setDenoiseMethod (obj, denoising_str.c_str ()); - setDenoiseWTMethod (obj, "dwt"); - setDenoiseWTExtension (obj, extension_str.c_str ()); - setDenoiseParameters (obj, threshold_str.c_str (), noise_str.c_str ()); - denoise (obj, data, temp); - for (int i = 0; i < data_len; i++) + if (!std::isfinite (start_freqs[i]) || !std::isfinite (stop_freqs[i]) || + (start_freqs[i] < 0.0) || (start_freqs[i] >= stop_freqs[i]) || + (stop_freqs[i] > nyquist)) { - data[i] = temp[i]; + data_logger->error ("Bands must satisfy 0 <= start < end <= Nyquist."); + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - delete[] temp; - temp = NULL; - denoise_free (obj); - obj = NULL; } - catch (const std::exception &e) + + int guard = 0; + std::vector sections; + if (!prepare_band_power ( + sampling_rate, apply_filters, nfft, low_cutoff, high_cutoff, mains, sections, guard)) + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + const int64_t required = (int64_t)nfft + 2 * (int64_t)guard; + if (cols < required) { - if (temp) - { - delete[] temp; - temp = NULL; - } - if (obj) - { - denoise_free (obj); - obj = NULL; - } - // more likely exception here occured because input buffer is to small to perform wavelet - // transform - data_logger->error ("Exception in wavelib: {}", e.what ()); + data_logger->error ("Band powers need at least {} samples: {} for Welch and {} at each " + "edge for filter settling; received {}.", + required, nfft, guard, cols); return (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; } - return (int)BrainFlowExitCodes::STATUS_OK; -} + const int retained = cols - 2 * guard; + std::vector exit_codes (rows, (int)BrainFlowExitCodes::STATUS_OK); + std::vector> bands (num_bands, std::vector (rows, 0.0)); -int get_csp (const double *data, const double *labels, int n_epochs, int n_channels, int n_times, - double *output_w, double *output_d) -{ - if ((!data) || (!labels) || n_epochs <= 0 || n_channels <= 0 || n_times <= 0) - { - data_logger->error ("Invalid function arguments provided. Please verify that all integer " - "arguments are positive and data and labels arrays aren't empty."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - try +#pragma omp parallel for + for (int i = 0; i < rows; i++) { - Eigen::Matrix sum1 ( - n_channels, n_channels); - Eigen::Matrix sum2 ( - n_channels, n_channels); - - sum1.setZero (); - sum2.setZero (); - - int n_class1 = 0; - int n_class2 = 0; - - // Compute an averaged covariance matrix for each class - for (int e = 0; e < n_epochs; e++) + try { - Eigen::Matrix X ( - n_channels, n_times); - for (int c = 0; c < n_channels; c++) + const double *channel = raw_data + (size_t)i * cols; + std::vector thread_data (channel, channel + cols); + for (double sample : thread_data) { - for (int t = 0; t < n_times; t++) + if (!std::isfinite (sample)) { - X (c, t) = data[e * n_channels * n_times + c * n_times + t]; + exit_codes[i] = (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + break; } } - - // center data - for (int i = 0; i < n_channels; i++) + if (exit_codes[i] != (int)BrainFlowExitCodes::STATUS_OK) { - double ctr = X.row (i).mean (); - for (int j = 0; j < n_times; j++) - { - X (i, j) -= ctr; - } + continue; + } + exit_codes[i] = detrend (thread_data.data (), cols, detrend_operation); + if (exit_codes[i] != (int)BrainFlowExitCodes::STATUS_OK) + continue; + if (!sections.empty () && + !band_power_helpers::filter_with_padding (thread_data, sections, guard)) + { + exit_codes[i] = (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + continue; } - // For centered data cov(X) = (X * X_T) / n - switch (int (labels[e])) + std::vector ampls (nfft / 2 + 1); + std::vector freqs (nfft / 2 + 1); + // Filter the complete input first, then exclude the settling margins from Welch. + exit_codes[i] = + get_psd_welch (thread_data.data () + guard, retained, nfft, (int)(4LL * nfft / 5), + sampling_rate, (int)WindowOperations::HANNING, ampls.data (), freqs.data ()); + for (int band_num = 0; band_num < num_bands; band_num++) { - case 0: - sum1 += ((X * X.transpose ()).eval ()) / double (n_times); - n_class1++; - break; - case 1: - sum2 += ((X * X.transpose ()).eval ()) / double (n_times); - n_class2++; - break; - default: - data_logger->error ("Invalid class label. Current class label: {}", labels[e]); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + if (exit_codes[i] != (int)BrainFlowExitCodes::STATUS_OK) + { break; + } + exit_codes[i] = get_band_power (ampls.data (), freqs.data (), nfft / 2 + 1, + start_freqs[band_num], stop_freqs[band_num], &bands[band_num][i]); } } + catch (...) + { + exit_codes[i] = (int)BrainFlowExitCodes::GENERAL_ERROR; + } + } - sum1 /= double (n_class1); - sum2 /= double (n_class2); + for (int code : exit_codes) + { + if (code != (int)BrainFlowExitCodes::STATUS_OK) + { + return code; + } + } - // Compute the CSP filters - Eigen::GeneralizedSelfAdjointEigenSolver< - Eigen::Matrix> - ges (sum1, sum1 + sum2); + // A common scale preserves channel weighting while avoiding overflow when accumulating + // powers or squaring deviations. This is still mean absolute power followed by normalization. + double scale = 0.0; + for (const auto &band : bands) + { + for (double power : band) + { + scale = std::max (scale, power); + } + } + if (scale == 0.0) + { + std::fill (avg_band_powers, avg_band_powers + num_bands, 0.0); + std::fill (stddev_band_powers, stddev_band_powers + num_bands, 0.0); + return (int)BrainFlowExitCodes::STATUS_OK; + } - for (int i = 0; i < n_channels; i++) + std::vector means (num_bands, 0.0); + std::vector deviations (num_bands, 0.0); + double total = 0.0; + for (int i = 0; i < num_bands; i++) + { + for (double power : bands[i]) { - output_d[i] = ges.eigenvalues () (i); - for (int j = 0; j < n_channels; j++) - { - output_w[i * n_channels + j] = ges.eigenvectors () (j, i); - } + means[i] += (power / scale) / rows; + } + for (double power : bands[i]) + { + const double difference = power / scale - means[i]; + deviations[i] += difference * difference / rows; } + total += means[i]; } - catch (...) + for (int i = 0; i < num_bands; i++) { - data_logger->error ("Error with doing CSP filtering."); - return (int)BrainFlowExitCodes::GENERAL_ERROR; + avg_band_powers[i] = means[i] / total; + // The second output is the population coefficient of variation of absolute channel + // powers, not the standard deviation of the normalized averages. Define silent bands as 0. + stddev_band_powers[i] = (means[i] > 0.0) ? std::sqrt (deviations[i]) / means[i] : 0.0; } return (int)BrainFlowExitCodes::STATUS_OK; } -int get_window (int window_function, int window_len, double *output_window) +catch (...) { - if ((window_len <= 0) || (window_function < 0) || (output_window == NULL)) - { - data_logger->error ("Please check the arguments: data_len must be > 0, window_function >= " - "0 and output_window cannot be empty."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - // from https://www.edn.com/windowing-functions-improve-fft-results-part-i/ - switch (static_cast (window_function)) - { - case WindowOperations::NO_WINDOW: - no_window_function (window_len, output_window); - break; - case WindowOperations::HAMMING: - hamming_function (window_len, output_window); - break; - case WindowOperations::HANNING: - hanning_function (window_len, output_window); - break; - case WindowOperations::BLACKMAN_HARRIS: - blackman_harris_function (window_len, output_window); - break; - default: - data_logger->error ("Invalid Window function. Window function:{}", window_function); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int perform_fft ( - double *data, int data_len, int window_function, double *output_re, double *output_im) -{ - if ((!data) || (!output_re) || (!output_im) || (data_len <= 0) || (data_len % 2 == 1)) - { - data_logger->error ( - "Please check to make sure all arguments aren't empty and data_len is even."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - double *windowed_data = new double[data_len]; - int window_res = get_window (window_function, data_len, windowed_data); - if (window_res != (int)BrainFlowExitCodes::STATUS_OK) - { - delete[] windowed_data; - return window_res; - } - for (int i = 0; i < data_len; i++) - { - windowed_data[i] *= data[i]; - } - kiss_fft_cpx *sout = new kiss_fft_cpx[data_len]; - kiss_fftr_cfg cfg = NULL; - try - { - cfg = kiss_fftr_alloc (data_len, 0, 0, 0); - kiss_fftr (cfg, windowed_data, sout); - for (int i = 0; i < data_len / 2 + 1; i++) - { - output_re[i] = sout[i].r; - output_im[i] = sout[i].i; - } - delete[] sout; - delete[] windowed_data; - windowed_data = NULL; - sout = NULL; - kiss_fftr_free (cfg); - } - catch (...) - { - if (sout) - { - delete[] sout; - } - if (windowed_data) - { - delete[] windowed_data; - } - if (cfg) - { - kiss_fftr_free (cfg); - } - data_logger->error ("Error with doing FFT processing."); - return (int)BrainFlowExitCodes::GENERAL_ERROR; - } - return (int)BrainFlowExitCodes::STATUS_OK; -} - -// data_len here is an original size, not len of input_re input_im -int perform_ifft (double *input_re, double *input_im, int data_len, double *restored_data) -{ - if ((!restored_data) || (!input_re) || (!input_im) || (data_len <= 0) || (data_len % 2 == 1)) - { - data_logger->error ( - "Please check to make sure all arguments aren't empty and data_len is even."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - double *temp = new double[data_len]; - kiss_fft_cpx *cin = new kiss_fft_cpx[data_len]; - for (int i = 0; i < data_len / 2 + 1; i++) - { - cin[i].r = input_re[i]; - cin[i].i = input_im[i]; - } - kiss_fftr_cfg cfg = NULL; - try - { - cfg = kiss_fftr_alloc (data_len, 1, 0, 0); - kiss_fftri (cfg, cin, temp); - for (int i = 0; i < data_len; i++) - { - restored_data[i] = temp[i] / data_len; - } - delete[] cin; - cin = NULL; - delete[] temp; - temp = NULL; - kiss_fftr_free (cfg); - } - catch (...) - { - if (temp) - { - delete[] temp; - } - if (cin) - { - delete[] cin; - } - if (cfg) - { - kiss_fftr_free (cfg); - } - data_logger->error ("Error with doing inverse FFT."); - return (int)BrainFlowExitCodes::GENERAL_ERROR; - } - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int get_psd (double *data, int data_len, int sampling_rate, int window_function, - double *output_ampl, double *output_freq) -{ - if ((data == NULL) || (sampling_rate < 1) || (data_len < 1) || (data_len % 2 == 1) || - (output_ampl == NULL) || (output_freq == NULL)) - { - data_logger->error ("Please check to make sure all arguments aren't empty, sampling rate " - "is >=1 and data_len is even."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - double *re = new double[data_len / 2 + 1]; - double *im = new double[data_len / 2 + 1]; - int res = perform_fft (data, data_len, window_function, re, im); - if (res != (int)BrainFlowExitCodes::STATUS_OK) - { - delete[] re; - delete[] im; - return res; - } - double freq_res = (double)sampling_rate / (double)data_len; - for (int i = 0; i < data_len / 2 + 1; i++) - { - // https://www.mathworks.com/help/signal/ug/power-spectral-density-estimates-using-fft.html - output_ampl[i] = (re[i] * re[i] + im[i] * im[i]) / ((double)(sampling_rate * data_len)); - if ((i != 0) && (i != data_len / 2)) - { - output_ampl[i] *= 2; - } - output_freq[i] = i * freq_res; - } - delete[] re; - delete[] im; - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int get_band_power (double *ampl, double *freq, int data_len, double freq_start, double freq_end, - double *band_power) -{ - if ((ampl == NULL) || (freq == NULL) || (freq_start > freq_end) || (band_power == NULL) || - (data_len < 2)) - { - data_logger->error ("Please check to make sure all arguments aren't empty, freq_start > " - "freq_end and data_len >=2"); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - double res = 0.0; - int counter = 0; - double freq_res = freq[1] - freq[0]; - for (int i = 0; i < data_len - 1; i++) - { - if (freq[i] > freq_end) - { - break; - } - if (freq[i] >= freq_start) - { - res += 0.5 * freq_res * (ampl[i] + ampl[i + 1]); - counter++; - } - } - if (counter == 0) - { - data_logger->error ("No data between freq_end and freq_start."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - *band_power = res; - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int get_nearest_power_of_two (int value, int *output) -{ - if (value < 0) - { - data_logger->error ("Value must be postive. Value:{}", value); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - if (value == 1) - { - *output = 2; - return (int)BrainFlowExitCodes::STATUS_OK; - } - - int32_t v = (int32_t)value; - v--; - v |= v >> 1; - v |= v >> 2; - v |= v >> 4; - v |= v >> 8; - v |= v >> 16; - v++; // next power of 2 - int x = v >> 1; // previous power of 2 - *output = (v - value) > (value - x) ? x : v; - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int write_file ( - const double *data, int num_rows, int num_cols, const char *file_name, const char *file_mode) -{ - if ((strcmp (file_mode, "w") != 0) && (strcmp (file_mode, "w+") != 0) && - (strcmp (file_mode, "a") != 0) && (strcmp (file_mode, "a+") != 0)) - { - data_logger->error ("Incorrect file_mode. File_mode:{}", file_mode); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - FILE *fp; - fp = fopen (file_name, file_mode); - if (fp == NULL) - { - data_logger->error ( - "Couldn't open file with file_name and file_mode argument. File_Mode:{}, File_name:{}", - file_mode, file_name); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - // in read/write file data is transposed! - for (int i = 0; i < num_cols; i++) - { - for (int j = 0; j < num_rows - 1; j++) - { - fprintf (fp, "%lf\t", data[j * num_cols + i]); - } - fprintf (fp, "%lf\n", data[(num_rows - 1) * num_cols + i]); - } - fclose (fp); - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int read_file (double *data, int *num_rows, int *num_cols, const char *file_name, int num_elements) -{ - if (num_elements <= 0) - { - data_logger->error ("Nummber or elements must be greater than 0."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - FILE *fp; - fp = fopen (file_name, "r"); - if (fp == NULL) - { - data_logger->error ("Couldn't read file {}", file_name); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - char buf[4096]; - // rows and cols in tsv file, in data array its transposed! - int total_rows = 0; - int total_cols = 0; - - // count rows - char c; - for (c = getc (fp); !feof (fp); c = getc (fp)) - { - if (c == '\n') - { - total_rows++; - } - } - - fseek (fp, 0, SEEK_SET); - int current_row = 0; - int cur_pos = 0; - while (fgets (buf, sizeof (buf), fp) != NULL) - { - std::string tsv_string (buf); - std::stringstream ss (tsv_string); - std::vector splitted; - std::string tmp; - char sep = '\t'; - if (tsv_string.find ('\t') == std::string::npos) - { - sep = ','; - } - while (std::getline (ss, tmp, sep)) - { - if (tmp != "\n") - { - splitted.push_back (tmp); - } - } - if ((total_cols != 0) && (total_cols != (int)splitted.size ())) - { - data_logger->error ("some rows have more cols than others, invalid input file"); - fclose (fp); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - total_cols = (int)splitted.size (); - for (int i = 0; i < total_cols; i++) - { - try - { - data[i * total_rows + current_row] = std::stod (splitted[i]); - } - catch (const std::invalid_argument &) - { - fclose (fp); - data_logger->error ("found not a number in data file"); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - if (cur_pos == (num_elements - 1)) - { - *num_cols = current_row + 1; - *num_rows = total_cols; - fclose (fp); - return (int)BrainFlowExitCodes::STATUS_OK; - } - cur_pos++; - } - current_row++; - } - // more likely code below is unreachable - *num_cols = total_rows; - *num_rows = total_cols; - fclose (fp); - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int calc_stddev (double *data, int start_pos, int end_pos, double *output) -{ - if ((data == NULL) || (output == NULL) || (end_pos - start_pos < 2)) - { - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - double mean = 0; - for (int i = start_pos; i < end_pos; i++) - { - mean += data[i]; - } - mean /= (end_pos - start_pos); - double stddev = 0; - for (int i = start_pos; i < end_pos; i++) - { - stddev += (data[i] - mean) * (data[i] - mean); - } - stddev /= (end_pos - start_pos); - *output = sqrt (stddev); - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int get_num_elements_in_file (const char *file_name, int *num_elements) -{ - FILE *fp; - fp = fopen (file_name, "r"); - if (fp == NULL) - { - data_logger->error ("Couldn't read file {}", file_name); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - char buf[4096]; - int total_rows = 0; - - // count rows - char c; - for (c = getc (fp); !feof (fp); c = getc (fp)) - { - if (c == '\n') - { - total_rows++; - } - } - if (total_rows == 0) - { - *num_elements = 0; - fclose (fp); - data_logger->error ("Empty file {}", file_name); - return (int)BrainFlowExitCodes::EMPTY_BUFFER_ERROR; - } - - fseek (fp, 0, SEEK_SET); - while (fgets (buf, sizeof (buf), fp) != NULL) - { - std::string tsv_string (buf); - std::stringstream ss (tsv_string); - std::vector splitted; - std::string tmp; - char sep = '\t'; - if (tsv_string.find ('\t') == std::string::npos) - { - sep = ','; - } - while (std::getline (ss, tmp, sep)) - { - if (tmp != "\n") - { - splitted.push_back (tmp); - } - } - *num_elements = (int)splitted.size () * total_rows; - fclose (fp); - return (int)BrainFlowExitCodes::STATUS_OK; - } - *num_elements = 0; - fclose (fp); - data_logger->error ("File contents", file_name); - return (int)BrainFlowExitCodes::EMPTY_BUFFER_ERROR; -} - -int detrend (double *data, int data_len, int detrend_operation) -{ - if ((data == NULL) || (data_len < 1)) - { - data_logger->error ( - "Incorrect Data arguments. Data must not be empty and data_len must be >=1"); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - if (detrend_operation == (int)DetrendOperations::NO_DETREND) - { - return (int)BrainFlowExitCodes::STATUS_OK; - } - if (detrend_operation == (int)DetrendOperations::CONSTANT) - { - double mean = 0.0; - // subtract mean from data - for (int i = 0; i < data_len; i++) - { - mean += data[i]; - } - mean /= data_len; - for (int i = 0; i < data_len; i++) - { - data[i] -= mean; - } - return (int)BrainFlowExitCodes::STATUS_OK; - } - if (detrend_operation == (int)DetrendOperations::LINEAR) - { - // use mean and gradient to find a line - double mean_x = (data_len - 1) / 2.0; - double mean_y = 0; - for (int i = 0; i < data_len; i++) - { - mean_y += data[i]; - } - mean_y /= data_len; - double temp_xy = 0.0; - double temp_xx = 0.0; - for (int i = 0; i < data_len; i++) - { - temp_xy += i * data[i]; - temp_xx += i * i; - } - double s_xy = temp_xy / data_len - mean_x * mean_y; - double s_xx = temp_xx / data_len - mean_x * mean_x; - double grad = s_xy / s_xx; - double y_int = mean_y - grad * mean_x; - for (int i = 0; i < data_len; i++) - { - data[i] = data[i] - (grad * i + y_int); - } - return (int)BrainFlowExitCodes::STATUS_OK; - } - data_logger->error ("Detrend operation is incorrect. Detrend:{}", detrend_operation); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; -} - -int get_psd_welch (double *data, int data_len, int nfft, int overlap, int sampling_rate, - int window_function, double *output_ampl, double *output_freq) -{ - if ((data == NULL) || (data_len < 1) || (nfft & (nfft - 1)) || (output_ampl == NULL) || - (output_freq == NULL) || (sampling_rate < 1) || (overlap < 0) || (overlap >= nfft)) - { - data_logger->error ("Please review your arguments."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - double *ampls = new double[nfft / 2 + 1]; - int counter = 0; - for (int i = 0; i < nfft / 2 + 1; i++) - { - output_ampl[i] = 0.0; - } - for (int pos = 0; (pos + nfft) <= data_len; pos += (nfft - overlap), counter++) - { - int res = get_psd (data + pos, nfft, sampling_rate, window_function, ampls, output_freq); - if (res != (int)BrainFlowExitCodes::STATUS_OK) - { - delete[] ampls; - return res; - } - for (int i = 0; i < nfft / 2 + 1; i++) - { - output_ampl[i] += ampls[i]; - } - } - delete[] ampls; - if (counter == 0) - { - data_logger->error ("Nfft must be less than data_len."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - // average data - for (int i = 0; i < nfft / 2 + 1; i++) - { - output_ampl[i] /= counter; - } - - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int get_custom_band_powers (double *raw_data, int rows, int cols, double *start_freqs, - double *stop_freqs, int num_bands, int sampling_rate, int apply_filters, - double *avg_band_powers, double *stddev_band_powers) -{ - if ((sampling_rate < 1) || (raw_data == NULL) || (rows < 1) || (cols < 1) || - (avg_band_powers == NULL) || (stddev_band_powers == NULL) || (start_freqs == NULL) || - (stop_freqs == NULL) || (num_bands < 1)) - { - data_logger->error ("Please review your arguments."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - // rows - channels, cols - datapoints - int *exit_codes = new int[rows]; - for (int i = 0; i < rows; i++) - { - exit_codes[i] = (int)BrainFlowExitCodes::STATUS_OK; - } - int nfft = 0; - get_nearest_power_of_two (sampling_rate, &nfft); - nfft *= 2; // for resolution ~ 0.5 - // handle the case if nfft > number of data points - // its valid case but results will not be accurate - while (nfft > cols) - { - nfft /= 2; - } - if (nfft < 8) - { - data_logger->error ("Not enough data for calculation."); - delete[] exit_codes; - return (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; - } - double **bands = new double *[num_bands]; - for (int i = 0; i < num_bands; i++) - { - bands[i] = new double[rows]; - // to make valgrind happy - for (int j = 0; j < rows; j++) - { - bands[i][j] = 0.0; - } - } - -#pragma omp parallel for - for (int i = 0; i < rows; i++) - { - double *ampls = new double[nfft / 2 + 1]; - double *freqs = new double[nfft / 2 + 1]; - double *thread_data = new double[cols]; - memcpy (thread_data, raw_data + i * cols, sizeof (double) * cols); - - if (apply_filters) - { - exit_codes[i] = detrend (thread_data, cols, (int)DetrendOperations::CONSTANT); - if (exit_codes[i] == (int)BrainFlowExitCodes::STATUS_OK) - { - exit_codes[i] = perform_bandstop (thread_data, cols, sampling_rate, 48.0, 52.0, 4, - (int)FilterTypes::BUTTERWORTH_ZERO_PHASE, 0.0); - } - if (exit_codes[i] == (int)BrainFlowExitCodes::STATUS_OK) - { - exit_codes[i] = perform_bandstop (thread_data, cols, sampling_rate, 58.0, 62.0, 4, - (int)FilterTypes::BUTTERWORTH_ZERO_PHASE, 0.0); - } - if (exit_codes[i] == (int)BrainFlowExitCodes::STATUS_OK) - { - exit_codes[i] = perform_bandpass (thread_data, cols, sampling_rate, 2.0, 45.0, 4, - (int)FilterTypes::BUTTERWORTH_ZERO_PHASE, 0.0); - } - } - - // use 80% overlap, as long as it works fast overlap param can be big - exit_codes[i] = get_psd_welch (thread_data, cols, nfft, 4 * nfft / 5, sampling_rate, - (int)WindowOperations::HANNING, ampls, freqs); - for (int band_num = 0; band_num < num_bands; band_num++) - { - if (exit_codes[i] == (int)BrainFlowExitCodes::STATUS_OK) - { - exit_codes[i] = get_band_power (ampls, freqs, nfft / 2 + 1, start_freqs[band_num], - stop_freqs[band_num], &bands[band_num][i]); - } - } - - delete[] ampls; - delete[] freqs; - delete[] thread_data; - } - - for (int i = 0; i < rows; i++) - { - if (exit_codes[i] != (int)BrainFlowExitCodes::STATUS_OK) - { - int ec = exit_codes[i]; - delete[] exit_codes; - for (int j = 0; j < num_bands; j++) - { - delete[] bands[j]; - } - delete[] bands; - return ec; - } - } - - // find average and stddev - double *avg_bands = new double[num_bands]; - double *std_bands = new double[num_bands]; - memset (avg_bands, 0, sizeof (double) * num_bands); - memset (std_bands, 0, sizeof (double) * num_bands); - for (int i = 0; i < num_bands; i++) - { - for (int j = 0; j < rows; j++) - { - avg_bands[i] += bands[i][j]; - } - avg_bands[i] /= rows; - for (int j = 0; j < rows; j++) - { - std_bands[i] += (bands[i][j] - avg_bands[i]) * (bands[i][j] - avg_bands[i]); - } - std_bands[i] /= rows; - std_bands[i] = sqrt (std_bands[i]); - } - // use relative band powers - double sum = 0.0; - for (int i = 0; i < num_bands; i++) - { - sum += avg_bands[i]; - } - for (int i = 0; i < num_bands; i++) - { - avg_band_powers[i] = avg_bands[i] / sum; - // use relative stddev to 'normalize'(doesnt ensure range between 0 and 1) it and keep - // information about variance, division by max doesnt make any sense for stddev, it will - // lose information about ratio between mean and deviation - stddev_band_powers[i] = std_bands[i] / avg_bands[i]; - } - - delete[] exit_codes; - for (int j = 0; j < num_bands; j++) - { - delete[] bands[j]; - } - delete[] bands; - delete[] avg_bands; - delete[] std_bands; - - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int get_railed_percentage (double *raw_data, int data_len, int gain, double *output) -{ - if ((raw_data == NULL) || (data_len < 1) || (gain < 1) || (output == NULL)) - { - data_logger->error ("Please review your arguments."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - double scaler = (4.5 / (pow (2, 23) - 1) / gain * 1000000.); - double max_val = scaler * pow (2, 23); - double cur_max = abs (raw_data[0]); - bool is_straight_line = true; - for (int i = 1; i < data_len; i++) - { - if (abs (raw_data[i]) > cur_max) - { - cur_max = abs (raw_data[i]); - } - if ((abs (raw_data[i - 1] - raw_data[i]) > 0.00001) && (abs (raw_data[i]) > 0.00001)) - { - is_straight_line = false; - } - } - - if (is_straight_line) - { - *output = 100.0; - } - else - { - *output = (cur_max / max_val) * 100; - } - - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int get_oxygen_level (double *ppg_ir, double *ppg_red, int data_size, int sampling_rate, - double callib_coef1, double callib_coef2, double callib_coef3, double *oxygen_level) -{ - if ((ppg_red == NULL) || (ppg_ir == NULL) || (data_size < 40) || (sampling_rate < 1) || - (oxygen_level == NULL)) - { - data_logger->error ("invalid inputs for get_oxygen_level, ir {}, red {}, size {}, sampling " - "{}, output {}, min size is 40", - (ppg_ir != NULL), (ppg_red != NULL), data_size, sampling_rate, (oxygen_level != NULL)); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - int res = (int)BrainFlowExitCodes::STATUS_OK; - - double *red_raw = new double[data_size]; - double *ir_raw = new double[data_size]; - int filter_shift = 25; // to get rif of filtereing artifact, dont use first elements - int new_size = data_size - filter_shift; - double *new_red_raw = red_raw + filter_shift; - double *new_ir_raw = ir_raw + filter_shift; - memcpy (red_raw, ppg_red, data_size * sizeof (double)); - memcpy (ir_raw, ppg_ir, data_size * sizeof (double)); - - // need prefiltered mean of red and ir for dc - double mean_red = mean (new_red_raw, new_size); - double mean_ir = mean (new_ir_raw, new_size); - - // filtering(full size) - res = detrend (red_raw, data_size, (int)DetrendOperations::CONSTANT); - if (res == (int)BrainFlowExitCodes::STATUS_OK) - { - res = detrend (ir_raw, data_size, (int)DetrendOperations::CONSTANT); - } - if (res == (int)BrainFlowExitCodes::STATUS_OK) - { - res = perform_bandpass ( - red_raw, data_size, sampling_rate, 0.7, 1.5, 4, (int)FilterTypes::BUTTERWORTH, 0.0); - } - if (res == (int)BrainFlowExitCodes::STATUS_OK) - { - res = perform_bandpass ( - ir_raw, data_size, sampling_rate, 0.7, 1.5, 4, (int)FilterTypes::BUTTERWORTH, 0.0); - } - - if (res == (int)BrainFlowExitCodes::STATUS_OK) - { - // calculate AC & DC components using mean & rms: - double redac = rms (new_red_raw, new_size); - double irac = rms (new_ir_raw, new_size); - double reddc = mean_red; - double irdc = mean_ir; - - // https://www.maximintegrated.com/en/design/technical-documents/app-notes/6/6845.html - double r = (redac / reddc) / (irac / irdc); - data_logger->trace ("r is: {}", r); - double spo2 = callib_coef1 * r * r + callib_coef2 * r + callib_coef3; - if (spo2 > 100.0) - { - spo2 = 100.0; - } - if (spo2 < 0) - { - spo2 = 0.0; - } - *oxygen_level = spo2; - } - - delete[] red_raw; - delete[] ir_raw; - - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int restore_data_from_wavelet_detailed_coeffs (double *data, int data_len, int wavelet, - int decomposition_level, int level_to_restore, double *output) -{ - int extension = (int)WaveletExtensionTypes::SYMMETRIC; - if ((data == NULL) || (data_len <= 20) || (output == NULL) || (decomposition_level <= 0) || - (level_to_restore <= 0) || (level_to_restore > decomposition_level)) - { - data_logger->error ("Invalid input for restore_data_from_wavelet_detailed_coeffs."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - int max_wavelet_output_len = data_len + 2 * decomposition_level * (40 + 1); - double *wavelet_output = new double[max_wavelet_output_len]; - int *decomposition_lengths = new int[decomposition_level + 1]; - - int res = perform_wavelet_transform (data, data_len, wavelet, decomposition_level, extension, - wavelet_output, decomposition_lengths); - if (res == (int)BrainFlowExitCodes::STATUS_OK) - { - // zero approx coefs - for (int j = 0; j < decomposition_lengths[0]; j++) - { - wavelet_output[j] = 0.0; - } - int cur_sum = decomposition_lengths[0]; - // zero detailed coefs not from level_to_restore - for (int i = 1; i < decomposition_level + 1; i++) - { - int cur_level = decomposition_level + 1 - i; - if (cur_level != level_to_restore) - { - for (int j = cur_sum; j < cur_sum + decomposition_lengths[i]; j++) - { - wavelet_output[j] = 0.0; - } - } - cur_sum += decomposition_lengths[i]; - } - res = perform_inverse_wavelet_transform (wavelet_output, data_len, wavelet, - decomposition_level, extension, decomposition_lengths, output); - } - - delete[] wavelet_output; - delete[] decomposition_lengths; - - return res; -} - -// https://stackoverflow.com/a/22640362 -int detect_peaks_z_score ( - double *data, int data_len, int lag, double threshold, double influence, double *output) -{ - if ((data == NULL) || (data_len < lag) || (lag < 2) || (lag > data_len) || (threshold < 0) || - (influence < 0) || (output == NULL)) - { - data_logger->error ("invalid inputs for detect_peaks_z_score"); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - memset (output, 0, sizeof (double) * data_len); - double *filtered_data = new double[data_len]; - double *avg_filter = new double[data_len]; - double *std_filter = new double[data_len]; - memcpy (filtered_data, data, sizeof (double) * data_len); - - avg_filter[lag - 1] = mean (data, lag); - std_filter[lag - 1] = stddev (data, lag); - - for (int i = lag; i < data_len; i++) - { - if (abs (data[i] - avg_filter[i - 1]) > threshold * std_filter[i - 1]) - { - if (data[i] > avg_filter[i - 1]) - { - output[i] = 1; - } - else - { - output[i] = -1; - } - filtered_data[i] = influence * data[i] + (1 - influence) * filtered_data[i - 1]; - } - else - { - output[i] = 0; - } - avg_filter[i] = mean (filtered_data + i - lag, lag); - std_filter[i] = stddev (filtered_data + i - lag, lag); - } - - delete[] filtered_data; - delete[] avg_filter; - delete[] std_filter; - - return (int)BrainFlowExitCodes::STATUS_OK; -} - -int get_heart_rate ( - double *ppg_ir, double *ppg_red, int data_size, int sampling_rate, int fft_size, double *rate) -{ - if ((ppg_red == NULL) || (ppg_ir == NULL) || (data_size < fft_size) || (sampling_rate < 1) || - (rate == NULL) || (fft_size < 1024) || (fft_size % 2 != 0)) - { - data_logger->error ( - "invalid inputs for get_heart_rate, fft_len should be even and at least 1024"); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - int psd_size = fft_size / 2 + 1; - double *output_ampl_ir = new double[psd_size]; - double *output_ampl_red = new double[psd_size]; - double *output_freq = new double[psd_size]; - - int res = get_psd_welch (ppg_ir, data_size, fft_size, fft_size / 2, sampling_rate, - (int)WindowOperations::HANNING, output_ampl_ir, output_freq); - if (res == (int)BrainFlowExitCodes::STATUS_OK) - { - res = get_psd_welch (ppg_red, data_size, fft_size, fft_size / 2, sampling_rate, - (int)WindowOperations::HANNING, output_ampl_red, output_freq); - } - if (res == (int)BrainFlowExitCodes::STATUS_OK) - { - // calc HR using red/ir psd. HR range 35bpm-230bpm - // average ampls for red and ir, store in red - for (int i = 0; i < psd_size; i++) - { - output_ampl_red[i] = (output_ampl_red[i] + output_ampl_ir[i]) / 2; - } - double min_hr = 35.0 / 60.0; - double max_hr = 230.0 / 60.0; - // find max amplitude - double max_ampl = 0.0; - int max_ampl_index = 0; - for (int i = 0; i < psd_size; i++) - { - if (output_freq[i] > min_hr && output_freq[i] < max_hr && output_ampl_red[i] > max_ampl) - { - max_ampl = output_ampl_red[i]; - max_ampl_index = i; - } - else if (output_freq[i] > max_hr) - { - break; - } - } - double heart_rate = output_freq[max_ampl_index] * 60; - *rate = heart_rate; - } - - delete[] output_ampl_ir; - delete[] output_ampl_red; - delete[] output_freq; - - return res; -} - -int perform_ica (double *data, int rows, int cols, int num_components, double *w_mat, double *k_mat, - double *a_mat, double *s_mat) -{ - if ((data == NULL) || (rows < 2) || (cols < 2) || (num_components < 2) || (w_mat == NULL) || - (k_mat == NULL) || (a_mat == NULL) || (s_mat == NULL)) - { - data_logger->error ("invalid inputs for perform_ica."); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - Eigen::MatrixXd input_matrix = - Eigen::Map> ( - data, rows, cols); - - FastICA ica (num_components); - int res = ica.compute (input_matrix); - if (res == (int)BrainFlowExitCodes::STATUS_OK) - { - res = ica.get_matrixes (w_mat, k_mat, a_mat, s_mat); - } - return res; + return (int)BrainFlowExitCodes::GENERAL_ERROR; } -int get_activity_index (const double *accel_x, const double *accel_y, const double *accel_z, - int data_len, int sampling_rate, int period, double noise_var_x, double noise_var_y, - double noise_var_z, double *output) -{ - if ((accel_x == NULL) || (accel_y == NULL) || (accel_z == NULL) || (output == NULL) || - (data_len <= 0) || (sampling_rate <= 0) || (period < sampling_rate) || - (data_len < period) || (period % sampling_rate != 0) || !std::isfinite (noise_var_x) || - (noise_var_x < 0.0) || !std::isfinite (noise_var_y) || (noise_var_y < 0.0) || - !std::isfinite (noise_var_z) || (noise_var_z < 0.0)) - { - data_logger->error ("Invalid arguments for get_activity_index: accel_x {}, accel_y {}, " - "accel_z {}, output {}, data_len {}, sampling_rate {}, period {}, " - "noise_var_x {}, noise_var_y {}, noise_var_z {}", - (accel_x != NULL), (accel_y != NULL), (accel_z != NULL), (output != NULL), data_len, - sampling_rate, period, noise_var_x, noise_var_y, noise_var_z); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - - for (int i = 0; i < data_len; i++) - { - if (!std::isfinite (accel_x[i]) || !std::isfinite (accel_y[i]) || - !std::isfinite (accel_z[i])) - { - data_logger->error ("Non-finite sample in accelerometer data at index {}", i); - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; - } - } - - int sec_per_epoch = period / sampling_rate; - int num_epochs = data_len / period; - - for (int epoch = 0; epoch < num_epochs; epoch++) - { - double epoch_ai = 0.0; - int epoch_start_sec = epoch * sec_per_epoch; - for (int sec = 0; sec < sec_per_epoch; sec++) - { - int sec_idx = epoch_start_sec + sec; - int start_pos = sec_idx * sampling_rate; - int end_pos = start_pos + sampling_rate; - - double mean_x = 0.0, mean_y = 0.0, mean_z = 0.0; - for (int i = start_pos; i < end_pos; i++) - { - mean_x += accel_x[i]; - mean_y += accel_y[i]; - mean_z += accel_z[i]; - } - mean_x /= sampling_rate; - mean_y /= sampling_rate; - mean_z /= sampling_rate; - - double var_x = 0.0, var_y = 0.0, var_z = 0.0; - for (int i = start_pos; i < end_pos; i++) - { - var_x += (accel_x[i] - mean_x) * (accel_x[i] - mean_x); - var_y += (accel_y[i] - mean_y) * (accel_y[i] - mean_y); - var_z += (accel_z[i] - mean_z) * (accel_z[i] - mean_z); - } - var_x /= sampling_rate; - var_y /= sampling_rate; - var_z /= sampling_rate; - - double adj_x = var_x - noise_var_x; - double adj_y = var_y - noise_var_y; - double adj_z = var_z - noise_var_z; - double mean_adj = (adj_x + adj_y + adj_z) / 3.0; - double ai_1sec = sqrt (std::max (0.0, mean_adj)); - epoch_ai += ai_1sec; - } - output[epoch] = epoch_ai; - } - - return (int)BrainFlowExitCodes::STATUS_OK; -} int get_version_data_handler (char *version, int *num_chars, int max_chars) { diff --git a/src/data_handler/fastica.cpp b/src/data_handler/fastica.cpp index 2b9b39f09..9251f7270 100644 --- a/src/data_handler/fastica.cpp +++ b/src/data_handler/fastica.cpp @@ -1,183 +1,167 @@ #include "fastica.h" +#include #include +#include #include -// https://en.wikipedia.org/wiki/FastICA -// https://arnauddelorme.com/ica_for_dummies/ -int FastICA::compute (Eigen::MatrixXd &X) +namespace { - int rows = (int)X.rows (); - int cols = (int)X.cols (); - int min_rows_cols = rows < cols ? rows : cols; - if ((num_components < 2) || (max_it < 1) || (rows < 2) || (cols < 2) || - (num_components > min_rows_cols)) + bool decorrelate (Eigen::MatrixXd &matrix) { - return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + if (!matrix.allFinite ()) + { + return false; + } + Eigen::JacobiSVD svd (matrix, Eigen::ComputeFullU | Eigen::ComputeFullV); + const Eigen::VectorXd &values = svd.singularValues (); + if (!values.allFinite () || values[0] <= 0.0 || + values[values.size () - 1] <= + values[0] * std::numeric_limits::epsilon () * matrix.rows ()) + { + return false; + } + // The polar factor is symmetric decorrelation without dividing by small + // singular values or explicitly forming an inverse. + matrix = svd.matrixU () * svd.matrixV ().transpose (); + return matrix.allFinite (); } - - W.resize (num_components, num_components); - A.resize (rows, num_components); - K.resize (num_components, rows); - S.resize (num_components, cols); - - scale (X, true, row_norm); - - // Whitening - // X %*% t(X)/rows - Eigen::MatrixXd V = X * (X.array () / cols).matrix ().transpose (); - // s <- La.svd(V) - Eigen::BDCSVD s (V, Eigen::ComputeThinU | Eigen::ComputeThinV); - // D <- diag(c(1/sqrt(s$d))) - Eigen::MatrixXd D = s.singularValues ().array ().sqrt ().inverse ().matrix ().asDiagonal (); - // K <- D %*% t(s$u) - Eigen::MatrixXd K_temp = D * s.matrixU ().transpose (); - // K <- matrix( K[1:rows.comp, ], rows.comp, cols) - Eigen::MatrixXd K_temp2 = K_temp.block (0, 0, num_components, rows); - // X1 <- K %*% X - Eigen::MatrixXd X1 = K_temp2 * X; - Eigen::MatrixXd a = fast_ica_parallel_compute (X1); - // w <- a %*% K - Eigen::MatrixXd w = a * K_temp2; - // S <- w %*% X - S = w * X; - // A <- t(w) %*% solve(w %*% t(w)) - A = w.transpose () * (w * w.transpose ()).inverse (); - A.transposeInPlace (); - K = K_temp2; - W = a; - - return (int)BrainFlowExitCodes::STATUS_OK; } -Eigen::MatrixXd FastICA::fast_ica_parallel_compute (const Eigen::MatrixXd &X) +int FastICA::compute (Eigen::MatrixXd &X) { + int rows = (int)X.rows (); int cols = (int)X.cols (); - Eigen::MatrixXd W (num_components, num_components); - random_normal (W); - - // sW <- La.svd(W) - Eigen::BDCSVD sW (W, Eigen::ComputeThinU | Eigen::ComputeThinV); - // W <- sW$u %*% Diag(1/sW$d) %*% t(sW$u) %*% W - W = sW.matrixU () * (sW.singularValues ().array ().inverse ()).matrix ().asDiagonal () * - sW.matrixU ().transpose () * W; - Eigen::MatrixXd W1 = W; - // lim <- rep(1000, maxit) - std::vector lim (max_it, 1000); - // iteration counter - int it = 0; - - while (lim[it] > tol && it < (max_it - 1)) + if ((num_components < 2) || (max_it < 1) || (rows < 2) || (cols < 3) || + (num_components > std::min (rows, cols - 1)) || !std::isfinite (tol) || (tol <= 0.0) || + (tol >= 1.0) || (seed < -1) || !X.allFinite ()) { - // wx <- W %*% X - // gwx <- tanh(alpha * wx) - // alpha = 1 , so ignore - Eigen::MatrixXd gwx = (W * X).array ().tanh ().matrix (); - // v1 <- gwx %*% t(X)/cols - Eigen::MatrixXd v1 = gwx * (X.array () / cols).matrix ().transpose (); - // g.wx <- alpha * (1 - (gwx)^2) - // nb alpha == 1 - gwx = 1 - gwx.array ().square (); - // v2 <- Diag(apply(g.wx, 1, FUN = mean)) %*% W - Eigen::MatrixXd v2 = gwx.array ().rowwise ().mean ().matrix ().asDiagonal () * W; - // W1 <- v1 - v2 - W1 = v1 - v2; - // sW1 <- La.svd(W1) - Eigen::BDCSVD sW1 (W1, Eigen::ComputeThinU | Eigen::ComputeThinV); - // W1 <- sW1$u %*% Diag(1/sW1$d) %*% t(sW1$u) %*% W1 - W1 = sW1.matrixU () * (sW1.singularValues ().array ().inverse ()).matrix ().asDiagonal () * - sW1.matrixU ().transpose () * W1; - // lim[it + 1] <- max( Mod( Mod( diag(W1 %*% t(W) ) ) - 1 ) ) - lim[it + 1] = ((W1 * W.transpose ()).diagonal ().array ().abs () - 1).abs ().maxCoeff (); - // W <- W1 - W = W1; - ++it; + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - return W; -} - -void FastICA::scale (Eigen::Ref M, bool center, bool normalize, - bool ignore_invariants, std::vector *zeros) -{ - int rows = (int)M.rows (); - Eigen::Array means = M.rowwise ().mean (); - - if (normalize) + // Work at unit scale so centering and covariance do not overflow for otherwise + // representable signals. K and A below retain their original physical units. + double input_scale = X.cwiseAbs ().maxCoeff (); + if (!(input_scale > 0.0)) { - // TODO: This block has not been tested yet - Eigen::Array sds = - ((M.array ().rowwise () - means).square ().colwise ().sum () / (rows - 1)).sqrt (); + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + X /= input_scale; + Eigen::VectorXd means = X.rowwise ().mean (); + X.colwise () -= means; + Eigen::MatrixXd covariance = X * (X / (double)cols).transpose (); + Eigen::SelfAdjointEigenSolver solver (covariance); + if (solver.info () != Eigen::Success || !solver.eigenvalues ().allFinite ()) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } - for (int i = 0; i < sds.size (); i++) - { - if (sds[i] == 0) - { - if (!ignore_invariants) - return; - if (zeros != NULL) - zeros->push_back (i); - sds[i] = 1.0; - } - } + double largest = solver.eigenvalues ()[rows - 1]; + double rank_tolerance = + largest * std::numeric_limits::epsilon () * std::max (rows, cols); + if (!(largest > 0.0) || solver.eigenvalues ()[rows - num_components] <= rank_tolerance) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } - if (center) - { - M.array ().rowwise () -= means; - } - M.array ().rowwise () /= sds; + Eigen::MatrixXd basis (rows, num_components); + Eigen::VectorXd scales (num_components); + for (int i = 0; i < num_components; i++) + { + basis.col (i) = solver.eigenvectors ().col (rows - 1 - i); + scales[i] = std::sqrt (solver.eigenvalues ()[rows - 1 - i]); } - else + Eigen::MatrixXd whitening = scales.cwiseInverse ().asDiagonal () * basis.transpose (); + Eigen::MatrixXd whitened = whitening * X; + Eigen::MatrixXd weights; + if (!whitened.allFinite () || !fast_ica_parallel_compute (whitened, weights)) + { + // A nonconverged estimate must not be silently returned as successful ICA. + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + + W = weights; + K = whitening / input_scale; + S = weights * whitened; + // W is orthogonal, so this is pinv(W*K), without an unstable matrix inverse. + // Keep A channels-by-components, matching every documented output shape. + A = (basis * scales.asDiagonal () * weights.transpose ()) * input_scale; + if (!W.allFinite () || !K.allFinite () || !S.allFinite () || !A.allFinite ()) { - M.array ().transpose ().rowwise () -= means; + return (int)BrainFlowExitCodes::GENERAL_ERROR; } + return (int)BrainFlowExitCodes::STATUS_OK; } -void FastICA::random_normal (Eigen::MatrixXd &M) +bool FastICA::fast_ica_parallel_compute (const Eigen::MatrixXd &X, Eigen::MatrixXd &result) { - std::random_device rd {}; - std::mt19937 gen {rd ()}; - std::normal_distribution d {0, 1}; + Eigen::MatrixXd weights (num_components, num_components); + random_normal (weights); + if (!decorrelate (weights)) + { + return false; + } - for (int r = 0; r < M.rows (); r++) + const double samples = (double)X.cols (); + for (int iteration = 0; iteration < max_it; iteration++) { - for (int c = 0; c < M.cols (); c++) + Eigen::MatrixXd activation = (weights * X).array ().tanh ().matrix (); + Eigen::VectorXd derivative = (1.0 - activation.array ().square ()).rowwise ().mean (); + Eigen::MatrixXd next = + activation * (X / samples).transpose () - derivative.asDiagonal () * weights; + if (!decorrelate (next)) + { + return false; + } + double change = + ((next * weights.transpose ()).diagonal ().array ().abs () - 1.0).abs ().maxCoeff (); + weights.swap (next); + if (change < tol) { - M (r, c) = d (gen); + result = weights; + return true; } } + return false; } -int FastICA::get_matrixes (double *w_mat, double *k_mat, double *a_mat, double *s_mat) +void FastICA::random_normal (Eigen::MatrixXd &matrix) { - for (int r = 0; r < (int)W.rows (); r++) + std::mt19937 generator; + if (seed < 0) { - for (int c = 0; c < (int)W.cols (); c++) - { - w_mat[r * W.cols () + c] = W (r, c); - } + std::random_device device; + generator.seed (device ()); } - for (int r = 0; r < (int)S.rows (); r++) + else { - for (int c = 0; c < (int)S.cols (); c++) - { - s_mat[r * S.cols () + c] = S (r, c); - } + generator.seed ((unsigned int)seed); } - for (int r = 0; r < (int)K.rows (); r++) + std::normal_distribution distribution (0.0, 1.0); + for (int row = 0; row < matrix.rows (); row++) { - for (int c = 0; c < (int)K.cols (); c++) + for (int col = 0; col < matrix.cols (); col++) { - k_mat[r * K.cols () + c] = K (r, c); + matrix (row, col) = distribution (generator); } } - for (int r = 0; r < (int)A.rows (); r++) +} + +int FastICA::get_matrixes (double *w_mat, double *k_mat, double *a_mat, double *s_mat) +{ + if (!w_mat || !k_mat || !a_mat || !s_mat || W.size () == 0 || K.size () == 0 || + A.size () == 0 || S.size () == 0) { - for (int c = 0; c < (int)A.cols (); c++) - { - a_mat[r * A.cols () + c] = A (r, c); - } + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - + typedef Eigen::Matrix RowMatrix; + Eigen::Map w_output (w_mat, W.rows (), W.cols ()); + Eigen::Map k_output (k_mat, K.rows (), K.cols ()); + Eigen::Map a_output (a_mat, A.rows (), A.cols ()); + Eigen::Map s_output (s_mat, S.rows (), S.cols ()); + w_output = W; + k_output = K; + a_output = A; + s_output = S; return (int)BrainFlowExitCodes::STATUS_OK; } diff --git a/src/data_handler/inc/common_data_handler_helpers.h b/src/data_handler/inc/common_data_handler_helpers.h index 2fa4ec0b7..46dc5528a 100644 --- a/src/data_handler/inc/common_data_handler_helpers.h +++ b/src/data_handler/inc/common_data_handler_helpers.h @@ -1,39 +1,72 @@ #pragma once -#include -#include +#include +#include +#include + +inline bool finite_signal (const double *data, int n) +{ + if (!data || n <= 0) + return false; + for (int i = 0; i < n; i++) + if (!std::isfinite (data[i])) + return false; + return true; +} + +inline double signal_scale (const double *data, int n) +{ + double scale = 0.0; + for (int i = 0; i < n; i++) + scale = std::max (scale, std::abs (data[i])); + return scale; +} inline double rms (double x[], int n) { + const double scale = signal_scale (x, n); + if (scale == 0.0) + return 0.0; double sum = 0; for (int i = 0; i < n; i++) { - sum += x[i] * x[i]; + const double value = x[i] / scale; + sum += value * value / n; } - return sqrt (sum / n); + return scale * sqrt (std::min (1.0, sum)); } -inline double mean (double x[], int n) +inline double mean (const double x[], int n) { - double sum = 0; + const double scale = signal_scale (x, n); + if (scale == 0.0) + return 0.0; + double sum = 0, correction = 0; for (int i = 0; i < n; i++) { - sum += x[i]; + const double value = (x[i] / scale) / n - correction; + const double next = sum + value; + correction = (next - sum) - value; + sum = next; } - return sum / n; + return std::max (-1.0, std::min (1.0, sum)) * scale; } -inline double stddev (double data[], int len) +inline double stddev (const double data[], int len) { - double the_mean = mean (data, len); + const double scale = signal_scale (data, len); + if (scale == 0.0) + return 0.0; + double the_mean = mean (data, len) / scale; double deviation = 0.0; for (int i = 0; i < len; ++i) { - deviation += pow (data[i] - the_mean, 2); + const double delta = data[i] / scale - the_mean; + deviation += delta * delta / len; } - return sqrt (deviation / len); + return scale * sqrt (std::min (1.0, deviation)); } @@ -48,4 +81,4 @@ inline void reverse_array (double data[], int len) data[i] = data[len - i - 1]; data[len - i - 1] = temp; } -} \ No newline at end of file +} diff --git a/src/data_handler/inc/data_handler.h b/src/data_handler/inc/data_handler.h index 517623de2..600c964fc 100644 --- a/src/data_handler/inc/data_handler.h +++ b/src/data_handler/inc/data_handler.h @@ -24,12 +24,29 @@ extern "C" double *data, int data_len, int period, int agg_operation); SHARED_EXPORT int CALLING_CONVENTION perform_downsampling ( double *data, int data_len, int period, int agg_operation, double *output_data); + // FIR decimation: floor(data_len/factor) outputs, first sample aligned to input[0]. + SHARED_EXPORT int CALLING_CONVENTION perform_decimation ( + const double *data, int data_len, int factor, double *output_data); + // Streaming causal filter kinds: lowpass=0, highpass=1, bandpass=2, bandstop=3. + SHARED_EXPORT int CALLING_CONVENTION get_filter_settling_samples (int kind, int sampling_rate, + double low_cutoff, double high_cutoff, int order, int filter_type, double ripple, + int *edge_samples); + SHARED_EXPORT int CALLING_CONVENTION create_streaming_filter (int kind, int sampling_rate, + double low_cutoff, double high_cutoff, int order, int filter_type, double ripple, + int *handle); + SHARED_EXPORT int CALLING_CONVENTION perform_streaming_filter ( + int handle, double *data, int data_len); + SHARED_EXPORT int CALLING_CONVENTION reset_streaming_filter (int handle); + SHARED_EXPORT int CALLING_CONVENTION release_streaming_filter (int handle); SHARED_EXPORT int CALLING_CONVENTION perform_wavelet_transform (double *data, int data_len, int wavelet, int decomposition_level, int extension, double *output_data, int *decomposition_lengths); SHARED_EXPORT int CALLING_CONVENTION perform_inverse_wavelet_transform (double *wavelet_coeffs, int original_data_len, int wavelet, int decomposition_level, int extension, int *decomposition_lengths, double *output_data); + SHARED_EXPORT int CALLING_CONVENTION perform_inverse_wavelet_transform_checked (double *coeffs, + int coeff_count, int original_data_len, int wavelet, int level, int extension, int *lengths, + int lengths_count, double *output, int output_count); SHARED_EXPORT int CALLING_CONVENTION perform_wavelet_denoising (double *data, int data_len, int wavelet, int decomposition_level, int wavelet_denoising, int threshold, int extenstion_type, int noise_level); @@ -56,8 +73,21 @@ extern "C" SHARED_EXPORT int CALLING_CONVENTION get_custom_band_powers (double *raw_data, int rows, int cols, double *start_freqs, double *stop_freqs, int num_bands, int sampling_rate, int apply_filters, double *avg_band_powers, double *stddev_band_powers); + // nfft=0 selects the default; cutoff=0 disables that passband edge. + // mains: -1=automatic, 0=none, 1=50Hz, 2=60Hz, 3=both. + SHARED_EXPORT int CALLING_CONVENTION get_band_power_settings (int sampling_rate, + int apply_filters, int nfft, double low_cutoff, double high_cutoff, int mains, + int *effective_nfft, int *edge_samples); + SHARED_EXPORT int CALLING_CONVENTION get_custom_band_powers_with_options (double *raw_data, + int rows, int cols, double *start_freqs, double *stop_freqs, int num_bands, + int sampling_rate, int apply_filters, int nfft, double low_cutoff, double high_cutoff, + int mains, int detrend_operation, double *avg_band_powers, double *stddev_band_powers); SHARED_EXPORT int CALLING_CONVENTION get_railed_percentage ( double *raw_data, int data_len, int gain, double *output); + SHARED_EXPORT int CALLING_CONVENTION get_clipping_percentage ( + const double *data, int data_len, double lower_bound, double upper_bound, double *output); + SHARED_EXPORT int CALLING_CONVENTION get_flatline_percentage ( + const double *data, int data_len, double tolerance, double *output); SHARED_EXPORT int CALLING_CONVENTION get_oxygen_level (double *ppg_ir, double *ppg_red, int data_size, int sampling_rate, double callib_coef1, double callib_coef2, double callib_coef3, double *oxygen_level); @@ -69,6 +99,9 @@ extern "C" double *data, int data_len, int lag, double threshold, double influence, double *output); SHARED_EXPORT int CALLING_CONVENTION perform_ica (double *data, int rows, int cols, int num_components, double *w_mat, double *k_mat, double *a_mat, double *s_mat); + SHARED_EXPORT int CALLING_CONVENTION perform_ica_with_options (double *data, int rows, int cols, + int num_components, double *w_mat, double *k_mat, double *a_mat, double *s_mat, + int max_iterations, double tolerance, int seed); SHARED_EXPORT int CALLING_CONVENTION get_activity_index (const double *accel_x, const double *accel_y, const double *accel_z, int data_len, int sampling_rate, int period, double noise_var_x, double noise_var_y, double noise_var_z, double *activity_index); diff --git a/src/data_handler/inc/fastica.h b/src/data_handler/inc/fastica.h index f0f5b08e4..bf58df879 100644 --- a/src/data_handler/inc/fastica.h +++ b/src/data_handler/inc/fastica.h @@ -1,8 +1,5 @@ #pragma once -#include -#include - #include "Eigen/Dense" #include "brainflow_constants.h" @@ -11,13 +8,12 @@ class FastICA { public: - FastICA (int num_components, int max_it = 300, double tol = 0.0001) + FastICA (int num_components, int max_it = 1000, double tol = 0.0001, int seed = -1) { this->max_it = max_it; this->num_components = num_components; this->tol = tol; - alpha = 1; - row_norm = false; + this->seed = seed; } int compute (Eigen::MatrixXd &X); @@ -29,14 +25,11 @@ class FastICA Eigen::MatrixXd A; Eigen::MatrixXd S; - Eigen::MatrixXd fast_ica_parallel_compute (const Eigen::MatrixXd &X); - void scale (Eigen::Ref m, bool, bool, bool ignore_invariants = false, - std::vector *zeros = NULL); + bool fast_ica_parallel_compute (const Eigen::MatrixXd &X, Eigen::MatrixXd &result); void random_normal (Eigen::MatrixXd &m); int max_it; int num_components; double tol; - int alpha; - bool row_norm; + int seed; }; diff --git a/src/ml/generated/mindfulness_model.cpp b/src/ml/generated/mindfulness_model.cpp index c6f117772..4c1a89e57 100644 --- a/src/ml/generated/mindfulness_model.cpp +++ b/src/ml/generated/mindfulness_model.cpp @@ -1,6 +1,6 @@ - #include "mindfulness_model.h" + // clang-format off -const double mindfulness_coefficients[5] = {-1.4060899708538128,2.597693987367105,-30.96470526503066,12.04593986553724,45.773017975354556}; -double mindfulness_intercept = 0.000000; +const double mindfulness_coefficients[5] = {0.7498839050366688,6.5799521937192189,-28.99411366453635,-0.87503878875158769,76.86709227040761}; +double mindfulness_intercept = -2.1467613529400662; // clang-format on diff --git a/src/ml/inc/brainflow_model_params.h b/src/ml/inc/brainflow_model_params.h index 3dedebf04..bdea5a3f0 100644 --- a/src/ml/inc/brainflow_model_params.h +++ b/src/ml/inc/brainflow_model_params.h @@ -28,7 +28,7 @@ struct BrainFlowModelParams bool operator< (const struct BrainFlowModelParams &other) const { return std::tie (metric, classifier, file, other_info, output_name, max_array_size) < - std::tie (other.metric, other.classifier, other.file, other.other_info, output_name, - max_array_size); + std::tie (other.metric, other.classifier, other.file, other.other_info, + other.output_name, other.max_array_size); } }; diff --git a/src/ml/mindfulness_classifier.cpp b/src/ml/mindfulness_classifier.cpp index ca1006a1e..a7182df44 100644 --- a/src/ml/mindfulness_classifier.cpp +++ b/src/ml/mindfulness_classifier.cpp @@ -9,18 +9,31 @@ int MindfulnessClassifier::calculate (double *data, int data_len, double *output, int *output_len) { - if ((data_len < 5) || (data == NULL) || (output == NULL)) + if ((data_len < 5) || (data == NULL) || (output == NULL) || (output_len == NULL) || + (params.max_array_size < 1)) { safe_logger (spdlog::level::err, "Incorrect arguments. Null pointers or invalid feature vector size."); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } - double value = 0.0; - for (int i = 0; i < std::min (data_len, 5); i++) + *output_len = 0; + double value = mindfulness_intercept; + // Preserve the historical contract: additional features are ignored. + for (int i = 0; i < 5; i++) { + if (!std::isfinite (data[i])) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } value += mindfulness_coefficients[i] * data[i]; } - double mindfulness = 1.0 / (1.0 + exp (-1.0 * (mindfulness_intercept + value))); + if (!std::isfinite (value)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + const double exponential = std::exp (-std::abs (value)); + double mindfulness = + (value >= 0.0) ? 1.0 / (1.0 + exponential) : exponential / (1.0 + exponential); *output = mindfulness; *output_len = 1; return (int)BrainFlowExitCodes::STATUS_OK; diff --git a/src/ml/ml_module.cpp b/src/ml/ml_module.cpp index 55e8ebaaf..45010292d 100644 --- a/src/ml/ml_module.cpp +++ b/src/ml/ml_module.cpp @@ -26,6 +26,10 @@ std::mutex models_mutex; int prepare (const char *json_params) { + if (json_params == NULL) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } std::lock_guard lock (models_mutex); std::shared_ptr model = NULL; @@ -82,6 +86,12 @@ int prepare (const char *json_params) int predict (double *data, int data_len, double *output, int *output_len, const char *json_params) { + if ((json_params == NULL) || (data == NULL) || (data_len <= 0) || (output == NULL) || + (output_len == NULL)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + *output_len = 0; std::lock_guard lock (models_mutex); struct BrainFlowModelParams key ( (int)BrainFlowMetrics::MINDFULNESS, (int)BrainFlowClassifiers::DEFAULT_CLASSIFIER); @@ -102,6 +112,10 @@ int predict (double *data, int data_len, double *output, int *output_len, const int release (const char *json_params) { + if (json_params == NULL) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } std::lock_guard lock (models_mutex); struct BrainFlowModelParams key ( @@ -136,6 +150,10 @@ int string_to_brainflow_model_params (const char *json_params, struct BrainFlowM params->output_name = config["output_name"]; params->other_info = config["other_info"]; params->max_array_size = config["max_array_size"]; + if (params->max_array_size < 1) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } return (int)BrainFlowExitCodes::STATUS_OK; } catch (json::exception &e) @@ -199,4 +217,4 @@ int get_version_ml_module (char *version, int *num_chars, int max_chars) strncpy (version, BRAINFLOW_VERSION_STRING, max_chars); *num_chars = std::min (max_chars, (int)strlen (BRAINFLOW_VERSION_STRING)); return (int)BrainFlowExitCodes::STATUS_OK; -} \ No newline at end of file +} diff --git a/src/ml/onnx/onnx_classifier.cpp b/src/ml/onnx/onnx_classifier.cpp index eb2678eb5..54fe1065b 100644 --- a/src/ml/onnx/onnx_classifier.cpp +++ b/src/ml/onnx/onnx_classifier.cpp @@ -2,6 +2,9 @@ #include "brainflow_constants.h" #include "get_dll_dir.h" +#include +#include + void log_onnx_msg (void *param, OrtLoggingLevel severity, const char *category, const char *logid, const char *code_location, const char *message) @@ -69,11 +72,39 @@ int OnnxClassifier::calculate (double *data, int data_len, double *output, int * { return (int)BrainFlowExitCodes::CLASSIFIER_IS_NOT_PREPARED_ERROR; } - if ((data == NULL) || (data_len < 1) || (output == NULL) || (output_len == NULL)) + if ((data == NULL) || (data_len < 1) || (output == NULL) || (output_len == NULL) || + (params.max_array_size < 1)) { safe_logger (spdlog::level::err, "invalid input arguments"); return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; } + *output_len = 0; + + size_t input_size = 1; + for (int64_t dimension : input_node_dims) + { + if ((dimension <= 0) || ((uint64_t)dimension > (size_t)data_len / input_size)) + { + safe_logger (spdlog::level::err, + "Input dimensions must be fixed and match the feature vector size"); + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + input_size *= (size_t)dimension; + } + if (input_size != (size_t)data_len) + { + safe_logger (spdlog::level::err, "Feature vector size does not match the input tensor"); + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + for (int i = 0; i < data_len; i++) + { + if (!std::isfinite (data[i]) || + ((input_type == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) && + (std::abs (data[i]) > std::numeric_limits::max ()))) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + } // todo add support for ints and float16 float *float_data = NULL; @@ -100,6 +131,7 @@ int OnnxClassifier::calculate (double *data, int data_len, double *output, int * OrtMemoryInfo *memory_info = NULL; OrtValue *input_tensor = NULL; OrtValue *output_tensor = NULL; + OrtTensorTypeAndShapeInfo *output_shape = NULL; OrtStatus *onnx_status = ort->CreateCpuMemoryInfo (OrtArenaAllocator, OrtMemTypeDefault, &memory_info); if (onnx_status != NULL) @@ -202,20 +234,42 @@ int OnnxClassifier::calculate (double *data, int data_len, double *output, int * } } - // Get pointer to output tensor float values - size_t output_size = 1; + // Symbolic output dimensions are resolved only after running the graph. Never use + // the session metadata to decide how many elements to read from an actual tensor. + size_t output_size = 0; if (res == (int)BrainFlowExitCodes::STATUS_OK) { - for (int64_t output_dim : output_node_dims) + onnx_status = ort->GetTensorTypeAndShape (output_tensor, &output_shape); + if (onnx_status != NULL) { - output_size *= output_dim; + safe_logger (spdlog::level::err, "GetTensorTypeAndShape failed: {}", + ort->GetErrorMessage (onnx_status)); + ort->ReleaseStatus (onnx_status); + res = (int)BrainFlowExitCodes::GENERAL_ERROR; } - if (output_size > (uint64_t)params.max_array_size) + else if (output_shape == NULL) { - safe_logger (spdlog::level::warn, "output is bigger than allocated array"); - output_size = params.max_array_size; + res = (int)BrainFlowExitCodes::GENERAL_ERROR; } - + } + if (res == (int)BrainFlowExitCodes::STATUS_OK) + { + onnx_status = ort->GetTensorShapeElementCount (output_shape, &output_size); + if (onnx_status != NULL) + { + safe_logger (spdlog::level::err, "GetTensorShapeElementCount failed: {}", + ort->GetErrorMessage (onnx_status)); + ort->ReleaseStatus (onnx_status); + res = (int)BrainFlowExitCodes::GENERAL_ERROR; + } + else if (output_size > (size_t)params.max_array_size) + { + safe_logger (spdlog::level::err, "Output is bigger than max_array_size"); + res = (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; + } + } + if ((res == (int)BrainFlowExitCodes::STATUS_OK) && (output_size != 0)) + { void *output_tensor_data = NULL; onnx_status = ort->GetTensorMutableData (output_tensor, &output_tensor_data); if (onnx_status != NULL) @@ -232,7 +286,6 @@ int OnnxClassifier::calculate (double *data, int data_len, double *output, int * } else { - *output_len = (int)output_size; switch (output_type) { case ONNX_TENSOR_ELEMENT_DATA_TYPE_UNDEFINED: @@ -370,6 +423,15 @@ int OnnxClassifier::calculate (double *data, int data_len, double *output, int * } } + if (res == (int)BrainFlowExitCodes::STATUS_OK) + { + *output_len = (int)output_size; + } + if (output_shape != NULL) + { + ort->ReleaseTensorTypeAndShapeInfo (output_shape); + } + if (output_tensor != NULL) { ort->ReleaseValue (output_tensor); @@ -398,13 +460,26 @@ int OnnxClassifier::release_classifier () { OrtStatus *status = ort->AllocatorFree ( allocator, const_cast (reinterpret_cast (node_name))); + if (status != NULL) + { + ort->ReleaseStatus (status); + } } for (const char *node_name : output_node_names) { OrtStatus *status = ort->AllocatorFree ( allocator, const_cast (reinterpret_cast (node_name))); + if (status != NULL) + { + ort->ReleaseStatus (status); + } } } + input_node_names.clear (); + output_node_names.clear (); + input_node_dims.clear (); + output_node_dims.clear (); + allocator = NULL; if ((session_options != NULL) && (ort != NULL)) { ort->ReleaseSessionOptions (session_options); @@ -764,12 +839,12 @@ int OnnxClassifier::get_output_info () else { safe_logger (spdlog::level::info, "found output node: {}", output_name); - if (((num_output_nodes == 1) || + if (((!params.output_name.empty ()) && (strcmp (params.output_name.c_str (), output_name) == 0)) || ((params.output_name.empty ()) && - (strcmp (output_name, "output_probability") == 0)) || - ((params.output_name.empty ()) && - (strcmp (output_name, "probabilities") == 0))) + ((num_output_nodes == 1) || + (strcmp (output_name, "output_probability") == 0) || + (strcmp (output_name, "probabilities") == 0)))) { output_node_names.resize (1); output_node_names[0] = output_name; @@ -779,6 +854,10 @@ int OnnxClassifier::get_output_info () else { onnx_status = ort->AllocatorFree (allocator, output_name); + if (onnx_status != NULL) + { + ort->ReleaseStatus (onnx_status); + } } } } @@ -787,7 +866,7 @@ int OnnxClassifier::get_output_info () if (!node_found) { safe_logger (spdlog::level::err, - "Model has multiple output nodes, you need to provide correct node name via " + "Unable to select an output node. Provide a correct node name via " "BrainFlowModelParams.output_name, you can use https://netron.app/ to inspect the " "model"); res = (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; @@ -840,7 +919,7 @@ int OnnxClassifier::get_output_info () } else { - safe_logger (spdlog::level::info, "output type is: {}", (int)input_type); + safe_logger (spdlog::level::info, "output type is: {}", (int)output_type); } } diff --git a/src/ml/train/logreg_mindfulness.onnx b/src/ml/train/logreg_mindfulness.onnx index d497275495bb41c61076b0001d78546d5222a720..177831710367ca140301e5c9d2edd41effa72514 100644 GIT binary patch delta 263 zcmX@eyoF_gQvEMxt`Eywu|2_{_Y5(h?z#vc#gy#H5^5DYoF`#GKS3 zB`B?>!o`-KmR6ivVp~*m$If=wnO-|vhjL*%TltE8wzf$xlWZ3-vS}%Eu@-~$*|J-n zcd!+|Xy#~})v4)dYyW<(qixuq`HnzER$StVnR@woc@=uOIb8o3x!{&gv`RB{)p)zt zHY-PXzwHrueFs~!UQ=6JjsK+wY=IKCAPIYr!~u}RUXX;t#EWraqDe)GnR#hB`Q`CQ YewrM>7$eHU#UQ}##K*;juv35$0Fqx@I{*Lx delta 148 zcmdnOa*%m~l0-W*S347zU~Xn!N?K`7UTSf1d}dxjY01PLX@-Y>ZML_a=b(1LcKi14 zj<(u+91hw}5;s3&3zXOol5hY?90W-?f+UZc}SXdewq)k4|7$eHZ#g&+;m!Fqcp_iK@!05!n#UQ{90EVzL0RR91 diff --git a/src/ml/train/requirements.txt b/src/ml/train/requirements.txt index fc658311d..bdfbffd4f 100644 --- a/src/ml/train/requirements.txt +++ b/src/ml/train/requirements.txt @@ -1,5 +1,11 @@ -scikit-learn -brainflow -metric-learn -libsvm -skl2onnx \ No newline at end of file +# Reference training/export stack for Python 3.11. +# BrainFlow must come from this checkout with rebuilt native libraries; see README.md. +numpy==1.25.2 +scipy==1.11.2 +scikit-learn==1.5.1 +joblib==1.4.2 +threadpoolctl==3.5.0 +skl2onnx==1.17.0 +onnxconverter-common==1.14.0 +onnx==1.16.2 +onnxruntime==1.19.2 diff --git a/src/ml/train/train_classifiers.py b/src/ml/train/train_classifiers.py index 06de987f0..4a7b8907d 100644 --- a/src/ml/train/train_classifiers.py +++ b/src/ml/train/train_classifiers.py @@ -1,227 +1,506 @@ -import glob +"""Select five-band classifiers on development groups, evaluate a frozen holdout, +then refit production models on all recordings. Reports distinguish both stages. +""" import argparse -import os -import pickle +from datetime import datetime, timezone +import hashlib +import importlib.metadata +import json import logging +from pathlib import Path +import platform +import shutil +import sys +import traceback + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +sys.path.insert(0, str(ROOT / "python_package")) import numpy as np -from sklearn.svm import SVC -from sklearn.linear_model import LogisticRegression -from sklearn.ensemble import RandomForestClassifier -from sklearn.ensemble import StackingClassifier -from sklearn.neighbors import KNeighborsClassifier -from sklearn.neural_network import MLPClassifier -from sklearn.model_selection import cross_val_score - -from brainflow.board_shim import BoardShim -from brainflow.data_filter import DataFilter - -from skl2onnx import convert_sklearn -from skl2onnx.common.data_types import FloatTensorType - - -def write_model(intercept, coefs, model_type): - coefficients_string = '%s' % (','.join([str(x) for x in coefs[0]])) - file_content = ''' -#include "%s" -// clang-format off -const double %s_coefficients[%d] = {%s}; -double %s_intercept = %lf; -// clang-format on -''' % (f'{model_type}_model.h', model_type, len(coefs[0]), coefficients_string, model_type, intercept) - file_name = f'{model_type}_model.cpp' - file_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'generated', file_name) - with open(file_path, 'w') as f: - f.write(file_content) - -def prepare_data(first_class, second_class, blacklisted_channels=None): - # use different windows, its kinda data augmentation - window_sizes = [4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] - overlaps = [0.5, 0.475, 0.45, 0.425, 0.4, 0.375, 0.35] # percentage of window_size - dataset_x = list() - dataset_y = list() - for data_type in (first_class, second_class): - for file in glob.glob(os.path.join('data', data_type, '*', '*.csv')): - data_x_temp = list() - data_y_temp = list() - logging.info(file) - board_id = os.path.basename(os.path.dirname(file)) - try: - board_id = int(board_id) - data = DataFilter.read_file(file) - sampling_rate = BoardShim.get_sampling_rate(board_id) - eeg_channels = get_eeg_channels(board_id, blacklisted_channels) - for num, window_size in enumerate(window_sizes): - cur_pos = sampling_rate * 10 - while cur_pos + int(window_size * sampling_rate) < data.shape[1]: - data_in_window = data[:, cur_pos:cur_pos + int(window_size * sampling_rate)] - data_in_window = np.ascontiguousarray(data_in_window) - bands = DataFilter.get_avg_band_powers(data_in_window, eeg_channels, sampling_rate, True) - feature_vector = bands[0] - feature_vector = feature_vector.astype(float) - dataset_x.append(feature_vector) - data_x_temp.append(feature_vector) - if data_type == first_class: - dataset_y.append(0) - data_y_temp.append(0) - else: - dataset_y.append(1) - data_y_temp.append(0) - cur_pos = cur_pos + int(window_size * overlaps[num] * sampling_rate) - except Exception as e: - logging.error(str(e), exc_info=True) - print_dataset_info((data_x_temp, data_y_temp)) - - logging.info('1st Class: %d 2nd Class: %d' % (len([x for x in dataset_y if x == 0]), len([x for x in dataset_y if x == 1]))) - - with open('dataset_x.pickle', 'wb') as f: - pickle.dump(dataset_x, f, protocol=3) - with open('dataset_y.pickle', 'wb') as f: - pickle.dump(dataset_y, f, protocol=3) - - return dataset_x, dataset_y - -def get_eeg_channels(board_id, blacklisted_channels): - eeg_channels = BoardShim.get_eeg_channels(board_id) +from joblib import Parallel, delayed, parallel_config +from scipy.special import expit +from sklearn.metrics import (accuracy_score, balanced_accuracy_score, brier_score_loss, + confusion_matrix, f1_score, log_loss, precision_score, recall_score, + roc_auc_score) +from threadpoolctl import threadpool_limits +from training_data import extract_dataset +from training_export import export_model, write_native_logistic +from training_models import build_model, build_stacking, candidate_specs, fit_model, grouped_splits + +FILENAMES = {"logreg": "logreg_mindfulness.onnx", "svm": "svm_mindfulness.onnx", + "random_forest": "forest_mindfulness.onnx", "knn": "knn_mindfulness.onnx", + "mlp": "mlp_mindfulness.onnx", "stacking": "stacking_mindfulness.onnx", + "extra_trees": "extra_trees_mindfulness.onnx", + "hist_gradient_boosting": "hist_gradient_boosting_mindfulness.onnx", + "polynomial_logreg": "polynomial_logreg_mindfulness.onnx", + "gradient_boosting": "gradient_boosting_mindfulness.onnx"} +LEGACY_COEFFICIENTS = np.array([-1.4060899708538128, 2.597693987367105, + -30.96470526503066, 12.04593986553724, 45.773017975354556]) + + +def _json(value): + return json.dumps(value, sort_keys=True, indent=2, allow_nan=False) + + +def _write_json(path, value): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(_json(value) + "\n", encoding="utf-8") + temporary.replace(path) + + +def _sha256(path): + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _metrics(y, probabilities, weights=None): + y, probabilities = np.asarray(y), np.asarray(probabilities, dtype=np.float64) + if (len(y) != len(probabilities) or not np.isfinite(probabilities).all() + or np.any(probabilities < 0) or np.any(probabilities > 1)): + raise ValueError("Scoring requires one finite probability in [0, 1] per row") + predicted = (probabilities >= 0.5).astype(np.int64) + return {"macro_f1": float(f1_score(y, predicted, labels=[0, 1], average="macro", sample_weight=weights, zero_division=0)), + "macro_precision": float(precision_score(y, predicted, labels=[0, 1], average="macro", sample_weight=weights, zero_division=0)), + "macro_recall": float(recall_score(y, predicted, labels=[0, 1], average="macro", sample_weight=weights, zero_division=0)), + "accuracy": float(accuracy_score(y, predicted, sample_weight=weights)), + "confusion_matrix": confusion_matrix(y, predicted, labels=[0, 1], sample_weight=weights).tolist(), + "balanced_accuracy": float(balanced_accuracy_score(y, predicted, sample_weight=weights)), + "roc_auc": float(roc_auc_score(y, probabilities, sample_weight=weights)), + "brier": float(brier_score_loss(y, probabilities, sample_weight=weights)), + "log_loss": float(log_loss(y, np.column_stack((1 - probabilities, probabilities)), labels=[0, 1], sample_weight=weights))} + + +def score_predictions(y, probabilities, recording_ids): + """Weight windows equally per recording; separately score mean probabilities.""" + y, probabilities, recording_ids = map(np.asarray, (y, probabilities, recording_ids)) + _, inverse, counts = np.unique(recording_ids, return_inverse=True, return_counts=True) + record_y, record_probability, records = [], [], [] + for recording in np.unique(recording_ids): + selected = recording_ids == recording + labels = np.unique(y[selected]) + if len(labels) != 1: + raise ValueError("Every recording must have one label") + probability = float(np.mean(probabilities[selected])) + record_y.append(int(labels[0])) + record_probability.append(probability) + records.append({"recording_id": str(recording), "label": int(labels[0]), "probability": probability, + "windows": int(selected.sum())}) + return {"window": _metrics(y, probabilities, 1.0 / counts[inverse]), + "recording": _metrics(record_y, record_probability), + "window_count": len(y), "recording_count": len(records), "recordings": records} + + +def year_sensitivity(dataset, rows, probabilities, year=2020): + """A descriptive holdout slice using recording timestamps, not group names.""" + records = {record["recording_id"] for record in dataset.metadata.get("recordings", []) + if str(record.get("recording_day_utc", "")).startswith(f"{year}-")} + selected = np.isin(dataset.recording_ids[rows], list(records)) + subset = rows[selected] + if len(np.unique(dataset.y[subset])) != 2: + return {"status": "unavailable", "year": year, "window_count": len(subset), + "reason": "The timestamp-defined holdout slice must contain both classes"} + return {"status": "ok", "year": year, "interpretation": "descriptive slice; never used for selection", + **score_predictions(dataset.y[subset], np.asarray(probabilities)[selected], dataset.recording_ids[subset])} + + +def parity_probes(dataset, observed_rows, seed): + observed = dataset.X[observed_rows[np.linspace(0, len(observed_rows) - 1, min(128, len(observed_rows)), dtype=int)]] + # Cover feature boundaries and interior compositions as well as observed EEG. + rng = np.random.default_rng(seed) + return np.vstack((observed, np.eye(5), np.full((1, 5), .2), + rng.dirichlet(np.full(5, .3), size=64), rng.dirichlet(np.ones(5), size=64))) + + +def freeze_splits(dataset, cv_folds=4, holdout_folds=5, holdout_fold=0, seed=42): + """Stratify one representative per recording, never thousands of windows.""" + canonical, representative = ~dataset.augmented, [] + for recording in np.unique(dataset.recording_ids): + all_rows = dataset.recording_ids == recording + indices = np.flatnonzero(all_rows & canonical) + if not len(indices): + raise ValueError(f"Recording {recording} has no canonical feature windows") + if len(np.unique(dataset.y[all_rows])) != 1 or len(np.unique(dataset.groups[all_rows])) != 1: + raise ValueError(f"Recording {recording} must have one label and one group") + representative.append(indices[0]) + representative = np.asarray(representative, dtype=np.int64) + holdouts = grouped_splits(dataset.y[representative], dataset.groups[representative], n_splits=holdout_folds, seed=seed) + if holdout_fold < 0 or holdout_fold >= len(holdouts): + raise ValueError(f"holdout-fold must be in [0, {len(holdouts) - 1}] for these groups") + development_rep, holdout_rep = (representative[indices] for indices in holdouts[holdout_fold]) + development_groups, holdout_groups = np.unique(dataset.groups[development_rep]), np.unique(dataset.groups[holdout_rep]) + development = np.flatnonzero(np.isin(dataset.groups, development_groups)) + holdout = np.flatnonzero(np.isin(dataset.groups, holdout_groups) & canonical) + cv = grouped_splits(dataset.y[development_rep], dataset.groups[development_rep], n_splits=cv_folds, seed=seed) + folds, serialized = [], [] + for train_rep, validation_rep in cv: + train_groups = np.unique(dataset.groups[development_rep[train_rep]]) + validation_groups = np.unique(dataset.groups[development_rep[validation_rep]]) + train = np.flatnonzero(np.isin(dataset.groups, train_groups)) + validation = np.flatnonzero(np.isin(dataset.groups, validation_groups) & canonical) + folds.append((train, validation)) + serialized.append({"train_groups": train_groups.tolist(), "validation_groups": validation_groups.tolist(), + "train_recordings": np.unique(dataset.recording_ids[train]).tolist(), + "validation_recordings": np.unique(dataset.recording_ids[validation]).tolist(), + "canonical_validation_windows": len(validation)}) + description = {"seed": seed, "stratification_unit": "one canonical representative per recording", + "grouping": "dataset groups; recording UTC day unless overridden by manifest", + "requested_holdout_folds": holdout_folds, "actual_holdout_folds": len(holdouts), + "holdout_fold": holdout_fold, "requested_cv_folds": cv_folds, "actual_cv_folds": len(cv), + "development_groups": development_groups.tolist(), "holdout_groups": holdout_groups.tolist(), + "development_recordings": np.unique(dataset.recording_ids[development]).tolist(), + "holdout_recordings": np.unique(dataset.recording_ids[holdout]).tolist(), + "holdout_canonical_windows": len(holdout), "folds": serialized} + return development, holdout, folds, description + + +def _training_indices(indices, augmented, spec): + return indices if spec["augmentation"] else indices[~augmented[indices]] + + +def _build(spec, X, y, groups, seed): + if spec["family"] == "stacking": + model = build_stacking(spec["params"]["base_specs"], X, y, groups, seed=seed) + model.set_params(passthrough=spec["params"]["passthrough"]) + model.final_estimator.set_params(C=spec["params"]["C"]) + return model + return build_model(spec, X, y, groups, seed=seed) + + +def _probabilities(model, X): + if not np.array_equal(model.classes_, [0, 1]): + raise ValueError("Expected classes [0, 1] with focused probability in column 1") + return np.asarray(model.predict_proba(X), dtype=np.float64)[:, 1] + + +def evaluate_candidate(spec, X, y, groups, recording_ids, augmented, folds, seed): + """Worker entry point; folds contain development rows exclusively.""" + try: + validation_rows, validation_probability, fold_metrics, summaries = [], [], [], [] + with threadpool_limits(limits=1): + for fold_number, (train_rows, validation) in enumerate(folds): + train = _training_indices(train_rows, augmented, spec) + model = _build(spec, X[train], y[train], groups[train], seed + fold_number) + fit_model(model, X[train], y[train], recording_ids[train]) + probability = _probabilities(model, X[validation]) + fold_metrics.append(score_predictions(y[validation], probability, recording_ids[validation])) + summaries.append(model._brainflow_fit_summary) + validation_rows.append(validation) + validation_probability.append(probability) + rows, probability = np.concatenate(validation_rows), np.concatenate(validation_probability) + return {"spec": spec, "status": "ok", "folds": fold_metrics, + "mean_macro_f1": float(np.mean([fold["window"]["macro_f1"] for fold in fold_metrics])), + "std_macro_f1": float(np.std([fold["window"]["macro_f1"] for fold in fold_metrics])), + "pooled": score_predictions(y[rows], probability, recording_ids[rows]), "fit_summaries": summaries} + except Exception as error: + return {"spec": spec, "status": "failed", "error": f"{type(error).__name__}: {error}", + "traceback": traceback.format_exc(limit=5)} + + +def _spec_key(spec): + return json.dumps({key: spec[key] for key in ("family", "params", "augmentation")}, sort_keys=True) + + +def _ranking(result): + return (-result["mean_macro_f1"], -result["pooled"]["window"]["macro_f1"], + -result["pooled"]["window"]["roc_auc"], result["spec"]["name"]) + + +def run_search(specs, dataset, folds, args, checkpoint): + existing = {_spec_key(result["spec"]) for result in checkpoint["results"]} + pending = [] + for spec in specs: + if _spec_key(spec) not in existing: + pending.append(spec) + existing.add(_spec_key(spec)) + if not pending: + return + logging.info("Evaluating %d candidates with %d workers", len(pending), args.jobs) + with parallel_config(backend="loky", inner_max_num_threads=1): + results = Parallel(n_jobs=args.jobs, return_as="generator_unordered")( + delayed(evaluate_candidate)(spec, dataset.X, dataset.y, dataset.groups, dataset.recording_ids, + dataset.augmented, folds, args.seed) for spec in pending) + for result in results: + checkpoint["results"].append(result) + _write_json(args.work_dir / "search.json", checkpoint) + if result["status"] == "ok": + logging.info("%s: CV macro F1 %.4f", result["spec"]["name"], result["mean_macro_f1"]) + else: + logging.error("%s failed: %s", result["spec"]["name"], result["error"]) + + +def _winners(results): + winners = {} + for result in sorted((item for item in results if item["status"] == "ok"), key=_ranking): + winners.setdefault(result["spec"]["family"], result) + return winners + + +def _versions(): + versions = {"python": platform.python_version(), "platform": platform.platform()} + for package in ("numpy", "scipy", "scikit-learn", "joblib", "threadpoolctl", "onnx", "skl2onnx", "onnxruntime"): + try: + versions[package] = importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError: + versions[package] = None + return versions + + +def _fingerprint(dataset, splits, quick): + sources = {name: _sha256(HERE / name) for name in + ("train_classifiers.py", "training_models.py", "training_data.py", "training_export.py")} + digest = hashlib.sha256() + for value in (dataset.X, dataset.y, dataset.groups, dataset.recording_ids, dataset.augmented): + value = np.ascontiguousarray(value) + digest.update(str((value.dtype, value.shape)).encode()) + digest.update(value.tobytes()) + identity = {"dataset_fingerprint": dataset.metadata.get("fingerprint"), "feature_arrays_sha256": digest.hexdigest(), + "sources": sources, "splits": splits, "quick": quick, "versions": _versions()} + return hashlib.sha256(_json(identity).encode()).hexdigest(), identity + + +def _write_report(args, report): + for winner in report["models"].values(): + winner["cv_summary"] = { + metric: {"mean": float(np.mean([fold["window"][metric] for fold in winner["cv"]["folds"]])), + "std": float(np.std([fold["window"][metric] for fold in winner["cv"]["folds"]]))} + for metric in ("macro_f1", "macro_precision", "macro_recall", "accuracy", "balanced_accuracy", + "roc_auc", "brier", "log_loss")} + lines = ["# Mindfulness classifier training", "", + "Selection used development group cross-validation. Holdout scores below precede refitting on all recordings.", "", + f"CV-selected family: **{report['selected_global_family']}**. The portable native default remains **logreg**.", "", + "## Cross-validation", "", + f"Means across {report['splits']['actual_cv_folds']} group-disjoint folds; F1 spread is the fold standard deviation, not a confidence interval.", "", + "| Family | Macro F1 (mean +/- SD) | Macro precision | Macro recall / balanced accuracy | ROC AUC |", + "| --- | ---: | ---: | ---: | ---: |"] + for family, winner in sorted(report["models"].items(), key=lambda item: -item[1]["cv"]["mean_macro_f1"]): + cv = winner["cv_summary"] + lines.append(f"| {family} | {cv['macro_f1']['mean']:.4f} +/- {cv['macro_f1']['std']:.4f} | {cv['macro_precision']['mean']:.4f} | {cv['balanced_accuracy']['mean']:.4f} | {cv['roc_auc']['mean']:.4f} |") + lines.extend(["", "## Frozen holdout", "", + "These scores are reported after model selection; they are not used to choose hyperparameters or model families.", "", + "| Family | Window macro F1 | Window accuracy | Recording accuracy | Window ROC AUC |", + "| --- | ---: | ---: | ---: | ---: |"]) + for family, winner in report["models"].items(): + holdout = winner["holdout"] + lines.append(f"| {family} | {holdout['window']['macro_f1']:.4f} | {holdout['window']['accuracy']:.4f} | {holdout['recording']['accuracy']:.4f} | {holdout['window']['roc_auc']:.4f} |") + baseline = report["historical_native_baseline"]["holdout"] + lines.extend([f"| Historical native weights on current DSP | {baseline['window']['macro_f1']:.4f} | {baseline['window']['accuracy']:.4f} | {baseline['recording']['accuracy']:.4f} | {baseline['window']['roc_auc']:.4f} |", "", + f"Canonical windows: {report['dataset']['canonical_windows']}; augmented training windows: {report['dataset']['augmented_windows']}; recordings: {report['dataset']['recordings']}; groups: {report['dataset']['groups']}.", "", + "Window metrics give each recording equal total weight. Recording metrics threshold mean window probability. Selection maximizes mean fold window macro F1 at probability 0.5.", "", + "## Limitations", ""]) + lines.extend("- " + limitation for limitation in report["limitations"]) + lines.extend(["", "## Holdout sensitivity within 2020", "", + "This descriptive slice uses recording timestamps and is not used to select models.", "", + "| Family | Recordings | Macro F1 | Accuracy |", + "| --- | ---: | ---: | ---: |"]) + for family, winner in report["models"].items(): + sensitivity = winner["holdout_2020_only"] + if sensitivity["status"] == "ok": + lines.append(f"| {family} | {sensitivity['recording_count']} | {sensitivity['window']['macro_f1']:.4f} | {sensitivity['window']['accuracy']:.4f} |") + else: + lines.append(f"| {family} | unavailable | - | - |") + lines.extend(["", "The JSON report includes splits, candidate scores, extraction provenance, model hashes, export parity, and versions.", + "Evaluation ONNX files and holdout_predictions.npz in the work directory preserve the evaluation. Production ONNX files are refit on all groups and have no independent test score.", ""]) + for directory in {args.work_dir, args.output_dir}: + _write_json(directory / "training_report.json", report) + (directory / "training_report.md").write_text("\n".join(lines), encoding="utf-8") + + +def parse_args(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--board-id", type=int) + parser.add_argument("--manifest", type=Path) + parser.add_argument("--data-dir", type=Path, default=HERE / "data") + parser.add_argument("--cache", type=Path) + parser.add_argument("--work-dir", type=Path, default=ROOT / "build" / "classifier_training") + parser.add_argument("--output-dir", type=Path, default=HERE) + parser.add_argument("--jobs", type=int, default=6) + parser.add_argument("--quick", action="store_true", help="Small search for smoke tests") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--cv-folds", "--cvfolds", type=int, default=4) + parser.add_argument("--holdout-folds", "--holdoutfolds", type=int, default=5) + parser.add_argument("--holdout-fold", "--holdoutfold", type=int, default=0) + parser.add_argument("--extract-only", action="store_true") + parser.add_argument("--reuse-dataset", action="store_true", + help="Deprecated: validated NPZ cache is reused automatically; legacy pickles are unsupported") + parser.add_argument("--window-seconds", type=float, default=8.0) + parser.add_argument("--augmentation-windows", type=float, nargs="*", default=[5.0, 12.0]) + parser.add_argument("--max-windows-per-recording", type=int, default=128) + parser.add_argument("--skip-seconds", type=float, default=10.0) + parser.add_argument("--channel-subsets", action="store_true", help="Add leave-one-channel-out training augmentation") + args = parser.parse_args(argv) + if args.jobs < 1 or args.cv_folds < 2 or args.holdout_folds < 2: + parser.error("jobs must be positive and CV/holdout folds must be at least two") + args.work_dir, args.output_dir = args.work_dir.resolve(), args.output_dir.resolve() + args.cache = (args.cache or args.work_dir / "features.npz").resolve() + return args + + +def main(argv=None): + args = parse_args(argv) + args.work_dir.mkdir(parents=True, exist_ok=True) + args.output_dir.mkdir(parents=True, exist_ok=True) + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", + handlers=[logging.StreamHandler(), logging.FileHandler( + args.work_dir / "training.log", encoding="utf-8")]) + dataset = extract_dataset(args.data_dir, board_id=args.board_id, manifest=args.manifest, + window_seconds=args.window_seconds, augmentation_windows=args.augmentation_windows, + max_windows_per_recording=args.max_windows_per_recording, seed=args.seed, + cache_path=args.cache, workers=args.jobs, skip_seconds=args.skip_seconds, + channel_subsets=True if args.channel_subsets else None) + logging.info("Features: %d canonical, %d augmented, %d recordings, %d groups", int((~dataset.augmented).sum()), + int(dataset.augmented.sum()), len(np.unique(dataset.recording_ids)), len(np.unique(dataset.groups))) + if args.extract_only: + return 0 + development, holdout, folds, splits = freeze_splits(dataset, args.cv_folds, args.holdout_folds, args.holdout_fold, args.seed) + fingerprint, identity = _fingerprint(dataset, splits, args.quick) + _write_json(args.work_dir / "splits.json", {"fingerprint": fingerprint, **splits}) + checkpoint = {"fingerprint": fingerprint, "identity": identity, "results": []} + checkpoint_path = args.work_dir / "search.json" + prior_attempts = [] + if checkpoint_path.is_file(): + try: + previous = json.loads(checkpoint_path.read_text(encoding="utf-8")) + if previous.get("fingerprint") == fingerprint: + checkpoint = previous + logging.info("Resuming %d checkpointed candidates", len(checkpoint["results"])) + else: + logging.info("Ignoring checkpoint with changed data, code, splits or environment") + prior_attempts.append({"fingerprint": previous.get("fingerprint"), + "completed_candidates": len(previous.get("results", [])), + "reason": "Pipeline or environment changed; prior search is not reused"}) + except (ValueError, KeyError, OSError) as error: + logging.warning("Ignoring unreadable search checkpoint: %s", error) + _write_json(checkpoint_path, checkpoint) + initial_specs = candidate_specs(seed=args.seed, quick=args.quick) + run_search(initial_specs, dataset, folds, args, checkpoint) + initial_keys = {_spec_key(spec) for spec in initial_specs} + alternates = [] + for family in sorted({spec["family"] for spec in initial_specs}): + successful = sorted((item for item in checkpoint["results"] if item["status"] == "ok" + and item["spec"]["family"] == family + and _spec_key(item["spec"]) in initial_keys), key=_ranking) + for result in successful[:3]: + spec = dict(result["spec"]) + spec["augmentation"] = not spec["augmentation"] + spec["name"] += "_augmented" if spec["augmentation"] else "_canonical" + alternates.append(spec) + run_search(alternates, dataset, folds, args, checkpoint) + base_winners = _winners(checkpoint["results"]) + expected = set(FILENAMES) - {"stacking"} + if expected - set(base_winners): + raise RuntimeError(f"No successful candidate for families: {sorted(expected - set(base_winners))}") + base_specs = [base_winners[family]["spec"] for family in ("logreg", "random_forest", "knn", "mlp")] + stacks = [{"name": f"stacking_C{strength:g}_passthrough{int(passthrough)}", "family": "stacking", + "params": {"C": strength, "passthrough": passthrough, "base_specs": base_specs}, "augmentation": False} + for strength in ([1.0] if args.quick else [0.1, 1.0, 10.0]) for passthrough in (False, True)] + run_search(stacks, dataset, folds, args, checkpoint) + winners = _winners(checkpoint["results"]) + if set(FILENAMES) - set(winners): + raise RuntimeError("Stacking has no successful group-isolated candidate") + global_winner = min(winners.values(), key=_ranking) + selection = {"fingerprint": fingerprint, "selection_metric": "mean fold recording-weighted window macro F1", + "selected_global_family": global_winner["spec"]["family"], "selected_global_spec": global_winner["spec"], + "families": {family: result["spec"] for family, result in winners.items()}} + _write_json(args.work_dir / "selection.json", selection) + logging.info("Selection frozen before holdout evaluation. CV winner: %s", global_winner["spec"]["name"]) + report = {"schema_version": 1, "created_utc": datetime.now(timezone.utc).isoformat(), "fingerprint": fingerprint, + "identity": identity, "versions": _versions(), + "prior_attempts": prior_attempts, + "board_metadata_source": "explicit --board-id" if args.board_id is not None else "manifest", + "arguments": {key: str(value) if isinstance(value, Path) else value for key, value in vars(args).items()}, + "dataset": {"canonical_windows": int((~dataset.augmented).sum()), "augmented_windows": int(dataset.augmented.sum()), + "recordings": len(np.unique(dataset.recording_ids)), "groups": len(np.unique(dataset.groups)), + "provenance": dataset.metadata}, "splits": splits, "selection": selection, + "selected_global_family": selection["selected_global_family"], "native_default_family": "logreg", + "search_results": checkpoint["results"], "models": {}, "limitations": [ + "Subject identities are unavailable unless supplied in the manifest; day grouping does not establish subject-independent generalization.", + "Collection era and class are confounded in the supplied dataset: focused recordings are from 2020, while many relaxed recordings are from 2022. Models may learn session or hardware differences.", + "Only one frozen grouped holdout is evaluated; small numbers of independent groups make scores uncertain. Inspect recording-level results as well as pooled metrics.", + "CV selects hyperparameters, augmentation and stacking bases; selected CV scores are optimistic. The untouched holdout evaluates this selection procedure.", + "Aborted export-validation attempts may precede this run. Pipeline fixes are not selected using holdout scores; rerunning the same split does not create a new independent validation study.", + "Labels describe the recording task and are not independently validated mental-state measurements.", + "Holdout scores belong to development-only evaluation models. Final production models are refit on all recordings, including the former holdout."]} + baseline_probability = expit(dataset.X[holdout] @ LEGACY_COEFFICIENTS) + report["historical_native_baseline"] = {"coefficients": LEGACY_COEFFICIENTS.tolist(), "intercept": 0.0, + "feature_source": "current DSP, not historical extraction", + "holdout": score_predictions(dataset.y[holdout], baseline_probability, dataset.recording_ids[holdout]), + "holdout_2020_only": year_sensitivity(dataset, holdout, baseline_probability)} + predictions = {"X": dataset.X[holdout], "y": dataset.y[holdout], "recording_ids": dataset.recording_ids[holdout], + "groups": dataset.groups[holdout], "historical_native": baseline_probability} + probes = parity_probes(dataset, development[~dataset.augmented[development]], args.seed) + staging = args.work_dir / "release_staging" / fingerprint + staging.mkdir(parents=True, exist_ok=True) + publications = [] + all_rows, final_logistic = np.arange(len(dataset.y)), None + with threadpool_limits(limits=1): + for family in FILENAMES: + result, spec = winners[family], winners[family]["spec"] + logging.info("Evaluating frozen %s winner", family) + train = _training_indices(development, dataset.augmented, spec) + model = _build(spec, dataset.X[train], dataset.y[train], dataset.groups[train], args.seed) + fit_model(model, dataset.X[train], dataset.y[train], dataset.recording_ids[train]) + probability = _probabilities(model, dataset.X[holdout]) + predictions[family] = probability + staged_evaluation = staging / "evaluation_models" / FILENAMES[family] + evaluation_target = args.work_dir / "evaluation_models" / FILENAMES[family] + evaluation = export_model(model, staged_evaluation, probes) + evaluation["path"] = str(evaluation_target) + publications.append((staged_evaluation, evaluation_target)) + entry = {"spec": spec, "cv": result, "holdout": score_predictions(dataset.y[holdout], probability, dataset.recording_ids[holdout]), + "holdout_2020_only": year_sensitivity(dataset, holdout, probability), + "evaluation_export": evaluation, "evaluation_fit": model._brainflow_fit_summary} + logging.info("Refitting %s on all recording groups", family) + train = _training_indices(all_rows, dataset.augmented, spec) + final_model = _build(spec, dataset.X[train], dataset.y[train], dataset.groups[train], args.seed) + fit_model(final_model, dataset.X[train], dataset.y[train], dataset.recording_ids[train]) + staged_production = staging / "production_models" / FILENAMES[family] + production_target = args.output_dir / FILENAMES[family] + entry["production_export"] = export_model(final_model, staged_production, probes) + entry["production_export"]["path"] = str(production_target) + publications.append((staged_production, production_target)) + entry["production_fit"] = final_model._brainflow_fit_summary + report["models"][family] = entry + if family == "logreg": + final_logistic = final_model + _write_json(args.work_dir / "training_report.partial.json", report) + with (args.work_dir / "holdout_predictions.npz.tmp").open("wb") as stream: + np.savez_compressed(stream, **predictions) + (args.work_dir / "holdout_predictions.npz.tmp").replace(args.work_dir / "holdout_predictions.npz") + native_path = (HERE.parent / "generated" / "mindfulness_model.cpp" if args.output_dir == HERE + else args.output_dir / "mindfulness_model.cpp") + staged_native = staging / "mindfulness_model.cpp" + native = write_native_logistic(final_logistic, staged_native) + native_probability = expit(probes @ np.asarray(native["coefficients"]) + native["intercept"]) + native["parity_max_absolute_error"] = float(np.max(np.abs(native_probability - _probabilities(final_logistic, probes)))) + if native["parity_max_absolute_error"] > 1e-10: + raise RuntimeError("Folded native logistic coefficients failed probability parity") + report["native_logistic"] = {"path": str(native_path), **native} + publications.append((staged_native, native_path)) + # Only publish after every evaluation/production graph and native parameters + # pass validation. Each replacement is atomic, including across source drives. + for source, target in publications: + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_suffix(target.suffix + ".tmp") + shutil.copyfile(source, temporary) + temporary.replace(target) + report["publication"] = {"validated_before_publish": True, "artifact_count": len(publications), + "staging_directory": str(staging), "atomicity": "per-file replacement"} + report["holdout_predictions_sha256"] = _sha256(args.work_dir / "holdout_predictions.npz") + report["completed_utc"] = datetime.now(timezone.utc).isoformat() + _write_report(args, report) + logging.info("Training complete: %s", args.output_dir / "training_report.md") + return 0 + + +if __name__ == "__main__": try: - eeg_names = BoardShim.get_eeg_names(board_id) - selected_channels = list() - if blacklisted_channels is None: - blacklisted_channels = set() - for i, channel in enumerate(eeg_names): - if not channel in blacklisted_channels: - selected_channels.append(eeg_channels[i]) - eeg_channels = selected_channels - except Exception as e: - logging.warn(str(e)) - logging.info('channels to use: %s' % str(eeg_channels)) - return eeg_channels - -def print_dataset_info(data): - x, y = data - first_class_ids = [idx[0] for idx in enumerate(y) if idx[1] == 0] - second_class_ids = [idx[0] for idx in enumerate(y) if idx[1] == 1] - x_first_class = list() - x_second_class = list() - - for i, x_data in enumerate(x): - if i in first_class_ids: - x_first_class.append(x_data.tolist()) - elif i in second_class_ids: - x_second_class.append(x_data.tolist()) - second_class_np = np.array(x_second_class) - first_class_np = np.array(x_first_class) - - logging.info('1st Class Dataset Info:') - logging.info('Mean:') - logging.info(np.mean(first_class_np, axis=0)) - logging.info('2nd Class Dataset Info:') - logging.info('Mean:') - logging.info(np.mean(second_class_np, axis=0)) - -def train_regression_mindfulness(data): - model = LogisticRegression(solver='liblinear', max_iter=4000, - penalty='l2', random_state=2, fit_intercept=False, intercept_scaling=3) - logging.info('#### Logistic Regression ####') - scores = cross_val_score(model, data[0], data[1], cv=5, scoring='f1_macro', n_jobs=8) - logging.info('f1 macro %s' % str(scores)) - model.fit(data[0], data[1]) - logging.info(model.intercept_) - logging.info(model.coef_) - - initial_type = [('mindfulness_input', FloatTensorType([1, 5]))] - onx = convert_sklearn(model, initial_types=initial_type, target_opset=11, options={type(model): {'zipmap': False}}) - with open('logreg_mindfulness.onnx', 'wb') as f: - f.write(onx.SerializeToString()) - write_model(model.intercept_, model.coef_, 'mindfulness') - -def train_svm_mindfulness(data): - model = SVC(kernel='linear', verbose=True, random_state=1, class_weight='balanced', probability=True) - logging.info('#### SVM ####') - model.fit(data[0], data[1]) - initial_type = [('mindfulness_input', FloatTensorType([1, 5]))] - onx = convert_sklearn(model, initial_types=initial_type, target_opset=11, options={type(model): {'zipmap': False}}) - with open('svm_mindfulness.onnx', 'wb') as f: - f.write(onx.SerializeToString()) - -def train_random_forest_mindfulness(data): - model = RandomForestClassifier(class_weight='balanced', random_state=1, n_jobs=15, n_estimators=200) - logging.info('#### Random Forest ####') - scores = cross_val_score(model, data[0], data[1], cv=5, scoring='f1_macro', n_jobs=15) - logging.info('f1 macro %s' % str(scores)) - model.fit(data[0], data[1]) - - initial_type = [('mindfulness_input', FloatTensorType([1, 5]))] - onx = convert_sklearn(model, initial_types=initial_type, target_opset=11, options={type(model): {'zipmap': False}}) - with open('forest_mindfulness.onnx', 'wb') as f: - f.write(onx.SerializeToString()) - -def train_knn_mindfulness(data): - model = KNeighborsClassifier(n_neighbors=10, n_jobs=8) - logging.info('#### KNN ####') - scores = cross_val_score(model, data[0], data[1], cv=5, scoring='f1_macro', n_jobs=15) - logging.info('f1 macro %s' % str(scores)) - model.fit(data[0], data[1]) - - initial_type = [('mindfulness_input', FloatTensorType([1, 5]))] - onx = convert_sklearn(model, initial_types=initial_type, target_opset=11, options={type(model): {'zipmap': False}}) - with open('knn_mindfulness.onnx', 'wb') as f: - f.write(onx.SerializeToString()) - -def train_mlp_mindfulness(data): - model = MLPClassifier(hidden_layer_sizes=(100, 20),learning_rate='adaptive', max_iter=1000, - random_state=1, verbose=True, activation='logistic', solver='adam') - logging.info('#### MLP ####') - scores = cross_val_score(model, data[0], data[1], cv=5, scoring='f1_macro', n_jobs=15) - logging.info('f1 macro %s' % str(scores)) - model.fit(data[0], data[1]) - - initial_type = [('mindfulness_input', FloatTensorType([1, 5]))] - onx = convert_sklearn(model, initial_types=initial_type, target_opset=11, options={type(model): {'zipmap': False}}) - with open('mlp_mindfulness.onnx', 'wb') as f: - f.write(onx.SerializeToString()) - -def train_stacking_classifier(data): - model1 = MLPClassifier(hidden_layer_sizes=(100, 20),learning_rate='adaptive', max_iter=1000, - random_state=1, verbose=True, activation='logistic', solver='adam') - model2 = KNeighborsClassifier(n_neighbors=10, n_jobs=8) - model3 = RandomForestClassifier(class_weight='balanced', random_state=1, n_jobs=8, n_estimators=200) - meta_model = LogisticRegression() - sclf = StackingClassifier(estimators=[('MLPClassifier', model1), ('KNeighborsClassifier', model2), ('RandomForestClassifier', model3)], - final_estimator=meta_model, n_jobs=15, - passthrough=True) - logging.info('#### Stacking ####') - scores = cross_val_score(sclf, data[0], data[1], cv=5, scoring='f1_macro', n_jobs=15) - logging.info('f1 macro %s' % str(scores)) - sclf.fit(data[0], data[1]) - - initial_type = [('mindfulness_input', FloatTensorType([1, 5]))] - onx = convert_sklearn(sclf, initial_types=initial_type, target_opset=11, options={type(sclf): {'zipmap': False}}) - with open('stacking_mindfulness.onnx', 'wb') as f: - f.write(onx.SerializeToString()) - -def main(): - logging.basicConfig(level=logging.INFO) - parser = argparse.ArgumentParser() - parser.add_argument('--reuse-dataset', action='store_true') - args = parser.parse_args() - - if args.reuse_dataset: - with open('dataset_x.pickle', 'rb') as f: - dataset_x = pickle.load(f) - with open('dataset_y.pickle', 'rb') as f: - dataset_y = pickle.load(f) - data = dataset_x, dataset_y - else: - data = prepare_data('relaxed', 'focused') - print_dataset_info(data) - train_regression_mindfulness(data) - train_svm_mindfulness(data) - train_knn_mindfulness(data) - train_random_forest_mindfulness(data) - train_mlp_mindfulness(data) - train_stacking_classifier(data) - - -if __name__ == '__main__': - main() + raise SystemExit(main()) + except Exception: + logging.exception("Training failed; validated search results remain in the work directory") + raise diff --git a/src/utils/inc/brainflow_array.h b/src/utils/inc/brainflow_array.h index 5dba6e732..77f7133a1 100644 --- a/src/utils/inc/brainflow_array.h +++ b/src/utils/inc/brainflow_array.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -23,6 +24,10 @@ static inline int product (const std::array &array) for (int i = 0; i < N; i++) { + if (array[i] < 0 || (array[i] != 0 && result > std::numeric_limits::max () / array[i])) + { + throw BrainFlowException ("invalid array dimensions", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } result *= array[i]; } @@ -69,6 +74,10 @@ static inline std::array make_stride (const std::array &size) stride[N - 1] = 1; for (int i = (int)N - 2; i >= 0; i--) { + if (size[i + 1] < 0 || (size[i + 1] != 0 && stride[i + 1] > std::numeric_limits::max () / size[i + 1])) + { + throw BrainFlowException ("invalid array dimensions", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } stride[i] = stride[i + 1] * size[i + 1]; } @@ -86,6 +95,14 @@ class BrainFlowArray std::array stride; T *origin; + void validate_index (int index, int dim) const + { + if (index < 0 || index >= size[dim]) + { + throw BrainFlowException ("out of range", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } + } + public: friend std::ostream &operator<<<> (std::ostream &out, const BrainFlowArray &array); @@ -113,7 +130,7 @@ class BrainFlowArray } explicit BrainFlowArray (int size0) - : length (size0) + : length (product (make_array (size0))) , size (make_array (size0)) , stride (make_stride<1> (make_array (size0))) , origin (nullptr) @@ -124,7 +141,7 @@ class BrainFlowArray } BrainFlowArray (int size0, int size1) - : length (size0 * size1) + : length (product (make_array (size0, size1))) , size (make_array (size0, size1)) , stride (make_stride<2> (make_array (size0, size1))) , origin (nullptr) @@ -135,7 +152,7 @@ class BrainFlowArray } BrainFlowArray (int size0, int size1, int size2) - : length (size0 * size1 * size2) + : length (product (make_array (size0, size1, size2))) , size (make_array (size0, size1, size2)) , stride (make_stride<3> (make_array (size0, size1, size2))) , origin (nullptr) @@ -146,7 +163,7 @@ class BrainFlowArray } BrainFlowArray (T *ptr, int size0) - : length (size0) + : length (product (make_array (size0))) , size (make_array (size0)) , stride (make_stride<1> (make_array (size0))) , origin (nullptr) @@ -157,7 +174,7 @@ class BrainFlowArray } BrainFlowArray (T *ptr, int size0, int size1) - : length (size0 * size1) + : length (product (make_array (size0, size1))) , size (make_array (size0, size1)) , stride (make_stride<2> (make_array (size0, size1))) , origin (nullptr) @@ -168,7 +185,7 @@ class BrainFlowArray } BrainFlowArray (T *ptr, int size0, int size1, int size2) - : length (size0 * size1 * size2) + : length (product (make_array (size0, size1, size2))) , size (make_array (size0, size1, size2)) , stride (make_stride<3> (make_array (size0, size1, size2))) , origin (nullptr) @@ -266,6 +283,10 @@ class BrainFlowArray int get_stride (int dim) const { + if (dim < 0 || dim >= Dim) + { + throw BrainFlowException ("invalid dim argument", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } return stride[dim]; } @@ -294,6 +315,7 @@ class BrainFlowArray /// access element at index T &at (int index0) { + validate_index (index0, 0); int idx = index0 * get_stride (0); if (idx >= length) { @@ -306,6 +328,7 @@ class BrainFlowArray /// access element at index const T &at (int index0) const { + validate_index (index0, 0); int idx = index0 * get_stride (0); if (idx >= length) { @@ -330,12 +353,20 @@ class BrainFlowArray /// access element at index T &operator[] (int index0) { + if (index0 < 0 || index0 >= length) + { + throw BrainFlowException ("out of range", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } return origin[index0]; } /// access element at index const T &operator[] (int index0) const { + if (index0 < 0 || index0 >= length) + { + throw BrainFlowException ("out of range", (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR); + } return origin[index0]; } @@ -343,6 +374,8 @@ class BrainFlowArray T &at (int index0, int index1) { static_assert (Dim >= 2, "BrainFlowArray dimension bounds error"); + validate_index (index0, 0); + validate_index (index1, 1); int idx = index0 * get_stride (0) + index1 * get_stride (1); if (idx >= length) { @@ -356,6 +389,8 @@ class BrainFlowArray const T &at (int index0, int index1) const { static_assert (Dim >= 2, "BrainFlowArray dimension bounds error"); + validate_index (index0, 0); + validate_index (index1, 1); int idx = index0 * get_stride (0) + index1 * get_stride (1); if (idx >= length) { @@ -381,6 +416,9 @@ class BrainFlowArray T &at (int index0, int index1, int index2) { static_assert (Dim >= 3, "BrainFlowArray dimension bounds error"); + validate_index (index0, 0); + validate_index (index1, 1); + validate_index (index2, 2); int idx = index0 * get_stride (0) + index1 * get_stride (1) + index2 * get_stride (2); if (idx >= length) { @@ -394,6 +432,9 @@ class BrainFlowArray const T &at (int index0, int index1, int index2) const { static_assert (Dim >= 3, "BrainFlowArray dimension bounds error"); + validate_index (index0, 0); + validate_index (index1, 1); + validate_index (index2, 2); int idx = index0 * get_stride (0) + index1 * get_stride (1) + index2 * get_stride (2); if (idx >= length) { @@ -424,27 +465,27 @@ class BrainFlowArray /// use it to get pointer to row in matrix or to get pointer to matrix from 3d array T *get_address (int index0) { - return &origin[index0 * get_stride (0)]; + return &at (index0); } /// use it to get pointer to row in 3d array T *get_address (int index0, int index1) { static_assert (Dim >= 2, "Dim should be >= 2"); - return &origin[index0 * get_stride (0) + index1 * get_stride (1)]; + return &at (index0, index1); } /// use it to get pointer to row in matrix or to get pointer to matrix from 3d array const T *get_address (int index0) const { - return &origin[index0 * get_stride (0)]; + return &at (index0); } /// use it to get pointer to row in 3d array const T *get_address (int index0, int index1) const { static_assert (Dim >= 2, "Dim should be >= 2"); - return &origin[index0 * get_stride (0) + index1 * get_stride (1)]; + return &at (index0, index1); } /// fill already preallocated buffer diff --git a/swift_package/Sources/BrainFlow/DataFilter.swift b/swift_package/Sources/BrainFlow/DataFilter.swift index 89008de40..7a0b000b6 100644 --- a/swift_package/Sources/BrainFlow/DataFilter.swift +++ b/swift_package/Sources/BrainFlow/DataFilter.swift @@ -3,7 +3,7 @@ import Foundation public enum DataFilter { public static func set_log_level(_ log_level: Int) throws { try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.set_log_level_data_handler(CInt(log_level)), "Error in set_log_level") + try checkBrainFlowExitCode(native.set_log_level_data_handler(checkedCInt(log_level)), "Error in set_log_level") } } @@ -35,7 +35,7 @@ public enum DataFilter { var mutableMessage = Array(message.utf8CString) try mutableMessage.withUnsafeMutableBufferPointer { pointer in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.log_message_data_handler(CInt(log_level), pointer.baseAddress), "Error in log_message") + try checkBrainFlowExitCode(native.log_message_data_handler(checkedCInt(log_level), pointer.baseAddress), "Error in log_message") } } } @@ -54,7 +54,7 @@ public enum DataFilter { ) throws { try withMutableData(&data) { pointer, count in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_lowpass(pointer, CInt(count), CInt(sampling_rate), cutoff, CInt(order), CInt(filter_type), ripple), "Failed to perform lowpass") + try checkBrainFlowExitCode(native.perform_lowpass(pointer, checkedCInt(count), checkedCInt(sampling_rate), cutoff, checkedCInt(order), checkedCInt(filter_type), ripple), "Failed to perform lowpass") } } } @@ -80,7 +80,7 @@ public enum DataFilter { ) throws { try withMutableData(&data) { pointer, count in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_highpass(pointer, CInt(count), CInt(sampling_rate), cutoff, CInt(order), CInt(filter_type), ripple), "Failed to perform highpass") + try checkBrainFlowExitCode(native.perform_highpass(pointer, checkedCInt(count), checkedCInt(sampling_rate), cutoff, checkedCInt(order), checkedCInt(filter_type), ripple), "Failed to perform highpass") } } } @@ -107,7 +107,7 @@ public enum DataFilter { ) throws { try withMutableData(&data) { pointer, count in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_bandpass(pointer, CInt(count), CInt(sampling_rate), start_freq, stop_freq, CInt(order), CInt(filter_type), ripple), "Failed to perform bandpass") + try checkBrainFlowExitCode(native.perform_bandpass(pointer, checkedCInt(count), checkedCInt(sampling_rate), start_freq, stop_freq, checkedCInt(order), checkedCInt(filter_type), ripple), "Failed to perform bandpass") } } } @@ -135,7 +135,7 @@ public enum DataFilter { ) throws { try withMutableData(&data) { pointer, count in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_bandstop(pointer, CInt(count), CInt(sampling_rate), start_freq, stop_freq, CInt(order), CInt(filter_type), ripple), "Failed to perform bandstop") + try checkBrainFlowExitCode(native.perform_bandstop(pointer, checkedCInt(count), checkedCInt(sampling_rate), start_freq, stop_freq, checkedCInt(order), checkedCInt(filter_type), ripple), "Failed to perform bandstop") } } } @@ -155,7 +155,7 @@ public enum DataFilter { public static func remove_environmental_noise(data: inout [Double], sampling_rate: Int, noise_type: Int) throws { try withMutableData(&data) { pointer, count in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.remove_environmental_noise(pointer, CInt(count), CInt(sampling_rate), CInt(noise_type)), "Failed to remove environmental noise") + try checkBrainFlowExitCode(native.remove_environmental_noise(pointer, checkedCInt(count), checkedCInt(sampling_rate), checkedCInt(noise_type)), "Failed to remove environmental noise") } } } @@ -167,7 +167,7 @@ public enum DataFilter { public static func perform_rolling_filter(data: inout [Double], period: Int, operation: Int) throws { try withMutableData(&data) { pointer, count in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_rolling_filter(pointer, CInt(count), CInt(period), CInt(operation)), "Failed to perform rolling filter") + try checkBrainFlowExitCode(native.perform_rolling_filter(pointer, checkedCInt(count), checkedCInt(period), checkedCInt(operation)), "Failed to perform rolling filter") } } } @@ -179,7 +179,7 @@ public enum DataFilter { public static func detrend(data: inout [Double], detrend_operation: Int) throws { try withMutableData(&data) { pointer, count in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.detrend(pointer, CInt(count), CInt(detrend_operation)), "Failed to detrend data") + try checkBrainFlowExitCode(native.detrend(pointer, checkedCInt(count), checkedCInt(detrend_operation)), "Failed to detrend data") } } } @@ -195,7 +195,7 @@ public enum DataFilter { try input.withUnsafeMutableBufferPointer { inputPtr in try output.withUnsafeMutableBufferPointer { outputPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_downsampling(inputPtr.baseAddress, CInt(data.count), CInt(period), CInt(operation), outputPtr.baseAddress), "Failed to perform downsampling") + try checkBrainFlowExitCode(native.perform_downsampling(inputPtr.baseAddress, checkedCInt(data.count), checkedCInt(period), checkedCInt(operation), outputPtr.baseAddress), "Failed to perform downsampling") } } } @@ -212,7 +212,10 @@ public enum DataFilter { decomposition_level: Int, extension_type: Int ) throws -> WaveletTransform { - guard decomposition_level > 0 else { throw invalidArguments("Invalid decomposition level") } + guard decomposition_level > 0, decomposition_level <= 100, !data.isEmpty, + data.count <= Int(CInt.max) - 82 * decomposition_level else { + throw invalidArguments("Invalid wavelet dimensions") + } var input = data var output = [Double](repeating: 0.0, count: data.count + 2 * decomposition_level * 41) var lengths = [CInt](repeating: 0, count: decomposition_level + 1) @@ -220,7 +223,7 @@ public enum DataFilter { try output.withUnsafeMutableBufferPointer { outputPtr in try lengths.withUnsafeMutableBufferPointer { lengthsPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_wavelet_transform(inputPtr.baseAddress, CInt(data.count), CInt(wavelet), CInt(decomposition_level), CInt(extension_type), outputPtr.baseAddress, lengthsPtr.baseAddress), "Failed to perform wavelet transform") + try checkBrainFlowExitCode(native.perform_wavelet_transform(inputPtr.baseAddress, checkedCInt(data.count), checkedCInt(wavelet), checkedCInt(decomposition_level), checkedCInt(extension_type), outputPtr.baseAddress, lengthsPtr.baseAddress), "Failed to perform wavelet transform") } } } @@ -245,6 +248,19 @@ public enum DataFilter { decomposition_level: Int, extension_type: Int ) throws -> [Double] { + guard original_data_len > 0, original_data_len <= Int(CInt.max), + original_data_len <= wavelet_output.coefficients.count, + decomposition_level > 0, decomposition_level <= 100, + wavelet_output.decomposition_lengths.count == decomposition_level + 1, + wavelet_output.coefficients.count <= Int(CInt.max), + CInt(exactly: wavelet) != nil, CInt(exactly: extension_type) != nil, + wavelet_output.decomposition_lengths.allSatisfy({ $0 > 0 && $0 <= Int(CInt.max) }) else { + throw invalidArguments("Invalid wavelet metadata") + } + let coefficientCount = wavelet_output.decomposition_lengths.reduce(Int64(0)) { $0 + Int64($1) } + guard coefficientCount == Int64(wavelet_output.coefficients.count) else { + throw invalidArguments("Wavelet coefficient lengths do not match") + } var coeffs = wavelet_output.coefficients var lengths = wavelet_output.decomposition_lengths.map(CInt.init) var output = [Double](repeating: 0.0, count: original_data_len) @@ -252,7 +268,7 @@ public enum DataFilter { try lengths.withUnsafeMutableBufferPointer { lengthsPtr in try output.withUnsafeMutableBufferPointer { outputPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_inverse_wavelet_transform(coeffsPtr.baseAddress, CInt(original_data_len), CInt(wavelet), CInt(decomposition_level), CInt(extension_type), lengthsPtr.baseAddress, outputPtr.baseAddress), "Failed to perform inverse wavelet transform") + try checkBrainFlowExitCode(native.perform_inverse_wavelet_transform_checked(coeffsPtr.baseAddress, checkedCInt(coeffsPtr.count), checkedCInt(original_data_len), checkedCInt(wavelet), checkedCInt(decomposition_level), checkedCInt(extension_type), lengthsPtr.baseAddress, checkedCInt(lengthsPtr.count), outputPtr.baseAddress, checkedCInt(outputPtr.count)), "Failed to perform inverse wavelet transform") } } } @@ -281,7 +297,7 @@ public enum DataFilter { ) throws { try withMutableData(&data) { pointer, count in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_wavelet_denoising(pointer, CInt(count), CInt(wavelet), CInt(decomposition_level), CInt(wavelet_denoising), CInt(threshold), CInt(extension_type), CInt(noise_level)), "Failed to perform wavelet denoising") + try checkBrainFlowExitCode(native.perform_wavelet_denoising(pointer, checkedCInt(count), checkedCInt(wavelet), checkedCInt(decomposition_level), checkedCInt(wavelet_denoising), checkedCInt(threshold), checkedCInt(extension_type), checkedCInt(noise_level)), "Failed to perform wavelet denoising") } } } @@ -309,7 +325,7 @@ public enum DataFilter { try input.withUnsafeMutableBufferPointer { inputPtr in try output.withUnsafeMutableBufferPointer { outputPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.restore_data_from_wavelet_detailed_coeffs(inputPtr.baseAddress, CInt(data.count), CInt(wavelet), CInt(decomposition_level), CInt(level_to_restore), outputPtr.baseAddress), "Failed to restore wavelet detailed coeffs") + try checkBrainFlowExitCode(native.restore_data_from_wavelet_detailed_coeffs(inputPtr.baseAddress, checkedCInt(data.count), checkedCInt(wavelet), checkedCInt(decomposition_level), checkedCInt(level_to_restore), outputPtr.baseAddress), "Failed to restore wavelet detailed coeffs") } } } @@ -331,7 +347,7 @@ public enum DataFilter { try input.withUnsafeMutableBufferPointer { inputPtr in try output.withUnsafeMutableBufferPointer { outputPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.detect_peaks_z_score(inputPtr.baseAddress, CInt(data.count), CInt(lag), threshold, influence, outputPtr.baseAddress), "Failed to detect peaks") + try checkBrainFlowExitCode(native.detect_peaks_z_score(inputPtr.baseAddress, checkedCInt(data.count), checkedCInt(lag), threshold, influence, outputPtr.baseAddress), "Failed to detect peaks") } } } @@ -350,21 +366,21 @@ public enum DataFilter { throw invalidArguments("CSP data must be rectangular") } var flattened = [Double]() - flattened.reserveCapacity(nEpochs * nChannels * nTimes) + flattened.reserveCapacity(try checkedBufferCount(nEpochs, nChannels, nTimes)) for epoch in data { for channel in epoch { flattened.append(contentsOf: channel) } } var mutableLabels = labels - var filters = [Double](repeating: 0.0, count: nChannels * nChannels) + var filters = [Double](repeating: 0.0, count: try checkedBufferCount(nChannels, nChannels)) var eigenvalues = [Double](repeating: 0.0, count: nChannels) try flattened.withUnsafeMutableBufferPointer { dataPtr in try mutableLabels.withUnsafeMutableBufferPointer { labelsPtr in try filters.withUnsafeMutableBufferPointer { filtersPtr in try eigenvalues.withUnsafeMutableBufferPointer { eigenPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_csp(dataPtr.baseAddress, labelsPtr.baseAddress, CInt(nEpochs), CInt(nChannels), CInt(nTimes), filtersPtr.baseAddress, eigenPtr.baseAddress), "Failed to get CSP") + try checkBrainFlowExitCode(native.get_csp(dataPtr.baseAddress, labelsPtr.baseAddress, checkedCInt(nEpochs), checkedCInt(nChannels), checkedCInt(nTimes), filtersPtr.baseAddress, eigenPtr.baseAddress), "Failed to get CSP") } } } @@ -374,11 +390,13 @@ public enum DataFilter { } public static func get_window(window_function: Int, window_len: Int) throws -> [Double] { - guard window_len > 0 else { throw invalidArguments("window_len must be positive") } + guard window_len > 0, window_len <= Int(CInt.max) else { + throw invalidArguments("window_len must be a positive native integer") + } var output = [Double](repeating: 0.0, count: window_len) try output.withUnsafeMutableBufferPointer { pointer in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_window(CInt(window_function), CInt(window_len), pointer.baseAddress), "Failed to get window") + try checkBrainFlowExitCode(native.get_window(checkedCInt(window_function), checkedCInt(window_len), pointer.baseAddress), "Failed to get window") } } return output @@ -399,7 +417,7 @@ public enum DataFilter { try real.withUnsafeMutableBufferPointer { realPtr in try imag.withUnsafeMutableBufferPointer { imagPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_fft(inputPtr.baseAddress, CInt(inputCount), CInt(window), realPtr.baseAddress, imagPtr.baseAddress), "Failed to perform FFT") + try checkBrainFlowExitCode(native.perform_fft(inputPtr.baseAddress, checkedCInt(inputCount), checkedCInt(window), realPtr.baseAddress, imagPtr.baseAddress), "Failed to perform FFT") } } } @@ -423,13 +441,13 @@ public enum DataFilter { guard data.count >= 2 else { throw invalidArguments("FFT data must contain at least two bins") } var real = data.map(\.real) var imag = data.map(\.imag) - let restoredLength = (data.count - 1) * 2 + let restoredLength = try checkedBufferCount(data.count - 1, 2) var output = [Double](repeating: 0.0, count: restoredLength) try real.withUnsafeMutableBufferPointer { realPtr in try imag.withUnsafeMutableBufferPointer { imagPtr in try output.withUnsafeMutableBufferPointer { outputPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_ifft(realPtr.baseAddress, imagPtr.baseAddress, CInt(restoredLength), outputPtr.baseAddress), "Failed to perform IFFT") + try checkBrainFlowExitCode(native.perform_ifft(realPtr.baseAddress, imagPtr.baseAddress, checkedCInt(restoredLength), outputPtr.baseAddress), "Failed to perform IFFT") } } } @@ -448,7 +466,7 @@ public enum DataFilter { try ampl.withUnsafeMutableBufferPointer { amplPtr in try freq.withUnsafeMutableBufferPointer { freqPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_psd(inputPtr.baseAddress, CInt(inputCount), CInt(sampling_rate), CInt(window), amplPtr.baseAddress, freqPtr.baseAddress), "Failed to get PSD") + try checkBrainFlowExitCode(native.get_psd(inputPtr.baseAddress, checkedCInt(inputCount), checkedCInt(sampling_rate), checkedCInt(window), amplPtr.baseAddress, freqPtr.baseAddress), "Failed to get PSD") } } } @@ -480,7 +498,7 @@ public enum DataFilter { try ampl.withUnsafeMutableBufferPointer { amplPtr in try freq.withUnsafeMutableBufferPointer { freqPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_psd_welch(inputPtr.baseAddress, CInt(data.count), CInt(nfft), CInt(overlap), CInt(sampling_rate), CInt(window), amplPtr.baseAddress, freqPtr.baseAddress), "Failed to get PSD Welch") + try checkBrainFlowExitCode(native.get_psd_welch(inputPtr.baseAddress, checkedCInt(data.count), checkedCInt(nfft), checkedCInt(overlap), checkedCInt(sampling_rate), checkedCInt(window), amplPtr.baseAddress, freqPtr.baseAddress), "Failed to get PSD Welch") } } } @@ -501,13 +519,15 @@ public enum DataFilter { try ampl.withUnsafeMutableBufferPointer { amplPtr in try freq.withUnsafeMutableBufferPointer { freqPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_band_power(amplPtr.baseAddress, freqPtr.baseAddress, CInt(psd.ampl.count), freq_start, freq_end, &output), "Failed to get band power") + try checkBrainFlowExitCode(native.get_band_power(amplPtr.baseAddress, freqPtr.baseAddress, checkedCInt(psd.ampl.count), freq_start, freq_end, &output), "Failed to get band power") } } } return output } + /// Calculate normalized mean powers for bands 2-4, 4-8, 8-13, 13-30, 30-45 Hz. + /// Uses get_custom_band_powers preprocessing, minimum retained length, and variation semantics. public static func get_avg_band_powers(data: [[Double]], channels: [Int], sampling_rate: Int, apply_filter: Bool) throws -> BandPowerResult { let defaultBands = [ FrequencyBand(start: 2.0, stop: 4.0), @@ -519,6 +539,18 @@ public enum DataFilter { return try get_custom_band_powers(data: data, bands: defaultBands, channels: channels, sampling_rate: sampling_rate, apply_filter: apply_filter) } + /// Return normalized channel-mean band powers and coefficients of variation (population + /// stddev / mean of absolute channel powers). Zero-power bands have zero variation; + /// an all-zero total returns zero normalized powers. + /// Filtering demeans and applies padded, initialized zero-phase 48-52 and 58-62 Hz + /// notches only when their upper edges are below 90% of Nyquist. There is no automatic + /// passband, so preprocessing is independent of the requested integration bands. + /// Margins estimated from the complete cascade impulse-response tail are discarded at + /// both ends; this estimate is not a guaranteed artifact bound. Supply surrounding + /// samples and account for the resulting delay in live analysis. Without filtering + /// there is no preprocessing or trimming. At least max(8, 2 * get_nearest_power_of_two(sampling_rate)) + /// samples must remain. Data and edges must be finite; bands require + /// 0 <= start < stop <= Nyquist. Mains notches also attenuate overlapping bands. public static func get_custom_band_powers( data: [[Double]], bands: [FrequencyBand], @@ -532,7 +564,7 @@ public enum DataFilter { guard bands.allSatisfy({ $0.start < $0.stop }) else { throw invalidArguments("Band start frequency must be less than stop frequency") } guard sampling_rate > 0 else { throw invalidArguments("sampling_rate must be positive") } var selected = [Double]() - selected.reserveCapacity(channels.count * cols) + selected.reserveCapacity(try checkedBufferCount(channels.count, cols)) for channel in channels { selected.append(contentsOf: data[channel]) } @@ -546,7 +578,7 @@ public enum DataFilter { try avg.withUnsafeMutableBufferPointer { avgPtr in try stddev.withUnsafeMutableBufferPointer { stddevPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_custom_band_powers(dataPtr.baseAddress, CInt(channels.count), CInt(cols), startsPtr.baseAddress, stopsPtr.baseAddress, CInt(bands.count), CInt(sampling_rate), apply_filter ? 1 : 0, avgPtr.baseAddress, stddevPtr.baseAddress), "Failed to get custom band powers") + try checkBrainFlowExitCode(native.get_custom_band_powers(dataPtr.baseAddress, checkedCInt(channels.count), checkedCInt(cols), startsPtr.baseAddress, stopsPtr.baseAddress, checkedCInt(bands.count), checkedCInt(sampling_rate), apply_filter ? 1 : 0, avgPtr.baseAddress, stddevPtr.baseAddress), "Failed to get custom band powers") } } } @@ -561,25 +593,27 @@ public enum DataFilter { let selectedChannels = channels ?? Array(0..= 0 && $0 < rows }) else { throw invalidArguments("Channel index is out of range") } - guard cols >= 2 else { throw invalidArguments("ICA data must contain at least two samples") } + guard cols >= 3 else { throw invalidArguments("ICA data must contain at least three samples") } guard selectedChannels.count >= 2 else { throw invalidArguments("ICA requires at least two channels") } - guard num_components >= 2, num_components <= selectedChannels.count else { throw invalidArguments("num_components must be between 2 and the selected channel count") } + guard num_components >= 2, num_components <= min(selectedChannels.count, cols - 1) else { + throw invalidArguments("num_components exceeds the possible centered data rank") + } var selected = [Double]() - selected.reserveCapacity(selectedChannels.count * cols) + selected.reserveCapacity(try checkedBufferCount(selectedChannels.count, cols)) for channel in selectedChannels { selected.append(contentsOf: data[channel]) } - var w = [Double](repeating: 0.0, count: num_components * num_components) - var k = [Double](repeating: 0.0, count: selectedChannels.count * num_components) - var a = [Double](repeating: 0.0, count: num_components * selectedChannels.count) - var s = [Double](repeating: 0.0, count: cols * num_components) + var w = [Double](repeating: 0.0, count: try checkedBufferCount(num_components, num_components)) + var k = [Double](repeating: 0.0, count: try checkedBufferCount(selectedChannels.count, num_components)) + var a = [Double](repeating: 0.0, count: try checkedBufferCount(num_components, selectedChannels.count)) + var s = [Double](repeating: 0.0, count: try checkedBufferCount(cols, num_components)) try selected.withUnsafeMutableBufferPointer { dataPtr in try w.withUnsafeMutableBufferPointer { wPtr in try k.withUnsafeMutableBufferPointer { kPtr in try a.withUnsafeMutableBufferPointer { aPtr in try s.withUnsafeMutableBufferPointer { sPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.perform_ica(dataPtr.baseAddress, CInt(selectedChannels.count), CInt(cols), CInt(num_components), wPtr.baseAddress, kPtr.baseAddress, aPtr.baseAddress, sPtr.baseAddress), "Failed to perform ICA") + try checkBrainFlowExitCode(native.perform_ica(dataPtr.baseAddress, checkedCInt(selectedChannels.count), checkedCInt(cols), checkedCInt(num_components), wPtr.baseAddress, kPtr.baseAddress, aPtr.baseAddress, sPtr.baseAddress), "Failed to perform ICA") } } } @@ -602,7 +636,7 @@ public enum DataFilter { var output = 0.0 try input.withUnsafeMutableBufferPointer { pointer in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.calc_stddev(pointer.baseAddress, CInt(start), CInt(end), &output), "Failed to calc stddev") + try checkBrainFlowExitCode(native.calc_stddev(pointer.baseAddress, checkedCInt(start), checkedCInt(end), &output), "Failed to calc stddev") } } return output @@ -614,7 +648,7 @@ public enum DataFilter { var output = 0.0 try input.withUnsafeMutableBufferPointer { pointer in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_railed_percentage(pointer.baseAddress, CInt(data.count), CInt(gain), &output), "Failed to get railed percentage") + try checkBrainFlowExitCode(native.get_railed_percentage(pointer.baseAddress, checkedCInt(data.count), checkedCInt(gain), &output), "Failed to get railed percentage") } } return output @@ -629,7 +663,7 @@ public enum DataFilter { try ir.withUnsafeMutableBufferPointer { irPtr in try red.withUnsafeMutableBufferPointer { redPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_oxygen_level(irPtr.baseAddress, redPtr.baseAddress, CInt(ppg_ir.count), CInt(sampling_rate), coef1, coef2, coef3, &output), "Failed to get oxygen level") + try checkBrainFlowExitCode(native.get_oxygen_level(irPtr.baseAddress, redPtr.baseAddress, checkedCInt(ppg_ir.count), checkedCInt(sampling_rate), coef1, coef2, coef3, &output), "Failed to get oxygen level") } } } @@ -646,7 +680,7 @@ public enum DataFilter { try ir.withUnsafeMutableBufferPointer { irPtr in try red.withUnsafeMutableBufferPointer { redPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_heart_rate(irPtr.baseAddress, redPtr.baseAddress, CInt(ppg_ir.count), CInt(sampling_rate), CInt(fft_size), &output), "Failed to get heart rate") + try checkBrainFlowExitCode(native.get_heart_rate(irPtr.baseAddress, redPtr.baseAddress, checkedCInt(ppg_ir.count), checkedCInt(sampling_rate), checkedCInt(fft_size), &output), "Failed to get heart rate") } } } @@ -656,19 +690,20 @@ public enum DataFilter { public static func get_nearest_power_of_two(_ value: Int) throws -> Int { var output: CInt = 0 try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_nearest_power_of_two(CInt(value), &output), "Failed to get nearest power of two") + try checkBrainFlowExitCode(native.get_nearest_power_of_two(checkedCInt(value), &output), "Failed to get nearest power of two") } return Int(output) } public static func write_file(data: [[Double]], file_name: String, file_mode: String) throws { let (rows, cols) = try BrainFlowArray.validateRectangular(data) + _ = try checkedBufferCount(rows, cols) var linear = reshape_data_to_1d(num_rows: rows, num_cols: cols, buf: data) try file_name.withCString { fileNamePtr in try file_mode.withCString { fileModePtr in try linear.withUnsafeMutableBufferPointer { linearPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.write_file(linearPtr.baseAddress, CInt(rows), CInt(cols), fileNamePtr, fileModePtr), "Failed to write file") + try checkBrainFlowExitCode(native.write_file(linearPtr.baseAddress, checkedCInt(rows), checkedCInt(cols), fileNamePtr, fileModePtr), "Failed to write file") } } } @@ -733,7 +768,7 @@ public enum DataFilter { try accel_z.withUnsafeBufferPointer { zPtr in try output.withUnsafeMutableBufferPointer { outPtr in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native.get_activity_index(xPtr.baseAddress, yPtr.baseAddress, zPtr.baseAddress, CInt(dataLen), CInt(sampling_rate), CInt(periodToUse), noise_var_x, noise_var_y, noise_var_z, outPtr.baseAddress), "Failed to calculate activity index") + try checkBrainFlowExitCode(native.get_activity_index(xPtr.baseAddress, yPtr.baseAddress, zPtr.baseAddress, checkedCInt(dataLen), checkedCInt(sampling_rate), checkedCInt(periodToUse), noise_var_x, noise_var_y, noise_var_z, outPtr.baseAddress), "Failed to calculate activity index") } } } @@ -742,6 +777,28 @@ public enum DataFilter { return output } + private static func checkedCInt(_ value: Int) throws -> CInt { + guard let result = CInt(exactly: value) else { + throw invalidArguments("Integer argument exceeds the native integer range") + } + return result + } + + private static func checkedBufferCount(_ dimensions: Int...) throws -> Int { + var count = 1 + for dimension in dimensions { + guard dimension >= 0, dimension <= Int(CInt.max) else { + throw invalidArguments("Invalid buffer dimension") + } + let (next, overflow) = count.multipliedReportingOverflow(by: dimension) + guard !overflow, next <= Int(CInt.max) else { + throw invalidArguments("Buffer exceeds the native integer range") + } + count = next + } + return count + } + private static func withMutableData(_ data: inout [Double], _ body: (UnsafeMutablePointer?, Int) throws -> T) throws -> T { try data.withUnsafeMutableBufferPointer { pointer in try body(pointer.baseAddress, pointer.count) @@ -754,7 +811,7 @@ public enum DataFilter { var length: CInt = 0 try bytes.withUnsafeMutableBufferPointer { pointer in try DataFilterNative.withData { native in - try checkBrainFlowExitCode(native[keyPath: function](pointer.baseAddress, &length, CInt(maxLength)), "Error in get_version") + try checkBrainFlowExitCode(native[keyPath: function](pointer.baseAddress, &length, checkedCInt(maxLength)), "Error in get_version") } } return String(bytes: bytes.prefix(Int(length)).map { UInt8(bitPattern: $0) }, encoding: .utf8) ?? "" @@ -772,7 +829,7 @@ final class DataFilterNative { let perform_rolling_filter: @convention(c) (UnsafeMutablePointer?, CInt, CInt, CInt) -> CInt let perform_downsampling: @convention(c) (UnsafeMutablePointer?, CInt, CInt, CInt, UnsafeMutablePointer?) -> CInt let perform_wavelet_transform: @convention(c) (UnsafeMutablePointer?, CInt, CInt, CInt, CInt, UnsafeMutablePointer?, UnsafeMutablePointer?) -> CInt - let perform_inverse_wavelet_transform: @convention(c) (UnsafeMutablePointer?, CInt, CInt, CInt, CInt, UnsafeMutablePointer?, UnsafeMutablePointer?) -> CInt + let perform_inverse_wavelet_transform_checked: @convention(c) (UnsafeMutablePointer?, CInt, CInt, CInt, CInt, CInt, UnsafeMutablePointer?, CInt, UnsafeMutablePointer?, CInt) -> CInt let perform_wavelet_denoising: @convention(c) (UnsafeMutablePointer?, CInt, CInt, CInt, CInt, CInt, CInt, CInt) -> CInt let get_csp: @convention(c) (UnsafePointer?, UnsafePointer?, CInt, CInt, CInt, UnsafeMutablePointer?, UnsafeMutablePointer?) -> CInt let get_window: @convention(c) (CInt, CInt, UnsafeMutablePointer?) -> CInt @@ -810,7 +867,7 @@ final class DataFilterNative { private static func load() throws -> DataFilterNative { lock.lock() defer { lock.unlock() } - if let cached { return cached } + if let cached = cached { return cached } let value = try DataFilterNative(library: NativeLibraries.dataHandler.load()) cached = value return value @@ -825,7 +882,7 @@ final class DataFilterNative { perform_rolling_filter = try library.symbol("perform_rolling_filter", as: type(of: perform_rolling_filter)) perform_downsampling = try library.symbol("perform_downsampling", as: type(of: perform_downsampling)) perform_wavelet_transform = try library.symbol("perform_wavelet_transform", as: type(of: perform_wavelet_transform)) - perform_inverse_wavelet_transform = try library.symbol("perform_inverse_wavelet_transform", as: type(of: perform_inverse_wavelet_transform)) + perform_inverse_wavelet_transform_checked = try library.symbol("perform_inverse_wavelet_transform_checked", as: type(of: perform_inverse_wavelet_transform_checked)) perform_wavelet_denoising = try library.symbol("perform_wavelet_denoising", as: type(of: perform_wavelet_denoising)) get_csp = try library.symbol("get_csp", as: type(of: get_csp)) get_window = try library.symbol("get_window", as: type(of: get_window)) diff --git a/swift_package/Tests/BrainFlowTests/BrainFlowTests.swift b/swift_package/Tests/BrainFlowTests/BrainFlowTests.swift index 73ad196ae..aadbf5a1b 100644 --- a/swift_package/Tests/BrainFlowTests/BrainFlowTests.swift +++ b/swift_package/Tests/BrainFlowTests/BrainFlowTests.swift @@ -53,6 +53,24 @@ final class BrainFlowTests: XCTestCase { } func testDataFilterRejectsInvalidArgumentsBeforeNativeCalls() throws { + assertInvalidArguments(try DataFilter.perform_wavelet_transform( + data: [1.0, 2.0], wavelet: .DB3, decomposition_level: Int.max, + extension_type: .SYMMETRIC)) + assertInvalidArguments(try DataFilter.perform_wavelet_transform( + data: [1.0, 2.0], wavelet: .DB3, decomposition_level: 101, + extension_type: .SYMMETRIC)) + assertInvalidArguments(try DataFilter.get_window( + window_function: .HANNING, window_len: Int.max)) + assertInvalidArguments(try DataFilter.perform_inverse_wavelet_transform( + wavelet_output: WaveletTransform(coefficients: [0.0, 0.0], decomposition_lengths: [1, 1]), + original_data_len: Int(CInt.max), wavelet: .HAAR, decomposition_level: 1, + extension_type: .PERIODIC)) + assertInvalidArguments(try DataFilter.perform_inverse_wavelet_transform( + wavelet_output: WaveletTransform(coefficients: [0.0], decomposition_lengths: [19, 19, 34]), + original_data_len: 64, wavelet: WaveletTypes.DB3, decomposition_level: 2, extension_type: WaveletExtensionTypes.SYMMETRIC)) + assertInvalidArguments(try DataFilter.perform_inverse_wavelet_transform( + wavelet_output: WaveletTransform(coefficients: [0.0], decomposition_lengths: [Int.max]), + original_data_len: 64, wavelet: WaveletTypes.DB3, decomposition_level: 2, extension_type: WaveletExtensionTypes.SYMMETRIC)) assertInvalidArguments(try DataFilter.get_csp(data: [[[1.0, 2.0]], [[3.0]]], labels: [0.0, 1.0])) assertInvalidArguments(try DataFilter.get_csp(data: [[[1.0, 2.0]]], labels: [])) assertInvalidArguments(try DataFilter.get_window(window_function: WindowOperations.HANNING.rawValue, window_len: 0)) @@ -73,6 +91,20 @@ final class BrainFlowTests: XCTestCase { assertInvalidArguments(try DataFilter.get_heart_rate(ppg_ir: [1.0, 2.0], ppg_red: [1.0, 2.0], sampling_rate: 25, fft_size: 1023)) } + func testDataFilterRejectsOutOfRangeNativeIntegers() throws { + try requireNativeLibraries() + var data = [1.0, 2.0, 3.0, 4.0] + assertInvalidArguments(try DataFilter.perform_lowpass( + data: &data, sampling_rate: Int.max, cutoff: 20.0, order: 4, + filter_type: .BUTTERWORTH, ripple: 0.0)) + assertInvalidArguments(try DataFilter.perform_rolling_filter( + data: &data, period: Int.max, operation: .MEAN)) + assertInvalidArguments(try DataFilter.get_nearest_power_of_two(Int.max)) + assertInvalidArguments(try DataFilter.get_psd( + data: data, sampling_rate: Int.max, window: .NO_WINDOW)) + XCTAssertEqual(data, [1.0, 2.0, 3.0, 4.0]) + } + func testBrainFlowGetDataSyntheticBoard() throws { try requireNativeLibraries() let board = try BoardShim(board_id: .SYNTHETIC_BOARD) diff --git a/swift_package/examples/tests/ica/ica.swift b/swift_package/examples/tests/ica/ica.swift index 92840e65f..c45bc6a00 100644 --- a/swift_package/examples/tests/ica/ica.swift +++ b/swift_package/examples/tests/ica/ica.swift @@ -1,14 +1,21 @@ +import Foundation import BrainFlow -import BrainFlowExampleSupport @main enum ICAExample { static func main() throws { - let sample = try SyntheticBoardDataReader.read(maxSamples: 500) - let channels = Array(sample.eegChannels.prefix(4)) - let ica = try DataFilter.perform_ica(data: sample.data, num_components: 2, channels: channels) - - print("W: \(ica.w.count)x\(ica.w.first?.count ?? 0)") - print("S: \(ica.s.count)x\(ica.s.first?.count ?? 0)") + // Two simultaneous mixtures: rows are channels and columns are samples. + let samples = 1024 + var data = [[Double]](repeating: [Double](repeating: 0, count: samples), count: 2) + for i in 0.. Date: Mon, 5 Oct 2026 02:38:56 +0200 Subject: [PATCH 2/4] Add omitted signal processing and classifier assets Signed-off-by: Andrey Parfenov --- .../src/signal_processing_regression.cpp | 75 + .../signal_processing_regression/Program.cs | 58 + .../signal_processing_regression.csproj | 9 + .../BindingValidationRegression.java | 98 + .../MLBindingValidationRegression.java | 50 + .../test/binding_validation_regression.jl | 34 + .../test/ml_model_validation_regression.jl | 52 + .../examples/binding_validation_regression.m | 31 + .../examples/ml_model_validation_regression.m | 54 + .../tests/binding_validation_regression.ts | 50 + nodejs_package/tests/eeg_metrics.ts | 3 +- python_package/brainflow/data_filter.py | 1 + .../examples/tests/band_power_regression.py | 214 + .../tests/binding_validation_regression.py | 184 + .../tests/decomposition_regression.py | 278 + .../examples/tests/ml_binding_regression.py | 82 + .../examples/tests/ml_export_regression.py | 343 + .../tests/signal_metrics_regression.py | 211 + .../tests/signal_processing_regression.py | 200 + src/data_handler/decomposition.cpp | 404 + src/data_handler/inc/band_power_helpers.h | 89 + src/data_handler/inc/filter_helpers.h | 116 + src/data_handler/signal_metrics.cpp | 449 + src/data_handler/signal_processing.cpp | 729 + src/ml/train/DATASET.md | 139 + src/ml/train/README.md | 188 + src/ml/train/extra_trees_mindfulness.onnx | Bin 0 -> 3780664 bytes src/ml/train/forest_mindfulness.onnx | Bin 0 -> 3964524 bytes .../train/gradient_boosting_mindfulness.onnx | Bin 0 -> 102983 bytes .../hist_gradient_boosting_mindfulness.onnx | Bin 0 -> 160779 bytes src/ml/train/knn_mindfulness.onnx | Bin 0 -> 229013 bytes src/ml/train/mlp_mindfulness.onnx | Bin 0 -> 1777 bytes .../train/polynomial_logreg_mindfulness.onnx | Bin 0 -> 11446 bytes src/ml/train/stacking_mindfulness.onnx | Bin 0 -> 4025804 bytes src/ml/train/svm_mindfulness.onnx | Bin 0 -> 171849 bytes src/ml/train/test_training_data.py | 95 + src/ml/train/tests/test_training_models.py | 102 + src/ml/train/tests/test_training_selection.py | 104 + src/ml/train/training_data.py | 364 + src/ml/train/training_export.py | 448 + src/ml/train/training_models.py | 274 + src/ml/train/training_report.json | 126346 +++++++++++++++ src/ml/train/training_report.md | 74 + src/ml/train/verification_report.json | 158 + src/ml/train/verify_classifiers.py | 136 + src/utils/inc/brainflow_array.h | 23 +- 46 files changed, 132256 insertions(+), 9 deletions(-) create mode 100644 cpp_package/examples/signal_processing/src/signal_processing_regression.cpp create mode 100644 csharp_package/brainflow/examples/signal_processing_regression/Program.cs create mode 100644 csharp_package/brainflow/examples/signal_processing_regression/signal_processing_regression.csproj create mode 100644 java_package/brainflow/src/test/java/brainflow/BindingValidationRegression.java create mode 100644 java_package/brainflow/src/test/java/brainflow/MLBindingValidationRegression.java create mode 100644 julia_package/brainflow/test/binding_validation_regression.jl create mode 100644 julia_package/brainflow/test/ml_model_validation_regression.jl create mode 100644 matlab_package/brainflow/examples/binding_validation_regression.m create mode 100644 matlab_package/brainflow/examples/ml_model_validation_regression.m create mode 100644 nodejs_package/tests/binding_validation_regression.ts create mode 100644 python_package/examples/tests/band_power_regression.py create mode 100644 python_package/examples/tests/binding_validation_regression.py create mode 100644 python_package/examples/tests/decomposition_regression.py create mode 100644 python_package/examples/tests/ml_binding_regression.py create mode 100644 python_package/examples/tests/ml_export_regression.py create mode 100644 python_package/examples/tests/signal_metrics_regression.py create mode 100644 python_package/examples/tests/signal_processing_regression.py create mode 100644 src/data_handler/decomposition.cpp create mode 100644 src/data_handler/inc/band_power_helpers.h create mode 100644 src/data_handler/inc/filter_helpers.h create mode 100644 src/data_handler/signal_metrics.cpp create mode 100644 src/data_handler/signal_processing.cpp create mode 100644 src/ml/train/DATASET.md create mode 100644 src/ml/train/README.md create mode 100644 src/ml/train/extra_trees_mindfulness.onnx create mode 100644 src/ml/train/forest_mindfulness.onnx create mode 100644 src/ml/train/gradient_boosting_mindfulness.onnx create mode 100644 src/ml/train/hist_gradient_boosting_mindfulness.onnx create mode 100644 src/ml/train/knn_mindfulness.onnx create mode 100644 src/ml/train/mlp_mindfulness.onnx create mode 100644 src/ml/train/polynomial_logreg_mindfulness.onnx create mode 100644 src/ml/train/stacking_mindfulness.onnx create mode 100644 src/ml/train/svm_mindfulness.onnx create mode 100644 src/ml/train/test_training_data.py create mode 100644 src/ml/train/tests/test_training_models.py create mode 100644 src/ml/train/tests/test_training_selection.py create mode 100644 src/ml/train/training_data.py create mode 100644 src/ml/train/training_export.py create mode 100644 src/ml/train/training_models.py create mode 100644 src/ml/train/training_report.json create mode 100644 src/ml/train/training_report.md create mode 100644 src/ml/train/verification_report.json create mode 100644 src/ml/train/verify_classifiers.py diff --git a/cpp_package/examples/signal_processing/src/signal_processing_regression.cpp b/cpp_package/examples/signal_processing/src/signal_processing_regression.cpp new file mode 100644 index 000000000..5b882849c --- /dev/null +++ b/cpp_package/examples/signal_processing/src/signal_processing_regression.cpp @@ -0,0 +1,75 @@ +#include +#include +#include +#include +#include + +#include "data_filter.h" + +static void reject (const std::function &action) +{ + try + { + action (); + } + catch (const BrainFlowException &) + { + return; + } + throw std::runtime_error ("Expected invalid input to be rejected"); +} + +int main () +{ + BrainFlowArray data (2, 256); + reject ([&] { data.at (-1, 0); }); + reject ([&] { data.at (0, 256); }); + reject ([&] { data.get_address (-1); }); + reject ([&] { data[-1]; }); + reject ([&] { BrainFlowArray invalid (-1, 256); }); + reject ( + [&] { + DataFilter::get_csp ( + BrainFlowArray (2, 2, 8), BrainFlowArray (1)); + }); + reject ([&] { DataFilter::perform_ica (data, 2, {-1, 1}); }); + reject ([&] { DataFilter::get_avg_band_powers (data, {-1}, 256, false); }); + reject ([&] { DataFilter::perform_fft (data.get_raw_ptr (), 256, 0, nullptr); }); + reject ([&] { DataFilter::get_psd (data.get_raw_ptr (), 256, 256, 0, nullptr); }); + reject ([&] { DataFilter::perform_wavelet_transform (data.get_raw_ptr (), 256, 3, -1); }); + reject ( + [&] { + DataFilter::perform_inverse_wavelet_transform ( + std::vector (4), {2, 2}, 8, 3, 2); + }); + for (int original_length : + {4, std::numeric_limits::max () / 2, std::numeric_limits::max ()}) + reject ( + [&] + { + DataFilter::perform_inverse_wavelet_transform ( + std::vector (2), {1, 1}, original_length, 0, 1); + }); + + std::vector input (256); + for (size_t i = 0; i < input.size (); ++i) + input[i] = std::sin (0.1 * i); + auto transform = + DataFilter::perform_wavelet_transform (input.data (), (int)input.size (), 3, 3); + std::vector lengths (transform.second, transform.second + 4); + int total = 0; + for (int length : lengths) + total += length; + std::vector coefficients (transform.first, transform.first + total); + delete[] transform.first; + delete[] transform.second; + auto restored = + DataFilter::perform_inverse_wavelet_transform (coefficients, lengths, 256, 3, 3); + for (size_t i = 0; i < input.size (); ++i) + if (std::abs (input[i] - restored[i]) > 1e-10) + throw std::runtime_error ("Wavelet round trip failed"); + lengths[0]++; + reject ( + [&] { DataFilter::perform_inverse_wavelet_transform (coefficients, lengths, 256, 3, 3); }); + std::cout << "Signal-processing binding validation passed." << std::endl; +} diff --git a/csharp_package/brainflow/examples/signal_processing_regression/Program.cs b/csharp_package/brainflow/examples/signal_processing_regression/Program.cs new file mode 100644 index 000000000..25283bb45 --- /dev/null +++ b/csharp_package/brainflow/examples/signal_processing_regression/Program.cs @@ -0,0 +1,58 @@ +using System; +using brainflow; + +class Program +{ + static void Reject(Action action) + { + try { action(); } + catch (BrainFlowError) { return; } + throw new Exception("Expected invalid input to be rejected"); + } + + static void CheckMLSnapshot(string modelPath) + { + var parameters = new BrainFlowModelParams(2, 2); + parameters.file = System.IO.Path.GetFullPath(modelPath); + parameters.max_array_size = 2; + var model = new MLModel(parameters); + model.prepare(); + try + { + parameters.max_array_size = 1; + double[] result = model.predict(new[] {0.2, 0.2, 0.2, 0.2, 0.2}); + if (result.Length != 2 || result[0] < 0 || result[1] < 0 || + double.IsNaN(result[0]) || double.IsNaN(result[1]) || + Math.Abs(result[0] + result[1] - 1) > 1e-6) + throw new Exception("Invalid probabilities after mutating model params"); + } + finally { model.release(); } + Console.WriteLine("ML capacity snapshot validation passed."); + } + + static void Main(string[] args) + { + var data = new double[2, 256]; + Reject(() => DataFilter.perform_fft(data, 1, 0, 512, 0)); + Reject(() => DataFilter.get_psd(data, 1, 0, 512, 256, 0)); + Reject(() => DataFilter.calc_stddev(data, 0, -1, 10)); + Reject(() => DataFilter.calc_stddev(data, 1, 0, 512)); + Reject(() => DataFilter.calc_stddev(new double[4], 0, 8)); + Reject(() => DataFilter.get_band_power(Tuple.Create(new double[3], new double[2]), 0, 1)); + Reject(() => DataFilter.get_csp(new double[2, 2, 8], new double[1])); + Reject(() => DataFilter.perform_wavelet_transform(new double[256], 3, -1, 0)); + Reject(() => DataFilter.perform_inverse_wavelet_transform(Tuple.Create(new double[4], new[] {2, 2}), 8, 3, 2, 0)); + foreach (int originalLength in new[] {4, int.MaxValue / 2, int.MaxValue}) + Reject(() => DataFilter.perform_inverse_wavelet_transform(Tuple.Create(new double[2], new[] {1, 1}), originalLength, 0, 1, 0)); + foreach (int capacity in new[] {0, -1}) + { + var parameters = new BrainFlowModelParams(0, 0); + parameters.max_array_size = capacity; + Reject(() => new MLModel(parameters)); + } + Reject(() => new MLModel(null)); + if (args.Length > 0) + CheckMLSnapshot(args[0]); + Console.WriteLine("Signal-processing binding validation passed."); + } +} diff --git a/csharp_package/brainflow/examples/signal_processing_regression/signal_processing_regression.csproj b/csharp_package/brainflow/examples/signal_processing_regression/signal_processing_regression.csproj new file mode 100644 index 000000000..15818ddd5 --- /dev/null +++ b/csharp_package/brainflow/examples/signal_processing_regression/signal_processing_regression.csproj @@ -0,0 +1,9 @@ + + + Exe + net8.0 + + + + + diff --git a/java_package/brainflow/src/test/java/brainflow/BindingValidationRegression.java b/java_package/brainflow/src/test/java/brainflow/BindingValidationRegression.java new file mode 100644 index 000000000..cc168bbb4 --- /dev/null +++ b/java_package/brainflow/src/test/java/brainflow/BindingValidationRegression.java @@ -0,0 +1,98 @@ +package brainflow; + +import org.apache.commons.lang3.tuple.Pair; +import java.util.Arrays; +import java.util.List; + +/** Run with the current native DataHandler library on the classpath/library path. */ +public class BindingValidationRegression +{ + interface CheckedCall + { + void run () throws Exception; + } + + private static void rejects (CheckedCall call) throws Exception + { + try + { + call.run (); + } catch (BrainFlowError expected) + { + return; + } + throw new AssertionError ("Expected BrainFlowError"); + } + + public static void main (String[] args) throws Exception + { + rejects (() -> DataFilter.calc_stddev (new double[2], -1, 2)); + rejects (() -> DataFilter.calc_stddev (new double[2], 0, 3)); + rejects (() -> DataFilter.get_band_power (Pair.of (new double[2], new double[1]), 0, 1)); + rejects (() -> DataFilter.get_csp (new double[4][2][20], new double[3])); + rejects (() -> DataFilter.perform_inverse_wavelet_transform ( + Pair.of (new double[1], new int[] {19, 19, 34}), 64, WaveletTypes.DB3, 2, + WaveletExtensionTypes.SYMMETRIC)); + double[] signal = new double[64]; + for (int i = 0; i < signal.length; i++) + { + signal[i] = Math.sin (i * 0.3); + } + Pair transformed = DataFilter.perform_wavelet_transform ( + signal, WaveletTypes.DB3, 2, WaveletExtensionTypes.SYMMETRIC); + double[] restored = DataFilter.perform_inverse_wavelet_transform ( + transformed, signal.length, WaveletTypes.DB3, 2, WaveletExtensionTypes.SYMMETRIC); + for (int i = 0; i < signal.length; i++) + { + if (Math.abs (signal[i] - restored[i]) > 1e-10) + { + throw new AssertionError ("Wavelet roundtrip failed"); + } + } + rejects (() -> DataFilter.perform_inverse_wavelet_transform ( + transformed, 32, WaveletTypes.DB3, 2, WaveletExtensionTypes.SYMMETRIC)); + rejects (() -> DataFilter.perform_inverse_wavelet_transform ( + transformed, Integer.MAX_VALUE, WaveletTypes.DB3, 2, WaveletExtensionTypes.SYMMETRIC)); + rejects (() -> DataFilter.perform_wavelet_transform ( + signal, WaveletTypes.DB3, Integer.MAX_VALUE, WaveletExtensionTypes.SYMMETRIC)); + rejects (() -> DataFilter.perform_wavelet_transform ( + signal, WaveletTypes.DB3, 101, WaveletExtensionTypes.SYMMETRIC)); + rejects (() -> DataFilter.perform_ica (new double[][] {new double[64], new double[32]}, + 2, new int[] {0, 1})); + rejects (() -> DataFilter.get_custom_band_powers ( + new double[][] {new double[64], new double[32]}, + Arrays.asList (Pair.of (2.0, 4.0)), new int[] {0, 1}, 128, false)); + rejects (() -> DataFilter.perform_ica (new double[2][64], 2, new int[] {-1, 0})); + + // Unselected rows do not determine native input/output dimensions. Previously, + // S was allocated using row zero while native ICA wrote the selected row length. + double[][] mixed = new double[][] {new double[1], new double[1024], new double[1024]}; + for (int i = 0; i < 1024; i++) + { + double time = i / 256.0; + double first = Math.sin (2.0 * Math.PI * 7.0 * time); + double second = Math.pow (Math.sin (2.0 * Math.PI * 13.0 * time), 3); + mixed[1][i] = first + 0.3 * second; + mixed[2][i] = 0.2 * first + second; + } + List decomposition = DataFilter.perform_ica (mixed, 2, new int[] {2, 1}); + double[][] mixing = decomposition.get (2); + double[][] sources = decomposition.get (3); + if (sources.length != 2 || sources[0].length != 1024) + { + throw new AssertionError ("ICA source dimensions must follow selected channels"); + } + for (int row = 0; row < 2; row++) + { + for (int i = 0; i < 1024; i++) + { + double restored_sample = mixing[row][0] * sources[0][i] + mixing[row][1] * sources[1][i]; + if (Math.abs (restored_sample - mixed[2 - row][i]) > 1e-9) + { + throw new AssertionError ("ICA channel-order reconstruction failed"); + } + } + } + System.out.println ("Binding validation regressions passed"); + } +} diff --git a/java_package/brainflow/src/test/java/brainflow/MLBindingValidationRegression.java b/java_package/brainflow/src/test/java/brainflow/MLBindingValidationRegression.java new file mode 100644 index 000000000..ccbe2ecb9 --- /dev/null +++ b/java_package/brainflow/src/test/java/brainflow/MLBindingValidationRegression.java @@ -0,0 +1,50 @@ +package brainflow; + +import java.nio.file.Paths; + +/** Run with an ONNX classifier returning [relaxed, focused] and rebuilt MLModule. */ +public class MLBindingValidationRegression +{ + private static void rejectsCapacity (int capacity) + { + BrainFlowModelParams params = new BrainFlowModelParams (0, 0); + params.max_array_size = capacity; + try + { + new MLModel (params); + } catch (IllegalArgumentException expected) + { + return; + } + throw new AssertionError ("Invalid output capacity was accepted"); + } + + public static void main (String[] args) throws Exception + { + rejectsCapacity (0); + rejectsCapacity (-1); + if (args.length != 1) + { + throw new IllegalArgumentException ("Supply the path to logreg_mindfulness.onnx"); + } + BrainFlowModelParams params = new BrainFlowModelParams (2, 2); + params.file = Paths.get (args[0]).toAbsolutePath ().toString (); + params.max_array_size = 2; + MLModel model = new MLModel (params); + model.prepare (); + try + { + params.max_array_size = 1; + double[] result = model.predict (new double[] {0.2, 0.2, 0.2, 0.2, 0.2}); + if (result.length != 2 || !Double.isFinite (result[0]) || !Double.isFinite (result[1]) || + result[0] < 0 || result[1] < 0 || Math.abs (result[0] + result[1] - 1) > 1e-6) + { + throw new AssertionError ("Invalid probabilities after mutating model params"); + } + } finally + { + model.release (); + } + System.out.println ("ML capacity snapshot validation passed."); + } +} diff --git a/julia_package/brainflow/test/binding_validation_regression.jl b/julia_package/brainflow/test/binding_validation_regression.jl new file mode 100644 index 000000000..01896c8c9 --- /dev/null +++ b/julia_package/brainflow/test/binding_validation_regression.jl @@ -0,0 +1,34 @@ +using Test +using BrainFlow + +@testset "Binding validation" begin + @test_throws BrainFlow.BrainFlowError BrainFlow.get_band_power((ones(2), ones(1)), 0.0, 1.0) + @test_throws BrainFlow.BrainFlowError BrainFlow.get_csp(ones(4, 2, 20), zeros(3)) + @test_throws BrainFlow.BrainFlowError BrainFlow.perform_inverse_wavelet_transform( + (zeros(1), Cint[19, 19, 34]), 64, BrainFlow.DB3, 2, BrainFlow.SYMMETRIC) + data = sin.(2pi .* 10 .* (0:1024) ./ 256) + amplitudes, frequencies = BrainFlow.get_psd_welch(data, 256, 128, 256, BrainFlow.HANNING) + @test length(amplitudes) == 129 + @test length(frequencies) == 129 + @test frequencies[end] == 128 + transformed = BrainFlow.perform_wavelet_transform(data, BrainFlow.DB3, 2, BrainFlow.SYMMETRIC) + restored = BrainFlow.perform_inverse_wavelet_transform(transformed, length(data), BrainFlow.DB3, 2, BrainFlow.SYMMETRIC) + @test restored ≈ data atol=1e-10 + @test_throws BrainFlow.BrainFlowError BrainFlow.perform_inverse_wavelet_transform( + transformed, 64, BrainFlow.DB3, 2, BrainFlow.SYMMETRIC) + @test_throws BrainFlow.BrainFlowError BrainFlow.perform_inverse_wavelet_transform( + transformed, typemax(Cint), BrainFlow.DB3, 2, BrainFlow.SYMMETRIC) + for values in (view(data, length(data):-1:1), view(data, 1:2:length(data))) + @test BrainFlow.calc_stddev(values) ≈ BrainFlow.calc_stddev(collect(values)) + @test_throws BrainFlow.BrainFlowError BrainFlow.detrend(values, BrainFlow.CONSTANT) + actual = BrainFlow.perform_wavelet_transform(values, BrainFlow.DB3, 2, BrainFlow.SYMMETRIC) + expected = BrainFlow.perform_wavelet_transform(collect(values), BrainFlow.DB3, 2, BrainFlow.SYMMETRIC) + @test actual == expected + end + contiguous = copy(data) + view_to_process = view(contiguous, 2:20) + expected = copy(view_to_process) + BrainFlow.detrend(view_to_process, BrainFlow.CONSTANT) + BrainFlow.detrend(expected, BrainFlow.CONSTANT) + @test view_to_process == expected +end diff --git a/julia_package/brainflow/test/ml_model_validation_regression.jl b/julia_package/brainflow/test/ml_model_validation_regression.jl new file mode 100644 index 000000000..28ffea4a5 --- /dev/null +++ b/julia_package/brainflow/test/ml_model_validation_regression.jl @@ -0,0 +1,52 @@ +using BrainFlow +using Test + +@testset "ML prediction buffers and input layout" begin + features = Float64[.1, .1, .3, .3, .2] + params = BrainFlowModelParams(BrainFlow.MINDFULNESS, BrainFlow.DEFAULT_CLASSIFIER) + params.max_array_size = 2 + BrainFlow.prepare(params) + try + expected = BrainFlow.predict(features, params) + @test length(expected) == 1 + @test isfinite(expected[1]) && 0 <= expected[1] <= 1 + backing = repeat(features, inner=2) + @test BrainFlow.predict(@view(backing[1:2:end]), params) == expected + @test BrainFlow.predict(@view(reverse(features)[end:-1:1]), params) == expected + @test_throws BrainFlow.BrainFlowError BrainFlow.predict(reshape(features, 1, 5), params) + @test_throws BrainFlow.BrainFlowError BrainFlow.predict(Float64[], params) + @test_throws BrainFlow.BrainFlowError BrainFlow.predict([NaN, .1, .3, .3, .2], params) + @test_throws BrainFlow.BrainFlowError BrainFlow.predict(ComplexF64.(features), params) + finally + BrainFlow.release(params) + end + params.max_array_size = 0 + @test_throws BrainFlow.BrainFlowError BrainFlow.prepare(params) + @test_throws BrainFlow.BrainFlowError BrainFlow.predict(features, params) +end + +if haskey(ENV, "BRAINFLOW_TEST_ONNX_MODEL") + @testset "Two-output ONNX prediction" begin + params = BrainFlowModelParams(BrainFlow.USER_DEFINED, BrainFlow.ONNX_CLASSIFIER) + params.file = ENV["BRAINFLOW_TEST_ONNX_MODEL"] + params.output_name = get(ENV, "BRAINFLOW_TEST_ONNX_OUTPUT", "probabilities") + params.max_array_size = 2 + BrainFlow.prepare(params) + try + for _ in 1:20 + scores = BrainFlow.predict(Float64[.1, .1, .3, .3, .2], params) + @test length(scores) == 2 + @test all(isfinite, scores) + @test sum(scores) ≈ 1.0 atol=1e-6 + end + # A changed parameter object describes a different native model, + # and must fail lookup rather than write to a smaller buffer. + params.max_array_size = 1 + @test_throws BrainFlow.BrainFlowError BrainFlow.predict(ones(5), params) + params.max_array_size = 2 + finally + params.max_array_size = 2 + BrainFlow.release(params) + end + end +end diff --git a/matlab_package/brainflow/examples/binding_validation_regression.m b/matlab_package/brainflow/examples/binding_validation_regression.m new file mode 100644 index 000000000..3fe6ebb22 --- /dev/null +++ b/matlab_package/brainflow/examples/binding_validation_regression.m @@ -0,0 +1,31 @@ +function binding_validation_regression() +% Run with regenerated DataHandler library and header on the MATLAB path. + data = sin(2 * pi * 10 * (0:1024) / 256); + assert(abs(DataFilter.calc_stddev(data) - DataFilter.calc_stddev(data.')) < 1e-12); + [a, f] = DataFilter.get_psd_welch(data.', 256, 128, 256, int32(WindowOperations.HANNING)); + assert(numel(a) == 129 && numel(f) == 129 && f(end) == 128); + [coeffs, lengths] = DataFilter.perform_wavelet_transform(data.', int32(WaveletTypes.DB3), 2, int32(WaveletExtensionTypes.SYMMETRIC)); + restored = DataFilter.perform_inverse_wavelet_transform(coeffs.', lengths.', numel(data), int32(WaveletTypes.DB3), 2, int32(WaveletExtensionTypes.SYMMETRIC)); + assert(max(abs(restored - data)) < 1e-10); + rejects(@() DataFilter.perform_inverse_wavelet_transform(coeffs(1), lengths, numel(data), int32(WaveletTypes.DB3), 2, int32(WaveletExtensionTypes.SYMMETRIC))); + rejects(@() DataFilter.perform_inverse_wavelet_transform(coeffs, lengths, 64, int32(WaveletTypes.DB3), 2, int32(WaveletExtensionTypes.SYMMETRIC))); + rejects(@() DataFilter.get_band_power([1, 1], 0, 0, 1)); + rejects(@() DataFilter.get_csp(zeros(4, 2, 20), zeros(3, 1))); + rejects(@() DataFilter.get_heart_rate([1, 2], 1, 256, 256)); + bands = [2; 8]; + stops = [4; 12]; + matrix = [data; 2 * data]; + [column_bands, ~] = DataFilter.get_custom_band_powers(matrix, bands, stops, [1; 2], 256, false); + [row_bands, ~] = DataFilter.get_custom_band_powers(matrix, bands.', stops.', [1, 2], 256, false); + assert(max(abs(column_bands - row_bands)) < 1e-12); + disp('Binding validation regressions passed'); +end + +function rejects(action) + try + action(); + catch + return; + end + error('Expected invalid input to be rejected'); +end diff --git a/matlab_package/brainflow/examples/ml_model_validation_regression.m b/matlab_package/brainflow/examples/ml_model_validation_regression.m new file mode 100644 index 000000000..9de68ed98 --- /dev/null +++ b/matlab_package/brainflow/examples/ml_model_validation_regression.m @@ -0,0 +1,54 @@ +function ml_model_validation_regression(onnx_file, output_name) +% Run with current MLModule library/header on the MATLAB path. Optionally pass +% a binary ONNX classifier with five inputs and two probability outputs. + data = [0.1, 0.1, 0.3, 0.3, 0.2]; + params = BrainFlowModelParams(int32(0), int32(0)); + params.max_array_size = 2; + model = MLModel(params); + model.prepare(); + cleanup = onCleanup(@() model.release()); + expected = model.predict(data); + assert(numel(expected) == 1 && isfinite(expected) && expected >= 0 && expected <= 1); + assert(isequal(model.predict(data.'), expected)); + assert(isequal(model.predict(sparse(data)), expected)); + model.input_params.max_array_size = 1; + model.input_json = '{}'; + assert(isequal(model.predict(data), expected)); + rejects(@() model.predict([])); + rejects(@() model.predict(ones(2, 3))); + rejects(@() model.predict([NaN, data(2:end)])); + rejects(@() model.predict(complex(data, ones(size(data))))); + clear cleanup; + for invalid = {0, -1, 1.5, NaN, Inf, double(intmax('int32')) + 1} + params.max_array_size = invalid{1}; + rejects(@() MLModel(params)); + end + if nargin >= 1 + if nargin < 2 + output_name = 'probabilities'; + end + params = BrainFlowModelParams(int32(2), int32(2)); + params.file = onnx_file; + params.output_name = output_name; + params.max_array_size = 2; + model = MLModel(params); + model.prepare(); + cleanup = onCleanup(@() model.release()); + model.input_params.max_array_size = 1; + for iteration = 1:20 + result = model.predict(data.'); + assert(numel(result) == 2 && all(isfinite(result)) && abs(sum(result) - 1) < 1e-6); + end + clear cleanup; + end + disp('ML model binding validation regressions passed'); +end + +function rejects(action) + try + action(); + catch + return; + end + error('Expected invalid input to be rejected'); +end diff --git a/nodejs_package/tests/binding_validation_regression.ts b/nodejs_package/tests/binding_validation_regression.ts new file mode 100644 index 000000000..6d77a1c97 --- /dev/null +++ b/nodejs_package/tests/binding_validation_regression.ts @@ -0,0 +1,50 @@ +import assert from 'assert'; +import {DataFilter, BrainFlowError, WaveletTypes, WaveletExtensionTypes, WindowOperations} from 'brainflow'; + +assert.throws(() => DataFilter.getBandPower([[1, 1], [0]], 0, 1), BrainFlowError); +assert.throws(() => DataFilter.calcStddev([1, 2], 0, 3), BrainFlowError); +assert.throws(() => DataFilter.calcStddev([1, 2], -1, 2), BrainFlowError); +assert.throws(() => DataFilter.getOxygenLevel([1, 2], [1], 256), BrainFlowError); +assert.throws(() => DataFilter.getHeartRate([1, 2], [1], 256, 256), BrainFlowError); +for (const ragged of [[[1, 2], [3]], [[1], [2, 3]], [[], []]]) +{ + assert.throws(() => DataFilter.performIca(ragged, 2, [0, 1]), BrainFlowError); + assert.throws(() => DataFilter.getCustomBandPowers(ragged, [[0, 1]], [0, 1], 256, false), BrainFlowError); + assert.throws(() => DataFilter.writeFile(ragged, 'invalid-matrix-must-not-be-written.tsv', 'w'), BrainFlowError); +} +const sparseRows: number[][] = new Array(2); +sparseRows[0] = [1, 2]; +assert.throws(() => DataFilter.writeFile(sparseRows, 'invalid-matrix-must-not-be-written.tsv', 'w'), BrainFlowError); +assert.throws(() => DataFilter.writeFile([new Array(2), [1, 2]], 'invalid-matrix-must-not-be-written.tsv', 'w'), BrainFlowError); +for (const channels of [[-1], [2], [0.5], []]) +{ + assert.throws(() => DataFilter.performIca([[1, 2], [3, 4]], 2, channels), BrainFlowError); + assert.throws(() => DataFilter.getCustomBandPowers([[1, 2], [3, 4]], [[0, 1]], channels, 256, false), BrainFlowError); +} +for (const components of [0, 1, 3, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) +{ + assert.throws(() => DataFilter.performIca([[1, 2], [3, 4]], components, [0, 1]), BrainFlowError); +} +assert.throws(() => DataFilter.getCustomBandPowers([[1, 2]], [[0]], [0], 256, false), BrainFlowError); +assert.throws(() => DataFilter.performInverseWaveletTransform( + [[0], [19, 19, 34]], 64, WaveletTypes.DB3, 2, WaveletExtensionTypes.SYMMETRIC), BrainFlowError); +for (const originalLength of [4, 1073741823, 2147483647]) +{ + assert.throws(() => DataFilter.performInverseWaveletTransform( + [[0, 0], [1, 1]], originalLength, WaveletTypes.HAAR, 1, WaveletExtensionTypes.PERIODIC), BrainFlowError); +} + +const signal = Array.from({length: 1025}, (_, i) => Math.sin(2 * Math.PI * 10 * i / 256)); +const [selectedPowers] = DataFilter.getCustomBandPowers( + [signal, new Array(signal.length).fill(Number.NaN)], [[8, 12]], [0, 0], 256, false); +assert.strictEqual(selectedPowers[0], 1); +const [amplitudes, frequencies] = DataFilter.getPsdWelch(signal, 256, 128, 256, WindowOperations.HANNING); +assert.strictEqual(amplitudes.length, 129); +assert.strictEqual(frequencies.length, 129); +assert.strictEqual(frequencies[128], 128); +const transformed = DataFilter.performWaveletTransform(signal, WaveletTypes.DB3, 2, WaveletExtensionTypes.SYMMETRIC); +const restored = DataFilter.performInverseWaveletTransform(transformed, signal.length, WaveletTypes.DB3, 2, WaveletExtensionTypes.SYMMETRIC); +assert.ok(restored.every((value, i) => Math.abs(value - signal[i]) < 1e-10)); +assert.throws(() => DataFilter.performInverseWaveletTransform( + transformed, 64, WaveletTypes.DB3, 2, WaveletExtensionTypes.SYMMETRIC), BrainFlowError); +console.log('Binding validation regressions passed'); diff --git a/nodejs_package/tests/eeg_metrics.ts b/nodejs_package/tests/eeg_metrics.ts index ebd25c6b6..695a72271 100644 --- a/nodejs_package/tests/eeg_metrics.ts +++ b/nodejs_package/tests/eeg_metrics.ts @@ -18,7 +18,8 @@ async function runExample (): Promise const board = new BoardShim (boardId, {}); board.prepareSession(); board.startStream(); - await sleep (3000); + // Include enough data for Welch estimation after trimming filter transients. + await sleep (8000); board.stopStream(); const data = board.getBoardData(); board.releaseSession(); diff --git a/python_package/brainflow/data_filter.py b/python_package/brainflow/data_filter.py index bd325c901..e1abd5835 100644 --- a/python_package/brainflow/data_filter.py +++ b/python_package/brainflow/data_filter.py @@ -681,6 +681,7 @@ def __del__(self): try: self.close() except Exception: + # Finalizers may run after the native library has been torn down. pass diff --git a/python_package/examples/tests/band_power_regression.py b/python_package/examples/tests/band_power_regression.py new file mode 100644 index 000000000..cc884c9c4 --- /dev/null +++ b/python_package/examples/tests/band_power_regression.py @@ -0,0 +1,214 @@ +"""Numerical regressions for band integration and multichannel band powers. + +Run against freshly built native libraries: + python python_package/examples/tests/band_power_regression.py +""" + +import unittest + +import numpy as np + +from brainflow.data_filter import DataFilter +from brainflow.exit_codes import BrainFlowError, BrainFlowExitCodes + + +DEFAULT_BANDS = [(2.0, 4.0), (4.0, 8.0), (8.0, 13.0), (13.0, 30.0), (30.0, 45.0)] + + +def signal(sampling_rate=128, seconds=40, tones=((10.0, 1.0), (20.0, 2.0)), phase=0.37): + time = np.arange(int(sampling_rate * seconds), dtype=np.float64) / sampling_rate + return sum(amplitude * np.sin(2.0 * np.pi * frequency * time + phase) + for frequency, amplitude in tones) + + +class BandPowerRegression(unittest.TestCase): + def assert_invalid(self, function, *args, code=BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR): + with self.assertRaises(BrainFlowError) as error: + function(*args) + self.assertEqual(error.exception.exit_code, code.value) + + def test_partial_bins_use_interpolated_endpoints(self): + frequencies = np.arange(6, dtype=np.float64) + amplitudes = 2.0 + frequencies + lower, upper = 0.25, 3.75 + # Integral of the linear density 2 + f, independent of the bin boundaries. + expected = 2.0 * (upper - lower) + (upper ** 2 - lower ** 2) / 2.0 + actual = DataFilter.get_band_power((amplitudes, frequencies), lower, upper) + self.assertAlmostEqual(actual, expected, places=12) + + def test_adjacent_bands_partition_power_without_double_counting(self): + frequencies = np.arange(6, dtype=np.float64) + amplitudes = np.array([1.0, 2.0, 7.0, 3.0, 4.0, 2.0]) + psd = amplitudes, frequencies + for boundary in (2.0, 2.25): + with self.subTest(boundary=boundary): + left = DataFilter.get_band_power(psd, 0.0, boundary) + right = DataFilter.get_band_power(psd, boundary, 5.0) + whole = DataFilter.get_band_power(psd, 0.0, 5.0) + self.assertAlmostEqual(left + right, whole, places=12) + + def test_invalid_psd_and_integration_bounds(self): + frequencies = np.arange(6, dtype=np.float64) + amplitudes = np.ones(6, dtype=np.float64) + for lower, upper in ((-1.0, 2.0), (1.0, 6.0), (3.0, 2.0), (2.0, 2.0), + (np.nan, 2.0), (1.0, np.inf)): + with self.subTest(lower=lower, upper=upper): + self.assert_invalid(DataFilter.get_band_power, + (amplitudes, frequencies), lower, upper) + for bad in (np.nan, np.inf): + with self.subTest(value=bad): + bad_amplitudes = amplitudes.copy() + bad_amplitudes[2] = bad + self.assert_invalid(DataFilter.get_band_power, + (bad_amplitudes, frequencies), 0.0, 5.0) + bad_frequencies = frequencies.copy() + bad_frequencies[2] = bad + self.assert_invalid(DataFilter.get_band_power, + (amplitudes, bad_frequencies), 0.0, 5.0) + + def test_known_tone_powers_and_channel_variation(self): + waveform = signal(seconds=6) + data = np.vstack((waveform, 2.0 * waveform)) + average, variation = DataFilter.get_custom_band_powers( + data, [(8.0, 13.0), (17.0, 23.0)], [0, 1], 128, False) + # Sine powers are proportional to squared amplitudes: 1 and 4. + np.testing.assert_allclose(average, [0.2, 0.8], atol=1e-8, rtol=0.0) + # Across channels, powers also have ratio 1:4; population std / mean = 0.6. + np.testing.assert_allclose(variation, [0.6, 0.6], atol=1e-12, rtol=0.0) + + def test_common_amplitude_scaling_preserves_normalized_outputs(self): + waveform = signal() + data = np.vstack((waveform, 2.0 * waveform)) + for filtered in (False, True): + with self.subTest(filtered=filtered): + baseline = DataFilter.get_avg_band_powers(data, [0, 1], 128, filtered) + scaled = DataFilter.get_avg_band_powers(7.0 * data, [0, 1], 128, filtered) + np.testing.assert_allclose(scaled[0], baseline[0], atol=1e-10, rtol=1e-8) + # Tiny leakage-only bands can be less stable than the measured tones. + np.testing.assert_allclose(scaled[1][2:4], baseline[1][2:4], + atol=1e-10, rtol=1e-8) + + def test_default_bands_use_custom_implementation(self): + data = np.vstack((signal(), signal(tones=((6.0, 1.0), (35.0, 1.0))))) + for filtered in (False, True): + with self.subTest(filtered=filtered): + average = DataFilter.get_avg_band_powers(data, [0, 1], 128, filtered) + custom = DataFilter.get_custom_band_powers( + data, DEFAULT_BANDS, [0, 1], 128, filtered) + np.testing.assert_allclose(average, custom, atol=0.0, rtol=0.0) + + def test_zero_input_returns_finite_zeros(self): + data = np.zeros((2, 128 * 40), dtype=np.float64) + for filtered in (False, True): + with self.subTest(filtered=filtered): + average, variation = DataFilter.get_avg_band_powers(data, [0, 1], 128, filtered) + np.testing.assert_array_equal(average, np.zeros(5)) + np.testing.assert_array_equal(variation, np.zeros(5)) + + def test_constant_nonzero_channels_return_exact_zeros_after_filtering(self): + data = np.vstack((np.full(128 * 40, 0.1), np.full(128 * 40, 1e6))) + average, variation = DataFilter.get_avg_band_powers(data, [0, 1], 128, True) + np.testing.assert_array_equal(average, np.zeros(5)) + np.testing.assert_array_equal(variation, np.zeros(5)) + + def test_large_finite_signal_does_not_overflow_channel_variation(self): + waveform = 1e100 * signal(seconds=6) + data = np.vstack((waveform, 2.0 * waveform)) + average, variation = DataFilter.get_custom_band_powers( + data, [(8.0, 13.0), (17.0, 23.0)], [0, 1], 128, False) + # Powers fit in a double, but squaring their unscaled deviations would overflow. + np.testing.assert_allclose(average, [0.2, 0.8], atol=1e-8, rtol=0.0) + np.testing.assert_allclose(variation, [0.6, 0.6], atol=1e-12, rtol=0.0) + + def test_nonfinite_samples_are_rejected(self): + for filtered in (False, True): + for bad in (np.nan, np.inf, -np.inf): + with self.subTest(filtered=filtered, value=bad): + data = np.vstack((signal(), signal())) + data[1, data.shape[1] // 2] = bad + self.assert_invalid(DataFilter.get_avg_band_powers, + data, [0, 1], 128, filtered) + + def test_invalid_custom_band_bounds_are_rejected(self): + data = signal()[np.newaxis, :] + for filtered in (False, True): + for band in ((-1.0, 4.0), (8.0, 8.0), (12.0, 8.0), (1.0, 65.0), + (np.nan, 4.0), (1.0, np.inf)): + with self.subTest(filtered=filtered, band=band): + self.assert_invalid(DataFilter.get_custom_band_powers, + data, [band], [0], 128, filtered) + + def test_short_input_does_not_silently_lower_frequency_resolution(self): + sampling_rate = 128 + nfft = 2 * DataFilter.get_nearest_power_of_two(sampling_rate) + # Unfiltered input must accommodate the full FFT. Filtering needs extra + # samples on both ends, even when an FFT alone would fit. + for filtered, length in ((False, nfft - 1), (True, nfft + 1)): + with self.subTest(filtered=filtered, length=length): + data = np.ones((1, length), dtype=np.float64) + self.assert_invalid(DataFilter.get_avg_band_powers, + data, [0], sampling_rate, filtered, + code=BrainFlowExitCodes.INVALID_BUFFER_SIZE_ERROR) + + data = signal(sampling_rate, seconds=nfft / sampling_rate)[np.newaxis, :] + average, _ = DataFilter.get_avg_band_powers(data, [0], sampling_rate, False) + self.assertAlmostEqual(float(np.sum(average)), 1.0, places=12) + + def test_custom_filter_preserves_requested_low_and_high_frequencies(self): + data = signal(256, tones=((1.0, 1.0), (10.0, 1.0), (75.0, 1.0)))[np.newaxis, :] + average, variation = DataFilter.get_custom_band_powers( + data, [(0.25, 2.0), (8.0, 12.0), (65.0, 100.0)], [0], 256, True) + # A fixed 2-45 Hz passband suppresses the outer tones almost entirely. + self.assertTrue(np.all(average > 0.2), average) + self.assertAlmostEqual(float(np.sum(average)), 1.0, places=12) + np.testing.assert_array_equal(variation, np.zeros(3)) + + def test_filters_work_when_mains_notches_are_above_nyquist(self): + data = signal(100, tones=((10.0, 1.0), (20.0, 1.0)))[np.newaxis, :] + average, variation = DataFilter.get_custom_band_powers( + data, [(8.0, 13.0), (13.0, 30.0)], [0], 100, True) + self.assertTrue(np.all(np.isfinite(average)), average) + self.assertTrue(np.all(average > 0.25), average) + self.assertAlmostEqual(float(np.sum(average)), 1.0, places=12) + np.testing.assert_array_equal(variation, np.zeros(2)) + + def test_custom_range_can_start_at_zero(self): + data = signal(128, tones=((1.0, 1.0), (10.0, 1.0)))[np.newaxis, :] + average, variation = DataFilter.get_custom_band_powers( + data, [(0.0, 3.0), (8.0, 16.0)], [0], 128, True) + self.assertTrue(np.all(average > 0.35), average) + self.assertAlmostEqual(float(np.sum(average)), 1.0, places=12) + np.testing.assert_array_equal(variation, np.zeros(2)) + + def test_filtering_removes_dc_offsets(self): + data = signal()[np.newaxis, :] + reference, _ = DataFilter.get_avg_band_powers(data, [0], 128, True) + shifted, _ = DataFilter.get_avg_band_powers(data + 1e6, [0], 128, True) + np.testing.assert_allclose(shifted, reference, atol=1e-8, rtol=0.0) + + def test_short_recording_with_endpoint_transients_matches_long_reference(self): + # Eight seconds is close to the default filter's minimum at 128 Hz, + # making retained samples near the exclusion margins matter to the PSD. + # The 3 Hz tone also probes the lower edge of the analysis passband. + tones = ((3.0, 1.0), (10.0, 1.0), (20.0, 2.0)) + for phase in (0.0, 0.7, 2.0): + with self.subTest(phase=phase): + long_data = signal(tones=tones, phase=phase)[np.newaxis, :] + reference, _ = DataFilter.get_avg_band_powers(long_data, [0], 128, True) + short_data = signal(seconds=8, tones=tones, phase=phase)[np.newaxis, :] + short_data[0, 0] += 1000.0 + short_data[0, -1] -= 250.0 + actual, _ = DataFilter.get_avg_band_powers(short_data, [0], 128, True) + np.testing.assert_allclose(actual, reference, atol=2e-5, rtol=0.0) + + def test_input_is_unchanged(self): + data = np.vstack((signal(), signal(tones=((6.0, 1.0),)))) + original = data.copy() + for filtered in (False, True): + with self.subTest(filtered=filtered): + DataFilter.get_avg_band_powers(data, [0, 1], 128, filtered) + np.testing.assert_array_equal(data, original) + + +if __name__ == '__main__': + unittest.main() diff --git a/python_package/examples/tests/binding_validation_regression.py b/python_package/examples/tests/binding_validation_regression.py new file mode 100644 index 000000000..e0d1753be --- /dev/null +++ b/python_package/examples/tests/binding_validation_regression.py @@ -0,0 +1,184 @@ +"""Binding shape validation and checked inverse-wavelet regression tests.""" +import unittest + +import numpy as np + +from brainflow.data_filter import DataFilter, DetrendOperations, FilterTypes, StreamingFilter, WaveletTypes, WindowOperations +from brainflow.exit_codes import BrainFlowError + + +class BindingValidationRegression(unittest.TestCase): + def test_psd_arrays_must_match(self): + with self.assertRaises(BrainFlowError): + DataFilter.get_band_power((np.ones(5), np.arange(4, dtype=float)), 1.0, 2.0) + + def test_psd_views_must_be_contiguous(self): + with self.assertRaises(BrainFlowError): + DataFilter.get_band_power((np.ones(10)[::2], np.arange(5, dtype=float)), 1.0, 2.0) + + def test_csp_labels_and_layout(self): + data = np.ones((4, 2, 20)) + for labels in (np.zeros(3), np.zeros(8)[::2]): + with self.subTest(labels=labels), self.assertRaises(BrainFlowError): + DataFilter.get_csp(data, labels) + + def test_inplace_filters_reject_readonly_data(self): + data = np.ones(256) + data.flags.writeable = False + operations = [ + lambda: DataFilter.perform_lowpass(data, 256, 30, 2, FilterTypes.BUTTERWORTH, 0), + lambda: DataFilter.perform_highpass(data, 256, 2, 2, FilterTypes.BUTTERWORTH, 0), + lambda: DataFilter.perform_bandpass(data, 256, 2, 30, 2, FilterTypes.BUTTERWORTH, 0), + lambda: DataFilter.perform_bandstop(data, 256, 48, 52, 2, FilterTypes.BUTTERWORTH, 0), + lambda: DataFilter.remove_environmental_noise(data, 256, 0), + lambda: DataFilter.perform_rolling_filter(data, 4, 0), + lambda: DataFilter.perform_wavelet_denoising(data, WaveletTypes.DB3, 2), + lambda: DataFilter.detrend(data, 1), + ] + for operation in operations: + with self.subTest(operation=operation), self.assertRaises(BrainFlowError): + operation() + np.testing.assert_array_equal(data, 1) + + def test_inverse_wavelet_rejects_short_and_inconsistent_metadata(self): + coefficients = np.zeros(72) + lengths = np.array([19, 19, 34], dtype=np.int32) + cases = [(coefficients[:1], lengths), (coefficients, lengths[:1]), + (coefficients, np.array([-1, 39, 34], dtype=np.int32)), + (coefficients, lengths.astype(np.int64))] + for output in cases: + with self.subTest(output=output), self.assertRaises(BrainFlowError): + DataFilter.perform_inverse_wavelet_transform(output, 64, WaveletTypes.DB3, 2) + + def test_inverse_wavelet_checked_native_configuration(self): + data = np.random.default_rng(7).normal(size=64) + transformed = DataFilter.perform_wavelet_transform(data, WaveletTypes.DB3, 2) + restored = DataFilter.perform_inverse_wavelet_transform(transformed, 64, WaveletTypes.DB3, 2) + np.testing.assert_allclose(restored, data, atol=1e-10) + with self.assertRaises(BrainFlowError): + DataFilter.perform_inverse_wavelet_transform(transformed, 32, WaveletTypes.DB3, 2) + + def test_welch_odd_recording(self): + data = np.sin(2 * np.pi * 10 * np.arange(1025) / 256) + amplitudes, frequencies = DataFilter.get_psd_welch(data, 256, 128, 256, WindowOperations.HANNING) + self.assertEqual(amplitudes.size, 129) + self.assertEqual(frequencies.size, 129) + self.assertEqual(frequencies[-1], 128) + + def test_band_power_metadata_matches_required_input(self): + options = dict(nfft=256, low_cutoff=2.0, high_cutoff=40.0, mains=0) + info = DataFilter.get_band_power_info(256, **options) + self.assertEqual(info['nfft'], 256) + self.assertEqual(info['frequency_resolution'], 1.0) + self.assertEqual(info['minimum_samples'], 256 + 2 * info['edge_samples']) + data = np.random.default_rng(42).normal(size=(2, info['minimum_samples'])) + bands = [(2, 4), (8, 12)] + powers, _ = DataFilter.get_custom_band_powers_with_options(data, bands, [0, 1], 256, **options) + self.assertTrue(np.isfinite(powers).all()) + ready = DataFilter.get_band_power_info(256, data_len=data.shape[1], **options) + self.assertEqual(ready['usable_stop'] - ready['usable_start'], 256) + with self.assertRaises(BrainFlowError): + DataFilter.get_custom_band_powers_with_options(data[:, :-1].copy(), bands, [0, 1], 256, **options) + + def test_adding_bands_does_not_change_preprocessing(self): + time = np.arange(4096) / 125 + data = (np.sin(2 * np.pi * 2 * time) + np.sin(2 * np.pi * 10 * time))[None, :] + first, _ = DataFilter.get_custom_band_powers(data, [(2, 4), (8, 12)], [0], 125, True) + extended, _ = DataFilter.get_custom_band_powers( + data, [(0, 1), (2, 4), (8, 12), (20, 30)], [0], 125, True) + self.assertAlmostEqual(first[0] / first[1], extended[1] / extended[2], places=12) + + def test_band_power_channel_selection_and_resolution(self): + data = np.random.default_rng(3).normal(size=(2, 2048)) + for channels in ([-1], [2], [0.5]): + with self.subTest(channels=channels), self.assertRaises(BrainFlowError): + DataFilter.get_avg_band_powers(data, channels, 256, False) + selected = [1, 0, 1] + bands = [(2, 8), (8, 30)] + actual = DataFilter.get_custom_band_powers(data, bands, selected, 256, False) + expected = DataFilter.get_custom_band_powers(data[selected].copy(), bands, [0, 1, 2], 256, False) + for left, right in zip(actual, expected): + np.testing.assert_allclose(left, right) + info = DataFilter.get_band_power_info(256, False, nfft=1024) + self.assertEqual(info['frequency_resolution'], 0.25) + self.assertEqual(info['edge_samples'], 0) + narrow, _ = DataFilter.get_custom_band_powers_with_options( + data, [(8.25, 8.75), (10.25, 10.75)], [0, 1], 256, False, nfft=1024, + detrend_operation=DetrendOperations.NO_DETREND) + self.assertAlmostEqual(narrow.sum(), 1.0) + + def test_ica_reproducible_controls_and_reconstruction(self): + source = np.random.default_rng(11).laplace(size=(2, 2000)) + data = np.ascontiguousarray(np.array([[1., 2.], [3., 4.], [.5, 1.5]]) @ source) + first = DataFilter.perform_ica(data, 2, seed=7) + second = DataFilter.perform_ica(data, 2, seed=7) + for left, right in zip(first, second): + np.testing.assert_array_equal(left, right) + np.testing.assert_allclose(first[2] @ first[3], data - data.mean(axis=1, keepdims=True), atol=1e-10) + with self.assertRaises(BrainFlowError): + DataFilter.perform_ica(data, 2, max_iterations=1, seed=7) + + def test_explicit_clipping_and_flatline_metrics(self): + self.assertEqual(DataFilter.get_clipping_percentage(np.array([-2., -1., 0., 1., 2.]), -1, 1), 80) + self.assertEqual(DataFilter.get_flatline_percentage(np.array([1., 1., 2., 2., 2.])), 75) + + def test_wavelet_dimensions_rejected_before_allocation(self): + for level in (-1, 0, 101, 2147483647): + with self.subTest(level=level), self.assertRaises(BrainFlowError): + DataFilter.perform_wavelet_transform(np.ones(64), WaveletTypes.DB3, level) + with self.assertRaises(BrainFlowError): + DataFilter.perform_inverse_wavelet_transform( + (np.zeros(72), np.array([19, 19, 34], dtype=np.int32)), 2147483647, WaveletTypes.DB3, 2) + + def test_streaming_chunks_and_reset_match_causal_filter(self): + data = np.random.default_rng(3).normal(size=1024) + expected = data.copy() + DataFilter.perform_lowpass(expected, 256, 30., 4, FilterTypes.BUTTERWORTH, 1.) + chunks = [chunk.copy() for chunk in np.split(data, [1, 33, 241, 888])] + with StreamingFilter(0, 256, low_cutoff=30.) as stream: + for chunk in chunks: + stream.process(chunk) + np.testing.assert_allclose(np.concatenate(chunks), expected, atol=1e-12) + stream.reset() + again = data.copy() + stream.process(again) + np.testing.assert_array_equal(again, np.concatenate(chunks)) + stream.close() + with self.assertRaises(BrainFlowError): + stream.process(data.copy()) + + def test_invalid_streaming_chunk_preserves_state_and_data(self): + data = np.random.default_rng(7).normal(size=128) + with StreamingFilter(0, 256, low_cutoff=30.) as stream, StreamingFilter(0, 256, low_cutoff=30.) as reference: + warmup = data.copy() + stream.process(warmup) + reference.process(data.copy()) + bad = np.array([1., np.nan, 3.]) + with self.assertRaises(BrainFlowError): + stream.process(bad) + np.testing.assert_array_equal(bad, [1., np.nan, 3.]) + actual, expected = data.copy(), data.copy() + stream.process(actual) + reference.process(expected) + np.testing.assert_array_equal(actual, expected) + + def test_decimation_preserves_passband_and_suppresses_alias(self): + time = np.arange(4097) / 256 + low = np.sin(2 * np.pi * 8 * time) + high = np.sin(2 * np.pi * 80 * time) + decimated_low = DataFilter.perform_decimation(low, 4) + decimated_high = DataFilter.perform_decimation(high, 4) + self.assertEqual(decimated_low.size, 1024) + np.testing.assert_allclose(decimated_low[20:-20], low[:4096:4][20:-20], atol=0.01) + self.assertLess(np.sqrt(np.mean(decimated_high[20:-20] ** 2)), 0.01) + np.testing.assert_array_equal(DataFilter.perform_decimation(low, 1), low) + + def test_public_filter_margin_query(self): + margin = DataFilter.get_filter_settling_samples(0, 256, low_cutoff=30.) + self.assertGreater(margin, 0) + with self.assertRaises(BrainFlowError): + DataFilter.get_filter_settling_samples(0, 256, low_cutoff=200.) + + +if __name__ == '__main__': + unittest.main() diff --git a/python_package/examples/tests/decomposition_regression.py b/python_package/examples/tests/decomposition_regression.py new file mode 100644 index 000000000..4d05f7029 --- /dev/null +++ b/python_package/examples/tests/decomposition_regression.py @@ -0,0 +1,278 @@ +"""Native wavelet, CSP and ICA safety and numerical regression checks. + +Run with the local Python package and freshly built native libraries: + python python_package/examples/tests/decomposition_regression.py +""" + +import ctypes +import unittest + +import numpy as np + +from brainflow.data_filter import DataHandlerDLL + + +DOUBLE = ctypes.POINTER(ctypes.c_double) +INTEGER = ctypes.POINTER(ctypes.c_int) +INT = ctypes.c_int +INVALID = 13 +BUFFER_ERROR = 9 +GENERAL_ERROR = 17 + + +def doubles(array): + return array.ctypes.data_as(DOUBLE) + + +def integers(array): + return array.ctypes.data_as(INTEGER) + + +class DecompositionRegression(unittest.TestCase): + @classmethod + def setUpClass(cls): + library = DataHandlerDLL.get_instance().lib + + def native(name, *arguments): + # Independent prototypes keep tests from changing binding-level argtypes. + return ctypes.CFUNCTYPE(INT, *arguments)((name, library)) + + cls.forward = staticmethod(native( + 'perform_wavelet_transform', DOUBLE, INT, INT, INT, INT, DOUBLE, INTEGER)) + cls.inverse = staticmethod(native( + 'perform_inverse_wavelet_transform_checked', DOUBLE, INT, INT, INT, INT, + INT, INTEGER, INT, DOUBLE, INT)) + cls.legacy_inverse = staticmethod(native( + 'perform_inverse_wavelet_transform', DOUBLE, INT, INT, INT, INT, INTEGER, DOUBLE)) + cls.denoise = staticmethod(native( + 'perform_wavelet_denoising', DOUBLE, INT, INT, INT, INT, INT, INT, INT)) + cls.restore = staticmethod(native( + 'restore_data_from_wavelet_detailed_coeffs', DOUBLE, INT, INT, INT, INT, DOUBLE)) + cls.csp = staticmethod(native( + 'get_csp', DOUBLE, DOUBLE, INT, INT, INT, DOUBLE, DOUBLE)) + cls.ica = staticmethod(native( + 'perform_ica_with_options', DOUBLE, INT, INT, INT, DOUBLE, DOUBLE, + DOUBLE, DOUBLE, INT, ctypes.c_double, INT)) + + def transform(self, data, wavelet=3, level=2, extension=0): + coefficients = np.empty(data.size + 82 * level, dtype=np.float64) + lengths = np.empty(level + 1, dtype=np.int32) + self.assertEqual(self.forward(doubles(data), data.size, wavelet, level, extension, + doubles(coefficients), integers(lengths)), 0) + return coefficients[:int(lengths.sum())].copy(), lengths + + def reconstruct(self, coefficients, lengths, count, wavelet=3, level=2, extension=0): + output = np.empty(count, dtype=np.float64) + self.assertEqual(self.inverse( + doubles(coefficients), coefficients.size, count, wavelet, level, extension, + integers(lengths), lengths.size, doubles(output), output.size), 0) + return output + + def test_wavelet_roundtrips_all_families(self): + generator = np.random.default_rng(745) + for count in (256, 257): + data = generator.normal(size=count) + for wavelet in range(45): + for extension in (0, 1): + for level in (1, 2, 3): + with self.subTest(count=count, wavelet=wavelet, + extension=extension, level=level): + coefficients, lengths = self.transform( + data, wavelet, level, extension) + restored = self.reconstruct( + coefficients, lengths, count, wavelet, level, extension) + np.testing.assert_allclose(restored, data, atol=1e-9, rtol=1e-9) + + def test_inverse_rejects_inconsistent_metadata_before_access(self): + data = np.linspace(-1.0, 1.0, 64) + coefficients, lengths = self.transform(data) + output = np.full(64, 321.0) + for bad_lengths in (np.array([1000, 1000, 1000], dtype=np.int32), + np.array([-1, 19, 34], dtype=np.int32), + lengths[::-1].copy()): + with self.subTest(lengths=bad_lengths): + self.assertEqual(self.inverse( + doubles(coefficients), coefficients.size, 64, 3, 2, 0, + integers(bad_lengths), bad_lengths.size, doubles(output), output.size), + INVALID) + self.assertEqual(self.legacy_inverse( + doubles(coefficients), 64, 3, 2, 0, integers(bad_lengths), + doubles(output)), INVALID) + self.assertEqual(self.inverse( + doubles(coefficients), coefficients.size, 32, 3, 2, 0, + integers(lengths), lengths.size, doubles(output), output.size), INVALID) + np.testing.assert_array_equal(output, np.full(64, 321.0)) + + def test_inverse_rejects_short_actual_arrays(self): + coefficients, lengths = self.transform(np.arange(64, dtype=np.float64)) + output = np.full(64, 321.0) + self.assertEqual(self.inverse( + doubles(coefficients[:1].copy()), 1, 64, 3, 2, 0, + integers(lengths), 3, doubles(output), 64), INVALID) + self.assertEqual(self.inverse( + doubles(coefficients), coefficients.size, 64, 3, 2, 0, + integers(lengths[:1].copy()), 1, doubles(output), 64), INVALID) + self.assertEqual(self.inverse( + doubles(coefficients), coefficients.size, 64, 3, 2, 0, + integers(lengths), 3, doubles(output[:1]), 1), INVALID) + np.testing.assert_array_equal(output, np.full(64, 321.0)) + + def test_wavelet_rejects_nonfinite_samples_and_bad_levels(self): + output = np.full(500, 321.0) + lengths = np.zeros(101, dtype=np.int32) + for bad_value in (np.nan, np.inf, -np.inf): + data = np.arange(64, dtype=np.float64) + data[30] = bad_value + self.assertEqual(self.forward( + doubles(data), data.size, 3, 2, 0, doubles(output), integers(lengths)), + INVALID) + original = data.copy() + self.assertEqual(self.denoise(doubles(data), data.size, 3, 2, 0, 0, 0, 0), + INVALID) + np.testing.assert_array_equal(data, original) + for level in (0, -1, 101, 2**31 - 1): + self.assertEqual(self.forward( + doubles(np.ones(64)), 64, 3, level, 0, doubles(output), integers(lengths)), + INVALID) + self.assertEqual(self.forward( + doubles(np.ones(64)), 64, 3, 20, 0, doubles(output), integers(lengths)), + BUFFER_ERROR) + coefficients, valid_lengths = self.transform(np.ones(64)) + coefficients[0] = np.nan + self.assertEqual(self.inverse( + doubles(coefficients), coefficients.size, 64, 3, 2, 0, + integers(valid_lengths), valid_lengths.size, doubles(output), output.size), + INVALID) + np.testing.assert_array_equal(output, np.full(500, 321.0)) + + def test_detail_reconstruction_matches_coefficient_mask(self): + data = np.random.default_rng(13).normal(size=257) + for wavelet in (0, 3, 20, 35, 44): + coefficients, lengths = self.transform(data, wavelet, 3) + offsets = np.concatenate(([0], np.cumsum(lengths))) + for detail in (1, 2, 3): + masked = np.zeros_like(coefficients) + block = 4 - detail + masked[offsets[block]:offsets[block + 1]] = coefficients[ + offsets[block]:offsets[block + 1]] + expected = self.reconstruct(masked, lengths, data.size, wavelet, 3) + actual = np.empty_like(data) + self.assertEqual(self.restore( + doubles(data), data.size, wavelet, 3, detail, doubles(actual)), 0) + np.testing.assert_allclose(actual, expected, atol=1e-12, rtol=1e-12) + + def test_wavelet_denoising_preserves_constant_signals(self): + for method in (0, 1): + for threshold in (0, 1): + for extension in (0, 1): + for noise in (0, 1): + data = np.full(256, 12.5) + self.assertEqual(self.denoise( + doubles(data), data.size, 3, 3, method, threshold, extension, + noise), 0) + np.testing.assert_allclose(data, np.full(256, 12.5), atol=1e-10) + + def test_csp_whitens_and_diagonalizes_covariances(self): + data = np.random.default_rng(187).normal(size=(6, 3, 256)) + data[3:, 0] *= 4.0 + labels = np.array([0., 0., 0., 1., 1., 1.]) + filters, values = np.empty((3, 3)), np.empty(3) + self.assertEqual(self.csp( + doubles(data), doubles(labels), 6, 3, 256, doubles(filters), doubles(values)), 0) + centered = data - data.mean(axis=2, keepdims=True) + covariance = centered @ centered.transpose(0, 2, 1) / 256 + first, second = covariance[:3].mean(axis=0), covariance[3:].mean(axis=0) + np.testing.assert_allclose(filters @ (first + second) @ filters.T, + np.eye(3), atol=1e-12, rtol=1e-12) + np.testing.assert_allclose(filters @ first @ filters.T, + np.diag(values), atol=1e-12, rtol=1e-12) + + def test_csp_rejects_invalid_labels_rank_and_samples(self): + valid = np.random.default_rng(187).normal(size=(4, 3, 128)) + filters, values = np.full((3, 3), 321.0), np.full(3, 321.0) + labels = np.array([0., 0., 1., 1.]) + for bad_labels in (np.zeros(4), np.ones(4), np.array([0.1, 0.9, 1.1, 1.9]), + np.array([0., 0., 1., np.nan])): + self.assertEqual(self.csp( + doubles(valid), doubles(bad_labels), 4, 3, 128, + doubles(filters), doubles(values)), INVALID) + rank_one = np.tile(valid[:, :1], (1, 3, 1)) + for bad_data in (np.zeros_like(valid), np.ones_like(valid), rank_one, + np.full_like(valid, np.nan), np.full_like(valid, np.inf)): + self.assertEqual(self.csp( + doubles(bad_data), doubles(labels), 4, 3, 128, + doubles(filters), doubles(values)), INVALID) + self.assertEqual(self.csp( + doubles(valid), doubles(labels), 4, 3, 128, None, doubles(values)), INVALID) + np.testing.assert_array_equal(filters, np.full((3, 3), 321.0)) + np.testing.assert_array_equal(values, np.full(3, 321.0)) + + @staticmethod + def mixture(square=False): + generator = np.random.default_rng(2841) + sources = np.vstack((generator.laplace(size=5000), generator.uniform(-1, 1, 5000), + generator.standard_t(6, size=5000))) + mixing = np.array([[1., 0.4, -0.2], [-0.6, 1.3, 0.7], [0.3, -0.7, 1.2]]) + if not square: + sources, mixing = sources[:2], mixing[:, :2] + return np.ascontiguousarray(mixing @ sources + np.array([[10.], [-5.], [2.]])) + + def run_ica(self, data, components=2, iterations=1000, tolerance=1e-4, seed=42): + rows, columns = data.shape + outputs = (np.full((components, components), 321.0), + np.full((components, rows), 321.0), + np.full((rows, components), 321.0), + np.full((components, columns), 321.0)) + code = self.ica(doubles(data), rows, columns, components, + *(doubles(output) for output in outputs), + iterations, tolerance, seed) + return code, outputs + + def test_ica_rectangular_and_square_reconstruction(self): + for square in (False, True): + data = self.mixture(square) + code, (weights, whitening, mixing, sources) = self.run_ica( + data, 3 if square else 2) + self.assertEqual(code, 0) + centered = data - data.mean(axis=1, keepdims=True) + np.testing.assert_allclose(mixing @ sources, centered, atol=1e-10, rtol=1e-10) + np.testing.assert_allclose(mixing, np.linalg.pinv(weights @ whitening), + atol=1e-10, rtol=1e-10) + np.testing.assert_allclose(sources, weights @ whitening @ centered, + atol=1e-10, rtol=1e-10) + + def test_ica_seed_is_repeatable_and_scaling_stays_finite(self): + data = self.mixture() + first_code, first = self.run_ica(data) + second_code, second = self.run_ica(data) + self.assertEqual(first_code, 0) + self.assertEqual(second_code, 0) + for left, right in zip(first, second): + np.testing.assert_array_equal(left, right) + for scale in (1e-100, 1e100): + code, (_, _, mixing, sources) = self.run_ica(data * scale) + self.assertEqual(code, 0) + np.testing.assert_allclose(mixing @ sources / scale, + data - data.mean(axis=1, keepdims=True), + atol=1e-10, rtol=1e-10) + + def test_ica_rejects_bad_rank_nonfinite_and_nonconvergence(self): + data = self.mixture() + for invalid in (np.zeros((3, 100)), np.ones((3, 100)), + np.tile(np.arange(100.), (3, 1)), + np.full((3, 100), np.nan), np.full((3, 100), np.inf)): + code, outputs = self.run_ica(invalid) + self.assertEqual(code, INVALID) + for output in outputs: + self.assertTrue(np.all(output == 321.0)) + code, outputs = self.run_ica(data, iterations=1, tolerance=1e-14) + self.assertEqual(code, GENERAL_ERROR) + for output in outputs: + self.assertTrue(np.all(output == 321.0)) + for options in ({'iterations': 0}, {'tolerance': 0.0}, {'tolerance': np.nan}, + {'tolerance': 1.0}, {'seed': -2}): + self.assertEqual(self.run_ica(data, **options)[0], INVALID) + + +if __name__ == '__main__': + unittest.main() diff --git a/python_package/examples/tests/ml_binding_regression.py b/python_package/examples/tests/ml_binding_regression.py new file mode 100644 index 000000000..9f2dba3f7 --- /dev/null +++ b/python_package/examples/tests/ml_binding_regression.py @@ -0,0 +1,82 @@ +"""Python ML buffer ownership and input-layout regressions; no native model needed.""" +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / 'python_package')) +from brainflow.board_shim import BrainFlowError +from brainflow.ml_model import BrainFlowModelParams, MLModel, MLModuleDLL + + +class MLBindingRegression(unittest.TestCase): + def test_output_capacity_matches_initial_serialized_settings(self): + params = BrainFlowModelParams(0, 0) + params.max_array_size = 4 + model = MLModel(params) + params.max_array_size = 1 + + def predict(data, size, output, output_len, serialized): + self.assertEqual(json.loads(serialized)['max_array_size'], 4) + self.assertEqual(len(output), 4) + output[:] = [0.1, 0.2, 0.3, 0.4] + output_len[0] = 4 + return 0 + + with patch.object(MLModuleDLL, 'get_instance') as library: + library.return_value.predict.side_effect = predict + np.testing.assert_array_equal(model.predict([1, 2, 3, 4, 5]), [0.1, 0.2, 0.3, 0.4]) + + def test_capacity_must_be_a_positive_native_integer(self): + for capacity in (0, -1, 1.5, True, np.bool_(True), '4', 2 ** 31): + params = BrainFlowModelParams(0, 0) + params.max_array_size = capacity + with self.subTest(capacity=capacity), self.assertRaises(BrainFlowError): + MLModel(params) + params = BrainFlowModelParams(0, 0) + params.max_array_size = np.int64(4) + self.assertEqual(json.loads(MLModel(params).serialized_params)['max_array_size'], 4) + + def test_strided_input_is_normalized_before_native_access(self): + model = MLModel(BrainFlowModelParams(0, 0)) + values = np.arange(10, dtype=np.float32)[::-2] + + def predict(data, size, output, output_len, serialized): + self.assertEqual(data.dtype, np.float64) + self.assertTrue(data.flags.c_contiguous) + np.testing.assert_array_equal(data, values) + self.assertEqual(size, 5) + output[0], output_len[0] = 0.5, 1 + return 0 + + with patch.object(MLModuleDLL, 'get_instance') as library: + library.return_value.predict.side_effect = predict + np.testing.assert_array_equal(model.predict(values), [0.5]) + + def test_invalid_inputs_do_not_reach_native_code(self): + model = MLModel(BrainFlowModelParams(0, 0)) + with patch.object(MLModuleDLL, 'get_instance') as library: + for values in ([], 1.0, [[1, 2]], [1, np.nan], [np.inf], [1 + 2j], ['not numeric']): + with self.subTest(values=values), self.assertRaises(BrainFlowError): + model.predict(values) + library.assert_not_called() + + def test_invalid_native_output_count_is_rejected(self): + params = BrainFlowModelParams(0, 0) + params.max_array_size = 2 + model = MLModel(params) + for invalid_count in (-1, 3): + def predict(data, size, output, output_len, serialized): + output_len[0] = invalid_count + return 0 + with patch.object(MLModuleDLL, 'get_instance') as library: + library.return_value.predict.side_effect = predict + with self.subTest(count=invalid_count), self.assertRaises(BrainFlowError): + model.predict([1, 2, 3, 4, 5]) + + +if __name__ == '__main__': + unittest.main() diff --git a/python_package/examples/tests/ml_export_regression.py b/python_package/examples/tests/ml_export_regression.py new file mode 100644 index 000000000..f4bdf5c1a --- /dev/null +++ b/python_package/examples/tests/ml_export_regression.py @@ -0,0 +1,343 @@ +"""Classifier export parity and native ML buffer/selection regression checks. + +Run after rebuilding MLModule with BUILD_ONNX=ON, using the local Python package: + python python_package/examples/tests/ml_export_regression.py +Training checks also require src/ml/train/requirements.txt. +""" + +import ctypes +import hashlib +from pathlib import Path +import re +import sys +import tempfile +import unittest +from unittest import mock +import warnings + +import numpy as np +import onnx +from onnx import TensorProto, helper +from scipy.special import expit +from sklearn.calibration import CalibratedClassifierCV +from sklearn.ensemble import (ExtraTreesClassifier, GradientBoostingClassifier, HistGradientBoostingClassifier, + RandomForestClassifier, StackingClassifier) +from sklearn.linear_model import LogisticRegression +from sklearn.neighbors import KNeighborsClassifier +from sklearn.neural_network import MLPClassifier +from sklearn.pipeline import make_pipeline +from sklearn.preprocessing import PolynomialFeatures, StandardScaler +from sklearn.svm import SVC + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / "python_package")) +sys.path.insert(0, str(ROOT / "src/ml/train")) + +from brainflow.board_shim import BrainFlowError +from brainflow.ml_model import ( + BrainFlowClassifiers, BrainFlowMetrics, BrainFlowModelParams, MLModel, MLModuleDLL) +from training_export import export_model, extract_native_logistic, write_native_logistic + + +def samples(): + generator = np.random.default_rng(317) + data = generator.dirichlet(np.array([2, 3, 4, 2, 1]), size=160) + labels = (data @ np.array([-2, -1, 3, 1, -2]) > .4).astype(int) + probes = np.concatenate((data[-20:], np.eye(5), np.full((1, 5), .2))) + return data[:-20], labels[:-20], probes + + +def fitted_models(): + data, labels, _ = samples() + models = { + "logreg": make_pipeline(StandardScaler(), LogisticRegression(C=.7, random_state=4)), + "polynomial_logreg": make_pipeline(PolynomialFeatures(2, include_bias=False), + StandardScaler(), LogisticRegression(C=.1, random_state=4)), + "gradient_boosting": GradientBoostingClassifier(n_estimators=12, max_depth=2, random_state=4), + "svm": CalibratedClassifierCV(make_pipeline(StandardScaler(), SVC(C=.5, random_state=4)), + ensemble=False, cv=3, method="sigmoid"), + "forest": RandomForestClassifier(n_estimators=8, max_depth=4, random_state=4), + "extra_trees": ExtraTreesClassifier(n_estimators=8, max_depth=4, random_state=4), + "hist_gradient_boosting": HistGradientBoostingClassifier( + max_iter=15, max_leaf_nodes=7, min_samples_leaf=5, random_state=4), + "knn": make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5, weights="distance")), + "mlp": make_pipeline(StandardScaler(), MLPClassifier( + hidden_layer_sizes=(6,), activation="logistic", solver="lbfgs", + max_iter=1000, random_state=4)), + "stacking": StackingClassifier(estimators=[ + ("linear", LogisticRegression(C=.7, random_state=4)), + ("trees", RandomForestClassifier(n_estimators=5, max_depth=3, random_state=4)), + ("neighbors", make_pipeline(StandardScaler(), KNeighborsClassifier( + n_neighbors=5, weights="distance")))], + final_estimator=LogisticRegression(C=.3), passthrough=True, cv=2, n_jobs=1), + } + return {name: model.fit(data, labels) for name, model in models.items()} + + +class ExportRegression(unittest.TestCase): + def test_folded_scaler_probability_parity(self): + data, labels, probes = samples() + for center, scale in ((True, True), (False, True), (True, False), (False, False)): + with self.subTest(center=center, scale=scale): + model = make_pipeline(StandardScaler(with_mean=center, with_std=scale), + LogisticRegression(C=.7, random_state=4)).fit(data, labels) + coefficients, intercept = extract_native_logistic(model) + np.testing.assert_allclose(expit(probes @ coefficients + intercept), + model.predict_proba(probes)[:, 1], atol=1e-14, rtol=0) + + def test_binary_multinomial_probability_parity(self): + data, labels, probes = samples() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", FutureWarning) + model = make_pipeline(StandardScaler(), LogisticRegression( + C=.7, multi_class="multinomial", random_state=4)).fit(data, labels) + coefficients, intercept = extract_native_logistic(model) + np.testing.assert_allclose(expit(probes @ coefficients + intercept), + model.predict_proba(probes)[:, 1], atol=1e-14, rtol=0) + + def test_liblinear_without_intercept_folds_scalar_zero(self): + data, labels, probes = samples() + model = make_pipeline(StandardScaler(), LogisticRegression( + solver="liblinear", fit_intercept=False, random_state=4)).fit(data, labels) + coefficients, intercept = extract_native_logistic(model) + np.testing.assert_allclose(expit(probes @ coefficients + intercept), + model.predict_proba(probes)[:, 1], atol=1e-14, rtol=0) + + def test_native_writer_roundtrips_all_parameters(self): + data, labels, _ = samples() + model = make_pipeline(StandardScaler(), LogisticRegression(C=.7)).fit(data, labels) + coefficients, intercept = extract_native_logistic(model) + self.assertNotEqual(intercept, 0) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "mindfulness_model.cpp" + metadata = write_native_logistic(model, path) + content = path.read_text() + self.assertEqual(metadata["sha256"], hashlib.sha256(path.read_bytes()).hexdigest()) + saved = np.fromstring(re.search(r"\{([^}]+)\}", content).group(1), sep=",") + saved_intercept = float(re.search(r"intercept = ([^;]+)", content).group(1)) + np.testing.assert_array_equal(saved, coefficients) + self.assertEqual(saved_intercept, intercept) + + def test_all_model_families_export_with_probability_parity(self): + _, _, probes = samples() + with tempfile.TemporaryDirectory() as directory: + for name, model in fitted_models().items(): + with self.subTest(model=name): + path = Path(directory) / (name + ".onnx") + metadata = export_model(model, path, probes) + self.assertTrue(path.is_file()) + self.assertEqual(metadata["input_shape"], [1, 5]) + self.assertEqual(metadata["output_shape"], [1, 2]) + self.assertLessEqual(metadata["parity"]["max_absolute_error"], 2e-5) + onnx.checker.check_model(onnx.load(path)) + + def test_calibrated_svm_linear_and_rbf_ensembles_preserve_probabilities(self): + data, labels, probes = samples() + with tempfile.TemporaryDirectory() as directory: + for kernel in ("linear", "rbf"): + for ensemble in (False, True): + with self.subTest(kernel=kernel, ensemble=ensemble): + model = CalibratedClassifierCV(make_pipeline( + StandardScaler(), SVC(kernel=kernel, C=3.0)), + ensemble=ensemble, cv=3).fit(data, labels) + metadata = export_model(model, Path(directory) / "calibrated.onnx", probes) + self.assertEqual(metadata["exporter"], "double_precision_calibrated_svm") + self.assertLess(metadata["parity"]["max_absolute_error"], 1e-6) + + def test_export_failure_preserves_existing_artifact(self): + data, labels, probes = samples() + model = LogisticRegression().fit(data, labels) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "model.onnx" + path.write_bytes(b"previous validated artifact") + wrong = np.tile([1., 0.], (len(probes), 1)) + with mock.patch("training_export._runtime_probabilities", + return_value=(wrong, "test", "test")): + with self.assertRaisesRegex(ValueError, "parity failed"): + export_model(model, path, probes) + self.assertEqual(path.read_bytes(), b"previous validated artifact") + self.assertEqual(len(list(Path(directory).iterdir())), 1) + + def test_export_bytes_are_reproducible(self): + data, labels, probes = samples() + model = LogisticRegression().fit(data, labels) + expected = model.predict_proba(probes) + with tempfile.TemporaryDirectory() as directory: + first, second = Path(directory) / "first.onnx", Path(directory) / "second.onnx" + with mock.patch("training_export._runtime_probabilities", + return_value=(expected, "test", "test")): + export_model(model, first, probes) + export_model(model, second, probes) + self.assertEqual(first.read_bytes(), second.read_bytes()) + + def test_knn_exact_duplicate_and_near_zero_distance_weights(self): + data = np.array([[1, 0, 0, 0, 0], [1, 0, 0, 0, 0], [.75, .25, 0, 0, 0], + [.5, .5, 0, 0, 0], [.25, .75, 0, 0, 0]], dtype=np.float64) + labels = np.array([0, 1, 1, 1, 0]) + probes = np.array([[1, 0, 0, 0, 0], [1 - 2**-24, 2**-24, 0, 0, 0], + [.875, .125, 0, 0, 0], [.5, .5, 0, 0, 0]], dtype=np.float64) + with tempfile.TemporaryDirectory() as directory: + for metric in (1, 2): + with self.subTest(minkowski_p=metric): + model = KNeighborsClassifier(n_neighbors=4, weights="distance", p=metric).fit( + data, labels) + np.testing.assert_array_equal(model.predict_proba(probes[:1]), [[.5, .5]]) + metadata = export_model(model, Path(directory) / f"knn_p{metric}.onnx", probes) + self.assertEqual(metadata["distance_weight_repairs"], 1) + + def test_rejects_wrong_feature_or_class_contract(self): + data, labels, probes = samples() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "model.onnx" + with self.assertRaisesRegex(ValueError, "classes"): + export_model(LogisticRegression().fit(data, labels + 1), path, probes) + with self.assertRaisesRegex(ValueError, "shape"): + export_model(LogisticRegression().fit(data, labels), path, probes[:, :4]) + with self.assertRaisesRegex(ValueError, "finite"): + invalid = probes.copy() + invalid[0, 0] = np.nan + export_model(LogisticRegression().fit(data, labels), path, invalid) + + +class NativeMLRegression(unittest.TestCase): + @classmethod + def setUpClass(cls): + MLModel.disable_ml_logger() + cls.directory = tempfile.TemporaryDirectory() + cls.single = Path(cls.directory.name) / "single.onnx" + cls.multiple = Path(cls.directory.name) / "multiple.onnx" + cls.dynamic = Path(cls.directory.name) / "dynamic_output.onnx" + input_info = helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 5]) + + def save(path, nodes, outputs): + graph = helper.make_graph(nodes, "regression", [input_info], outputs) + model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 11)]) + model.ir_version = 6 + onnx.checker.check_model(model) + onnx.save(model, path) + + positive = helper.make_tensor_value_info("positive", TensorProto.FLOAT, [1, 5]) + negative = helper.make_tensor_value_info("negative", TensorProto.FLOAT, [1, 5]) + save(cls.single, [helper.make_node("Identity", ["input"], ["positive"])], [positive]) + save(cls.multiple, [helper.make_node("Identity", ["input"], ["positive"]), + helper.make_node("Neg", ["input"], ["negative"])], [positive, negative]) + save(cls.dynamic, [helper.make_node("NonZero", ["input"], ["positions"])], + [helper.make_tensor_value_info("positions", TensorProto.INT64, [2, None])]) + library = MLModuleDLL.get_instance().lib + cls.raw_predict = staticmethod(ctypes.CFUNCTYPE( + ctypes.c_int, ctypes.POINTER(ctypes.c_double), ctypes.c_int, + ctypes.POINTER(ctypes.c_double), ctypes.POINTER(ctypes.c_int), ctypes.c_char_p)( + ("predict", library))) + + @classmethod + def tearDownClass(cls): + cls.directory.cleanup() + + def tearDown(self): + MLModel.release_all() + + def onnx_model(self, path, output_name="", capacity=16): + params = BrainFlowModelParams(BrainFlowMetrics.USER_DEFINED, BrainFlowClassifiers.ONNX_CLASSIFIER) + params.file = str(path) + params.output_name = output_name + params.max_array_size = capacity + return MLModel(params) + + def builtin(self, metric=BrainFlowMetrics.MINDFULNESS, capacity=1): + params = BrainFlowModelParams(metric, BrainFlowClassifiers.DEFAULT_CLASSIFIER) + params.max_array_size = capacity + return MLModel(params) + + def assert_error(self, code, operation): + with self.assertRaises(BrainFlowError) as failure: + operation() + self.assertEqual(failure.exception.exit_code, code) + + def test_builtin_matches_generated_weights_and_restfulness_complement(self): + content = (ROOT / "src/ml/generated/mindfulness_model.cpp").read_text() + coefficients = np.fromstring(re.search(r"\{([^}]+)\}", content).group(1), sep=",") + intercept = float(re.search(r"intercept = ([^;]+)", content).group(1)) + mind = self.builtin() + rest = self.builtin(BrainFlowMetrics.RESTFULNESS) + mind.prepare() + rest.prepare() + _, _, probes = samples() + for row in probes: + expected = expit(row @ coefficients + intercept) + self.assertAlmostEqual(mind.predict(row)[0], expected, places=13) + self.assertAlmostEqual(rest.predict(row)[0], 1 - expected, places=13) + np.testing.assert_array_equal(mind.predict(np.r_[probes[0], 1234.]), mind.predict(probes[0])) + + def test_builtin_rejects_invalid_inputs_and_capacity(self): + self.assert_error(13, lambda: self.builtin(capacity=0).prepare()) + model = self.builtin() + model.prepare() + for data in (np.ones(4), np.array([.2, .2, np.nan, .2, .2]), + np.array([.2, .2, np.inf, .2, .2])): + self.assert_error(13, lambda: model.predict(data)) + values = np.ones(5) + output = np.zeros(1) + self.assertEqual(self.raw_predict( + values.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), 5, + output.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), None, + model.serialized_params), 13) + + def test_registry_distinguishes_output_names_and_capacities(self): + positive = self.onnx_model(self.multiple, "positive", 5) + negative = self.onnx_model(self.multiple, "negative", 5) + larger = self.onnx_model(self.multiple, "positive", 6) + positive.prepare() + negative.prepare() + larger.prepare() + data = np.arange(5, dtype=np.float64) + np.testing.assert_array_equal(positive.predict(data), data) + np.testing.assert_array_equal(negative.predict(data), -data) + np.testing.assert_array_equal(larger.predict(data), data) + + def test_actual_dynamic_output_size_and_empty_output(self): + model = self.onnx_model(self.dynamic, capacity=8) + model.prepare() + np.testing.assert_array_equal(model.predict(np.array([0., .2, 0., .3, 0.])), [0, 0, 1, 3]) + self.assertEqual(model.predict(np.zeros(5)).size, 0) + + def test_output_capacity_rejects_instead_of_truncating(self): + model = self.onnx_model(self.single, capacity=2) + model.prepare() + values = np.arange(5, dtype=np.float64) + output = np.full(5, 12345., dtype=np.float64) + count = ctypes.c_int(-1) + self.assertEqual(self.raw_predict( + values.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), 5, + output.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), ctypes.byref(count), + model.serialized_params), 9) + self.assertEqual(count.value, 0) + np.testing.assert_array_equal(output, 12345.) + + def test_explicit_unknown_output_is_rejected_even_for_single_output(self): + self.assert_error(13, self.onnx_model(self.single, "not_an_output").prepare) + + def test_onnx_rejects_wrong_input_count_nonfinite_and_float_overflow(self): + model = self.onnx_model(self.single) + model.prepare() + for data in (np.ones(4), np.ones(6), np.full(5, np.nan), np.full(5, np.inf), + np.full(5, np.finfo(np.float64).max)): + self.assert_error(13, lambda: model.predict(data)) + + def test_exported_models_match_sklearn_in_bundled_runtime(self): + _, _, probes = samples() + for name, fitted in fitted_models().items(): + with self.subTest(model=name): + path = Path(self.directory.name) / (name + ".onnx") + metadata = export_model(fitted, path, probes) + model = self.onnx_model(path, metadata["output_name"], capacity=2) + model.prepare() + try: + probabilities = np.asarray([model.predict(row) for row in probes]) + finally: + model.release() + np.testing.assert_allclose(probabilities, fitted.predict_proba(probes), + atol=2e-5, rtol=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/python_package/examples/tests/signal_metrics_regression.py b/python_package/examples/tests/signal_metrics_regression.py new file mode 100644 index 000000000..dcd27bcf7 --- /dev/null +++ b/python_package/examples/tests/signal_metrics_regression.py @@ -0,0 +1,211 @@ +"""PPG validity, rolling peaks, clipping, flatline and stable activity regressions.""" + +import ctypes +import unittest + +import numpy as np + +from brainflow.data_filter import DataHandlerDLL + + +DOUBLE = ctypes.POINTER(ctypes.c_double) +INT = ctypes.c_int +REAL = ctypes.c_double +INVALID = 13 +BUFFER_ERROR = 9 + + +def pointer(array): + return array.ctypes.data_as(DOUBLE) + + +class SignalMetricsRegression(unittest.TestCase): + @classmethod + def setUpClass(cls): + library = DataHandlerDLL.get_instance().lib + + def native(name, *arguments): + return staticmethod(ctypes.CFUNCTYPE(INT, *arguments)((name, library))) + + cls.heart_rate = native( + 'get_heart_rate', DOUBLE, DOUBLE, INT, INT, INT, DOUBLE) + cls.oxygen = native( + 'get_oxygen_level', DOUBLE, DOUBLE, INT, INT, REAL, REAL, REAL, DOUBLE) + cls.peaks = native( + 'detect_peaks_z_score', DOUBLE, INT, INT, REAL, REAL, DOUBLE) + cls.railed = native('get_railed_percentage', DOUBLE, INT, INT, DOUBLE) + cls.clipping = native( + 'get_clipping_percentage', DOUBLE, INT, REAL, REAL, DOUBLE) + cls.flatline = native('get_flatline_percentage', DOUBLE, INT, REAL, DOUBLE) + cls.activity = native( + 'get_activity_index', DOUBLE, DOUBLE, DOUBLE, INT, INT, INT, + REAL, REAL, REAL, DOUBLE) + + @staticmethod + def ppg(frequency=1.23, sampling_rate=128, seconds=32): + time = np.arange(sampling_rate * seconds) / sampling_rate + pulse = np.sin(2 * np.pi * frequency * time + 0.23) + return 1000.0 + 10.0 * pulse, 800.0 + 4.0 * pulse + + def test_heart_rate_rejects_absent_nonfinite_and_trend_only_pulses(self): + output = np.array([321.0]) + for data in (np.zeros(4096), np.full(4096, 1000.), np.full(4096, np.nan), + np.full(4096, np.inf), 1000. + np.arange(4096.)): + with self.subTest(value=data[0]): + self.assertEqual(self.heart_rate( + pointer(data), pointer(data), data.size, 128, 1024, pointer(output)), + INVALID) + self.assertEqual(output[0], 321.0) + + def test_heart_rate_interpolation_and_dc_scale_invariance(self): + for frequency in (0.6, 1.23, 2.2, 3.7): + ir, red = self.ppg(frequency) + output = np.empty(1) + self.assertEqual(self.heart_rate( + pointer(ir), pointer(red), ir.size, 128, 1024, pointer(output)), 0) + self.assertAlmostEqual(output[0], frequency * 60.0, delta=0.3) + ir, red = ir * 1e100, red * 1e-100 + self.assertEqual(self.heart_rate( + pointer(ir), pointer(red), ir.size, 128, 1024, pointer(output)), 0) + self.assertAlmostEqual(output[0], frequency * 60.0, delta=0.3) + + def test_ppg_rejects_noise_and_disagreeing_channels(self): + generator = np.random.default_rng(3) + ir_noise = 1000. + generator.normal(size=4096) + red_noise = 800. + generator.normal(size=4096) + first, _ = self.ppg(1.0) + _, second = self.ppg(2.0) + output = np.array([321.0]) + for ir, red in ((ir_noise, red_noise), (first, second)): + self.assertEqual(self.heart_rate( + pointer(ir), pointer(red), ir.size, 128, 1024, pointer(output)), INVALID) + self.assertEqual(self.oxygen( + pointer(ir), pointer(red), ir.size, 128, 1., 2., 3., pointer(output)), + INVALID) + self.assertEqual(output[0], 321.0) + + def test_heart_rate_rejects_invalid_sampling_fft_and_short_input(self): + ir, red = self.ppg() + output = np.array([321.0]) + for sampling_rate, fft_size, length in ((0, 1024, 4096), (7, 1024, 4096), + (128, 1023, 4096), (128, 512, 4096), + (1000, 1024, 4096), (128, 8192, 4096)): + self.assertEqual(self.heart_rate( + pointer(ir), pointer(red), length, sampling_rate, fft_size, pointer(output)), + INVALID) + self.assertEqual(output[0], 321.0) + + def test_oxygen_uses_measured_ac_dc_ratio_over_supported_pulse_range(self): + for frequency in (0.8, 1.23, 2.2, 3.2): + for sampling_rate in (64, 128, 250): + ir, red = self.ppg(frequency, sampling_rate) + output = np.empty(1) + self.assertEqual(self.oxygen( + pointer(ir), pointer(red), ir.size, sampling_rate, + 1., 2., 3., pointer(output)), 0) + # (red AC/red DC)/(IR AC/IR DC) = 0.5, with polynomial r*r+2*r+3. + self.assertAlmostEqual(output[0], 4.25, delta=0.003) + + def test_oxygen_rejects_invalid_inputs_and_insufficient_guard_data(self): + output = np.array([321.0]) + for data in (np.zeros(4096), np.full(4096, 1000.), np.full(4096, np.nan), + np.full(4096, np.inf), 1000. + np.arange(4096.)): + self.assertEqual(self.oxygen( + pointer(data), pointer(data), data.size, 128, 1., 2., 3., pointer(output)), + INVALID) + ir, red = self.ppg() + for sampling_rate, coefficient in ((0, 1.), (8, 1.), (128, np.nan), (128, np.inf)): + self.assertEqual(self.oxygen( + pointer(ir), pointer(red), ir.size, sampling_rate, + coefficient, 2., 3., pointer(output)), INVALID) + self.assertEqual(self.oxygen( + pointer(ir), pointer(red), 128, 128, 1., 2., 3., pointer(output)), BUFFER_ERROR) + self.assertEqual(output[0], 321.0) + + @staticmethod + def reference_peaks(data, lag, threshold, influence): + filtered = data.copy() + result = np.zeros_like(data) + for i in range(lag, data.size): + baseline = filtered[i - lag:i] + if abs(data[i] - baseline.mean()) > threshold * baseline.std(): + result[i] = 1.0 if data[i] > baseline.mean() else -1.0 + filtered[i] = influence * data[i] + (1 - influence) * filtered[i - 1] + return result + + def test_peak_detector_trailing_window_matches_reference(self): + data = np.arange(10., dtype=np.float64) + output = np.empty_like(data) + self.assertEqual(self.peaks( + pointer(data), data.size, 2, 3., 1., pointer(output)), 0) + np.testing.assert_array_equal(output, np.zeros_like(data)) + generator = np.random.default_rng(193) + data = generator.normal(size=5000) + data[100:105] += 10. + data[500] -= 20. + for lag in (2, 17, 100): + for influence in (0., 0.4, 1.): + expected = self.reference_peaks(data, lag, 3., influence) + output = np.empty_like(data) + self.assertEqual(self.peaks( + pointer(data), data.size, lag, 3., influence, pointer(output)), 0) + np.testing.assert_array_equal(output, expected) + + def test_peak_detector_rejects_nonfinite_and_invalid_parameters(self): + data = np.arange(10.) + output = np.full_like(data, 321.) + for threshold, influence in ((np.nan, 1.), (np.inf, 1.), (3., np.nan), + (3., np.inf), (3., 1.5), (3., -1.)): + self.assertEqual(self.peaks( + pointer(data), data.size, 2, threshold, influence, pointer(output)), INVALID) + data[2] = np.nan + self.assertEqual(self.peaks( + pointer(data), data.size, 2, 3., 1., pointer(output)), INVALID) + np.testing.assert_array_equal(output, np.full_like(data, 321.)) + + def test_clipping_and_flatline_have_explicit_distinct_semantics(self): + data = np.array([-2., -1., 0., 0., 0.05, 1., 2.]) + output = np.empty(1) + self.assertEqual(self.clipping( + pointer(data), data.size, -1., 1., pointer(output)), 0) + self.assertAlmostEqual(output[0], 400. / 7) + self.assertEqual(self.flatline( + pointer(data), data.size, 0.1, pointer(output)), 0) + self.assertAlmostEqual(output[0], 100. / 3) + self.assertEqual(self.flatline( + pointer(data), data.size, 0., pointer(output)), 0) + self.assertAlmostEqual(output[0], 100. / 6) + self.assertEqual(self.clipping( + pointer(data), data.size, 1., -1., pointer(output)), INVALID) + self.assertEqual(self.flatline( + pointer(data), data.size, -1., pointer(output)), INVALID) + data[0] = np.nan + self.assertEqual(self.clipping( + pointer(data), data.size, -1., 1., pointer(output)), INVALID) + self.assertEqual(self.flatline( + pointer(data), data.size, 0., pointer(output)), INVALID) + + def test_legacy_railed_metric_is_bounded_and_rejects_nonfinite_data(self): + output = np.empty(1) + data = np.array([0., 1e100, -1e100]) + self.assertEqual(self.railed(pointer(data), data.size, 24, pointer(output)), 0) + self.assertEqual(output[0], 100.) + data = np.full(128, np.nan) + self.assertEqual(self.railed(pointer(data), data.size, 24, pointer(output)), INVALID) + + def test_activity_remains_finite_when_variance_would_overflow(self): + output = np.empty(1) + constant = np.full(128, 1e308) + self.assertEqual(self.activity( + pointer(constant), pointer(constant), pointer(constant), 128, 128, 128, + 0., 0., 0., pointer(output)), 0) + self.assertEqual(output[0], 0.) + data = np.tile(np.array([-1e200, 1e200]), 64) + self.assertEqual(self.activity( + pointer(data), pointer(data), pointer(data), 128, 128, 128, + 0., 0., 0., pointer(output)), 0) + self.assertAlmostEqual(output[0] / 1e200, 1., places=12) + + +if __name__ == '__main__': + unittest.main() diff --git a/python_package/examples/tests/signal_processing_regression.py b/python_package/examples/tests/signal_processing_regression.py new file mode 100644 index 000000000..6c980d3d1 --- /dev/null +++ b/python_package/examples/tests/signal_processing_regression.py @@ -0,0 +1,200 @@ +"""Numerical and input-boundary regressions for the native signal-processing core. + +Run against freshly built libraries with this checkout's Python package on PYTHONPATH. +""" + +import tempfile +import unittest +from pathlib import Path + +import numpy as np +from scipy import signal + +from brainflow.data_filter import DataFilter, DataHandlerDLL +from brainflow.exit_codes import BrainFlowError, BrainFlowExitCodes + + +class SignalProcessingRegression(unittest.TestCase): + def assert_invalid(self, function, *args): + with self.assertRaises(BrainFlowError) as error: + function(*args) + self.assertEqual(error.exception.exit_code, BrainFlowExitCodes.INVALID_ARGUMENTS_ERROR.value) + + def test_periodogram_matches_scipy_for_every_window(self): + data = np.random.default_rng(21).normal(size=1000) + for window in range(4): + with self.subTest(window=window): + weights = DataFilter.get_window(window, len(data)) + frequencies, expected = signal.periodogram(data, fs=250, window=weights, detrend=False) + actual, actual_frequencies = DataFilter.get_psd(data, 250, window) + np.testing.assert_allclose(actual_frequencies, frequencies, atol=1e-13) + np.testing.assert_allclose(actual, expected, rtol=2e-12, atol=1e-15) + + def test_welch_matches_scipy_with_odd_recording_length(self): + data = np.random.default_rng(22).normal(size=2049) + for window in range(4): + with self.subTest(window=window): + weights = DataFilter.get_window(window, 256) + frequencies, expected = signal.welch(data, fs=256, window=weights, + nperseg=256, noverlap=97, detrend=False) + actual, actual_frequencies = DataFilter.get_psd_welch(data, 256, 97, 256, window) + self.assertEqual(len(actual), 129) + np.testing.assert_allclose(actual_frequencies, frequencies, atol=1e-13) + np.testing.assert_allclose(actual, expected, rtol=2e-12, atol=1e-15) + + def test_psd_scaling_avoids_integer_overflow(self): + data = np.cos(2 * np.pi * 31 * np.arange(4096) / 4096) + frequencies, expected = signal.periodogram(data, fs=1_000_000, detrend=False) + actual, actual_frequencies = DataFilter.get_psd(data, 1_000_000, 0) + np.testing.assert_allclose(actual_frequencies, frequencies) + np.testing.assert_allclose(actual, expected, rtol=1e-12, atol=1e-20) + + def test_fft_and_ifft_match_numpy_and_preserve_input(self): + data = np.random.default_rng(23).normal(size=1000) + original = data.copy() + for window in range(4): + with self.subTest(window=window): + weights = DataFilter.get_window(window, len(data)) + actual = DataFilter.perform_fft(data, window) + np.testing.assert_allclose(actual, np.fft.rfft(data * weights), rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(DataFilter.perform_ifft(actual), data * weights, atol=1e-12) + np.testing.assert_array_equal(data, original) + + def test_spectral_nonfinite_inputs_are_rejected(self): + for value in (np.nan, np.inf, -np.inf): + data = np.ones(256) + data[100] = value + self.assert_invalid(DataFilter.perform_fft, data, 0) + self.assert_invalid(DataFilter.get_psd, data, 256, 0) + self.assert_invalid(DataFilter.get_psd_welch, data, 64, 32, 256, 0) + spectrum = np.ones(129, dtype=np.complex128) + spectrum[20] = complex(value, 0) + self.assert_invalid(DataFilter.perform_ifft, spectrum) + + def test_long_linear_detrend_matches_scipy(self): + data = 3.0 + 0.02 * np.arange(200_000, dtype=np.float64) + data += 0.5 * np.sin(np.arange(len(data)) * 0.11) + expected = signal.detrend(data) + DataFilter.detrend(data, 2) + np.testing.assert_allclose(data, expected, atol=5e-11, rtol=0) + + def test_singleton_and_large_magnitude_statistics(self): + for operation in (1, 2): + data = np.array([1e308]) + DataFilter.detrend(data, operation) + np.testing.assert_array_equal(data, [0]) + data = np.array([1e308, -1e308, 1e308, -1e308]) + self.assertAlmostEqual(DataFilter.calc_stddev(data) / 1e308, 1.0, places=14) + constant = np.full(20, 1e308) + DataFilter.detrend(constant, 1) + np.testing.assert_array_equal(constant, np.zeros(20)) + + def test_causal_butterworth_matches_scipy(self): + original = np.random.default_rng(24).normal(size=2048) + methods = ((DataFilter.perform_lowpass, 'lowpass', (30.0,)), + (DataFilter.perform_highpass, 'highpass', (5.0,)), + (DataFilter.perform_bandpass, 'bandpass', (5.0, 30.0)), + (DataFilter.perform_bandstop, 'bandstop', (48.0, 52.0))) + for method, kind, cutoffs in methods: + with self.subTest(kind=kind): + expected = signal.sosfilt(signal.butter(4, cutoffs[0] if len(cutoffs) == 1 else cutoffs, + btype=kind, fs=256, output='sos'), original) + actual = original.copy() + method(actual, 256, *cutoffs, 4, 0, 0.0) + np.testing.assert_allclose(actual, expected, atol=2e-11, rtol=1e-10) + + def test_zero_phase_filters_do_not_create_constant_edge_transients(self): + for method, cutoffs, expected in ((DataFilter.perform_lowpass, (30.0,), 17.0), + (DataFilter.perform_highpass, (5.0,), 0.0), + (DataFilter.perform_bandpass, (5.0, 30.0), 0.0), + (DataFilter.perform_bandstop, (48.0, 52.0), 17.0)): + with self.subTest(method=method.__name__): + actual = np.full(2048, 17.0) + method(actual, 256, *cutoffs, 4, 3, 0.0) + np.testing.assert_allclose(actual, expected, atol=1e-9, rtol=0) + + def test_both_mains_notches_are_applied_in_sequence(self): + original = np.random.default_rng(25).normal(size=4096) + combined = original.copy() + sequential = original.copy() + DataFilter.remove_environmental_noise(combined, 256, 2) + DataFilter.perform_bandstop(sequential, 256, 48.0, 52.0, 4, 3, 0.0) + DataFilter.perform_bandstop(sequential, 256, 58.0, 62.0, 4, 3, 0.0) + np.testing.assert_array_equal(combined, sequential) + + def test_invalid_filter_design_preserves_input(self): + original = np.arange(256, dtype=np.float64) + invalid_calls = ((DataFilter.perform_lowpass, (256, 128.0, 4, 0, 0.0)), + (DataFilter.perform_highpass, (256, 0.0, 4, 0, 0.0)), + (DataFilter.perform_bandpass, (256, 30.0, 5.0, 4, 0, 0.0)), + (DataFilter.perform_bandstop, (256, 48.0, np.nan, 4, 0, 0.0)), + (DataFilter.perform_lowpass, (256, 30.0, 9, 0, 0.0)), + (DataFilter.perform_lowpass, (256, 30.0, 4, 1, 0.0)), + (DataFilter.remove_environmental_noise, (100, 2))) + for method, args in invalid_calls: + with self.subTest(method=method.__name__, args=args): + actual = original.copy() + self.assert_invalid(method, actual, *args) + np.testing.assert_array_equal(actual, original) + + def test_rolling_mean_and_median_use_partial_warmup(self): + original = np.array([7., 2., 5., 1., 1., 8., -3., 4., 4.]) + for period in (1, 2, 4, 20): + for operation, aggregate in ((0, np.mean), (1, np.median)): + with self.subTest(period=period, operation=operation): + expected = [aggregate(original[max(0, i-period+1):i+1]) for i in range(len(original))] + actual = original.copy() + DataFilter.perform_rolling_filter(actual, period, operation) + np.testing.assert_allclose(actual, expected, atol=1e-14, rtol=0) + + def test_huge_constant_zero_phase_filter_does_not_overflow_padding(self): + data = np.full(128, 1e308) + DataFilter.perform_lowpass(data, 256, 20.0, 4, 3, 0.0) + np.testing.assert_allclose(data / 1e308, 1.0, atol=2e-14, rtol=0) + + def test_downsampling_without_a_complete_block_does_not_allocate_the_period(self): + data = np.arange(8, dtype=np.float64) + output = np.array([99.0]) + result = DataHandlerDLL.get_instance().perform_downsampling(data, len(data), 2147483647, 1, output) + self.assertEqual(result, 0) + np.testing.assert_array_equal(output, [99.0]) + + def test_nearest_power_of_two_checks_upper_overflow(self): + self.assertEqual(DataFilter.get_nearest_power_of_two(3), 4) + self.assertEqual(DataFilter.get_nearest_power_of_two(2**30), 2**30) + self.assertEqual(DataFilter.get_nearest_power_of_two(3 * 2**29 - 1), 2**30) + for value in (0, -1, 3 * 2**29, 2**31 - 1): + self.assert_invalid(DataFilter.get_nearest_power_of_two, value) + + def test_file_round_trip_preserves_full_precision_and_wide_rows(self): + data = np.random.default_rng(26).normal(size=(3000, 3)) + with tempfile.TemporaryDirectory() as directory: + path = str(Path(directory) / 'wide.tsv') + DataFilter.write_file(data, path, 'w') + np.testing.assert_array_equal(DataFilter.read_file(path), data) + file_path = Path(path) + file_path.write_bytes(file_path.read_bytes().rstrip(b'\r\n')) + np.testing.assert_array_equal(DataFilter.read_file(path), data) + + def test_file_parser_rejects_partial_tokens_and_ragged_rows(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'invalid.tsv' + for content in ('1x\t2\n', '1\t2\n3\n', '1\t2\t\n', '1\tNaN\n'): + with self.subTest(content=content): + path.write_text(content) + self.assert_invalid(DataFilter.read_file, str(path)) + + def test_native_file_read_enforces_actual_capacity_without_writing(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'data.tsv' + path.write_text('1\t2\n3\t4') + output = np.full(3, 99.0) + rows = np.zeros(1, dtype=np.int32) + cols = np.zeros(1, dtype=np.int32) + result = DataHandlerDLL.get_instance().read_file(output, rows, cols, str(path).encode(), len(output)) + self.assertEqual(result, BrainFlowExitCodes.INVALID_BUFFER_SIZE_ERROR.value) + np.testing.assert_array_equal(output, np.full(3, 99.0)) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/data_handler/decomposition.cpp b/src/data_handler/decomposition.cpp new file mode 100644 index 000000000..028d2ab16 --- /dev/null +++ b/src/data_handler/decomposition.cpp @@ -0,0 +1,404 @@ +#include +#include +#include +#include +#include +#include + +#include "data_handler.h" +#include "fastica.h" +#include "spdlog/spdlog.h" +#include "wauxlib.h" +#include "wavelet_helpers.h" +#include "wavelib.h" + +extern std::shared_ptr data_logger; + +namespace +{ + typedef std::unique_ptr Wave; + typedef std::unique_ptr Transform; + typedef std::unique_ptr Denoiser; + typedef Eigen::Matrix RowMatrix; + + bool finite_samples (const double *data, size_t count) + { + if (!data) + { + return false; + } + for (size_t i = 0; i < count; i++) + { + if (!std::isfinite (data[i])) + { + return false; + } + } + return true; + } + + bool valid_wavelet_configuration (int count, int level, wave_object wave) + { + if (count <= 0 || level <= 0 || level > 100) + { + return false; + } + // Wavelib uses int offsets and allocates count + 2*level*(filter_length+1). + long long capacity = (long long)count + 2LL * level * (wave->filtlength + 1); + if (capacity > std::numeric_limits::max ()) + { + return false; + } + double maximum = std::log2 ((double)count / (wave->filtlength - 1)); + return level <= maximum; + } + + int inverse_wavelet (double *coefficients, int coefficient_count, int original_count, + int wavelet, int level, int extension, int *lengths, int lengths_count, double *output, + int output_count, bool checked) + { + std::string wavelet_name = get_wavelet_name (wavelet); + std::string extension_name = get_extension_type (extension); + if (!coefficients || !lengths || !output || original_count <= 0 || level <= 0 || + level > 100 || wavelet_name.empty () || extension_name.empty () || + (checked && + (lengths_count != level + 1 || output_count < original_count || + coefficient_count < original_count))) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + Wave wave (wave_init (wavelet_name.c_str ()), wave_free); + if (!valid_wavelet_configuration (original_count, level, wave.get ())) + { + return (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; + } + // Derive metadata from the configuration, never from caller-provided sizes. + std::vector expected (level + 1); + int count = original_count; + int total = 0; + for (int i = level; i > 0; i--) + { + count = (int)(((long long)count + + (extension == (int)WaveletExtensionTypes::PERIODIC ? + 1 : + wave->filtlength - 1)) / + 2); + expected[i] = count; + total += count; + } + expected[0] = expected[1]; + total += expected[0]; + for (int i = 0; i <= level; i++) + { + if (lengths[i] != expected[i]) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + } + if (checked && coefficient_count != total) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + if (!finite_samples (coefficients, total)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + Transform transform (wt_init (wave.get (), "dwt", original_count, level), wt_free); + setDWTExtension (transform.get (), extension_name.c_str ()); + setWTConv (transform.get (), "direct"); + std::copy (expected.begin (), expected.end (), transform->length); + transform->length[level + 1] = original_count; + transform->outlength = total; + transform->zpad = 0; + std::copy (coefficients, coefficients + total, transform->output); + std::vector restored (original_count); + idwt (transform.get (), restored.data ()); + if (!finite_samples (restored.data (), restored.size ())) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + std::copy (restored.begin (), restored.end (), output); + return (int)BrainFlowExitCodes::STATUS_OK; + } + catch (const std::exception &error) + { + data_logger->error ("Inverse wavelet failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + } +} + +int perform_wavelet_transform (double *data, int data_len, int wavelet, int decomposition_level, + int extension, double *output_data, int *decomposition_lengths) +{ + std::string wavelet_name = get_wavelet_name (wavelet); + std::string extension_name = get_extension_type (extension); + if (data_len <= 0 || decomposition_level <= 0 || decomposition_level > 100 || + wavelet_name.empty () || extension_name.empty () || !output_data || + !decomposition_lengths || !finite_samples (data, data_len)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + Wave wave (wave_init (wavelet_name.c_str ()), wave_free); + if (!valid_wavelet_configuration (data_len, decomposition_level, wave.get ())) + { + return (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; + } + Transform transform (wt_init (wave.get (), "dwt", data_len, decomposition_level), wt_free); + setDWTExtension (transform.get (), extension_name.c_str ()); + setWTConv (transform.get (), "direct"); + dwt (transform.get (), data); + if (!finite_samples (transform->output, transform->outlength)) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + std::copy (transform->output, transform->output + transform->outlength, output_data); + std::copy ( + transform->length, transform->length + decomposition_level + 1, decomposition_lengths); + return (int)BrainFlowExitCodes::STATUS_OK; + } + catch (const std::exception &error) + { + data_logger->error ("Wavelet transform failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } +} + +int perform_inverse_wavelet_transform (double *coefficients, int original_count, int wavelet, + int level, int extension, int *lengths, double *output) +{ + // Legacy raw-pointer ABI cannot know actual allocation sizes. All bindings should + // use the checked entry point; this entry still rejects inconsistent metadata. + return inverse_wavelet ( + coefficients, 0, original_count, wavelet, level, extension, lengths, 0, output, 0, false); +} + +int perform_inverse_wavelet_transform_checked (double *coefficients, int coefficient_count, + int original_count, int wavelet, int level, int extension, int *lengths, int lengths_count, + double *output, int output_count) +{ + return inverse_wavelet (coefficients, coefficient_count, original_count, wavelet, level, + extension, lengths, lengths_count, output, output_count, true); +} + +int perform_wavelet_denoising (double *data, int data_len, int wavelet, int decomposition_level, + int wavelet_denoising, int threshold, int extension, int noise_level) +{ + std::string wavelet_name = get_wavelet_name (wavelet); + std::string denoising_name = get_wavelet_denoising_type (wavelet_denoising); + std::string threshold_name = get_threshold_type (threshold); + std::string extension_name = get_extension_type (extension); + std::string noise_name = get_noise_estimation_type (noise_level); + if (data_len <= 0 || decomposition_level <= 0 || decomposition_level > 100 || + wavelet_name.empty () || denoising_name.empty () || threshold_name.empty () || + extension_name.empty () || noise_name.empty () || !finite_samples (data, data_len)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + Wave wave (wave_init (wavelet_name.c_str ()), wave_free); + if (!valid_wavelet_configuration (data_len, decomposition_level, wave.get ())) + { + return (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; + } + Denoiser denoiser ( + denoise_init (data_len, decomposition_level, wavelet_name.c_str ()), denoise_free); + setDenoiseMethod (denoiser.get (), denoising_name.c_str ()); + setDenoiseWTMethod (denoiser.get (), "dwt"); + setDenoiseWTExtension (denoiser.get (), extension_name.c_str ()); + setDenoiseParameters (denoiser.get (), threshold_name.c_str (), noise_name.c_str ()); + std::vector result (data_len); + denoise (denoiser.get (), data, result.data ()); + if (!finite_samples (result.data (), result.size ())) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + std::copy (result.begin (), result.end (), data); + return (int)BrainFlowExitCodes::STATUS_OK; + } + catch (const std::exception &error) + { + data_logger->error ("Wavelet denoising failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } +} + +int restore_data_from_wavelet_detailed_coeffs (double *data, int data_len, int wavelet, + int decomposition_level, int level_to_restore, double *output) +{ + std::string wavelet_name = get_wavelet_name (wavelet); + if (data_len <= 0 || decomposition_level <= 0 || decomposition_level > 100 || + level_to_restore <= 0 || level_to_restore > decomposition_level || wavelet_name.empty () || + !output || !finite_samples (data, data_len)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + Wave wave (wave_init (wavelet_name.c_str ()), wave_free); + if (!valid_wavelet_configuration (data_len, decomposition_level, wave.get ())) + { + return (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; + } + Transform transform (wt_init (wave.get (), "dwt", data_len, decomposition_level), wt_free); + setDWTExtension (transform.get (), "sym"); + setWTConv (transform.get (), "direct"); + dwt (transform.get (), data); + int offset = 0; + for (int i = 0; i <= decomposition_level; i++) + { + if (i == 0 || decomposition_level + 1 - i != level_to_restore) + { + std::fill (transform->output + offset, + transform->output + offset + transform->length[i], 0.0); + } + offset += transform->length[i]; + } + std::vector restored (data_len); + idwt (transform.get (), restored.data ()); + if (!finite_samples (restored.data (), restored.size ())) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + std::copy (restored.begin (), restored.end (), output); + return (int)BrainFlowExitCodes::STATUS_OK; + } + catch (const std::exception &error) + { + data_logger->error ("Wavelet detail reconstruction failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } +} + +int get_csp (const double *data, const double *labels, int n_epochs, int n_channels, int n_times, + double *output_w, double *output_d) +{ + if (!data || !labels || !output_w || !output_d || n_epochs < 2 || n_channels <= 0 || + n_times < 2 || (long long)n_channels * n_times > std::numeric_limits::max () || + (long long)n_channels * n_channels > std::numeric_limits::max () || + (long long)n_epochs * n_channels * n_times > std::numeric_limits::max ()) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + int class_counts[2] = {0, 0}; + for (int epoch = 0; epoch < n_epochs; epoch++) + { + if (labels[epoch] != 0.0 && labels[epoch] != 1.0) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + class_counts[(int)labels[epoch]]++; + } + size_t total = (size_t)n_epochs * n_channels * n_times; + if (!class_counts[0] || !class_counts[1] || !finite_samples (data, total)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + double scale = 0.0; + for (size_t i = 0; i < total; i++) + { + scale = std::max (scale, std::abs (data[i])); + } + if (!(scale > 0.0)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + Eigen::MatrixXd covariances[2] = {Eigen::MatrixXd::Zero (n_channels, n_channels), + Eigen::MatrixXd::Zero (n_channels, n_channels)}; + for (int epoch = 0; epoch < n_epochs; epoch++) + { + Eigen::MatrixXd centered = + Eigen::Map ( + data + (size_t)epoch * n_channels * n_times, n_channels, n_times) / + scale; + Eigen::VectorXd means = centered.rowwise ().mean (); + centered.colwise () -= means; + covariances[(int)labels[epoch]] += centered * (centered / (double)n_times).transpose (); + } + covariances[0] /= (double)class_counts[0]; + covariances[1] /= (double)class_counts[1]; + Eigen::MatrixXd composite = covariances[0] + covariances[1]; + Eigen::SelfAdjointEigenSolver rank_solver ( + composite, Eigen::EigenvaluesOnly); + if (rank_solver.info () != Eigen::Success || !rank_solver.eigenvalues ().allFinite ()) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + double largest = rank_solver.eigenvalues ()[n_channels - 1]; + if (!(largest > 0.0) || + rank_solver.eigenvalues ()[0] <= + largest * std::numeric_limits::epsilon () * std::max (n_channels, n_times)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + Eigen::GeneralizedSelfAdjointEigenSolver solver ( + covariances[0], composite); + if (solver.info () != Eigen::Success || !solver.eigenvalues ().allFinite () || + !solver.eigenvectors ().allFinite ()) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + Eigen::MatrixXd filters = solver.eigenvectors ().transpose () / scale; + if (!filters.allFinite ()) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + Eigen::Map output_filters (output_w, n_channels, n_channels); + Eigen::Map output_values (output_d, n_channels); + output_filters = filters; + output_values = solver.eigenvalues (); + return (int)BrainFlowExitCodes::STATUS_OK; + } + catch (const std::exception &error) + { + data_logger->error ("CSP failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } +} + +int perform_ica_with_options (double *data, int rows, int cols, int num_components, double *w_mat, + double *k_mat, double *a_mat, double *s_mat, int max_iterations, double tolerance, int seed) +{ + if (!data || !w_mat || !k_mat || !a_mat || !s_mat || rows < 2 || cols < 3 || + num_components < 2 || num_components > std::min (rows, cols - 1) || + (long long)rows * cols > std::numeric_limits::max () || max_iterations <= 0 || + !std::isfinite (tolerance) || tolerance <= 0.0 || tolerance >= 1.0 || seed < -1) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + Eigen::MatrixXd input = Eigen::Map (data, rows, cols); + FastICA ica (num_components, max_iterations, tolerance, seed); + int result = ica.compute (input); + if (result == (int)BrainFlowExitCodes::STATUS_OK) + { + result = ica.get_matrixes (w_mat, k_mat, a_mat, s_mat); + } + else if (result == (int)BrainFlowExitCodes::GENERAL_ERROR) + { + data_logger->error ( + "ICA failed to converge to a finite solution in {} iterations", max_iterations); + } + return result; + } + catch (const std::exception &error) + { + data_logger->error ("ICA failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } +} + +int perform_ica (double *data, int rows, int cols, int num_components, double *w_mat, double *k_mat, + double *a_mat, double *s_mat) +{ + return perform_ica_with_options ( + data, rows, cols, num_components, w_mat, k_mat, a_mat, s_mat, 1000, 0.0001, -1); +} diff --git a/src/data_handler/inc/band_power_helpers.h b/src/data_handler/inc/band_power_helpers.h new file mode 100644 index 000000000..460eafda4 --- /dev/null +++ b/src/data_handler/inc/band_power_helpers.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include +#include +#include + +#include "DspFilters/Dsp.h" + +namespace band_power_helpers +{ + struct Section + { + double b0, b1, b2, a1, a2; + }; + + // Keep the filter design in DSPFilters, but initialize the SOS states separately for + // each direction. Reusing the forward pass's final state produces a boundary transient. + inline void append_filter ( + Dsp::Cascade &filter, std::vector
§ions, double &pole_radius) + { + for (int i = 0; i < filter.getNumStages (); i++) + { + const Dsp::Biquad &stage = filter[i]; + const double a0 = stage.getA0 (); + sections.push_back ({stage.getB0 () / a0, stage.getB1 () / a0, stage.getB2 () / a0, + stage.getA1 () / a0, stage.getA2 () / a0}); + } + for (const auto &pair : filter.getPoleZeros ()) + { + pole_radius = std::max (pole_radius, std::abs (pair.poles.first)); + pole_radius = std::max (pole_radius, std::abs (pair.poles.second)); + } + } + + inline bool filter_direction ( + std::vector &data, const std::vector
§ions, bool steady_state = true) + { + for (const Section &s : sections) + { + // Steady state for a constant input equal to the endpoint, in transposed + // direct form II. Each section sees the preceding section's endpoint gain. + const double initial = steady_state ? data.front () : 0.0; + const double output = initial * (s.b0 + s.b1 + s.b2) / (1.0 + s.a1 + s.a2); + double z1 = output - s.b0 * initial; + double z2 = s.b2 * initial - s.a2 * output; + for (double &sample : data) + { + const double filtered = s.b0 * sample + z1; + z1 = s.b1 * sample - s.a1 * filtered + z2; + z2 = s.b2 * sample - s.a2 * filtered; + if (!std::isfinite (filtered)) + { + return false; + } + sample = filtered; + } + } + return true; + } + + inline bool filter_with_padding ( + std::vector &data, const std::vector
§ions, int guard) + { + if (data.empty () || guard < 0) + return false; + const size_t pad = std::min ((size_t)guard, data.size () - 1); + const size_t size = data.size (); + std::vector padded (size + 2 * pad); + std::copy (data.begin (), data.end (), padded.begin () + pad); + for (size_t i = 0; i < pad; i++) + { + padded[pad - 1 - i] = data.front () + (data.front () - data[i + 1]); + padded[pad + size + i] = data.back () + (data.back () - data[size - 2 - i]); + } + if (!filter_direction (padded, sections)) + { + return false; + } + std::reverse (padded.begin (), padded.end ()); + if (!filter_direction (padded, sections)) + { + return false; + } + std::reverse (padded.begin (), padded.end ()); + std::copy (padded.begin () + pad, padded.begin () + pad + size, data.begin ()); + return true; + } +} diff --git a/src/data_handler/inc/filter_helpers.h b/src/data_handler/inc/filter_helpers.h new file mode 100644 index 000000000..ee958b919 --- /dev/null +++ b/src/data_handler/inc/filter_helpers.h @@ -0,0 +1,116 @@ +#pragma once + +#include "band_power_helpers.h" +#include "common_data_handler_helpers.h" + +namespace filter_helpers +{ + using band_power_helpers::Section; + + template + struct AccessibleFilter : Dsp::FilterDesign + { + Dsp::Cascade &cascade () + { + return this->m_design; + } + }; + + template + void append (const Dsp::Params ¶ms, std::vector
§ions, double &radius) + { + AccessibleFilter filter; + filter.setParams (params); + band_power_helpers::append_filter (filter.cascade (), sections, radius); + } + + // kind: lowpass=0, highpass=1, bandpass=2, bandstop=3. + inline bool design (int kind, int fs, double low, double high, int order, int type, + double ripple, std::vector
§ions, double &radius) + { + if (fs < 1 || order < 1 || order > 8 || type < 0 || type > 5 || kind < 0 || kind > 3 || + !std::isfinite (low) || low <= 0.0 || low >= fs / 2.0 || + (kind >= 2 && (!std::isfinite (high) || high <= low || high >= fs / 2.0)) || + (type % 3 == 1 && (!std::isfinite (ripple) || ripple <= 0.0))) + return false; + Dsp::Params params; + params[0] = fs; + params[1] = order; + params[2] = kind < 2 ? low : (low + high) / 2.0; + params[3] = kind < 2 ? ripple : high - low; + params[4] = ripple; +#define APPEND_FAMILY(FAMILY) \ + switch (kind) \ + { \ + case 0: \ + append> (params, sections, radius); \ + break; \ + case 1: \ + append> (params, sections, radius); \ + break; \ + case 2: \ + append> (params, sections, radius); \ + break; \ + case 3: \ + append> (params, sections, radius); \ + break; \ + } + switch (type % 3) + { + case 0: + APPEND_FAMILY (Butterworth); + break; + case 1: + APPEND_FAMILY (ChebyshevI); + break; + case 2: + APPEND_FAMILY (Bessel); + break; + } +#undef APPEND_FAMILY + if (!std::isfinite (radius) || radius >= 1.0) + return false; + for (const auto &s : sections) + if (!std::isfinite (s.b0) || !std::isfinite (s.b1) || !std::isfinite (s.b2) || + !std::isfinite (s.a1) || !std::isfinite (s.a2) || 1 + s.a1 + s.a2 <= 0) + return false; + return true; + } + + // Measure the complete cascade's impulse tail, instead of assigning a unit-amplitude + // transient to every pole. This remains an estimate, not a bound for arbitrary signals. + inline bool settling_samples (const std::vector
§ions, double radius, int &guard) + { + guard = 0; + if (sections.empty ()) + return true; + if (radius == 0.0) + { + guard = 1; + return true; + } + if (!(radius > 0 && radius < 1)) + return false; + const double horizon = std::ceil (std::log (1e-9) / std::log (radius)); + if (!std::isfinite (horizon) || horizon > 1000000) + return false; + std::vector impulse (std::max (32, (int)horizon), 0.0); + impulse[0] = 1.0; + if (!band_power_helpers::filter_direction (impulse, sections, false)) + return false; + double total = 0.0; + for (double value : impulse) + total += std::abs (value); + double tail = 0.0; + for (int i = (int)impulse.size () - 1; i >= 0; i--) + { + tail += std::abs (impulse[i]); + if (tail > 1e-3 * total) + { + guard = i + 1; + break; + } + } + return true; + } +} diff --git a/src/data_handler/signal_metrics.cpp b/src/data_handler/signal_metrics.cpp new file mode 100644 index 000000000..fca4e48d5 --- /dev/null +++ b/src/data_handler/signal_metrics.cpp @@ -0,0 +1,449 @@ +#include +#include +#include +#include +#include + +#include "brainflow_constants.h" +#include "common_data_handler_helpers.h" +#include "data_handler.h" +#include "filter_helpers.h" +#include "spdlog/spdlog.h" + +extern std::shared_ptr data_logger; + +namespace +{ + const double minimum_pulse_hz = 35.0 / 60.0; + const double maximum_pulse_hz = 230.0 / 60.0; + + int insufficient_pulse () + { + data_logger->error ("PPG has insufficient pulse energy or no distinct shared pulse peak"); + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + + bool center_ppg (const double *data, int count, std::vector ¢ered) + { + double scale = signal_scale (data, count); + if (!(scale > 0.0)) + { + return false; + } + centered.resize (count); + for (int i = 0; i < count; i++) + { + centered[i] = data[i] / scale; + } + if (detrend (centered.data (), count, (int)DetrendOperations::LINEAR) != + (int)BrainFlowExitCodes::STATUS_OK) + { + return false; + } + return stddev (centered.data (), count) > 64 * std::numeric_limits::epsilon (); + } + + int pulse_spectrum (double *data, int count, int sampling_rate, int fft_size, + std::vector &spectrum, std::vector &frequencies, bool periodogram = false) + { + spectrum.resize (fft_size / 2 + 1); + frequencies.resize (spectrum.size ()); + int result = periodogram ? + get_psd (data, fft_size, sampling_rate, (int)WindowOperations::HANNING, + spectrum.data (), frequencies.data ()) : + get_psd_welch (data, count, fft_size, fft_size / 2, sampling_rate, + (int)WindowOperations::HANNING, spectrum.data (), frequencies.data ()); + if (result != (int)BrainFlowExitCodes::STATUS_OK) + { + return result; + } + double total = 0.0; + for (size_t i = 0; i < spectrum.size (); i++) + { + if (frequencies[i] >= minimum_pulse_hz && frequencies[i] <= maximum_pulse_hz) + { + total += spectrum[i]; + } + } + if (!std::isfinite (total) || !(total > std::numeric_limits::min ())) + { + return insufficient_pulse (); + } + for (double &value : spectrum) + { + value /= total; + } + return (int)BrainFlowExitCodes::STATUS_OK; + } + + bool pulse_peak ( + const std::vector &spectrum, const std::vector &frequencies, int &peak) + { + std::vector background; + double total = 0.0; + peak = -1; + for (int i = 1; i + 1 < (int)spectrum.size (); i++) + { + if (frequencies[i] >= minimum_pulse_hz && frequencies[i] <= maximum_pulse_hz) + { + background.push_back (spectrum[i]); + total += spectrum[i]; + if (peak < 0 || spectrum[i] > spectrum[peak]) + { + peak = i; + } + } + } + if (background.size () < 5 || peak < 0 || !(total > 0.0)) + { + return false; + } + std::nth_element ( + background.begin (), background.begin () + background.size () / 2, background.end ()); + double median = background[background.size () / 2]; + double local = spectrum[peak - 1] + spectrum[peak] + spectrum[peak + 1]; + // A Hann-windowed pulse should concentrate power around a peak. This numerical + // quality gate rejects flat/noisy spectra; it is not a clinical validity claim. + return spectrum[peak] > 4.0 * median && local >= 0.45 * total && + spectrum[peak] >= spectrum[peak - 1] && spectrum[peak] >= spectrum[peak + 1]; + } +} + +int get_railed_percentage (double *raw_data, int data_len, int gain, double *output) +{ + if (!output || gain < 1 || !finite_signal (raw_data, data_len)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + bool flat = true; + for (int i = 1; i < data_len; i++) + { + if (std::abs (raw_data[i - 1] - raw_data[i]) > 0.00001 && std::abs (raw_data[i]) > 0.00001) + { + flat = false; + } + } + // Preserve the legacy ADS1299 4.5 V, 24-bit, microvolt peak metric and its flatline + // convention. get_clipping_percentage measures an actual clipped sample fraction. + double full_scale = (4.5 / (8388608.0 - 1.0) / gain * 1000000.0) * 8388608.0; + double fraction = std::min (1.0, signal_scale (raw_data, data_len) / full_scale); + *output = flat ? 100.0 : 100.0 * fraction; + return (int)BrainFlowExitCodes::STATUS_OK; +} + +int get_clipping_percentage ( + const double *data, int data_len, double lower_bound, double upper_bound, double *output) +{ + if (!output || !std::isfinite (lower_bound) || !std::isfinite (upper_bound) || + lower_bound >= upper_bound || !finite_signal (data, data_len)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + int count = 0; + for (int i = 0; i < data_len; i++) + { + if (data[i] <= lower_bound || data[i] >= upper_bound) + { + count++; + } + } + *output = 100.0 * ((double)count / data_len); + return (int)BrainFlowExitCodes::STATUS_OK; +} + +int get_flatline_percentage (const double *data, int data_len, double tolerance, double *output) +{ + if (!output || data_len < 2 || !std::isfinite (tolerance) || tolerance < 0.0 || + !finite_signal (data, data_len)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + int count = 0; + for (int i = 1; i < data_len; i++) + { + if (std::abs (data[i] - data[i - 1]) <= tolerance) + { + count++; + } + } + *output = 100.0 * ((double)count / (data_len - 1)); + return (int)BrainFlowExitCodes::STATUS_OK; +} + +int get_oxygen_level (double *ppg_ir, double *ppg_red, int data_size, int sampling_rate, + double coefficient1, double coefficient2, double coefficient3, double *oxygen_level) +{ + if (!oxygen_level || sampling_rate <= 8 || !std::isfinite (coefficient1) || + !std::isfinite (coefficient2) || !std::isfinite (coefficient3) || + !finite_signal (ppg_ir, data_size) || !finite_signal (ppg_red, data_size)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + // The previous 0.7-1.5 Hz passband excluded otherwise supported pulse rates. + std::vector sections; + double radius = 0.0; + int guard = 0; + if (!filter_helpers::design (2, sampling_rate, 0.5, 4.0, 2, + (int)FilterTypes::BUTTERWORTH_ZERO_PHASE, 0.0, sections, radius) || + !filter_helpers::settling_samples (sections, radius, guard)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + if ((long long)data_size < 2LL * guard + 4LL * sampling_rate) + { + data_logger->error ( + "Oxygen estimation needs at least {} samples including {} at each boundary", + 2LL * guard + 4LL * sampling_rate, guard); + return (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; + } + int retained = data_size - 2 * guard; + double ir_scale = signal_scale (ppg_ir, data_size); + double red_scale = signal_scale (ppg_red, data_size); + std::vector ir, red; + if (!center_ppg (ppg_ir, data_size, ir) || !center_ppg (ppg_red, data_size, red)) + { + return insufficient_pulse (); + } + double dc_ir = mean (ppg_ir + guard, retained) / ir_scale; + double dc_red = mean (ppg_red + guard, retained) / red_scale; + const double minimum_fraction = 64 * std::numeric_limits::epsilon (); + if (!(dc_ir > minimum_fraction) || !(dc_red > minimum_fraction)) + { + return insufficient_pulse (); + } + if (!band_power_helpers::filter_with_padding (ir, sections, guard) || + !band_power_helpers::filter_with_padding (red, sections, guard)) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + int fft_size = retained - retained % 2; + std::vector ir_spectrum, red_spectrum, frequencies; + int result = pulse_spectrum ( + ir.data () + guard, retained, sampling_rate, fft_size, ir_spectrum, frequencies, true); + if (result != (int)BrainFlowExitCodes::STATUS_OK) + { + return result; + } + result = pulse_spectrum (red.data () + guard, retained, sampling_rate, fft_size, + red_spectrum, frequencies, true); + if (result != (int)BrainFlowExitCodes::STATUS_OK) + { + return result; + } + int ir_peak, red_peak; + if (!pulse_peak (ir_spectrum, frequencies, ir_peak) || + !pulse_peak (red_spectrum, frequencies, red_peak) || std::abs (ir_peak - red_peak) > 1) + { + return insufficient_pulse (); + } + double ac_ir = rms (ir.data () + guard, retained); + double ac_red = rms (red.data () + guard, retained); + if (!(ac_ir > minimum_fraction) || !(ac_red > minimum_fraction)) + { + return insufficient_pulse (); + } + double ratio = (ac_red / dc_red) / (ac_ir / dc_ir); + double saturation = (coefficient1 * ratio + coefficient2) * ratio + coefficient3; + if (!std::isfinite (ratio) || !std::isfinite (saturation)) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + *oxygen_level = std::max (0.0, std::min (100.0, saturation)); + return (int)BrainFlowExitCodes::STATUS_OK; + } + catch (const std::exception &error) + { + data_logger->error ("Oxygen estimation failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } +} + +int get_heart_rate ( + double *ppg_ir, double *ppg_red, int data_size, int sampling_rate, int fft_size, double *rate) +{ + if (!rate || sampling_rate < 8 || fft_size < 1024 || fft_size % 2 != 0 || + data_size < fft_size || (double)sampling_rate / fft_size > 0.25 || + !finite_signal (ppg_ir, data_size) || !finite_signal (ppg_red, data_size)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + std::vector ir, red; + if (!center_ppg (ppg_ir, data_size, ir) || !center_ppg (ppg_red, data_size, red)) + { + return insufficient_pulse (); + } + std::vector ir_spectrum, red_spectrum, frequencies; + int result = pulse_spectrum ( + ir.data (), data_size, sampling_rate, fft_size, ir_spectrum, frequencies); + if (result != (int)BrainFlowExitCodes::STATUS_OK) + { + return result; + } + result = pulse_spectrum ( + red.data (), data_size, sampling_rate, fft_size, red_spectrum, frequencies); + if (result != (int)BrainFlowExitCodes::STATUS_OK) + { + return result; + } + int ir_peak, red_peak; + if (!pulse_peak (ir_spectrum, frequencies, ir_peak) || + !pulse_peak (red_spectrum, frequencies, red_peak) || std::abs (ir_peak - red_peak) > 1) + { + return insufficient_pulse (); + } + for (size_t i = 0; i < ir_spectrum.size (); i++) + { + ir_spectrum[i] = (ir_spectrum[i] + red_spectrum[i]) / 2.0; + } + int peak; + if (!pulse_peak (ir_spectrum, frequencies, peak)) + { + return insufficient_pulse (); + } + // Log-parabolic interpolation reduces FFT-bin quantization of a validated peak. + double offset = 0.0; + if (ir_spectrum[peak - 1] > 0.0 && ir_spectrum[peak + 1] > 0.0) + { + double left = std::log (ir_spectrum[peak - 1]); + double middle = std::log (ir_spectrum[peak]); + double right = std::log (ir_spectrum[peak + 1]); + double curvature = left - 2.0 * middle + right; + if (curvature < 0.0) + { + offset = std::max (-0.5, std::min (0.5, 0.5 * (left - right) / curvature)); + } + } + double frequency = (peak + offset) * ((double)sampling_rate / fft_size); + if (frequency < minimum_pulse_hz || frequency > maximum_pulse_hz) + { + return insufficient_pulse (); + } + *rate = frequency * 60.0; + return (int)BrainFlowExitCodes::STATUS_OK; + } + catch (const std::exception &error) + { + data_logger->error ("Heart-rate estimation failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } +} + +int detect_peaks_z_score ( + double *data, int data_len, int lag, double threshold, double influence, double *output) +{ + if (!output || lag < 2 || lag > data_len || !std::isfinite (threshold) || threshold < 0.0 || + !std::isfinite (influence) || influence < 0.0 || influence > 1.0 || + !finite_signal (data, data_len)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + double scale = signal_scale (data, data_len); + if (scale == 0.0) + { + std::fill (output, output + data_len, 0.0); + return (int)BrainFlowExitCodes::STATUS_OK; + } + std::vector filtered (data_len), peaks (data_len, 0.0); + for (int i = 0; i < data_len; i++) + { + filtered[i] = data[i] / scale; + } + double baseline = mean (filtered.data (), lag); + double deviation = stddev (filtered.data (), lag); + double squared_deviation = deviation * deviation * lag; + for (int i = lag; i < data_len; i++) + { + double current = data[i] / scale; + double limit = threshold * std::sqrt (std::max (0.0, squared_deviation) / lag); + if (std::abs (current - baseline) > + limit + 32 * std::numeric_limits::epsilon ()) + { + peaks[i] = current > baseline ? 1.0 : -1.0; + filtered[i] = influence * current + (1.0 - influence) * filtered[i - 1]; + } + // The next baseline includes this filtered sample and excludes i-lag. + double previous = filtered[i - lag]; + double next_baseline = baseline + (filtered[i] - previous) / lag; + squared_deviation += + (filtered[i] - previous) * (filtered[i] - next_baseline + previous - baseline); + baseline = next_baseline; + // Periodic recomputation bounds drift while retaining O(data_len) work. + if ((i - lag + 1) % lag == 0) + { + baseline = mean (filtered.data () + i - lag + 1, lag); + deviation = stddev (filtered.data () + i - lag + 1, lag); + squared_deviation = deviation * deviation * lag; + } + } + std::copy (peaks.begin (), peaks.end (), output); + return (int)BrainFlowExitCodes::STATUS_OK; + } + catch (const std::exception &error) + { + data_logger->error ("Peak detection failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } +} + +int get_activity_index (const double *accel_x, const double *accel_y, const double *accel_z, + int data_len, int sampling_rate, int period, double noise_var_x, double noise_var_y, + double noise_var_z, double *output) +{ + if (!output || sampling_rate <= 0 || period < sampling_rate || data_len < period || + period % sampling_rate != 0 || !std::isfinite (noise_var_x) || noise_var_x < 0.0 || + !std::isfinite (noise_var_y) || noise_var_y < 0.0 || !std::isfinite (noise_var_z) || + noise_var_z < 0.0 || !finite_signal (accel_x, data_len) || + !finite_signal (accel_y, data_len) || !finite_signal (accel_z, data_len)) + { + return (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + } + try + { + const double noise[] = { + std::sqrt (noise_var_x), std::sqrt (noise_var_y), std::sqrt (noise_var_z)}; + const double *axes[] = {accel_x, accel_y, accel_z}; + std::vector result (data_len / period, 0.0); + for (int epoch = 0; epoch < (int)result.size (); epoch++) + { + for (int second = 0; second < period / sampling_rate; second++) + { + int start = epoch * period + second * sampling_rate; + double deviations[3]; + double scale = std::max (noise[0], std::max (noise[1], noise[2])); + for (int axis = 0; axis < 3; axis++) + { + deviations[axis] = stddev (axes[axis] + start, sampling_rate); + scale = std::max (scale, deviations[axis]); + } + if (scale > 0.0) + { + double variance = 0.0; + for (int axis = 0; axis < 3; axis++) + { + double deviation = deviations[axis] / scale; + double floor = noise[axis] / scale; + variance += (deviation * deviation - floor * floor) / 3.0; + } + result[epoch] += scale * std::sqrt (std::max (0.0, variance)); + } + if (!std::isfinite (result[epoch])) + { + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } + } + } + std::copy (result.begin (), result.end (), output); + return (int)BrainFlowExitCodes::STATUS_OK; + } + catch (const std::exception &error) + { + data_logger->error ("Activity index failed: {}", error.what ()); + return (int)BrainFlowExitCodes::GENERAL_ERROR; + } +} diff --git a/src/data_handler/signal_processing.cpp b/src/data_handler/signal_processing.cpp new file mode 100644 index 000000000..fdaaa334f --- /dev/null +++ b/src/data_handler/signal_processing.cpp @@ -0,0 +1,729 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "brainflow_constants.h" +#include "common_data_handler_helpers.h" +#include "data_handler.h" +#include "filter_helpers.h" +#include "kiss_fftr.h" + +namespace +{ + const int ok = (int)BrainFlowExitCodes::STATUS_OK; + const int invalid = (int)BrainFlowExitCodes::INVALID_ARGUMENTS_ERROR; + const int failure = (int)BrainFlowExitCodes::GENERAL_ERROR; + const int short_buffer = (int)BrainFlowExitCodes::INVALID_BUFFER_SIZE_ERROR; + + struct StreamingFilterState + { + std::vector sections; + std::vector z1, z2; + std::mutex mutex; + }; + std::mutex streaming_registry_mutex; + std::map> streaming_filters; + int64_t next_streaming_handle = 1; + + std::shared_ptr find_streaming_filter (int handle) + { + std::lock_guard lock (streaming_registry_mutex); + auto found = streaming_filters.find (handle); + return found == streaming_filters.end () ? nullptr : found->second; + } + + int filter_signal (double *data, int n, int fs, double low, double high, int order, int type, + double ripple, int kind) + { + if (!finite_signal (data, n)) + return invalid; + std::vector sections; + double radius = 0.0; + if (!filter_helpers::design (kind, fs, low, high, order, type, ripple, sections, radius)) + return invalid; + std::vector result (data, data + n); + const double scale = signal_scale (data, n); + if (scale > 0.0) + for (double &sample : result) + sample /= scale; + if (type >= 3) + { + int guard; + if (!filter_helpers::settling_samples (sections, radius, guard) || + !band_power_helpers::filter_with_padding (result, sections, guard)) + return invalid; + } + else if (!band_power_helpers::filter_direction (result, sections, false)) + return invalid; + for (double &sample : result) + { + sample *= scale; + if (!std::isfinite (sample)) + return invalid; + } + std::copy (result.begin (), result.end (), data); + return ok; + } + + struct FftPlan + { + kiss_fftr_cfg cfg; + explicit FftPlan (int n, bool inverse = false) : cfg (kiss_fftr_alloc (n, inverse, 0, 0)) + { + if (!cfg) + throw std::bad_alloc (); + } + ~FftPlan () + { + kiss_fftr_free (cfg); + } + FftPlan (const FftPlan &) = delete; + FftPlan &operator= (const FftPlan &) = delete; + }; + + int spectrum (double *data, int n, int nfft, int overlap, int fs, int window, + double *amplitudes, double *frequencies) + { + if (!amplitudes || !frequencies || nfft < 2 || nfft % 2 || n < nfft || fs < 1 || + overlap < 0 || overlap >= nfft || !finite_signal (data, n)) + return invalid; + std::vector weights (nfft), input (nfft), result (nfft / 2 + 1, 0.0); + std::vector output (nfft / 2 + 1); + int res = get_window (window, nfft, weights.data ()); + if (res != ok) + return res; + double energy = 0.0; + for (double value : weights) + energy += value * value; + if (!(energy > 0.0)) + return invalid; + const double denominator = std::sqrt ((double)fs * energy); + const int step = nfft - overlap; + const int count = 1 + (n - nfft) / step; + FftPlan plan (nfft); + for (int segment = 0; segment < count; segment++) + { + const size_t start = (size_t)segment * step; + // Normalize before the transform to avoid overflowing an otherwise representable PSD. + for (int i = 0; i < nfft; i++) + input[i] = data[start + i] * (weights[i] / denominator); + kiss_fftr (plan.cfg, input.data (), output.data ()); + for (int i = 0; i <= nfft / 2; i++) + { + const double magnitude = std::hypot (output[i].r, output[i].i); + const double factor = (i == 0 || i == nfft / 2) ? 1.0 : 2.0; + const double averaged_magnitude = magnitude / std::sqrt ((double)count); + const double value = averaged_magnitude * averaged_magnitude * factor; + if (!std::isfinite (value)) + return invalid; + result[i] += value; + if (!std::isfinite (result[i])) + return invalid; + } + } + std::copy (result.begin (), result.end (), amplitudes); + for (int i = 0; i <= nfft / 2; i++) + frequencies[i] = i * ((double)fs / nfft); + return ok; + } + + int parse_file (const char *name, std::vector &values, int &rows, int &cols) + { + if (!name) + return invalid; + std::ifstream stream (name); + stream.imbue (std::locale::classic ()); + if (!stream) + return invalid; + rows = cols = 0; + std::string line; + while (std::getline (stream, line)) + { + if (!line.empty () && line.back () == '\r') + line.pop_back (); + if (line.empty ()) + return invalid; + const char separator = line.find ('\t') != std::string::npos ? '\t' : ','; + if (line.back () == separator) + return invalid; + std::istringstream row (line); + std::string token; + int width = 0; + while (std::getline (row, token, separator)) + { + std::istringstream number (token); + number.imbue (std::locale::classic ()); + double value; + if (!(number >> value) || !std::isfinite (value)) + return invalid; + number >> std::ws; + if (!number.eof () || values.size () >= (size_t)std::numeric_limits::max ()) + return invalid; + values.push_back (value); + width++; + } + if (!width || (cols && cols != width)) + return invalid; + cols = width; + rows++; + } + return stream.bad () || !rows ? invalid : ok; + } +} + +int perform_lowpass (double *data, int n, int fs, double cutoff, int order, int type, double ripple) +try +{ + return filter_signal (data, n, fs, cutoff, 0, order, type, ripple, 0); +} +catch (...) +{ + return failure; +} + +int get_filter_settling_samples (int kind, int fs, double low, double high, int order, int type, + double ripple, int *edge_samples) +try +{ + if (!edge_samples) + return invalid; + std::vector sections; + double radius = 0.0; + int guard; + if (!filter_helpers::design (kind, fs, low, high, order, type, ripple, sections, radius) || + !filter_helpers::settling_samples (sections, radius, guard)) + return invalid; + *edge_samples = guard; + return ok; +} +catch (...) +{ + return failure; +} + +int create_streaming_filter ( + int kind, int fs, double low, double high, int order, int type, double ripple, int *handle) +try +{ + if (!handle || type < 0 || type > 2) + return invalid; + auto state = std::make_shared (); + double radius = 0; + if (!filter_helpers::design (kind, fs, low, high, order, type, ripple, state->sections, radius)) + return invalid; + state->z1.resize (state->sections.size (), 0); + state->z2.resize (state->sections.size (), 0); + std::lock_guard lock (streaming_registry_mutex); + if (next_streaming_handle > std::numeric_limits::max ()) + return failure; + const int identifier = (int)next_streaming_handle++; + streaming_filters.emplace (identifier, state); + *handle = identifier; + return ok; +} +catch (...) +{ + return failure; +} + +int perform_streaming_filter (int handle, double *data, int n) +try +{ + if (!finite_signal (data, n)) + return invalid; + auto state = find_streaming_filter (handle); + if (!state) + return invalid; + std::lock_guard lock (state->mutex); + std::vector result (data, data + n), z1 = state->z1, z2 = state->z2; + for (size_t i = 0; i < state->sections.size (); i++) + { + const auto &s = state->sections[i]; + for (double &sample : result) + { + const double filtered = s.b0 * sample + z1[i]; + z1[i] = s.b1 * sample - s.a1 * filtered + z2[i]; + z2[i] = s.b2 * sample - s.a2 * filtered; + if (!std::isfinite (filtered) || !std::isfinite (z1[i]) || !std::isfinite (z2[i])) + return invalid; + sample = filtered; + } + } + state->z1.swap (z1); + state->z2.swap (z2); + std::copy (result.begin (), result.end (), data); + return ok; +} +catch (...) +{ + return failure; +} + +int reset_streaming_filter (int handle) +try +{ + auto state = find_streaming_filter (handle); + if (!state) + return invalid; + std::lock_guard lock (state->mutex); + std::fill (state->z1.begin (), state->z1.end (), 0.0); + std::fill (state->z2.begin (), state->z2.end (), 0.0); + return ok; +} +catch (...) +{ + return failure; +} + +int release_streaming_filter (int handle) +try +{ + std::lock_guard lock (streaming_registry_mutex); + return streaming_filters.erase (handle) ? ok : invalid; +} +catch (...) +{ + return failure; +} + +int perform_decimation (const double *data, int n, int factor, double *output) +try +{ + if (!output || factor < 1 || factor > n || factor > std::numeric_limits::max () / 20 || + !finite_signal (data, n)) + return invalid; + if (factor == 1) + { + std::copy (data, data + n, output); + return ok; + } + const int half = 10 * factor; + const double pi = std::acos (-1.0), cutoff = 0.4 / factor; + std::vector coefficients (2 * half + 1); + double sum = 0; + for (int i = -half; i <= half; i++) + { + const double sinc = i == 0 ? 2 * cutoff : std::sin (2 * pi * cutoff * i) / (pi * i); + coefficients[i + half] = sinc * (0.54 + 0.46 * std::cos (pi * i / half)); + sum += coefficients[i + half]; + } + const double scale = signal_scale (data, n); + std::vector result (n / factor, 0.0); + if (scale > 0) + { + const int64_t reflection_period = 2LL * (n - 1); + for (int j = 0; j < n / factor; j++) + { + double value = 0; + for (int k = -half; k <= half; k++) + { + int64_t index = ((int64_t)j * factor + k) % reflection_period; + if (index < 0) + index += reflection_period; + if (index >= n) + index = reflection_period - index; + value += (data[index] / scale) * (coefficients[k + half] / sum); + } + result[j] = value * scale; + if (!std::isfinite (result[j])) + return invalid; + } + } + std::copy (result.begin (), result.end (), output); + return ok; +} +catch (...) +{ + return failure; +} + +int perform_highpass ( + double *data, int n, int fs, double cutoff, int order, int type, double ripple) +try +{ + return filter_signal (data, n, fs, cutoff, 0, order, type, ripple, 1); +} +catch (...) +{ + return failure; +} + +int perform_bandpass ( + double *data, int n, int fs, double low, double high, int order, int type, double ripple) +try +{ + return filter_signal (data, n, fs, low, high, order, type, ripple, 2); +} +catch (...) +{ + return failure; +} + +int perform_bandstop ( + double *data, int n, int fs, double low, double high, int order, int type, double ripple) +try +{ + return filter_signal (data, n, fs, low, high, order, type, ripple, 3); +} +catch (...) +{ + return failure; +} + +int remove_environmental_noise (double *data, int n, int fs, int noise_type) +try +{ + if (noise_type < 0 || noise_type > 2 || !finite_signal (data, n) || fs <= 0 || + ((noise_type == 0 || noise_type == 2) && fs / 2.0 <= 52) || + ((noise_type == 1 || noise_type == 2) && fs / 2.0 <= 62)) + return invalid; + std::vector result (data, data + n); + int res = ok; + if (noise_type == 0 || noise_type == 2) + res = perform_bandstop (result.data (), n, fs, 48, 52, 4, 3, 0); + if (res == ok && (noise_type == 1 || noise_type == 2)) + res = perform_bandstop (result.data (), n, fs, 58, 62, 4, 3, 0); + if (res == ok) + std::copy (result.begin (), result.end (), data); + return res; +} +catch (...) +{ + return failure; +} + +int perform_fft (double *data, int n, int window, double *re, double *im) +try +{ + if (!re || !im || n < 2 || n % 2 || !finite_signal (data, n)) + return invalid; + std::vector input (n); + int res = get_window (window, n, input.data ()); + if (res != ok) + return res; + for (int i = 0; i < n; i++) + input[i] *= data[i]; + std::vector output (n / 2 + 1); + FftPlan plan (n); + kiss_fftr (plan.cfg, input.data (), output.data ()); + for (auto value : output) + if (!std::isfinite (value.r) || !std::isfinite (value.i)) + return invalid; + for (int i = 0; i <= n / 2; i++) + { + re[i] = output[i].r; + im[i] = output[i].i; + } + return ok; +} +catch (...) +{ + return failure; +} + +int perform_ifft (double *re, double *im, int n, double *restored) +try +{ + if (!restored || n < 2 || n % 2 || !finite_signal (re, n / 2 + 1) || + !finite_signal (im, n / 2 + 1)) + return invalid; + std::vector input (n / 2 + 1); + for (int i = 0; i <= n / 2; i++) + { + input[i].r = re[i] / n; + input[i].i = im[i] / n; + } + std::vector output (n); + FftPlan plan (n, true); + kiss_fftri (plan.cfg, input.data (), output.data ()); + if (!finite_signal (output.data (), n)) + return invalid; + std::copy (output.begin (), output.end (), restored); + return ok; +} +catch (...) +{ + return failure; +} + +int get_psd (double *data, int n, int fs, int window, double *ampl, double *freq) +try +{ + return spectrum (data, n, n, 0, fs, window, ampl, freq); +} +catch (...) +{ + return failure; +} + +int get_psd_welch ( + double *data, int n, int nfft, int overlap, int fs, int window, double *ampl, double *freq) +try +{ + if (nfft < 2 || (nfft & (nfft - 1))) + return invalid; + return spectrum (data, n, nfft, overlap, fs, window, ampl, freq); +} +catch (...) +{ + return failure; +} + +int get_nearest_power_of_two (int value, int *output) +{ + if (!output || value < 1) + return invalid; + int64_t lower = 1; + while (lower * 2 <= value) + lower *= 2; + const int64_t upper = lower * 2; + const int64_t result = value - lower < upper - value ? lower : upper; + if (result > std::numeric_limits::max ()) + return invalid; + *output = (int)result; + return ok; +} + +int calc_stddev (double *data, int start, int end, double *output) +{ + if (!data || !output || start < 0 || end <= start || !finite_signal (data + start, end - start)) + return invalid; + const double result = stddev (data + start, end - start); + if (!std::isfinite (result)) + return invalid; + *output = result; + return ok; +} + +int detrend (double *data, int n, int operation) +try +{ + if (operation < 0 || operation > 2 || !finite_signal (data, n)) + return invalid; + if (operation == 0) + return ok; + const double scale = signal_scale (data, n); + if (scale == 0.0) + return ok; + const double average = mean (data, n) / scale; + double slope = 0.0; + if (operation == 2 && n > 1) + { + const double center = (n - 1) / 2.0; + double covariance = 0.0, correction = 0.0; + for (int i = 0; i < n; i++) + { + const double value = ((i - center) / n) * (data[i] / scale - average) / n - correction; + const double next = covariance + value; + correction = (next - covariance) - value; + covariance = next; + } + slope = covariance / ((1.0 - 1.0 / ((double)n * n)) / 12.0); + } + std::vector result (n); + for (int i = 0; i < n; i++) + result[i] = ((data[i] / scale - average) - slope * ((i - (n - 1) / 2.0) / n)) * scale; + if (!finite_signal (result.data (), n)) + return invalid; + std::copy (result.begin (), result.end (), data); + return ok; +} +catch (...) +{ + return failure; +} + +int perform_rolling_filter (double *data, int n, int period, int operation) +try +{ + if (period <= 0 || operation < 0 || operation > 2 || !finite_signal (data, n)) + return invalid; + if (operation == 2) + return ok; + const double scale = signal_scale (data, n); + if (scale == 0.0) + return ok; + std::vector original (data, data + n); + std::multiset lower, upper; + double sum = 0.0, correction = 0.0; + auto add_sum = [&] (double value) + { + const double adjusted = value - correction; + const double next = sum + adjusted; + correction = (next - sum) - adjusted; + sum = next; + }; + for (int i = 0; i < n; i++) + { + if (operation == 0) + { + if (i >= period) + add_sum (-original[i - period] / scale); + add_sum (original[i] / scale); + data[i] = std::max (-1.0, std::min (1.0, sum / std::min (period, i + 1))) * scale; + } + else + { + if (i >= period) + { + auto it = lower.find (original[i - period]); + if (it != lower.end ()) + lower.erase (it); + else + upper.erase (upper.find (original[i - period])); + } + if (lower.empty () || original[i] <= *lower.rbegin ()) + lower.insert (original[i]); + else + upper.insert (original[i]); + while (lower.size () > upper.size () + 1) + { + auto it = std::prev (lower.end ()); + upper.insert (*it); + lower.erase (it); + } + while (lower.size () < upper.size ()) + { + auto it = upper.begin (); + lower.insert (*it); + upper.erase (it); + } + // Removing the sole lower element can leave its replacement above upper's minimum. + if (!upper.empty () && *lower.rbegin () > *upper.begin ()) + { + auto l = std::prev (lower.end ()); + auto u = upper.begin (); + double a = *l, b = *u; + lower.erase (l); + upper.erase (u); + lower.insert (b); + upper.insert (a); + } + data[i] = lower.size () == upper.size () ? *lower.rbegin () / 2 + *upper.begin () / 2 : + *lower.rbegin (); + } + } + return ok; +} +catch (...) +{ + return failure; +} + +int perform_downsampling (double *data, int n, int period, int operation, double *output) +try +{ + if (!output || period < 1 || operation < 0 || operation > 2 || !finite_signal (data, n)) + return invalid; + // No complete block means no output, even when period is much larger than the input. + if (period > n) + return ok; + std::vector block; + if (operation == 1) + block.resize (period); + for (int i = 0; i < n / period; i++) + { + const double *start = data + (size_t)i * period; + if (operation == 0) + output[i] = mean (start, period); + else if (operation == 2) + output[i] = start[period - 1]; + else + { + std::copy (start, start + period, block.begin ()); + std::sort (block.begin (), block.end ()); + output[i] = + period % 2 ? block[period / 2] : block[period / 2 - 1] / 2 + block[period / 2] / 2; + } + } + return ok; +} +catch (...) +{ + return failure; +} + +int write_file (const double *data, int rows, int cols, const char *name, const char *mode) +try +{ + if (!data || !name || !mode || rows < 1 || cols < 1 || + (int64_t)rows * cols > std::numeric_limits::max () || + !finite_signal (data, rows * cols)) + return invalid; + const std::string selected (mode); + if (selected != "w" && selected != "w+" && selected != "a" && selected != "a+") + return invalid; + std::ofstream stream ( + name, std::ios::out | (selected[0] == 'a' ? std::ios::app : std::ios::trunc)); + stream.imbue (std::locale::classic ()); + if (!stream) + return invalid; + stream << std::setprecision (std::numeric_limits::max_digits10); + for (int i = 0; i < cols; i++) + { + for (int j = 0; j < rows; j++) + { + if (j) + stream << '\t'; + stream << data[(size_t)j * cols + i]; + } + stream << '\n'; + } + stream.close (); + return stream ? ok : failure; +} +catch (...) +{ + return failure; +} + +int get_num_elements_in_file (const char *name, int *count) +try +{ + if (!count) + return invalid; + std::vector values; + int rows, cols; + int res = parse_file (name, values, rows, cols); + if (res == ok) + *count = (int)values.size (); + return res; +} +catch (...) +{ + return failure; +} + +int read_file (double *data, int *rows, int *cols, const char *name, int capacity) +try +{ + if (!data || !rows || !cols || capacity <= 0) + return invalid; + std::vector values; + int file_rows, file_cols; + int res = parse_file (name, values, file_rows, file_cols); + if (res != ok) + return res; + if (values.size () > (size_t)capacity) + return short_buffer; + for (int i = 0; i < file_rows; i++) + for (int j = 0; j < file_cols; j++) + data[(size_t)j * file_rows + i] = values[(size_t)i * file_cols + j]; + *rows = file_cols; + *cols = file_rows; + return ok; +} +catch (...) +{ + return failure; +} diff --git a/src/ml/train/DATASET.md b/src/ml/train/DATASET.md new file mode 100644 index 000000000..302740cc3 --- /dev/null +++ b/src/ml/train/DATASET.md @@ -0,0 +1,139 @@ +# Recording data and feature caches + +The training directory contains `relaxed/` and `focused/` class directories. These +directory names are the label source: relaxed is `0`, focused is `1`. Recordings +are headerless, tab-separated numeric files with one **sample per line** and one +board channel per column, matching the files written by `DataFilter.write_file`. +The `.csv` extension does not imply comma separators. In memory, BrainFlow uses +the transpose: channels by samples. + +Two layouts are supported: + +```text +data/relaxed/recording.csv +data/focused/recording.csv + +data/relaxed/7/recording.csv +data/focused/7/recording.csv +``` + +For the flat layout, supply a board ID explicitly or provide a manifest with board +metadata. For the legacy nested layout, the numeric parent directory supplies the +board ID when no explicit ID is given. The loader does not guess board identity +from the number of columns. A manifest entry takes precedence over the default +board ID. + +## Manifest + +Paths are relative to the training data directory, use `/` separators, and must +refer to existing CSV files. Entries can cover only the recordings that need +overrides. The remaining recordings use the explicit default board ID or legacy +directory metadata. + +```json +{ + "recordings": [ + { + "path": "relaxed/recording.csv", + "board_id": 7, + "subject_id": "participant-01", + "session_id": "visit-01" + }, + { + "path": "focused/recording.csv", + "board_id": 7, + "subject_id": "participant-01", + "session_id": "visit-01" + } + ] +} +``` + +`subject_id` keeps all recordings from that subject together, including different +sessions and labels. Without a subject ID, `session_id` is used. An explicit +`group_id` overrides both. Without any of these fields, the loader groups by the +UTC date of the first valid Unix timestamp. Use stable globally unique IDs; do not +reuse a session ID for unrelated sessions. Filename suffixes are not interpreted +as subject or session identifiers. + +Optional `sampling_rate`, `eeg_channels`, and `timestamp_channel` fields override +the board descriptor. Channel indices are zero-based board columns. A manifest +without a board ID must provide all three fields. For example, an explicitly +described four-channel recording can use: + +```json +{ + "recordings": [ + { + "path": "focused/custom.csv", + "sampling_rate": 250, + "eeg_channels": [1, 2, 3, 4], + "timestamp_channel": 10, + "subject_id": "participant-02" + } + ] +} +``` + +## Feature extraction + +`training_data.extract_dataset` returns a `FeatureDataset` containing `X` (five +float64 band powers), `y`, `groups`, `recording_ids`, `augmented`, and JSON-compatible +metadata. It calls the current `DataFilter.get_avg_band_powers` implementation, +with bands 2–4, 4–8, 8–13, 13–30, and 30–45 Hz. + +Defaults skip the first 10 seconds, distribute nonoverlapping eight-second +canonical windows across each recording, and cap them at 128 per recording. Extra +five- and twelve-second windows are marked `augmented=True`. Their cap is applied +separately to each duration. Optional channel subsets also produce augmented +rows. All augmented rows must remain in training folds; validation and test +scores use canonical rows only. Every row retains its recording and group ID. + +The loader queries the native filter guards before extracting features. A window +must include the required margins and retained FFT segment. At 250 Hz with the +current defaults, the minimum is 1,172 samples (4.688 seconds), including 330 +discarded samples at each edge. A shorter augmentation duration is reported and +skipped without discarding valid windows from the recording. Nonfinite signal +windows, zero total power, parsing errors, and native errors are logged and +included in `metadata["issues"]`; missing files are never silently ignored. + +Feature caches are compressed NPZ files containing numeric/string arrays and JSON +metadata. Loading never enables pickle. Cache fingerprints include relative +source paths, file sizes and modification times, extraction options, manifest +contents, resolved board/channel metadata, native guard settings, and SHA-256 +hashes of the DSP binary, Python DSP wrapper, and extractor. A mismatch triggers +fresh extraction. If external tooling edits a raw file while preserving both its +size and nanosecond modification time, delete the cache to force regeneration. +Cache files and raw data are local artifacts and should not be committed. + +## Current local collection + +The inspected collection contains 50 recordings: 29 focused recordings (about +4.90 hours at nominal 250 Hz) and 21 relaxed recordings (about 10.49 hours). Their +12-column schema matches BrainBit board 7: EEG columns 1–4, counter column 0, +resistance columns 5–8, battery column 9, timestamp column 10, and marker column 11. +The user has confirmed that this collection uses BrainBit board 7. Supply that +board ID explicitly because the files do not themselves store it. Subject and +session identifiers have not been provided. + +Most long-interval timestamp rates agree with nominal 250 Hz. Several older +focused recordings have substantial timestamp batching or clock drift; adjacent +timestamp spacing is not a reliable sampling-rate estimate. Thirty-six older +files end with a zero timestamp despite populated signal data. Group inference +ignores invalid timestamps, while feature extraction uses the configured nominal +rate and sample order. + +The collection has 25 UTC recording-day groups. Focused recordings span six days +in 2020; relaxed recordings span 20 days, including 14 files from 2022. Only one +day contains both labels. Day, era, and recording setup can therefore confound +classification. Holding out complete days prevents overlap leakage but does not +demonstrate generalization to new subjects. Report grouped metrics and the +2020-only sensitivity analysis alongside aggregate scores. Acquire explicit +subject/session metadata and both labels across comparable recording conditions +for stronger validation. + +The inspection found no identical complete recordings, identical complete EEG +arrays, or repeated aligned ten-second EEG blocks. A limited cross-file +correlation screen found strong shared 50 Hz contamination; correlation alone +does not establish duplicated recordings or common subjects. The detailed local +inventory is `build/reviews/training_data_inventory.json`. diff --git a/src/ml/train/README.md b/src/ml/train/README.md new file mode 100644 index 000000000..072ea625a --- /dev/null +++ b/src/ml/train/README.md @@ -0,0 +1,188 @@ +# Classifier training + +Run training from the repository root with Python 3.11 and the current checkout's +Python binding and native libraries. The classifier inputs are the five normalized +band powers produced by the current `DataFilter.get_avg_band_powers`: 2–4, 4–8, +8–13, 13–30, and 30–45 Hz. Relaxed is class 0; focused/mindfulness is class 1. + +## Setup + +Use a dedicated Python 3.11 environment and the pinned training/export packages: + +```sh +python -B -m pip install -r src/ml/train/requirements.txt +python -B -m pip install --no-deps -e python_package +python tools/build.py --onnx +``` + +The editable install uses this checkout. A PyPI BrainFlow wheel may contain older +DSP behavior and does not substitute for rebuilt `DataHandler`, `BoardController`, +and `MLModule` libraries. The build must include ONNX support for native inference +and export regression checks. See the repository build instructions for platform +compiler prerequisites. The Python ONNX Runtime package validates exports; the +BrainFlow-native ONNX runtime is built and packaged separately. + +## Train and reproduce + +For the local collection, confirmed by the user as BrainBit board 7: + +```sh +python -B src/ml/train/train_classifiers.py --board-id 7 --cache build/reviews/classifier_features.npz +``` + +Flat `data/relaxed/*.csv` and `data/focused/*.csv` directories require explicit +board metadata. Legacy `data///*.csv` directories are also +supported. [DATASET.md](DATASET.md) describes the file layout, manifest schema, +grouping rules, and limitations of the current collection. + +Useful options: + +| Option | Purpose/default | +| --- | --- | +| `--data-dir` | Raw recordings; defaults to `src/ml/train/data`. | +| `--manifest` | Per-recording board, subject, session, or group metadata. | +| `--cache` | NPZ feature cache; defaults to `/features.npz`. | +| `--work-dir` | Search checkpoints and evaluation artifacts; defaults to `build/classifier_training`. | +| `--output-dir` | Final models and reports; defaults to `src/ml/train`. | +| `--jobs` | Parallel search workers; defaults to 6. | +| `--quick` | Reduced candidate search for a faster pipeline check. | +| `--extract-only` | Extract or validate the feature cache without fitting classifiers. | +| `--seed` | Deterministic seed; defaults to 42. | +| `--cv-folds` | Grouped model-selection folds; defaults to 4. | +| `--holdout-folds`, `--holdout-fold` | Holdout partition count/index; defaults to 5 and 0. | +| `--window-seconds` | Canonical window duration; defaults to 8 seconds. | +| `--augmentation-windows` | Extra training durations; defaults to 5 and 12 seconds. Supply no values to disable. | +| `--max-windows-per-recording` | Canonical cap and cap for each augmentation duration; defaults to 128. | +| `--skip-seconds` | Initial samples excluded from each recording; defaults to 10 seconds. | +| `--channel-subsets` | Enable additional training-only channel-subset features. | + +For example, use an isolated output directory for a quick smoke run: + +```sh +python -B src/ml/train/train_classifiers.py --board-id 7 --quick --jobs 4 --cache build/reviews/classifier_features.npz --work-dir build/classifier_training_quick --output-dir build/classifier_models_quick +``` + +Canonical windows are nonoverlapping and uniformly distributed through each +recording after the initial skip. Augmented rows retain their recording and group +IDs and are used only in training. Native guard requirements are queried before +extraction. With the current defaults at 250 Hz, an input needs at least 1,172 +samples (4.688 seconds), including 330 discarded samples at each edge; the default +five-, eight-, and twelve-second windows satisfy this requirement. + +Cache fingerprints include source file sizes/modification times, manifest and +extraction options, resolved board metadata, filter settings, and hashes of the +native DSP binary, Python DSP wrapper, and extractor. Changed inputs or DSP cause +re-extraction. The cache contains NumPy arrays and JSON, with pickle disabled. +Search/checkpoint artifacts also record the data and model configuration needed +to audit a run. Keep raw data and generated local work directories out of commits. + +## Evaluation and model selection + +The holdout uses complete groups. Model selection uses grouped cross-validation +inside the development partition, with learned preprocessing fitted within each +training fold. By default, a group is the recording's UTC start date across both +labels; manifest subject/session metadata can supply stronger grouping. + +Only canonical windows contribute validation and holdout scores. Extra durations +and channel subsets stay within their group's training side. SVM probability +calibration uses group-disjoint folds. Stacking trains its meta-model from grouped +out-of-fold base predictions, rather than predictions on the same rows used to fit +the base estimators. Its training rows are canonical; it does not introduce an +ordinary random-window inner split. Recording-aware fitting limits domination by +long recordings and dense augmentation. + +The search covers logistic regression, calibrated SVM, random forest, KNN, MLP, +extra trees, histogram gradient boosting, polynomial logistic regression, +standard gradient boosting, and a grouped stacking ensemble. Read +the generated report for selection scores, held-out results, configuration, +feature provenance, and export parity. A model's final deployment fit uses the +available data after evaluation; its held-out result belongs to the separately +saved evaluation model, not to a fresh test of that final fit. + +## Outputs and native integration + +The output directory receives ten ONNX models, preserving the strongest +cross-validation configuration from each family: + +- `logreg_mindfulness.onnx` +- `svm_mindfulness.onnx` +- `forest_mindfulness.onnx` +- `knn_mindfulness.onnx` +- `mlp_mindfulness.onnx` +- `stacking_mindfulness.onnx` +- `extra_trees_mindfulness.onnx` +- `hist_gradient_boosting_mindfulness.onnx` +- `polynomial_logreg_mindfulness.onnx` +- `gradient_boosting_mindfulness.onnx` + +Exports have a fixed float32 input of shape `[1, 5]` and a dense probability output +of shape `[1, 2]`, ordered `[relaxed, focused]`. The exporter verifies ONNX +structure and probability agreement with scikit-learn before replacing a model. +Scaling and learned preprocessing remain inside the ONNX graph. +Calibrated SVMs use double-precision arithmetic internally to avoid cancellation +from quantized support-vector coefficients; their external input/output types +remain float32. Distance-weighted KNN export preserves inverse-distance voting +and the special handling of exactly coincident neighbors. +Export parity compares identical float32 input values, with an absolute +probability tolerance of `2e-5`. It separately reports and bounds changes caused +by rounding the original float64 features (`1e-4`); exact training-row KNN probes +can be sensitive to that input rounding. The saved-model verification also checks +all held-out predictions against the original scikit-learn results. + +The selected logistic regression also produces native coefficients with its +affine scaler folded into the five weights and intercept. With the default output +directory it updates `src/ml/generated/mindfulness_model.cpp`; with a custom +output directory it writes `mindfulness_model.cpp` there. Rebuild `MLModule` after +updating the generated native source before using the built-in classifier. ONNX +files remain usable through `ONNX_CLASSIFIER` with the rebuilt native runtime. + +`training_report.json` and `training_report.md` are saved to the output and work +directories. The work directory also contains `splits.json`, `selection.json`, +`search.json`, `training.log`, held-out prediction arrays, and +`evaluation_models/*.onnx`. Reports include fold means and standard deviations +for macro F1, precision, recall, accuracy, balanced accuracy, ROC AUC, Brier score, +and log loss. These group-based folds must not be interpreted as thousands of +independent EEG trials. + +After rebuilding with the new coefficients, verify the complete saved model set: + +```sh +python -B src/ml/train/verify_classifiers.py --output src/ml/train/verification_report.json +``` + +This checks artifact hashes, all held-out evaluation probabilities against saved +scikit-learn predictions, production models in both ONNX runtimes, and native +mindfulness/restfulness against the generated logistic coefficients. The runtime +verification report is separate from training metrics. + +## Tests + +After setup and rebuilding the native libraries, run from the repository root: + +```sh +python -B -m unittest discover -s src/ml/train -p test_training_data.py +python -B -m unittest discover -s src/ml/train/tests -p "test_*.py" +python -B python_package/examples/tests/ml_binding_regression.py +python -B python_package/examples/tests/ml_export_regression.py +``` + +The extraction tests check grouping, augmentation isolation, native window +requirements, reporting malformed files, and cache invalidation. Model tests +check grouped fitting/calibration/stacking. Export tests compare scikit-learn, +ONNX, and native predictions and exercise native buffer/output selection. + +## Limits of the current data + +The local collection has 50 recordings and 25 UTC day groups. All focused +recordings are from six days in 2020; relaxed recordings span 20 days, including +14 files from 2022. Only one day contains both labels. There are no recorded +subject IDs. Consequently, grouped scores can still reflect day, era, or setup +differences and cannot establish performance on unseen people. Report these +limits alongside accuracy and use the 2020-only sensitivity results when +assessing era confounding. Several older recordings also have batched/drifting +timestamps; feature extraction uses the explicit nominal board sampling rate. + +Retraining changes the numerical behavior of the built-in classifier and exported +models. Evaluate application thresholds and any downstream smoothing against the +new models and preprocessing before treating their scores as interchangeable +with a previous release. diff --git a/src/ml/train/extra_trees_mindfulness.onnx b/src/ml/train/extra_trees_mindfulness.onnx new file mode 100644 index 0000000000000000000000000000000000000000..ce8acc9342515c78633b081010232ccdaa1d051c GIT binary patch literal 3780664 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[95Kp*oD=LKn~yYgtrW=Un&-lhRy2I7Dk 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

4ULA7+sd$FIV^+(b}IDtL{QnI`IXkp5l zbEU=FHS|jC5lNR*2AshW&Y6-3)y8T6DRuKBAn7s?C%Vn-$^@r!q=3?ycWN{ReQ&BE zjW-TyIbnI+t+qpBEY?@~akks7M4O#i3ht#e{V%I-MY8gcMPNyNZ-y6S7MQ8-Wgiiv zXbvoYwu;4pcS{^cQat+^{1ya4}YX+%=QU^xOS4u*VN<6CbQm0N)Ej-46N zqM7t&N>uVJ^DWkON3OGO-_}X50!V4k3Fz?xX@JySDU^X-Vfl-H5O(w1dpA8T);kSc zkw%As7HiSANTY)vD&y?Oa%h2c7|7b~kqY^u)d5<`nzU!^1_rAYgz+nJFb4KOth#*# zjLxl6GvU|(w+}ccvrv zO=iWhIPDS}HnipwgN)`7?5)OBCSg{5OH}#qSSN>K-A?028p7VD)KGK1T81jk3+IuCm3bJ ze30j5Vi0!GyO17g-VXHE*{`fdtm#QV>M;EO>%^$vvvt9X`H`O1*Nsv7#Tq6iA4t|#)#?W^gt@SQS?#~R_51~(p734#iyxaYeXjUI-(;Q zd)>lN6-y2#qrs4Twtfb9kyM5d;x9Mt4TQdGVxHEMftfUO$-9jg zFzTr{p>mTuAxvkUqO{813S!|aGpSSMFMC)ezB~5}c3-{_Um#*PTz7pdntx&SA)eO0 zlgkoj`RDdL+LRwJZ4}cl`C@LvBshG{Uow9i)7SDhlr8%=nvI@G%q_Y$k*AcH@`Q;{ zpnLr~$4116dq~-%LMXRJ4iGBZ2Q+1GeU2$gK0IX0ZUY;GH&w^8>}gOsM5!vl(31Ml z<5_<^M#nKU@9~I&NoUfsf}otweVI`=nbNvLc(p%K9Djj1MNaV2>NUMBRqk)Q3y8rO zKTp)n(BU|rSq#rZD#v@QZCX@s$Ev%iK3+>-qJCFqf#M^1oNz>HfVFsENVaGRhH&eX z8!qCe{EYV#LADJ`8tTgB)7Q|c2dfM-F|#+;wEDKJ_#h1zcv^Ff6dN#zeP3mnyIfj@ zY~}2CTYpw(%748o>9_0W#YB7lEo+;g$rLa3o+wt+lox(%J|iD&TN0VchPsN72L;r__Q>5d~*_p+H6yDwu}8#IgwIOkK$uRoFeIhT zLPNI9Rx(E+3lWPDUEeuy$(}XX#90$ps?TpI3beJy&gyI?u zn(a#TG9;Tu$(Gtyi?v*7G>F+pm8AFwlXtaaRVFRGadRpP)GfvUJS+sLYP~cJz4_VO zfBRa@cR!ySR^w1HD`HgwHcRVc2yCMZ4B@!@_1Ll+a118w({3AUK+2A|NiKbgcaf!lV9!1H@7iXAzT5nmz{rTsk2Kiv;`riBJt8Wg6l~zS**7syoztTZ zAB_zm_PWAQ*RO4_FyUwb&2w(1@~aL8;UCU_t_Fyh9&1eH=oH}^YxgdKu+=trK~nnK zj-e*(j&*L|2!&iA1FbY$@D|PL3z4&LVJ6($Ig^+5kPX-E`MOC?md9Csy$H2yLgYC znSdZe5Hjsqt0yC`*5^Ok!g(h!$;?cF|SK%c54!Z~9xTrAOPA-DMu zzvQ@^J&vaLX7;A|yJ0KVfGe34Hr}va^OZTfYwbuC=`A8F_)9Sk$KjfC2rg_2^s){g z-JBq*@}y0GS|2oYY?f6i&@{(ln6w@>II-V=Qo3^CNP}OJs@>7S{c+IDR;ryu_S4O% z5&KJ@oZ;e_frK_gd=`u)>T52HsrIU z=#o9VYjS8KG@~awIdBc6fz%sm^usNJMadss|Sq^n~!Bj>9 z+K|-=czk2f(UmH*@i;#SyW1i$VfsQ27Hh$rC}j6*DB6cz@r(r04k!3FWer#~PYhkI zQDNpnI~eqdPAzC<%U+AMh?;$t`?uI$;ax%^XxE8b1SH3y(&IG@%e;bZ|MSj0K!T%2 z6^}|-zq|E}m8mXC0j;7tCOY65CLzUgI7i&U&4V!_GM%$4`B*pl`#kJDU_^Q--5>ap z*nhNG(;p=MH+M-rP2`s4klL!r0W#gPF#Rq%Y_i3ApksLy%2EVEU=|&gqB&j08$!J7 z0;d85J}wZtY7vGRn|TH8?Z3|;t#ux#6UsbLvu#PqBbQp|{8_jb8(pu#)K8H0!E^OnK`;}9H%25RFaJy9veh( zMeV!!=!?~8-OP{~f#pqK!$Fg*eSs2iV)E8RyMM!Q@~)N}2y5+;3Pyhp`$+Ru9MG{+ zdPAKA3A=$77*-XrN?q26$@eBWv367&#BT;D>H3CL#j%wM9Bsnv*rlw-cuS!aq=8oo zw1Y+by{vU@ka(Hz%id{oSRb75ppxFjbc7Xz|D<3*Kn4O#X+YDE(5lSiQ()Dky#m<` zfsm{`J~t7-LByq5aO|Hs04W7gy`jwGsl1cymd%Ko`I(uTnV;sb7s$^_>!;kT49q;B z&`x_ki>?B6ns!ThBYFVXxbTjg`_ z29|&=(w{LP?9&Z(|Mo zp={`ly@SvZt_cyz-B%cvK>GW(;R&vJh-)>WUnh3j9YVpMtXUmy@5V&z&ccGaa#g=L z0wxVa{oQB?8O55)mX?zlN9ZBQ0U>h}meaUy7Hd6wg9y-zAGPk9Hy7K(bSlCIu5ya;iS~9)KhPnw@m>p?6 zeTLPlC9vbp65c4)%Ea)h1YTuI=59FoCmyq!VO2WJ!yYpsBT5wp4mzGdDt=W#y|y`- zi2WcbN)ew-3v)Fs0sbytGdk}b- z)wQNh)tvx&Qu-URI)6JIx#r+uwr8f6TsDYjzANwrBAZ$3T$&m^1u}MdT9$Wx zPSdEQ%6&v;B;{NLr79_TbRT03+TjVdd*lfoW~rpAsmR#=iaAw*p%e~wb&D+v%gSug zG&i@(o0XTfLZZy9q-00qCVQNipN<&Ab>1MN)-N=}h!kZp?{fg7%Mr$LwkyhGVR;nZ zk(oN$!Sj%oGnMwk25f=7 z&lJLgl6R}$7iKD0si4j-9)uW_-GN)xL4m;Lcw=7`X>W_(e`VrRab2$tAcQl9@w>JvCQS&a&nDo}RIRo?!M&~%42dDB*9&0CpoF%!juqP+e`GIff zT`8~~&alE4Ow?Io_I7y9;^fj#P-_XS_f3UkPCtVHGU$dG z3?WXj21UDSG(JPH+KoGO)wqA)GTwV7PU&p#-PQ##(Xns;Z-53YmMo@jV$i0yxqNp4VmIX@^rCXY`<$EbKZV}teOw4{ zU`VJC-SU~H%;A10l!;*zEY@pF&;n8_57}xa`>xG*cI`9Zb}uA6_OeW3mL-N()I+eX zeq2L(_Y6ZF6cZYIY_*EOX1@M}3B1Da;gX$&;ZlxrLF?T&Xr8ebR^!(N&{^pskH;^W zcW0VRNTpWCG8!rqmP_4j*(LufA+%K6LrnP01sQoK{#tm*9enDY%PiJX!_YK8V(2n| zQ8@PD!mRjaRWNM@cYV#)Gh{A(*E{*Dx$X9*5bK7Or(iBkXv4VpScFqO+wB z3B6=HhP67_vo^s@e?E)yxy&KVRs%F>;!_vTU&~@`Su}}8lii*?sH(##(AoBx)-v4;9Wgly|6oARTttM+qHQ<52S|J0BAlySR*c;uR>N5L9304C zy1uqp-wujnqNCS>_C6aCoH5;zt-`Tl(s6hr)~2}6RbuaI5-lL0eb`t~k1g9XWH5Xe z}k=eIj^gE}~>-_rV9=>G)lLdo7%-gn?2wZlIZK@0_A2K>)wHBB%9oh;hQkqtN_yU5+i4x1HiHGfoO%ZY}XGhvog>3{mqEGT(^%CDXZEQbC|@)ct!ReCt~Ar7|{R*`Jt5meqV z5=vkB4udRvSp&zgWPZfzsG*xatOGfVBVo)biQw2GlVB;cw<+bZ;*!mH6iLb3y*g(t zAEN40I;TUyA7A8Y-Ph(E^O=o>@Ls)A;Giw2MjX9YdH6nG6D`sFT#YWLIBO{ zHQ*lm0C%oC3WuFYUYQgBQ4qy1WjWnjXch{Z1+n}s3jhg|!Dh?5qTKFuKwG?u9IyGs zw5D!znTf1>UIj|;X9%^*Fc|HXK>K0@_L)@?ri zOPR}zQnJYkK`2StRgEL2Z$|ag1+v6xGJ~WV-eU38d>7@sTxVr-RnHY^G~kjo>q$zK zd65#8&@X{#<$&?%;sqoGEfU<8T zS8*)JDJl`e|5b$WnA$RYTdkXJRRMqOk}DJeZzV#;_8wvt{^rDwX|Yt0oE{bIHP>i-R8u4fy^+vI{QC(B$Z2T zYKQ+)$eu%7^%F2(Ra@btj}*5(aC@>F22DbpCSE#NuuFwB0hS8{sbh7Wvo9)1xV zlxYP^nU$BV%ZC?q8BUx(-D(_<%*9luV@oy}zl za|yO-n#rElIjf;e`7w-0zO{``%xa(7Px$5GX99Fr(KOwUa(>1{Q!HD?%fAH=KbL05 zOc?df=P9V0TJR_c8N7l^vTH&TR(Xxf%Q>ijG?*54B-LkPZ)*0;XUjtrd{4p3a<|wJCO%>TcblSup{idA^ z8KtSj+YEt~>~#lp)7vrNAY1#ztnB?l*VV5AEswmX^A$r#Nxel|u``mAN0Q7yDye8c z*uv#c6`gfZr(mVv;O$;IRjWA~Q{L@%7HI^DtHX&=h`|_Kwj)yjbsq8Bd`Dd`bN>L9 zqF(t?z94b%7WOt~L#Ag9$2Nw{M}%4wn(COlZIIa7}Es3d;Etm7igbx80zmC3?^6m0dC%TnC&N-k)Dk!nezPn z1zxVMs;L`D^7bCV1(smL^*C&vsj(i-qRdAr6iqSd?rmX z4goUn&U_ zbuxpwObpc-W=Pbe)t&SZ%+l58%P=hsLE@7qpx)gec(Q5ZAZDrVMpKW~l@RhpgD%Fq zXYTO*%yNDI)85{UybEv&Bv9KBBzA9!5-eXEqAY%B=-5S_Fb>jQ88ziTdldzLco4>3U9y!Jb;7IYr_jx*-h2^KJM&~F&6NOs10V?|6X@z0ILaIw{H2l;l> zVvbdv%DvoTZL}sg$GHX|*dKPG54vslpn=dw>}QyWa+9F!*K7OrXYnEvc23T^z}!!5 zQaYm4Jrr$UCYkb1q7~+>{NmfAy1@KGQWYk^ldESI;Jj`wrY#kYg>%efFBnQv>Kb;x zCOCN00HZmm6LzbWuR+F-EgI;NZ=8i(mb}=T26};w$tpm1W>+3!JPB|N2Uf4a_L@+{ zKo(w8jZMZD>b3{%&Wn+m-N!*hyt5qw;N2^})#H`3MpHg`#U6fOp(3%ff`}f~^6Gc1 zf5TiQYr|Jd)$#VF-D~UKzNLeA^Qpyy?F~TF{SJi3?rx^u?o8mX$^Z+}9m9zw?>zW_ z&wcI^K5mL0vI&5PJjzvK^|eS0{&=-XxCW7iZW5R`uER1(YTam9UXKkmXQpZeq41_w z1Yz>-au+x!D1R1w>H?st>vRy-{_mMvP$E@Eem9K6r7(!{_g!}ccKbsJD%q&%(6^oRqBt_o!bz+qJCKgAPjy2dj=_Mp@X7)g)%|#L+GuBlcX;Vs}RHKw^oXCeuqNg6f zyeumPJ8eQy!;>y3oboQyWGic=M?q)UB3NbV!3yw84p?g8u{h%$^Byfw@H9Sp;VyAJ z1(R)sppuni0A@iBCu7M^&cJ*;9i#~=TCdZTqWB<_V6F`hn(G^eiQO)8g!PGWtxkiO( z8de0I`_q8LsQpQFZxhvtuZH!gcP$d(Z2S{GEmi}l#?K8Th+mq1TdssM0$R157~FRs z+UV@)2bA{RAHZO~yTe!hM1-YYf(?)-Z9jNippvzi>tlg={j#kZS1#&W$gFo^%+$e{ zglR*ba0cetLft@$rEpW!B7+m9QImq^P$hMw6Je1T=ls?FA-eRvxd*Q_(+#aWuBOfd z(@Z!P8$%0sC13%53EJ~u$9j{{6 z5rqw&46k4mth@~B&a_67=M*w1J++||p4Mm-R1sf<^YRX(vf0$cEMe?sIf;Oo?_Aq} z?6nn8GPl$T+M!lMrVF&iO%785VzcscHv^VO5m?iQ9dRmydj#Ru`oLU%BaLrkeGv_t zg%?FZneWZiujW3>TTm<6AO|=$I}GyrIDpSr%ipD*)x?ySPsgr^Hu;(2^<(a` zA_BIRR%W-)tajxrV+o=ch?q7IBG=!4i%kR3;9l6GZ+IbWp1Opn=~Jc(Np{2IvXggy zU)v%}EJn<1uh}Q{ zXtZAG(_mxKjoL^>I|z_z1SAtgl$c@oG`nY3CknO|>8_UkH0`xQ3rLkzGeE4Q9w#N0 zby+1sdQEIG6YBB+bG&A%!JcOt<`XxQ>r~pB`Psz~1}5uOL0^q^bw%QfprEoVS)KK* z3h~{%m>ub;z{29}6Kv^)?cNNUDtv`TA4{Pg%swO~ZoH&?re)V1$(nk=lqIp3Or^^I zJCq|Tcb`{gESS0ivecb|j4dlZ>#--wXsW4xu?O^<_DeY?%L(BZiLU+`fG(aei+3hK zrWV-y`601_ig9$r=`aRp>6?b&Ktw4$%8VJXdB-=j;3kts2Hy{@1OYM*Ll!0%>aFWN zy9X?Hj)VJZORA#HZsRVb%S{!*m2XvqPEy4?V>x&9K4!6Qo_3u;-k(1TJ`}*$(kM_# zt$zmbB$ewUg7djNO?A(0biS@{s4kT%=3~19s#LOFzQZrs3iXLyuh=ugjgl%!9@1OR zj}UKRT3~)w9Yz|^f+|1Dp{1PGVA;>tMlLt6j`P{z42B+h@aiLn`F58hp!O<)i(rp2 z7mDdppXhl*a9Vp5K)%1x%Ya}b)gZtU4T$}P3>b`TM@X6NiF<+H3dv;Sfk6T+@3c=CFspEz;d6J(Q`s687=EWys~1dm(JSS33uax_0)ohHZDXSodz2 z$rhgT6%_<@G;12tEh(89$~l%C(~(elcQS1WH0vh(=ix(zf_&k=6rMaXbBkut(jQ@J zhvkI{dhYeXHrA|(@(84X{OYFL(k(pyv%`7bdH6-9v=SPh9g2=v+QyCNJRr>TqEoCfA=WYcJB{*{&t*p6WSdIFP&}&wu^k4Nv zaHIi`Jt#qk@5YE^I$Ze-=T2r>i|8s+PabU!z#FyyZ8Da3)5g&J17`?t*4y_IZ|P!t z5SA;VUhIO`rwmxk%EQmWAbOq2&>NSo89Z6=8X9D)y7VUeJ@1NKH`w;YPB>dyd02oE z<*$G%G^%nM0wmp-8kx<97XSw#n*m7PT~7jKToa)6QWiw#JRMe$1@7`Nb)8F?v7*xf zch?UXX^Kr|wmVgRfT~Ym%YaP$;H9-N> zk!;9dCNgAO+*;{Z9+TDCIWudf!fI!+SX1R4s!N3}OxHl_nQLPs zhTgDPJ1#6rurMz=Od!~O0%1yD*pYWvNS%J4hWYs)*fHK_eYwx~(%~=c0csb>DnJbE zBMq#oir|#$0}o~RP0M>mhpGY?vvrR<#G5)pvOs5de7%&odDE1uYeIjw&R%ZEQd?T2 zDW3#YQQ^;FR>{g^=NW|af!fh{e=6ww_)VFK=`ir&Xq@e8A*FQXB2^9%f(r(gsabg! zxdKPCu{wTbHuW2oHjD{x{Z}W$i>(P4V0L}Z1dZ+#EY0aXpM64h8GVtj) zP$MTd(5MNiq`XQo%AA4di2Y@SpA8|gKY|<`-dPiuGhDQfp#`MhYiF^Ffct1@KSRx=@!L88ITmke=p;p6gOkU+w0s zj;+$-Ee*<`M^trT3Y7(#TkbXDn$BM5Ip%vIyWSu*Bmh1*bq{lV{Uh%-?ITnY>52*} zw;nJWxWETr3c$86E+)fq?Uon^_bs?j*6N?We+%cx$b++h*hjr+?`HcT{Vwf}L}Yj2 zuT=^iq#=FL3S}vv$u&~u?Fi!i`(B24$48fEu4a=3HdAMfTx+=VQ-@*pFvAJRBQwLq z-!|&@M6hM?)MPHlx&u@x6wKWiLp7^E1Z`Ev4nx&jO|Mrn4k=x(wiiD6O_%NF8nhgh z7mkSj#(0BM;0RagP>H$K79R3N*?eF~QpbBUv8y}S@MMxi&Slv>3Q68`5c!CiK%7&jTXfTXNJkN`4M)?fnSt1Zg+WBVAV|?^ zFAYe&X=&UTj8Z7RGiwubb0MIcBO^Li7O$p9JL`JuzgN(yqj{)2E;A0=9%sCzjO{~o z!Q(=P0w_2N{57}%npPd7H|c1@Lxbj$4PFemy{hEYrSeW5KYrH1NoZ$`t^dydiq)-r zeg5-VCl#ob20A^YA93Gey&sX6y(ASe9X4O$ zOcipe+l_Z7_Q1S*fh%**tl7Ct;>Isrk8xE8yQHA=SG?ha*3A}cRI{|~SWs7KT~-T5 zrB=ebr9V}`;|@y&HWmRv(m6T&@5c}gc+~iRd#Lj**h|A>eAYcn_y=I%xQ&#|gf!HVO$gRL~Z==9x9-cMF zVWy;B_{rS#?q8kN+rr=Yk{ zD+Kn`D`cU|bRIp%gM-a|Nu9`E16!tk07;eHRo(m79QDJYP9IEP?O#MNs@z9sg@GtpF_F4RlI@JiZQ(<7LP@nDOd_lab7n9Nv~}=e*)OV$bD9!ycU&zWj9|;-v>s-3Szv)Vsd>jHk8RFHe>;ucMlO zHAQicTq~sD+l1p-#y6WE6XDH1^GaQ~T53Zzc6yGq)2*ESJ&eGcUrL>oFJ4|jR^{l2 zERjs@vy3EN0K!c(c{f~rWO3ma{pOBJz2in&lY=r&0n>G3EXxB zqBK|Au;<0(AbP$82u>$67^%S`fRuU-H+09P8-1TURyTYDFWHb^>32e-j4-etaVe)U zS@nji4(`cfV{-ceG^SKutbWfj-4>>iMpJLWy2yVsx}?KH77TT9M2Ht|2ZB1M4AZuL zIE?P6w*JXy5mOA&eV7vX0$0OGWzu06vYtTA+1~xmGLgC(xWImxU;1^q0rShZ^)4F1 z{-hc075ic|Dy8yx-k;7N-utg-X;yKsMQ;nohU-Snf{il`GdAo72z&2tD2kqSK&;ez z-vixgesS>M!H^5B7^R~<$T_!?ma{kKG5Hhkjx0B39V4O*Gn?RoH;bJkQA^Ed088Hz z22QM%^gFM{jBS{W10Ga;5GHt*N4(%@pW*pqmr50L z4^{aYW!cd)-?-{VmQC_*SjC~qbWDR@E`s^r_b;c}r+wxSBFwc?YY5&sz{P7%z`%<< zVF44;S<4FImi@%ov`Egd`WA%ijz=3+gB^p_f#>T0q))5IkfST*^)#;SmPRlg`2$W~rn~g#4GEpXKCTkF*BgOT8|^ zprlL`%vwmcXWCYWv{S2aI3|5Iwg6Nkx+;q~VFQqE|FL)!LN~b|-TxJa*(1*>{qET0 zzFOGDYx#e7S(H?lL5?rz$QhW?EU&CQ%%oqym@*v-2C40o>(ycA?_EL8uKrF;jhd%p zzEv0J#BGx5s2S=ZNweY{99^794H|VlZWA$hufc?dUQ8#BDXUOf$|Yfia_UcZ)~KbA z@tGNAhw-p&@PV4R``Ko5PxM(x=G)MK-ZX7tyK#wgRhr;oJw3CQM(pmC5cI+(BP%c; zNeHf*_5!qA2>>_ua7>mIn2*HOD3$Di+Riw~w=gyW^U7&sG}h`y0&`VwZ0Nw2aF}x- z9o^ed7?*wbPK}%sPT-|Q-fBqWYX;L~I5Tx=W$+9}Bq@2AcYH`zW=fKBZ=~*G_<$JX zYM``unFe_w+!H!nKj5vx7fhHW-4spw332;(+Jz2=^4j7lD+tb6T$6gsni+&z^&>eO za@J5F^}3#`s#6l!iZ?dNngQ&jPYY`>>|5gQr*cn)V9(CY(d6w@b-~P^X?1GPj=?(h z{~CR?Z5Ep-*SVm=J3V6mkk@V2yxbwEKy_c8F<#ZH43aWg|3tjA7g6Uid5U;i&EFwSea`&D?9vQf#aoaztC&ldGL<=Y0bD)(Ouw5>qh!{r zlV&DPqivWfRL{|#->IwsKIg_c5QB&RbnIhaU0I~(bZs1}kPM+L@PwAu1sRf~rIweD zqm0fekGA)}O?%n@9pjdMGda`1gXs`?ffWa6PDOj{ptIotT>gzLnDCv+6o(04e(k19 z2i%RL6T2NjIKO}Eqf0%!im^3J-O@LpdRY`+H!R}o)Q0zUeg(L;Lh0aqn9rSv{p#<7*tqi@O(SA@U;qX3MV4g!q{_RK{d2NJT$ZWF)?J;n9@@6fL5F82 zuYuCnf*jZ}=#!8cRYiIm(7{oED-8&^N5*2eBs;YnhRX98?QIW63Hl93BuA_&&)J(A zyfgpzIp9sl|Bxr%$V1$4XVR9TfkZ2q!t34!>6CkRyuQq@V4sDR8PW6AaTrM|uQ>N# zi!=V8SY_*nt&n>D^B=PHDn&t^eUK2&Hhb&mj5vQD!=uq1WMTHq_W)FWpn@J)4qqV} zHRK(nDDSq>Vx2vzFbkIT04OxLHd|>1DZ7;|JBT<@j&SNxs>mn{UL_<@*NTyoz1U}+ z*jur2&1GHs{u?6np`Jk_=_uyk1rG(+A`tQ}z4{5)r=y6teLdu(Www$y1VO2B?Yn2t zO {^%t*4*u6H!aMgbspgKMpcgC>-qjd3tw3f^~oc$U8id1M|lM076F)yoivpylr z-2HTW@(UfAiIggXz0A}UUA*HYbEbUj4C#J32H~s}iE!Ghh=YkqNiAs(ADHhV@&ek$ zp{O+faX>Y!3>tIMisG@WtUbaI8mMD3_EXbC^XN)=k=ILMj&0*$s9Gzm{euy~d>e_! zt@}V9h}D&0$;2sb>NecMsQx5izYg=>brmw*W+dQtt1kWW#j8ERdc5MQnJAHzId3c< zh^CQu)%}@Y_Nh%%W1HUP^1=6!n4+SH?rvoqqLScLM%|iBc4>8HuIifI8|qrGpFfe> zDVIUmtZjKo)q#v{_ky+ZlU}pjP>fM_y8tu1Eyz%}eaD|PZ#Op2WIE5sAxNs?`~eOa z!)-ZXRujdNTCL9KRZ;h3owM1&x<#n z6ImVB7zgcjdo-N1cRi&cBpGhNe`TN3{FnB~{`zHLGVM}0G3hjh(7$X=wrQ+lfYVn2 z)hKmY$Mup7X3nlEY)e@J&85An9ieZhqj7||OcE+5DNB1&#ZE1^N-7>bBx_FV%~Dg_ zn(ri3V!D2kvgv^WkSas&qVFXoq49c8s_5+BcO{I|M3R;H+heB^IMJTaZPM(7kTwAr zdT5XOKd0nU!1Yr1sui*?h#{@InUgfm+x6DdlT%Tp#lz3(KI907wES)_t(5u`#O!H1 zIJlYee(mN}R;dl__|VUVXudIOV#C*V<(z7>f4wio9PH6rkt z+G2mYExKj@EWQ@&*(`IpEq))%!8g+ZOY>+HfNKTBrTQ|2>F95UAe*a_jy}Qy$~fqD z@ZV2jGpMWZcBns{3RGX~7%U)N20KL%06T`0knb~`(_3BoaP%F#adnrB1JLBymsI{f z3RRWaAFEb2!U?==EY4n+;>NJHD$ON*_;}ODocmaaCF&q_j}cJo&lkXTde8h)wqZ+q z`T+}=S+V<5damUT_LB_yr`;U({yr(fEdVhsp;q<+y0ZM3rcBuxY>BLt9&E(GAS`dq=C zYA6(W*FiC%q|8U6bl}R&UW|y7O}EsLZmCjzCKBH)qOkErBcr_@AIs=>fme8EH6tT*7=nA8 z5!>7n-88Q?RV1br?{2>0U(&Nz%`qnquDwBu9+^N+m&`qwm;G?nkT%@}3)rg*@N%ip z-xg(d?yBX->E~gy?R}9;8T9jloptJRODg~Rojl_+qu8Zm~b~op^wbzC=Pd?@v@|cndN{_dq|t@kOf~+@FWAW;I|QrFOV@9YATl zs(4=aCi`xVC&n@K3_1yh)X&X>2LWk7A!JK1)TD2D6n8I-bwb~f`m;58mc}MPOhWK- zejL8{nsC4{3GXdG)Ju|rahTt(d}46PZdW3U zzfLf==Sr{rc|jl3fAH-Pwb&GC(}8U+Q*6M?I%LarUN%->?5;9hN`;$TsW#aC6kHsy z*|i3X2Q!|qe{DMS*wA$w8u)!CH4Y@@%K<|gZ=Gcv^6u(AJiPls%%dtaLvwAp0N|77 zl|+51TLnx=PuWpmW(Vx-6+2twsZl!mDx(bj6Q#QhYRnKu*TiSN=AszM6(RXJGYEIg-Oo9f)O|n3_gaF|@p-#=lCb$@3$Kdh)0H$S+7hu?o3WM;>r- zYhd}V{+K={T0}ff1M&P4X;gM)n|l&=*4XR$_ZJ+C6XT$jX<9Khq;gQ_*4@N(UKpZC z11D?aV5FPj&Q_@v6JuX0Ev0gjMjWNEl?+hcO$tFL%Anm|f~)%zVQR@9R!3$x+@#-W z14Chf8U}NjxVoZ{E!o;n4Nw2`GbrQQuL>G?NK(|?BR~thYXlp{ZZ%r5Z%Xsi-We9h zY_xYBjMwn3Ls|NGkXWNwAgxt41?W8J#=IvxQ8jrtcJ6AO5^v5tcC&goN_wKY%R*Z2 zX_t`@zpG(Mc;wWIM$IYj(cFVs!_O*j)z)J<5;)CG7_PlqMTv9C{roZq8;~lCCYiaLN@grM``+M`TGxXtKwU+S{A>x(y-#I_ zPCSkr6|6(QKh%Y+g0S0gJV* z&H)(Bd%sY_J3VSMQM`7FopwLdXiDd8BttMdYE~;Dq3kvYUB+7o_LwJ7^>`adHt8pW z?p+-|*rRT)Pu7efc#SB*6xk*fiuP-B4Smy!)Qb)Q5p7?f3UV*V0pEv0ij}>=U$g5( zY}%#qWG~_&e#j)(+{=h0MDug(PfzQhFW^ev$wSC9=2<}&Z4)9)&l;myMUu@b@ls}d z(o_n4YA`z~o_wqxw4k`lnk3LS8h=vQLbid$Ys!CkDYSAhG%u6S8APmy^}a2~%Q(Id zr68>&djLwVst~ng*Pcd9Lq_@Q?5OvpQ43J0uoi5)NdyP0pl_JCtdHlK#t1r%pt0Cu#Hf#l; z>eFiyt165W*hKxc?3Fvl=9mLgwmbvxn!iInWGQ~tDkpWQy%glRj)S&403#Pr+n))v z+B+@1eL2ySwP^KgI@t`hw^L!LudYKde-a`3uXNpa$c*Cl`XO$FSbxJH8hWhN^Cswn z=;!vR5qBDJY=>+~>9OgCR{0cN9U^x$vK`F4W<`(U)dK7Ha|hg>F=y zrueU7S%xqwdjN}Y6Y#z~M~9#?Zqs@8is;14g%PWJ&p?Uv7M>eqOKP+F?WhS~di4W> zrr#iAN7tshpk_MkOs_n&BK5YZiG=w@ywrdwg9W4@?2yrA;R!TwZ-p@|DJ@nEqU7yu zXnC`#E%V|)wJC|=U_A0e!{rS|${MKq4+Jmq9_A%+GNU(d^9ypCqz|K+Uzp3IYwIyd zO7+o)18VBF3aN7V8OYvR2O7x^h=WrR;{3Z-5!1?OMB03d)zz%5V+9cT^fFtGp$;kf zK&UWWusg|pa8l1fOZg|mjvY`iMSz^2~ZY>m+BMqW-kD`va;)3cO}OsAuC0&#Yrh zn^yECLTCBeP35b7~1OJ(gN=nc^q7`x58H4f?x>8YbDUjh;@6#vt zIHbV549#pxuJP{H3XQ%sD|j}cudaU4YCZjp*zf>6M){&nj^42w-8mW^d$?>7mV_#& zXLDcK?#!68{Zdt+(MvC2u9+ten~iipHUI8|zLzS^DyC%CTjLn}(J!&aY&UUJ?B>Nv zu4u^@o3hW>w<^nLlTLxju^u?H0@ahPh4g2#@Kt>W9+VWw)J;+aTc*a$3I>s_Og`SgO_$$Rni>Zo;v3W9m~7Oci;T@>ePB z?@UZlqaOHmHDYq%#^0_xR^1 zK3HK6tEH>aC3a3YixG!$qh<|5EB`7^A18LA8B(i1aJHNY7(<_7L(|lRWSP1J>CW>n z#ST^t1x+RT*Tx^;!DB*mc=y&RLqW~VL+1ji`55w>Dwq4whrAnAl|DXhY!bWPrON2A zTPXm@W0APnjJ)E%b46xTlA2ivjr6D#k9Y-=EjhV|E{IAD)+(tTuq~fCv-&^A5cVWb z;328p|6OOD+Mgz!N@4ECeuV3ya~nL_)E+J|JG!Fo+>@+BTnOr^D;Hdo8=Cc1urc1U zzaacJ<;}_6JJN^w5Lef97g_~$;0}WW_dTsUHiCA`C>SnZRp@I%pi@A)zfdvm1Dyv| zrM(B8tLfAf7j)g%#Tcb~AF=0o(-KvSQeje4OJ)6$SgVKc?G0?yNQb%}vJJqk5%6=6 zE%eQK2SVAuX9&`-e_s%m{VT+Jwu2JZGZ z@Mn(5qXY~m*|Qg{M2~ud`*k@CGxlS?Q94#_r<8i7V<`Qz12Gsk32-NLfyk-S%QOSd z**Xx0lX?r%8dTU=&)_R5_@J)s4OerQ$9cV2U00ipq+mv#a{65Fzzs0<5%r(0`V|*w zY3$|<1XlGsxLOlHd-l+@tYx`@XZcpB(7H%jy#ljCUEwfTg~AGZRl|JN#3OTTMx)0* zxw2N&7H;o8F#zZIEfQbnEV6pm=Nh*I7WUC(9OTo6UpKzV!xq(urR)tDaTv#Ip$iAu z%Dhom6%=Pz(}VZ;-p5cpdHp<49-~VU#z>P#161m_wNsW(!j&X$Ar!MC(B2U}J0qDvngs%7KL;A>~ z9EOZ~Qbh3qLp_)g=kx9chE?DfQfq>7erFtAV&e}5Lt7(Yi=CiMBlTa3nH|A+rAeOn zdJQ2@vOj_7(_jp9o*k*ct_!Y}XBNayF3Z|Ks+wvZ&3f_`-sb8iXE@NKw$;kJ`~mRG z!|XuSVm&@hp~X4bnVxNDx#;9_{jHI4CY#WI(qN(e@uOq+qBbaXuhVRo4D2-OG zfO^e%)eue@QT94e!S^oOc5x!Z?Qy#UioL&Q=B%JM^0qRngg(=M@cIr7mGowEyJSrl zTfDhl>$=7U&D<=LK0lHtUrc*}?4M2eRyR7j*=)j3$q)mXEpEs%nZ=wpH!N2FY50Gh zh3kTdT2wjwI{{|mI#SX3DaZ~k0gYA-KxG7`^CQC2545NKd#^KO%r+CN(eX2bS*ZRK zVv_OKXju9A9&rdjm;fXXk#4!!_gT^BY9}Q+taY*=0!NK$`Q1I3Mp}uO^T^YhF#oYX<26cS!K%9ub(s!A0bCS3{Z-YcaErI>lG%#{P`P3a2&*oad;#zOiZRBrdJwx)D52O z+Pa>~A{s3(B*1xZ#DesZ8(1}srZ_S)q5L>qFvC6_&iiqYU27P32^@F}vu00U?mwT& zMBBWX%xv^nO`v+8J(=!&(IjLmQcW&4e@1Pd9SJ6T6o47*BQWouZ@{cxr_jKIPBR3v z6*+w&M|QS1A~Y%I&rhhR)#Zao@|<2n)xT2QFcWszkN$CTyw4Ce$+RUU@iFJ%fvU051`FmF4 zQfnBXeAX)&Q{Pjk;uyo(txN(z#9#33-NNb2}xT6s1PK9y|RJZcah=USP7*IOE&Lc^P1nX4IQm z&sBFMgpzrDy1eI^!1T5y%4x@7o+lJGgMx|Xpk$<9r&q)_B_)e5N7{VmEWp3>lId>c z;Fh$#20wr9Tbo&t(r_Wqm=5o>fVw494=^$QiGqI~4pcF|kf&i|AuSkk%z`dXJRN~u z_>Z)6cdWoDtuJh`+I`0&7V+mV4g$gSOi>z^pt7a(I|->wZAI`Ryqm$m?QUWXDmN~R z!_v1Z!>{b9NB%oE5M*g&=$xo@s1|9tm9k)xYI(aO|JJz;UWP3}dgPrvnssRr+dq(Z zrO*1uekVI+rQlTO<3xntU|n{pokX5lmvXs%t3|qxlcy``orSN5T-5I*e#M#h*zHME zrW-F=c}&Yu3*UPavA(0@^6IhqsNW3c@dlGOH&rIay%|RDtPT!bP^;G_hYdPMJ$DC0 z@@8eIFth@d^^Ld?K4}A#@3DVt;PRR0s5lLnO_`}dP2zCz)|xcmCD<@yq4OW8QPO;y z6=?o9>i(CBH+#m0s#sx!bKs^zoDMDy$)s{$we@OV5*($pb(dSL-+Bju$uorbKczuP z$|ZN#I|rolvGiD%4PkIopM|jUk;s&ou@~s)$Qd{`HSQt-zdON%2Y&>zWkhWNo^g^c z<=cyiG2rVz8347khfB&v@Q_k@WFP)tMbYT|r>`)?0g2uOM98hvBQ{1_jGjz{QqnpM*6}Ju|ej3~*FQh;m>E7=P0j zJh#hecq45Sf*h$bEDs2q-Wa8|d^4qP6`@mobsRk=6$r$RD zQPbEEVUJ>z23jRBxMbfBXI9NS0>Ou@knB+i#H&%P3i|l~Oye;I(H%USj!Lx8$db#W z{A`Dg4j>?@R^2Y&WgIff49T7Jc(cq(fK6HEpbN}B!m`0O`j@E!X{@kbg6@NVA7I%oZBn4}lmYlSlV1=i3 zxjG$uy)F_Jc(Vy-GjgH4snmbg-)@$R%OOApwPpQYpdpP?Wx_wd zY4i|W-Av;lh1o!ke}5_WpGK(^c}6om9jxUi#bR4rGT|XtEvidvHiw`I*1?@y zTA)kn-QnAo`;120B{5u$(EjKgAVR6qx%oELK2_idPXwpGALNnY%cJHOvVlE8=lUFw%w-^>{H6TA z!hPvOs{FR;joy83c&fGy#Knf;q%^WExc{~)8NraE!GxcN!+-UEWZ=MC>w3grp6t?K zY};yq3UBdLi(NvtCi(pFv`)2s%@%a)~s#_dq1`J{OYEb=&L7KSZTS| zQN(ZDYJ^j==HveUUZ`Pp>d1VTN}Z@G(+f;?+togHL7LZ)r+XC~g)@0#7dsPW=-r5X-UNignIqB4uI2Q#Za zjOHcq*5I5H@qh2^j-_Ko;!sPAb^JS)45T1O2Y52xtx|GRoRl64LR>b_HBh%B8m=oe zlYX1uKCBurgyi_VB&^tdNv&go^6ioW#3#p$I>GOn@=^KpDp2>kDNHS&jZ@df4Vd2m zMlT7*8|6fPrC{$i6zG$E_FOdRj}?-pjvi@;Gh5CccV-B*Z2MpyhiLAVM68PL;wHPb zC?oF{wD)Zs?S-pD2Ui@Z*M2k<>@^wQcyJ&gp09^VJOfSAygY6ui4$%!*TE`HW0vYX z1|QR`EUEI_pYY$H=S;e6FB_V0QMr6fr8|PAQ2*+zY3?Y3dL2=S8Lo$q9-xde(k2im zLER|V==Jp2Et`MPJ=Je0KN;$3gPgYvz_BR_UL4B7vFZOXi}}0=p_8T_Uxgm^=Noe5 zbw#v)$-n{1=Fc=SqWe6Axxw9?vv=;1y`iiVu zk0{f1U`aH)6k0Tt_5De~325M_6V_n&EvB2-ws1$N1zNs(gpsSTh)o>kd{zA7u@B%E znW8jb$Bgf&Gwj`NN(L@#yKIY-`V$7Rj390%bo=fIJIbFsHWpdT_V2K;*z~n~or3!! zozND08f4R7*d$!T9XTWQWh=3Rg+J34~R8{QdhbsCyd*>wV~+8@5${l>WNue*b70IMPL#J z*2PJXM&)5%b|+cCa!GY*{m#tK&0UyMPwvV8IOqV!Jf`D#&3TJ=lWZ@{nkMximPlS= zX0vqz@Vn#d=#jU`#jqy2;=lCiiJ}l}Tz3Vr~}8@*)wx}0IsGauc`|kM#KT=h>92%tAKiyrb;>^8g{?4632Xv5X;C#LM62$ z55}g>W*U8e1PCgt(?9Z=%dOHnCE1So7+Xs_3k&9ItwX+*hXfI}c3u{nBXsNwaoKhb zJ17mzRQEdXd|O;YV6G?E=wYM3{eR0VOVmb!gKt71Kt~x7MowQYMDypJVIOJ!*Hjl} zVeiKV!`v@w9yk)1Uq>;R$I2vh?m`1|c7Y*}tp*bFwuf%gs63inGW31P6u@E@%&`~+ zF2_(ZiPeuHrL_DKqu^Z#JXz4&@LA1oSN^~snldycE6a(xvK=M{R(7kBhNX&6&^)p| z67ahbLMLCm?EvSP-^NSzS%poOcQ(afq!1SxD3_vyekWoO{eU@%RB4c$vE`_O18%nR zA&FsT2Nyss=>o%<_|g{AW|=-NHe$t(FlPymu1vL9z2+Gi*Vi*YH^3{ z-<#cV^0OHz`86KsJ6-_eTVrZz=wtR{glDejU9(($iIPIiH?_ zt3Lk$?d7hd_@~>UZ|Dpw_o)ug$F{BGI8Ww}s-n{%0Rw+eiv4ZocAYstIBLKCIhhHw zq&EDti#-mB7)pT|uB=z8KFgjx7|f!5=~${5ypw;i7jc9NIdW=7k}qzLDacWKe@f>l z3@u5`0H>+z(`*kz-zf*syE5L45fIrehc7|Y9PpKcnpXs0cugGcK8nX@sBkAJk;*;Q zO~GT!g7$ZvX|!(>gF)=}qdTR_+YM;F$4|o)POIRpJHk1#M@}wSPhtyuhvzDV(w|Eh z+;LtzP9c6I2GBjQG=~UF(%a*l2=>Os$o|M~2-90P+Pk>T;7RFYe^*#QQu3I(s4c2s z3{@uP35;DJYfqEyFmg9rp$<=`JcVoO1Fo(vVw7eBI)!q3Z4*II=Y>Rd8FaN@xUg@w zg4nZvK>PprI_tQqmhR!(iK2j_Vq$LkAh+$q9}GD26ke1fZg3*ySuv;^IF*5 z-HQEwXSufn-skt8Kh9@nPp_UemHCw^GIZHeTNr{w;}*aH*O!?fvD`I5qBH*$YOmH@ z>j$D?B_YaD3tAe_?|~}%t3<|kEsbxhR-+tjW6g2Acmb@!&jKsQ1}1P;cITTAgEo64 z`v3nEKc@J!F0cL)pB$xFrnAYQ-7tj5>0o#;V0sNuP0~ob^?!`rj?q8|xv*X0O4U48My5g8NRo zU?W;f-N@2)PCfL3i;q#?fY&xm$TtGFqhE^K+#qlY;gRtoJ)N*GEvo30 z$u4~tBDB)IMGM8wGQY`SJRXAXb+h^ygOP8ivsZ~9UGLA>G~y4p$izi??)I#m)ahy@ zG>)TY$uiLs%1#>C0SPyg z#=_E(B*=n?cmpIVyMZKBbTD$GT~bu^s?T}{BU z*tymR^GFh;=2HHcIKJbl^yGT1`X|444!}aKTAET`@)e&CY=en~O>NZEM+O$CUUq=B$(kWP+e@m&5p60VYjbR*%0GZw z%E7Qu&0JtVG8IZQ)CZWlsJjCPdz&b;yIcUPG!tSy<)NO&pikv2g>NJY%r5mkT8u!x zb{o*uJpjoITv{E6+Upn1fVxW#?9(;xiV97PcCR^>#{6x~+lu;00GZ57|Kv~a#?ar|j5+6How{>4 zUYr)MIiZlJ2=-}7WM_p|KuFWy*+9l2(Z+w=7!v*Bos{!*Rggnt8b8G;`zjlLsb=4XOe-?&9#kgNU!8C;31gS z(Mi=IvpjzuJ-NTdq`%S2OXE#>Zi?X&wsftMLnSs6e1v?gF${XXhZihf>W z-eg2weGj$GMMou+?mBHm95MMk&DB2mA}ca{Np+nE5K}n8I1-JUWI|GJ0VptW%^%K# z{sa(uMlXlGDy!S^zpFPzPq(3KZ~v@F=)RP^iTKk+T2Y#Bk)Fq^$ zijnZ@p_c&X;%Q93S3{`fMQMzaGO5a=ywu)CDRHE~X|SwSxBSPig+BKiO_S4)=fkG< zHyKB=w0GQOP`kADtI<5(cUjuUVold}P<*G3UY1?E6m^$%Z$%DQ-ppuhYS2sUTE};; z0!2uray!27ppib-xlWEoYh@ZC4kcCp?aw;GNY##WNfpyTLfax~{9Bv#tRB`)!|>}f z?UQkQI9iM;ZI#(f6gSntV-p!+oDTV`DtT2m#pWj*NSV9PXzjxf(@ty{iYf}aNVG!; zgtWK&H`WLyqLcf30KecTDOoxX?0`_%bl50c;>=Vdq{ks4eO~Ra1c{13$#?C{$UOF()=>46b@dn=B`b~>J zq7I|y7*U&W=v11N{S0C=1g7BC1LUAq(=29m_%c(2bF#@BRc@jw1<`+)p*M&l*g)3uq(Ir^|Aq1tu& z@E1;rYlx_Z)ZK+R2~xTvi~NJ3p?+5|CfYV(SVyBA zS#a~kZ6R^6kTRQ)3^Afev&&31ux#_3hQAC{C@(=T}mp z;yvV@%N9D2Ec4U_$Vm#Byf5D>Bu&Jc(lMxb+r}u~rYxMu$b4J9&K36n9aUjFBU+(AC(6;O%8NtR@NX!_bI6j!;hSi>$Q; z{sV+E>^#r1)A|q;!R)v3WwDf~wr2x7?M|jMDv)pedLzvStS@7H^L~o`SN7!)AM4Mr z**J6%9KZ>!GH7;tiC#Yn%jem!^s=VB&Cflz~xL(H>21FwYyR(kiWh~;N?4zHKvjJbRfFN+nf2hYfck0{@y0T z3W%GXH8Za|-4oNrm#Iwn=-%<0lzdJ9$k>_#JASQyIx8Tc0JgWfJ1BS;L<`*3R2nU?1d) z1Zo)$Gb`F|5=i%Iz$!cjIvv`bAvg{eGJ`haE&%o>=`tcChBM)ncCGp7an%B&BBeUFVLn^;3B2L_G;AP0HV2>rpzP{0nrWj!H;~ zzh#lf9B@8bi)pFyObk_b)7#xfPA;AcU`GVGsS{Ah-So4_hxDY*(6V#A)$^q%C;bo z`|hg2yy7s>O>-V3hjcUEPO=ZeLO;F?3C$T@X!va{CYf50nQyYXOf=Y{A$vR zM31P2j1=E1ASl$@-AVrvOybU#Lv;+qWKZmjHIli49b;2(9%Oh7B)ya`x1G8k&*mq8 zQj7dOp9sI@2!`ahr7r|gt1Vh`$admnd_0onePxKDS~i;0#ipD9LfbB+Mk0-d5y$4C z`coqrYWd2!RX$U1>5yIukZ)qQ%OM}VXG~|-tLyq(a<`-gh)6rS zyXE%zgTY6&=*Em1u-d56wUJy(y^BX!>T}tVkAwX+fXne=fcWw+I7$+lR6>rg`V0~3 zpcAgG{nGR{{LKut8z`Rq0%H--DdZNHIJx{;w)4m|xP%Hn;M%m&)M|qaxoVOu;WYdb|TUBn2k!5WzXv6zaN|xT* zKJc*~XpVk!T9ySi*uC6htyIUHmP#?XQ7LeS9~`e7F7j>Bk^)#LPhjkiC36_VT0IQ@ zwVjR%-0lor3E0CYt>RgZPe8oTZp?W3=2jC)eZ3S6A<0uvKx`H{iB&ZZQklk+1*f6hK5k$r9}m?Mry+gVnoYI zt6rS+JP0R?uh}CMwIk!uwxdLiyJ$d<^g#dFtGh~4stpnP+wV@EU@$e$knq$QC9*FG z-RP7HV(5^QgnvsgqBo7uU^#OV`ZcK!a_9uI>91NO`a}`+!_hEU>g9+z`Q~&8dXAlM z%9n0)sJ_GlI8;)Po}*WvdchT)X2P(4ReGTkRb_n=`JaSswGdDZ9Ra(P*q_#k`PQir z{5d%w(Keq$acXY|?4`S%wOAVk-siHDnhdaGMp}C_E66A|{3A_%FT%GXsYC%1Ol$N3a^lq?UfjPDQEUd*F)ocp7ESztVy_c>>C6dgWjCV zG{N4iFheD}d$UH+kpbTRQUp{yPcXK14op0C|8ywZEnD^lsWTjYu`A;bL9$#-zxY4j zBtc)%D^ZaVVGqju@67rzi*8p!f9<;wqsTG#H9P#&`L_~E6bNQsO{#?3#uJLD)(s-u zy%B3u5{|uvxFpoPV_g==IzVQP&b`I*P&?SCyabp2UPzS#U5jzW``NUpn0&J>)=XL8 zJU=@?@@WW&iit&66b!_Y9=9H={=r+m1(d*M5Yn1=lV3p28CcpE|&kOxvvy(dxqN0zWWnMbp^6IwOq;qG-ROLoZj7uZ zRUuX0UnDC1(|?cpk-3H1s>1#qV)j&@p^HFu5%>T;VVzmeWhLB?8d|n`K=fYG~ zy+P}o#R0Ni0tZO3?`QggL=BmI>mc z_aXG2DNV8%InBgiMB{Yq^2q}?_6{J+{-$)}bvgv-p+%WgH|S*j_tEUPEsh}~LeOSP zH6uZ<_9bnN`jj3-Ut~IWvda&yzd2qC1RMr*_h*{j(4CyO0x($)MxYnAQeReIM6-WQ znEkykQt#GHkWwW#?DyaTy^>etJft#-2b}Bjpcp{$BaG>#BEd zlXj#-K7&j?+C33jTuQwheM-$gW=2-U0#G_YYDwFT?!G+qU%*D@y==5AZX3zGWMevB zLsmET4w>HBNGs~l2P%eRKaL}|* zFLhqc!Su+B%(ptx(_L5TOTD8E@Z0pPs$G*|SNzD#BI`#e@oA&9MuHqHT%nv2>xP(o zw4pKj!}Y2+e%96q!8F}*HI@1ftk&hL!{rtC`>}BNF31>>JT`jCwu!|WTq+Ga0-E8< z`LK#^+&{m=!0N;zM>x<}?6Sq`-}s!x>YVD49x^R%P7V0mwoGc(64u)e`O)O zp;He7?3#8PM3*r-sY^)c&PimkH2E$ESdA^fw8gapzPXSjIgQpNU(OS-bJ=8Ag0IKB zuy%4D#z~zMK_O+c?uMjv$B)L3Ei182OULGOQ%d5a$AP!!O9kdSfM-cU6Ho=chr?M&4&wsV5Oi973-) zs3(#}Zh<0J-9l(d1u6bXr9x1)9OAlI6WdX?r+m!S)FEYQNdC^QfaGnDabeEozRlzJ zPbPqpCI4yWjira;Np0JMu2r3MROK6l{qg|J7=tD%p*yY#q}k!=(9q{Td`mZg4*3Qn z8Gd~j3-LdN+Uq749Dx~+SO2ngC?m4?g*pwBCD(U$XGMa>X5s#*JM~J;;8&O*ZL4Bl zUfkr$6reF?ydHTT@z>c|w4a3(ZB$Ph2IS-KN!wczg zZ~ISU*)EW=)twI6UU`@dORH#pbEsV)c^PVng%IH&d&uHRI|OYj^`^WeWLnFx+UAF& zBeQqKjE_B4BCmfmhaDtbHqzkZVp99>f{sO|fN z{p{3-aWo#D2BYI64rd!b7`D{O+InJS!SHAB3_t!6Eazw1i+yr3+?@`)Ez(qoD%s}w#t zP#tR9*7=)mXthfwj*6>Y8y_m1Fqlrt&JDm!5l=_U={T@;UnDf|I3?WLyD)nngBU(x z1H_wNfY!pVA*v)xo!3-u8f?|CpL)~E{rq^k25VAJ8zXN+#Ry`tt7m64c|nmrS#QMZu5a_seB&q{b~28FQo zssN0$D#~QyeOc+QhMz}{xm?&3J%Bm;L}pn{Z%~2kIlkh{5LS&Ut}gmzSklIgBH(wl zJ?4TY3%wsif0L)4T6(iG+QC9r|Isk-az-`Buo6$P9idj zw}T;Ct%NV7Yw~x)y%%e|NyDgQV8q_cV%0RDaQxcRR#Xd{GfdDZM_Ug+gdWjQlwjbF$4~7iR5=?etF!Ls?^fv7vR^GWEC2Uh9jSINZ)jaqug>VIKK{(a$z#U`{h8C&g?wV7E{AJB7R@)ys^Nm$$^#RldH(&_;~Z)XraUt)*E%pGSbJ}Qabj3+3R#&IcHE=3uRNLWg#2fIGn< z8YD*^al%#78gIp=90f69mFUYAfJ_{vZne1o7H#!$i;0Tk(4Tib`{5J>`Q*#M@uAIhe01+ti1k8ra${$t z=va5F0sFc|8pp3dS0y3YiYX!SYE+w2%gCJC;WVqw3;P;J+ENXSG!n#0>!dEEk?wv< zgLIr$rwa#vb{3-t=}qPg>IoG;+=;OCh=fWO-o$`xml->8*YyM=lEy0?H)WGlI@6wF z61tfO?*2Cggmi8paIswtfAmm}yuyu5-IGWif$Z*7&nO|qc}j@qxbl}TCmH>QsASPb5}kAmNJ+ALA0{2*KL6AGt1!?C8(*x(@5W|n;{7q`&iv@5Iu{34^v|I2?OO$#WltcHA;UIJ$ zBYM1%@k%Y5{xz%Lo|0M$SqJ%8_Z%q*O~$6hwU#Ui)Ls(UXIuj+njfP2I~OUtCPs>( zKBud9_R2RXYVgA6FvvD4AQRtavo@P@Bx+H1dI~BUlzxqfEF5MuJ~kswN;kGUW5G}F ziRVtD>-N`TI(!Np<(S*dlKL1L;$R;LGxnG8KI+8rF#0IDT7ciJbj73_Fma6o*d@CT zV5vS%mKx>%8W5*wTZkJ8)CQz9>Ln0qBQQ1UNiLf>9gWPs`S_B>dTCZllb%BptYmhW z+ml6aZ`081Ix>}+k8x2Se}5@vnQw_LT5=%sna#7aG10DwL7K=Zx9R~U+QuS*I}WLX zRhkC!nZ3-5g|zp$gUPxvklv9V^(pn0$+z4ncKVM* zO04$f%C4M<+O|f%YkQ&2^%+2q6#HM^M$NjCMgFuGLT^4S@2f_{@Asd$?zMiabSbgX zW9vp9$O1Yt)?Eoo{kfsMs;+Il~L8 zKf8jmQk6~pd`4)+!w|g5IXqc%zDS8S4lsmd+0+RF?&a-4&FvEz-+XT)3UptxeRpFT zKGCdH5^`695_OijBZP&ZV*Ty(9k{lJx@bDdK)_^|fl4Pertw|pVd9n}VP|*88c@jw zI=MLFA(w})Nshewy-s3QWtZD*cWXTir5WoG8Sdd&&6xrq`SWG+7^rC9>75a3(_W?- zCK|;o+ZhS>RDA^}uPF#BV#~ulEpuc+?F5>c3uI`hDxlq)Nt*!dv7l#^GeYL=A!yQG zd0da?gE?KE2P4PTW;efBQwU125C`ya`2{H3oe$#oEL;L9wTxxsy=Aaa>{gy*qNpwe zmdqaxf~~rV;r&zR%V9N_BZ~&zvFg;Kqb)s6B{Dw%us4;*i!n;Xog0g4IgQLu;5qMISdoCc9r5nPxVH~KneMpJ1 zHFHolCeYyn8J9?JvmQ#@wh>X;N>x*w73l_g8P`6!-|R1AjF^RUYnQ z!GW}|P=!Qqa;&)y#a;Qb2{l@boWsDAfmz@DcV ze}$D_StD6OXNn?UB$TT%3S*qwIxf+u0tsZv(OkW`;z>Ps{~sTKNdg5+L+HON9R|9e z?KBBazMZTA!AYn~w~}nH9&M_u`n^z30xNy1#l4w$OJFGAgY@*G~Yo>+aYo3nO+g`srtd=JcYbVW*7>ZfTY|p;^SPn^d z9RKqX`Z6_!j?aDy?Mg#mTbU5LIhO&xIo{kzkW~G5dJyeeubMKfeZpk~ov%#``Yh1} z5x`VJ?w`Q)r!Ar!OmIrV%9S8Rmt7u)`Fko1uT`HOJxW<+*H!MiDX_VUK^8I9;mgO9 z5R4vg10k*-KCFp&nB$mz17mT6I-&I=|1x7WD6%qY_DyL7#i((JsuGPXOqUizm|_yg zd42J*Rw|9c{dvI!=gK@h%u(A+VVNV}Mm*p(xbFMwGB`BH9hR5`jbT@_AzruI7#*(? z1=Ve?1r}r+y_!SW4F=JMWS%kQDT`8mbwFc1lU}+g15Gkt@By5o4*6lty$>+Hxo_p$ z-WMQc_`e~>w^H31+tB{dc$)|08&s_-wqAOAaN|%?qoHm;Fo~tfdNT^KSz$exo>k1* zNX@Ya5|C8A$RN?=j|#IqItZyEH;&0SeYc^!&-7|iav$r62<8DR_f}-hW_)Gl``Ksu zSVvA|g1WaHvNJ9}Ael7;MKz~QB%ahRASyQahtZ^7P?b6Ht^iDSu{Ty9K$m@Rg}kfI zH4O*pf&8U@1#LX;4%I~r1b*$-yRm70DyYa+<=^-+LNe#hX5vjA1>X{~gB7gkB=O%_ zKHMoCi0}14IakVXZYK!K*EJ)AshiyVX)ucxYA;jE#+bjgSAk1G)VI9w>d7gvhHYLj zvspd&9B4IZw_bxUGHVl`D@fA>kY*f#f?mp!N?FS&ZOS3q}FJoEI0I5hlY-*aYQT6!POxTl6drUB=h>S~4tt@+E-i>f}|E{sz) zv;0>q4Jf9RUXOUIUz8_!=D3-nZ-7}i~~9k*Ctlmu$Baa(7R zrBEIHKSO%GN3yJ|6=O71FFz}fa&q^{Q+E!U%)u<%Ujo`V^6Mnis`_|w-nAcw?c9@l*Fo9?tozFOr(q z*2eOCho3})C&RD;u&BH^owg150*7`3U^b~}ZTS%B#ik!hWX2e5vpnzdZb`9P*BXGW z(Sfl2_Qcq>vGZ6(?uRo(Tur(g`rxv57fI^go(QezloT7A?vqjJKDA9;Fx{yG-1EFT zBi~pCEfm@pl_kZBw|UMDj-J)9PfJ2q-Q(=3MZG82wgRNmG@0-Rq*BI?A$&ep1DEZu z>R7KIOfjMf$=!)8I;ul4BLPWf^~lx1a0Ec3wurvZ7a;yji`T$*&r_e{qFVrLY=Q3ugxm+I5 zok~4HF43D=E}@xnRAoiG}eNBAcGMYL5k9g5#L#Ufgpl2s?vn;P^dpE+UEDpmSCv)38kxK^j3B`OL$)!FQL zan3pN8hmfYJ20>mZZ8{9;kzY?s*kGT_M}5c^-IwEr`hP=RrM&4^uWdgtlCVhh4!m} zX?0HBFZydPHpsOCMU7f~m!~yAA|)oeF*e^yk-?w z5KLWSvA%wt8M(U^0tv5Lh#90>=PdS_36_*NR~u?N)0osUKPa2_Ha&Pzr3I$=N#S$g z*EYQg(|*O8qa|Yj=kne3IYksh7@M)UQPrMmCT1RgGeeb|l5hT>hiGrLqQuKtNdC7K z8To=osOV0KT4B0u^)RSYfEfER50ZKXE+2``+k$MGlAgJcY$q>lgBjrA55#8;`i{_g zzl|LWZ^4I51_B&Slu6lP%bFV){LduBMEto?_C`bXnlQ^MJ1VQ?cU9NFLhU{fkYpKfG>rgjXxski}WDWg2a@l z!+_B!j8x_*<=fGbrZ#mPZVDbrs6B~8O%f7oQ!FxxcHV`<`9x?J&SmvjY1G_p1fqH8 zok}b;MSEa1J+wr+I=*|@7u)6fYQynH<`G)zqyYd%{ zK;BlOyito(b$SY!V{=q8zMYwe@EE7g>`AKgRb=soq|nPnt5&_hSyJyMhK3qm&CMy| z?~rI{E67_C{f(t zodWP+=XGJcSlzT+{Q7!c^WmhTdaALAl9wh3*#?V7pVU85K-!%wzwipCTv;- z&N-JK4BkqFcRFn_+&vK0R;Qnv>a)oPIwVU+5|ycpgl?Bb!>(Siftnqeu~bU@mdVOR z%>5&GaYx5h1A`r}-!n+mb(bj)U3R3TwHfwmedZKvvxEGB-%arG^(2_yRiAD=%npSK zz|I{n7axcR+5*2HQ2@KaNwTDp!f##1(#AwmYXIWq^m*on_5*v9L_NjnsZw{ zt{a#t<|?q7JAWq@&!OH1dYVoEu=`3C!42L3Xb7xa_VuORK#Z)$H4(Ja&!sRLS`Y(Q z{2Ede+d{p-KC2WYdSBhJHd$S~*YRBf&AH-V6E4!p9WKnCB-AGmnIoZ%>Jb46>A(Ep z@QjX1Xra0a;No60p|pA~)?1z8l++vQ_J%9n5&D69Q)7T9>Gz)sZr4xHP}&i|`P3OO z<2J$6LVFIISq?|{QWXFznW@*`i-4D?{JjjoP&zsY8hSVpTRF z-GJ&p^mH=%vU{!|U=RZ3rPw|R$W~uzWjj9zPmJSMrXH!X8SN(SR4aZDbdxiBZYyQo zv!NmzL{tB?Sf>uN&cUr<-Fiy%&7{cRv+-0w1bV-YKR+AJM*t{f!(SxMEsDrkYrF73j^`OqKBXpaqy zvp!p~KUp9jIi4t$(YY$f%yO+h*XA z)6^s(ZTU*HV+aFC6sMWQ>HAO7^Hf3Bh>J zxYB_c!5VNW{redNaO`@~`!rKPncl>EIQ*s2+Qe+E0WHkrjt{;K+F(LdFO1JHI3RsT zCTwGs5|MWGy}vefU{l>8>FHkw8+2+i2W;Fbrtp4O3$&VF@diBvmGVc0KFjJhEEyzgY zt;TgQLbqcPU+(GQP+xVcyrf32-O0|7^6=%uSgD>q&kwI`G^FvSL-Q;h|HGF$^_y32 z6R_%v-Zy8ly{Dcmg<%-PJfO%Iu&==+p>-eV-_eu*P0;yDl{JST-@^Y~be_`-M5S@` zLU21GJf5kOr)x8!9L_m_t$Z7acJGd`?BSrmew}=cZy?RC41->zs_Qn8)R@lvtv;5Z zjYr!oEFi1L5M*qkj&EmAYK%>$f~%Im5R!VsZ=w;BX!sS#Y}pSK+6p_w|CE7aI78XS zWcG(^?XZgnuz0^OJeg6JBM%eG9#Qc0;4~mKW*MX>eIBt0 zvXGFcJ=JU2i#Nb~19UH`W2!(%jeb-!vNW7B)ELZ9m&ZQVAC0MW)6jzPZ`IRPdDtcx zk?dWVRD`L5WZCB)j3JVqsg^F`J~27VD<%DzjF$OeB*@~++UfpWzS=7%bD^=A5Yeyh zs8q;oXf|*@V;kh{nGs>RxuYZ?1f^1$Hp|l1p07~O&H3=JB(!;oES3tYe>XYQdliFO zQN<*ANtM4_f*4z4>`4urYz|*=buL=Wl5Rg5 z+NTypNBTE{f9oNnrKd&vxg&CaPPSO(tpzEYt9mVq)u{tQ;oFPBxQYi*^TAq>kG*eE zES}>CEw9R?`j);(Ipzkj91?nQDl;f+(o?y7;M_* z$rix^fJ73?9yLK{I|aaQ&1#COnrTevO1{7TYz9!(V|Dw*=wPUB@;yjCY7hLD=+u0! zguRIROzI=zfyTpDAQP``D$Z19JJLt0@_a$>H*SkS1dX7UkZebBYIh*t_BgWaiQ=1E zCMGql*PD{Cg;Oc!obFI(wTefKVWr+qzDDXjpmF*8;oW~sUzWY3p_vmIOv5dhOiA9F zBdWN<#IN+A#=6$W1s+ z#9`G`UA&@Ul_O##PKg6_^6gm^WbSz<5Z76d8xNkt8a}5?sj|2Ovv(<$do0rI-fF-p z(W2^(3JD!(WXh&54vcMenUb(>fx8^zsqmR6ZE5&`xOa(PGUXkOutbMYM?YiA#eJypY5W=zgJ=v0DL7rUtigtA`ZEJJTCB zkh1#*V01`mKw78xT=8Q#GgnE_e|oKGP>JF`Rvm4#E)bUdnv8=cr_viDH^~)ymek6J zAiiR17z6Y;%Xnp|rvttjSdeJj)<}NcP_NSH3W+-1Lo~}Ved7u=XuRJjEMF-WyBQ$P z|7u42+d(<&{9vZ0qtOt1|A7u9-{n$QeXNDm&4Ov(nwh0~F@I;bDS(smY6GLmDfI>$ zfRKxhB;UMNKUhv5cgSL`7HQ>5tMof~=u7Qo#hqd|#&qCZ@{^=8gfk}~R+%@-ao`CX7_^j- zKaW+YI|J^C(iz8z5wGs>P`-ElETnf)7bZIzeFYQJj^ zGBvS8TQ#BV?*t{9zdLp^qE$Lm^V*_Ngrp8pTU0}A&6?%gq5BD>a{GEfojzJcEoGs zePc7SybP}QEB8*1^Gw{)QsT-1bX0>{h`B224n#QyFl)mbl>eHahC6FmKblOp3^tu| z*HnQrHlOHoEGqYd!6F_fz;u=%D0@(LZvzGymcWuOqu|CZX`$H02fDMTcQ z&1~mMfXTl)=H;SlLieCnT>R3wUXsim2bIV-`D@V=k=w3aQG}6N>XB~^Z>ctBd@-Ua zf3;!%!Im{f54Nfq{r2`%|IiS~XKu6j^<{{~)cBZ*KdB`$HzhRJ7E3+Pm{Ixu!@dH6LEbm0&pDZ4CgX8D zdZqhU{ft@FycG1b)(NwdvO!?LLRR}e?liyMiljANR)=P92sIK`D8119Jz&?Vz11@B zivTG5Uh;4Q_JbOykDq0zx*bruD@QuA9~@$!Ouoe??G`^OHScx>6=QxNee|(3*~TgJ z&`v(s)Ka#v&qCabaWtMI5KbN$XljE1f9!0j%Gw(YX9VbE&NHo%;V9VL$YET6rGPeZCf)D3-vF`!82<5-zx)Je9mY97W~+3 zvVk9uD#<+#(lsIK#=-fyneiYE`R-lCR&;d&zEB^NfPXZx#c*}0$&P>%$vM|ndK6o7 zZ)Y&Zw&RXzUP^s=tz^EL0$|U$O%GbCSBa&kC7qyM$#Q;4n2)tv^g679S6Ik9)WM%m zYh*-lS^iXJiHZ(rjY7>E8x^eIZw@-upKDX4X8B9{V0C;abmwAaxKU{f4hWs)LriTz zmk!n_SrHchycj8UcrbLYvqaqJg}qz97LscXvhK`H@=5CDg>b-^j+n3>Ut?J1K5h2u zb{4Z&lJ8&<@R3iM3lg2P6{Liz7sPAyCE-g<5CSiWsmdQ`VEsc*6s%FR2?37$E$xGu ztWOTfw`HH);@gt=2#@+Nyyu#e; z62^&!(2EQvNTN;xgGx?B3ygD)55%VD{>z~uDWL4hbr5O(v!IQReV86fr8|@*6Eg|0 z>o#=A%@3$aG_DQ;AV@un+D7e_^h#Nhs*b>ZKQn+vd97uVdb8y0IX{*|#zU-9?1b6` zD4|X%kq2w8VvEY8bz_g`+@xLl0UH=rAbqA^pXp;2t`%REv#ksI_Zf3%nYO&WivBoP zm+_tq&I@gl6#3IB(CvA z7Q}-@^-~xUeZ2<(@F@=*)%T7Ujo)|1L`;4SWqkls=*TzJw&d$j4|P)h1u~uQnz+SRS_yd=2#!JW+vi^=SeD+KYgQI z)ng30u?ut9S;{3EGFem+#pE!7On#Q!VOIIPB@@p>)1G3cJg#DlXtS3;B)Z!q@8I$Y zSR>6VD^9FzNRS0zkU23*?`uF@JPpK+D6Mr*YtAaK9_z{dVV^)$v*qkH==&Tw`RxK_ z%a$+9HiNndk3vI&FkSranIr#hf;dwhd$ybSf+(r^u^q~5{Yj`qqG{e>@$^l@cn9SH zq3&_O`DsAUKivR`x_=YaCP;WT5v{9Jie9X!uoILmngoaJn#G%tP zeOa;lL~E^EV8?3M>rlbApA52*W0GOoIqJmk!DJb_mxp+BxBXP4RI zhukOFB@4@nk$_~`26N6XM^o;2`5U#k<-(+UmkS#DwagXw*v2#ln8pmDe^(y+8oSE} zspI2&!w@6A-68Kv=`aYS&x>XUfZyCE_Vukk=N;M9s=*|gkDd=x-+-EP6%%09jzU)h z&ZE1VH)KY7#g~xFm9A*DNWdhS_1|zOkUTvtHu~7k$ei0ffwR61TUyId0Tz;waL=+3 zra{yH+-WCORdJ%b^B*x(-<%(r7IcAzW_+PTQN2LG5pSS&{UeB)zY|8 zz1md^2XV0)bay&^Ntx;f9=f{K66lSEu8%I%78uw*Jbj{E}%xJ(Zw} zqDA1m!~xqe`$?*L)v~1VEn*?l$bm;nflzmEsQn@?;8}%>7*$=jrZe*WM%kY=Ac?Mj zCb}C~Z7znZSib%B*XaR<(!Eucj8y%;eQX;jM@IDW^#DfsFF-8jk^o@4)Gk{+=!^~! zxRqsnW+)0reQIxhr0Rept}20GT(4Bar8o+29$5cA00ZM z2yWP{2fwSP!3GrK8L{^lW1qtAMe zgL5fc$R`|1Tv4={ag$TBRfI~dha&u1CN)aPQSnB-!j1ZlZZkvgj>3LzKbnA*)&dT3 zzM{qG#yE-QLqo@l()hT-AYgo0FZP^lFy&;-cnE#KVn}8GGgBT&1)Eb~I5bgDd=JPe zya3APHKdKAml(p7f04F(v!U6KyxoRwTnEL@Y(Zw(OFZi*8e5<-d`D^ah(wR|`TZnM zT%p^b=RhJ%$pS--Ec(81@i9uV3V$S=MD-#0|Fst^>1kHZQujobba6DNT2kzHRi$un z%2e?DCd>-?_NW+UK-~CD_=42i9J6=A0q=$)cuHi2D0>k2$m16o&goi|{jr~_`mn~=XPrp1C zVL_YP2aQ$k49gIW({F(`O4^CVJnK%PL3~h?Olq|0=BN-lk?_g9Q z->SA7WwGAA2B&n)g|+m&wrrU*IYsUO`(7O`8{~l`Y+8aztMdnHma?@rVJYD!z+F%z`zT<*y<2#|o?)DUqdR0xeX1QZWQnrwAkU$YChavmtbv-kDeWAW&oB7+2r4Zx2;Eq#q6obxx9r0lQ4WlDUZ7h<#R{Q3t)TC3` zr}Y&sbJ$XrPKG5?^#O=&nFbkDBGK4X>Db%z#KUN9+YRLD>AK)tH(qxP6d}=vRe@D8 zwJk-W<(whlM71E5!1P(6;9P_krY?m~`_KfePIXr{zeLBaXKa;|m&et4 zwkuAo8jz1HgiGq|yS3T&dz>~l_>r$jIYmyK((4>-&(SW_6KYxZ%4osUIq~rhR9jn8v z%wYxC`tM?`*O=Gz{J#veBD9wMeiB;W6m+?ME$0yHbk|YV* ze=pIwsSm^-Td_nj-21T}$#vajBv%hr%fq?j{K$wMCv7ney!}|l-+h)%2$g+}jt6&4 z0P!_Bn4)~}3SyDwD&5U)LG5&v*A$faN>>ivmv+0qW5GPyKOM3uCo^kt%Spn>)FFH; z#Geds;A@oJ+=m!S^9RwZwfiB$1wZ*V{$dTIs`LlsApl@66@2U5D*lLW?Z0EO&TGxE zzGP=&Tw*8VxV0F7jZTIjvtBldx^;{hO0t*Ux0__o^naqM#_~S%HY)X9f_LWk5M1#U zY+1Dy|Hi6h%yredKz--bshqii)d*)G`3)ObjyrBF%e7> zYtT^tYzWm`aVgO8_WXUUQwkY71^9i*g&6j$1u;nPlb%V%TGm2492$F>3-$FbOz4S8 z<3*a+GI~`_I;2Rs^=EoDaVMFl-lB1-f#_V$GpX?&_DI|jF0X9m6%GLzKT;dKGT zPKZknu!p(?a8YR*y4(_)JbIqN=+yl-uh`U;&)opiGdBi!HL5l@ORvO?>USawoAAs0 z!`oOzGjhi1j%jDNtpGUXAi|`2R4R>o2Oxrk)?&L68ut=P)C~oySA{~V<}@1Gb411q z<>*EJ&Wl;Oy>bRdOy3BhdTd}HJT~e@%6n(vx8OX8ktZ85g>tXmfw>h#hcaxDp;F7} z1~ZMCr7y-GK>XYTm9RM<2p#TBS?SQ{F=;uGv8gEdJ@m^cCYH2#aOL9uiuGAa$($4# zml7FXK{;z)!5_z8ra^c(;d5*HzCNq)YWYmEjQz93{&E38lyCc*JFyEro2en>oAF1V zFBO3`ba&+{WNn6u&6;=CM5BKh?GXBsc~Tc3_^$r{?i6`jV?GydNK?p!EHH{J(#tl zyKz=EcrRZ29{G)g!dm>-Hk#?QWLNsCO zAb!Z7+wtRWz@}H!RYqPNfc=9HwA=M3B6pkTXV%7i&__~scp``^mpZU@wm3@jwt5@J z_cEE!Rp48u_gFX2>jvY=3PTQ+>)-{)k3fd`hhlz61u5^F0w6XGGVCAqfc3mRCdIxT zY1%_~0!^|Vb{gwY%F5q3S2}J)p<;o{ivj!Ir3F`%#jc6Q^Uye{*%c8y2Q3&pmF;!^MW}Uf@9{@oZbAI z;(tR!zY{X~4zf;4Ga8&4}6X~+zV_2LxZ z(s9WrG`p2S`z@}8nO8TD(bEGqeDjayZ(34@e|kniE+0(IM!D}k5k`|A`|>S$+-4j? zv(QodERbkY2Ncz~?ZBX7N~(WQAG@>qUTh2Lix$$dbz@Y^tp9s}+bV9yNxtTb#kx2x zsyAB!tc=8qld&JJ2l7R#I-Dt(y)*4iznOfKzagV}H%N9EOz7v%MuHy>_}2fd6-k!A z{3ZJEv0Yn6Bw2>eX`CD{H`uHzLLsUHkVdi|xx8Jl; z!6cP)Wymr%>Jbbal!d`2?JwxDs=M5#X%VU`LZL@?95`nF46JUYQVZjT1Ijwj#hl)n*{r$vdPvXZjwzqh zr!y&0Drl*$mg=g?N;LKf$kR2K{3w<=OfuYRW|7pThiORXTUaDPF-e%Kc7f#U#GoVW zq^yY)@nFobN7l)kqAzFtW57=^BkO*Z)v=(l>S0zfyySK778lUJg2vvqRgK2awnSSs z+wI3(tqq{t`N4wFcV%^M=)S^CwcetR9o5Z;A8*&i&mVwgRCu}#hhIF!1(+~97W+&G0v0RXRGqYH;7|<=l zPuItUxJ2{khNx$%r97!%Pk&rt5|VchC8Yn#oY;tGR;@Bl-GV^Y2@;Z%aNYA|=eSD6 z9O_OOmL!L@zD$fu zD`Sa2Ry|gsJO&!;5KT^Qqioe#aIc z{A56~A~Ae9&la|qVm%$u0S<=%-Ir1j=IAI>%t`9K1Bkw7<00Ehp_I*BEjLH}Z}4(L zp2a2~WnKke9tvl?QetO2)N#NTWV?5vFu209&tl!+n8tvWs16ND3H|@gZdqMg;8KJ0 z*xNC-r474YQ1QiU?4pqhJnp0T-sC>U@_;(2ELkpuAmwMpg7`x#z=zx#YnPn?HvQBE z0kw6Y#d@I~bpJ*zdyWpozRIy6AHKPz4(wVRMfC^A1EEh-u!o}GQPo`a1lCCPj!s0B zNiW%_*s{-U0sy;Mo5kWv!~E4O8i2K03lSD<3fImZdlh5bsTjIj9dDPe4N|w<^#5D} zht*2D_F*uDG>Cl>*4QSn?g4i*pNvgzHjZNV{1^gXx?hOhO(lBH#1|Cfn~beb0g4@ zP?2(Vm~|{nAWBDjB~YpU9tjk#6qCQ5h3Mple6%6a3Kl@usaQ`VD#N<8`i_CT0jYj+ z?B*#8c;luNW&i0gpdnf0uX8a*DQC#tQbSVtnp4%fqj}?J(fTrVlUBw7b}3siu%$sS z`%j{UYO7Uq4m{vPnP!eWn=ibOvt4fvqqVI8-uf`2ltZ2wU2E`V1J5Z(r(y@rdnS8n z8qkC3xkK2ScLmD6KBBe78j%${@trfv690}5V*TwxIfIGAQ@g`BC*S*6BYGj~#qwRY z8uLis1{N?XhNV@t*(ie-qm1@lW4xS|C>HZFI)$qNu=PEl&MobsrbcT_rrX({jvxI+ z**5A;ngUi82P~;IoTd^=LV9?k8TvW3_|s?jzCwUuU9=XO1!ufYJ?LvJ{GCwO%JmGR z&--U)V!B=wks%Ms)ZBU5fLih5<^Z30!d4|#LDZVAbZBuZ5~dYROI67!5qQZY+qc(= zhQCf=bm(I9X9nVgIRzaIo57zX1g`xE(;0)E053(v;}5lr@cn zkKZ7vhywO#_Ku+Ocjnd9T>o|`X7@2Dr@&1rt(L=NHOZoZlQI(Z$4oWB9* zdA(0zRBvC^?7{D1>ZPWIhpMnv9t}B$tt*HPauk%U%+!DorwtaVDd^(T;YQLEt@8afVEmm0xetRCS6){)t-8SXT17Kh5 z;2wQ%g%pz`sdArhq0x_hta2euqx`7Am_4)D-w04X27ZD4>FjNo@T#jaRz?CzNjO^@^FuusYfrROqXRq?T7zPBZ7cZF&N8 zM7&^o$R;d+oogUv-8CVuP3zFVVxP)x0-N^sD4QRE_|698y=0b|tAA4@zaStY@F^sf z6UHvmq_~kKi82-?_3B1uixN8P%^YX^7Bq40CVYF@fh?6PL17~sIvH82_&_fQzCk`| z$}_;kM-wxw+fOalx9i;y^}lioAT^X^TK5iPKS+!*iMawV|kw?@XVrTY_; zKOO{=PY|&Bd9;DIK|v_ozPBqFoGhucv)G~=S1{Lo5D@2MZQCRpTO*c?GfI5-yRZy7K%QPuOR8q6FL)+^;MdH;cdBYD{ zONrW%Ea~h`t3^X-z&J*J;E!ua64pKeY7$ES#U%7*(-_wD24+IJ+?aHd<=QF$R_irv zx@&tiVAarsq@<=w25*&Y3+wirof>1}D-*j#_fdSA)gvGq{6R(aH^rE8*RRA>;bvt# zUKT=)92r)}TyToiB7f1!oNnaQ3u0|*CdhltwOvgnkx|N@wpk@QzaopBvWKPjNQ|+M zZ|vyvr3`Ex+KG1AHw9*@U4jms%N}MlStZ7l2f7CmO1r=WiB70tj}&#yjcP0N9H*ir ztoi|8+6+MiN%Zb*)KjtLCUfet63U|%fyZ4g&txi?V&})1-7xZ*9AGyT(7CA-;kttJ ze>^}dcQrQ>>Yg<_xpX*?JobYU8(Yu=Db^qr-|DRDZm_uM{f6@y!u#hgB-}7NxjjbU zOyhbApLon8ZoDh}+F~8jRw>**)p;!kLjT%CUuR>CHvM=Arl`Gn$%;p#oFlzFmNso0S_I5 zj0kWg)H=O0JI~6&5LL6Hg>-9e%knMPXcHegA@}X}@$*T-yGkZLJk|5AGh6uJgCE0S z(hM|VHQpB1=sKA{DRI0nSdfsz6&h;dNJ6!A@b8X7$UnJlx_=g!F7In}cThym_)X5Y z1I|O2q3#Yh147#zFqEFPhi979zs6dxFF1lU*3;Cxf}afJEX@DmQ6MBZb6}jc$Es}=;hWg5$wRJv;!f%F=;BUWP0D#kjhU} z{u_V1Row3z&*_O$(k>}^Ozi%1ckKIZ%h*>{2&=ySrMVVs&==H6%l27NEbF7=E9S^8 z_(uL3)|(+rc+38D0+;R2#!vOkQijXzW2X98&#sw)?>blZ_z<$6cf|60mFKVeswvSd z=vkQ@oDU91?~7LRuzbbHuL-)d+f8SZu681vyI#}Ce5P4`*4m=V8$FPF>BJV5vJX;u z8VR|(d0_4zrB~bjA6;i1S4HzRa9dPtvAd8kkn#do1w} zd|R{w?v|u3R}5j0K9ca~kuJ#5*YU_!XszT7NA5GWx|fUG;u(>_khjRpm(R$@%Mff{ z1!b9}0{Q#ejB<7Un5dI?!!w(rTWAp4;_oov^FM$Z{&)iKwesFLg^Q>=O69ZJ{m2O4 zX6pOAaSzQ%r*0arSR#({cvL2i=F(7&P;LC8;;{G1i*lnw)nx*dqE~Nh4+C0@Y|A==SmkayjtI0L37@D^rE8PPQ*b>I*7d z3dLPMg{!~{j~hs z?p<>qo|{xUdk*97N~wBUtcAxdCZ4EncMJ=}uxRKd6>kgQ?8KoKYgC30vCAFnn!oo2XH<21~_*$Em&42G3YtCj8z7a`rUKx%Y~aZu3zznbRXxz3+C z^250e8cyzgov_gUK zL^@nT-a2MNGi2ILB(T^SH{8Krfy9hn1mPz4fMn9a3+F*Zl~$W{7?z1>CogvFhsTgi2KP z)osB`ssQ}I!JW|F10Pdgs%1}%Z;yCRHBz@4CRDIM%j5RYZPo}=40?=NYWe4UO0N95 zS1Zo>n9HXddy(VaHz7sz!539slEsEpA5S6W`k$b0f&=+mGp`fD%tzq#!&G#g&gh-J z7))aWjmjj>WI%dCjTiEFa%0$chLEV7lsRoHBL2N_2>xz57$k5A8su?e%LmGfII4)$!4wE^KAts&JLb=8MG!4;|1@wSZ5kEX}4)>KjYiYB@pZd z33fBH?3ssIEkF3V)JY~XWTtwt2W~*J!&ev|B|_!qdQJ6d8`#2JALHopZ7mn&x_oXAB@@{4j1F__TQ*+Wl&GanMUkRZ``GWCl|EAR9 zv~gp`5q#hQ87#5R;&L5b!Kl9YOk9_dKDZi1MyF8Vn%}-Wh!(wA?6H@%R-)`IqFj&I z56@cwk7s97mNmBLanRrIte1+iFf_V`K#a_k){ua*m4U@Jykgw$Q?nkxq;;AQyVK*p zp?J>CMb5XKFQKLtv1(~b%qL`4AhYFPP*&S5Vf14T%dj7XN)m|jz-RD`Bz1cU&TQjh zNT_4eIJi&w%oNzv9Z+N+9MOcC??Yc9%CR~KNybtNTcgp^!WOikUo&uTtwZ_mf1E{E=%@q%|l;?CRaS}g0bfP+~uh{H7)9CS_-2%XK$CzlhuBFgma`of?O5L&tmhvO%+oF32ao<0tqofP_`=gQMG4UoE zmsGc0|J5&7_JfUvZKMb+`ejEDR(vtOnS9}WhlWO6Gr7s;3G-1xsPmaR8bwZJM~8KY zSrVxskW||(pz~ER{xp3hBrlJCw`#+1PPL7t8}e>k;0&XURw)}`2wtCzEh&15+w*48 zMux>5UnSEOQ#IR#!i0M#VOXYKgoo;$#m79~2#KHB$-$WE+EA9^T=!wnym~)86z5i` zxmj=hvGET;M4OK#3~(cN(97g+w;2Ele9xOR=Ow9psqspGOvQ7%P#6eOKE@a5xCuu@ za^Nc(16b6Ej=_KIvA{i4vhVrh&Et6w|4^=YCbuWc=kzD_d zXrw>opqo5?RQH_qtB#0=9Ytz4^+YiW6=VP-MOuGC|4B;zW;_aJy~;JDC-nytjLA+I zg7a+06%4l!~YE``!3 zBhs>vB@y#wT|H(S22GAA-!c+oIIayY`=LNo-|0W7*Likm<$iTc%4l}<-;s)|ydF_O zt=m5s$*FLq(!`^%0rP`6B-lJ>oMAq5K@QFMXH0=m-H>AhGb+V)dyAsPLb zP9MI4zR&q1vBg?;qAf&uoS*dqofcTE`*YBJG3sL81qYL=qGW7xgHHR#u*#D+;7T%5 zOorRf&VR;1I9Wl{-2u)9Ea7XZUf5d#bPL*IQqTA!oZ@i{eDBL+Lz8S+XLLs1Ej~9f z_M_P3c!z#z7rXOm4Bu0=fWcA=@(4iPi{W&9u7O)?ngkEozutsIc&79x3eJ(RKS=*gh46*fRBtrByo@Y6u-$BGpJaMSZ*%pSIr zm;%C?^*GGh^RFp5A#-7s`w^y8oS(go(+eNgrd^9*h%fG0Fg7nH^fB6qh?x+SCR;Q! zRm+I#RQmpLE$nI~DxJHhy79%;k#y_WLsJHC>;xeBwoU=1-^En#*>YMJsT&g0;aIFB0Ac&TR*q2MnC3MEf9T(F(Jfizx` z0n3qJ?@uNZp5DVH2O6s87janhqru(?j?FRV6piu=7^WkbU`=c+br3FVw;4+wkUYN zMJ_BLbofe<@u?LR09XDRKR<0im3ulCQ6%Nogt16eH}v>OwJ@e%$RGl`weig=C#q#^ zz|j>*O6-w7Ke33F5KTn8;uxElh2VnXZrR~``JzB;tlIZ!@PVs!5eIi=rlBR#q7MKq zx5b@3tDF@1)IjOk0U+zJY3X(FHdRoUkP<1(X=@L`719xS+rmk z+^DN`VO2gy zzg4+w8}BT;sdUm`$UWf!etW%nuIL<_`>Y@8O;EPtEiCpSHN=w&W~hbgnIg?D9hu$m z`4c<9%FAI3U9MP?n}i76dd7ZtDFO6oKf!b+a;d%R?~A;!So1#5r&JZTqV2_Uj94~7 zs9bfAaY*_)YFTvy*C2~Jo~M;BCNTLXvx1;W^eE{E*i4onuOEXtl&g<+d87VkG;3y% z+r17mI-9TKT1f}>pD2Zz9$Sr!PA^zDtaBwLR!vT!)fqCXl!4gdOW2PUw`>j6Ns+MJ zZ7lyLd?kRRKl`heKkmJq4(q2j{(PGZR5~|x82YMuc8px-`Z(ZEJKz^z-%q$rj>OjG zB&BWblP|%HikwY@=Kcj$wX7T{88N0K4&(y-d%Mk$`|B}mPjVmFC&Do!L$cpzSPWVc zUrLTQYPxw8PPt!z0nouDg<^xYXRA_k{H*%01bq?whTSy2ep4LY{kxZj7VWHOP=AhR z7p7YyG4HR+)?v(kqcN)(N}DwAzrB*K}Hcdj#$_&xhUiT+}4BdXVrb#2JaG#uq2?^5+J zT^D(L=?W`<%1tfbV>W2`?nMSK|E<8{j2~b!Lm*Oi!Nb%CE^AOhxiUfusld(0ghd`d zAArXX2LZu?GPPK{+QJT^^yiB_e~|Nh-*D+dBYR^o zeRn3z6RHAVdKZ+8v{7@t*@Z`Ha!nb{Fi-&w56@ymmB^K?KgM!cga)6-Z2G3Dt!Mwpd#CdF$9clENrMby$e8_8tC{cx~ zGS{2k5%pg$7~gxl`R&)Y0XRyYCTF|%1io+MP29J5jm&gXq4wHY;g|k3>ApZPWGnVV z!=n59P_4wuACR7C){*K@!Uw% z!Q${nwQ5lMW_oU^OkKr>SiuQgY$>M?(Ut|atGXe1;ia|^)H#|cHb7F?{wu3BB{elq zVRrql$cAxk7s-&KhRlSZ+sAe!qglJfMy_lH zlOuJTsa1q~2Sdi`7csNt|LJEcHKrh-`K~9#S%1wrClw)Li zWUCpzMRf6k#1qwZwsH!^2{$a0G%kOZGkz?k-aa_?J9$jJG9e3HO9P*f)XcWN=k-gKY3x`gX`=d^Y_ie6{Wse;=dKVZJKfnKUUt zdK^X!c9b@`Q`#u)Rn7QJ9v|$4nRE|Q9WT;XP>E+%N#NXMI zNN*VSyg(9v#@GkwzMT&g)ZJf}uXfBM%n&vNqEsj^3=Bui>4a|Ag`bS2muGj-q<;6m zQjXuyqJ?7aP`JAaPgR#&*5-duA;wck*pklZsLNc(I0K>~)xWTByCJJ0j=lmf%s(-T zX-X>3$pUam%yF$k#xK|4XGs~y#nB@HqmDXHb-daoE{`vlH;WD5Dm9U-4+qn{QRHrQ zLqgwqB~@R5uyajqIT!X+C9c(};md~PSs5`R1=*thDHNZzM|iK;)R!+rmt32TLobi* z&pF^Vx5B&b>Dk)Yr!JOGau?Y4<@OSH9FaaHf1>NUQ%=vSdYo)&)Z)4lg;IVJ+A*RD zM#L|q4C7FnH%9K|>OMwF-067JPSVhS{=ikQqNeopxq>Chq1HGE!aAsP{AX53$kq>K z4ATc-dro>+H_oxXA3X#XbpS&Ti{Qx|=}VEszsWuGE(LQ+M?x3^JOJYtmy#j%HEBmY z!qbz^$TqF>htYaxUlV-H*Rm+1#iTA4>(0d^4dm!053)k?$^>ttZ_)+5KVFxc7+%t# z*0XzzvHw?BGpjMvxuMtCgEa;ezpEF7Sgpfu4c-ahXV)SX%Xd=C@1H0hf%#We^hTR6 zD4upjn&XW$r(0KSP+EQyk=pM^TNvd+)0nm_#cKrd)?>)MXcL(1pNkH*D?lL6`k@Lc zt$!ba?ZW{Y}=QU|i$PC~A{>)6}_ zK~yI2<#sSMZABno)Th3L-XQi>Guqu7T}c&2W*%h28EY2tTJ;0yYgZXST(s$bp_g3w zOY#E`<?Zo_Q8EpZHXTyDdk|p@I=_CqwpV@7j$YE5>!BM=}EzyXx40_XR=D{^PK~juIJo ze9c8vm1{UkL zp_mSVRIl5V$7^diaYC@s;5$xbZRIh_2}5X;<*3j8f?D7={pK38;R@&8m>8@QjYIxk1n z`>VqskJiqNd;U}K0>aXIu;b>>W=G`{CV(0GV%bywScogshzddnWBjEbl@ctsSg)w7 za4XpEWF|P$$Dr2D_Qn50t=VgVUOv13=owz$V_{KRlXoW*#nF25Zg1M&7VDF`IOTGI zm%P)rnM;h~Yr#EGwF|DAhc!JXi?v!+c0OoJ@Xo!b0s&`uUb~ZDDqlu-yNckBl5_L zc3jk(RF>{kkW7Wqq!yp#z*j%!B*jd7P_)UKQRJO%80%UxS&ed1xU9M{_`u26$`Zx8N` zAtP*|PnHqO+3`Rt2G!0c@$M}{4LACQj#wLWNb9fu^k`lSV3BsOyg{X<4?^9fD3cCv zSl!tADgiye(Um}GX-5=e%|`g%m+F0I+15*t^JzA5t^p4`E!IV96+>_DdImsRt#3X9 z84tXK(s{>&UEw-e3!(Qf$h)vHI6FP>VkM^s6+~cXyEB;I`;KY5UNk91W-jkQ%&1d@ z5#4%~8_zu84}&Dhl^r*dlD~KxNaAg@6G<^{HUxG3ZNPZr+9F`+>=_Pt84G^`bLBZu z!J!=#?6*t=g2LCYFk<=p(*s8?62ZP~gQpO5;0YJk0gNn>}ig){znUHdaO7X3v1n|Tg zp0aT-Be{o)g5A@JkSHS;&?MDHof{yj_3Dy?8|`A`<1rRn7k<;s>3$0vb>W&y-sl0@)iax zI(K1a)M3M$bl-11O@6&ojs+1lpv>*_)$pZ?BgB`|2o7KdJ$iTe;KfM&E)Id78P$TQ ztB<$!Ts8qO*md22&+VlhHaRu?Cx@?Qh@iE~9}&sd{>Y+Yn@zUqkXL>%q6)0 zxd0WuqXH7J`2_s8)l!BqpDUG4m<5Ne`For-6Qii~`l7U~?fC;=%JPH^ss6*_8ffLvV3>ad?aDj(EB^yz4;uGLWglLSXl(%Gv4pxA9Sn6s zt>?DmPxp6+Iwi8jQV6wTm}#~h)KcJD^HB=P2O_#sxY1>VGgloROP$Tn)Z1q4`PLdp zyyV){$HdGX0PZtBB=>0;U078e>{hq}o`0vi!3lcn6JW~wc4uL<8m3*}jiXd-`2sI% z-fPrc?*Zh|P*urK%~u|ojz}$k42E?-^w33|kDK7?cdlyxZ|Bt*peP5N5-USZ3)eTbU$w|9!eT;HaSa#m$ z++NXmC%&k2%{vxr!?16R7Bez>l5#ma673e1@p=lDSl-EB#~W9@tcwx~rBMY(M?mB{ zzo5s(?Kun}{Z`^VgsH>O95131jm}UwymJ<#Dml?m>Koxdjfww}h+pUdq^wj`u;y-4 z9qt?nTRcxpfA@|72ls0hCQvpVgmt@Dl)>^;wrn?Sjy~wJ808P* z;F1!XEa3!^qgESWhHdF{p%iLX<@m^=S0B@_dE>jgrE^t#e^NLYl)qgNwrGQZDKyKvqa%ibicO{Ixnw&{qf3puVJtdo{Bk z@~HpXq@s<^Mff&VB2y&S4}ztnh-(fI@g)_k{4k$JJ3!7#gr-TM&Vvu`YBK-)5$MTe zZ4$wR?_G#XJqad6^O{_rhA?K=0L**XTAYp-b6>@#{Fl}#41KD&KpG<84@qM<3apst@o_=ocfmx1&uj0-MGHuOx}%A3$wnYIL9<)(?Bol9lulr`xi0%e|Hv15i!+^-84@c z^(lmHG?kIY{nhU#>h4yCJ8yqO6J|>R$L>};q-CZ*+UYyn9o4x^o(|ak4Sx!NakY`$ z`-+W0qbKD$Fs#3gb&;A^*9*l9UxTIV9B5}CXV5k$V|~PlFgBvLEWMA_;Odyu!sh}A zb&g(dV0T;c&p2qV{f&dHU#63&ktrV# zh&#5=GJ(UtE9gqEe=h z9`BOB!UODC+0Y&4|2cVzAxBbJ~*HN14J}E>Q-E$T1)TwoH+K0r8PoA%1tTqgqYesweh2_M2jsg)HIaMD+!73_%Samw= zbHxdpEDdcg2*;-1OdBKjKz+FhuFkv{+*sp)Ij+7d(5U59Bl^2zfVc5jQBA^mr?180 zw5yn>rN0r$qyeZt?L&NV7ZT+YkG+xk02 zuu=1vHxrDMG^EeElE-*|a@4S|x=U!m`Buhf&ZmBr`WKdqS6Dlc|1v(|MV>vHf=#w6 zetP!sYA>rp$QdTqZ`g8TWiUfLyA}Cx1NN9^Y}%5F>=d|&o;)(J0UJlRx}zxUN*X=# zu1SEY`v)9m>7Y|9^w4$X-N>W-jgLYqZq4D_kM5M4sKTF;aZp{U;IIRlPySnqJuB1FyW@M{fBvRJ0aBXUje;#r8EXNv>C9x%a6L2`u2P@zb&W~qr9uG z28t1hp^QA9%T*N7aN}J_7k_+^a31dtMNve0`J%oSm?~q!ZW{U!jv9HY)`Q5m|L@(7;_K#&XHxH7 zYfwL>I{5KrBte7(Z&PCUQYKUlnbjAs7+C6`(-v=8&+lLTE!K83{S2rEr>4`dCP4UX z^$@E*iKx`~B6#^7($1)_QXvncNnO39)+2$*E=4f#BSxxdzZ{F$bGnOTc>X66l z_aZIUR7Gzxk$V%XcA){tnH5HSt;OV~+`ST%9>E?00i=SC9bf@3b>{7W1E`U;BTBFe zL@ja}_JgiEyN*Nusq--pwOPu#=A^$lo@cTr)ANtXO%{qGYmfmlZh3}$jO>l8mTxRR zNS>|y3Bt~{Ahp(FGMv2od>72)0C}{dgGna$ zGrs6vrJRwPc7~CI??bHL^=x2B>Z?~E+{NR7Prvh0vCShs zaSX;h4=O!)x~x$y#{fEWj-z-#G|yY$Kh~mEPMIM^){a z4VsKxx?d%vGq&vI9fJwQXVv_o*YQ;Lh4nhAOIbR##3U@}00X!8tRBO=R3%G?^ta-@ zHo3Ec+xq@Ob7@lMAb;AGHVlx{4`&3TGmBN{WQPU%yZqoh!QJ$?%s1#8u@TLc{V-yB zCO_<$rzBpaR9~|T3RzM*fYYp5D&yT1&vZsgPXkKfdYg$Abm})*UGWG5Td&y?`zpGZ zP{|=!AZy-QjNX@`cXRwk20N-VM@tTacqKl&FiyE*l2Q6`N}XskB^s4+Dg%_UY{>^d zAHs;dDwhqWNnIV6#1R^ubZ*MAEC=COO^;Nw;e-A;AWEI@q%P)!g2n7`l-`EJaJrnN ziftkqpmF-8`Kd|HDMV%m?oWb zSQ-1RFM{~B!0~E&1NL73@0w((fWLcBP{Y#N{TyrX@4bjt2xz`rAfEqeH)2a|4jDz# z4QS1_|AVH@ z;w#WD89P6k=FTPN5G=`P^9)#0E*|M>)LMU;2)_fTE#R=y|x2<;O$M8- z_XWtC>JI6|6F^<(GH8Z_pWz|#}|tWsOhdVA+#o`2NPi*4-c3j%mG7?Os%bTodE%k z9zl{9EpsGF_XC-kxeML-os~OQ`-6#?fKLM9M|@5n8^szCI<>mX5C$gI(Ggdj&_ms( znEH94pQut(QsfIc&}^cIY2L-)F7NEaUj4aSjJ_3jNy^mf0h~MeB3rwGu?bNA_&bYr z=Jvu+p&%+Z>v1&U-%<7)usJ9-^F8X)a?MTQ_e>njE{-zF+XsYIo^h1T1RLPQXDcjf zb%G5}U?cjQuDc0=Ol#}8PXR+m#dXYFMhSXZg z*i@10Yls|ymw+M(P#FVTu;%>^^jJH#GS2K~q-WN|WCS^GmC`E-ZN2)?GS79s^$bL6HHIWL zunV9aX>Ga&J?o$zE~u!%y;P{Qww1~K+$NB*kXk(Kva-EV!LyBEcYl63QP-DhCVE3Z z9<2+bcPe1^Lo#w_Gb>0OA&5Zrh0imQv+XfFsVj(=y4;F#nUl379J9<-l>>kVqa%nc>+s6AsN9ea%ltl5fE-yNbB}Sb%c{ty0lWip< z_21bX=am12m$i97PKXzhnCaGEK%Y1jYN>BH!nAEG>LIv2ZeElf!B*dMah3xH?_XuH z>S)UTfmA=$59t|}lW(Pshr!vPs(lf{Hq#tALBJ2qFvbS*tc^OxNds-Y4P0ec7kiJ$ z3!Ymq69o`Q=yvQXW1KlEmBt(TiVVsXvXa|D9Z_YDWR+{YdCi!RGrje?kjahGvT{|>B1#<$wB zsjz1e2KV_3)ZEiL&Q$P+!RQGug!4^qgx%^`32!N~CS%F@*nqtkz*X8|AUkn3zN+;MTd z33788bLW3F0wJYkU8kn5wmM&U=}O+6{!4p`2& zC2i42!^hE(+c}g`xps(sr7abqsydEM%KbT+f&Lyp%?j92CfYl;Gqv8OPw#1r?!rooxpq6a&ftQoMG<2x7d;tZs zjly6^&6l=A=`RH^4$JcdvHRyippnrkzdy63%A+{x$#_)`(2~}+EQ5mI{EOn`-$G2w zmBI?mv9FJC&T+tCIDTg3MFkYuvPCIkPcCMz5dVQ@ACthv1?Y2|rG7sj+b`bucy7GAjA5-s~@N|so} z7;!mTKCDw&)fx?bYyH$>{W0dNaq*w?xMD_zXV!I(Kf3s=I{i$p(U9E|Qp7#amDpft1t7TB=Qy0~zY*Od zheWM|h%FW1*zD!aL80!xUNe7X-07d9NADe z?~`X`Dj*bJ<_Y|@) zqf`Qn;WQ^Ugq|wEIskvPysqkg%QuXG{%nA+5+af|RlS+@y^p3DRT(dQ=X5`Ol{{vB zQhkX~@E3RsT{1u1G+C12u^b4#Iq{i17JACJ#+^CvhP;!%>pd|El6sQ@YoIeyomPS! zqdYQQ21D(YLt4^1iII`JswvpxCt@@os{4+|xPkGie*W^AQv zc4-->-DV3Qp3arjy-=$EsqQ<@f4q;8n<61u50%8hDaejBsv5hSKu5sMsE^HBR;xcXde)Jb>c(qRGd>#kqsRhXj)l^Nst4>SygxN5a+4dWH*k=$hWkL0ov_qbkP1-Pj2>_#H}qpsdcDbnKW0;|uxC}?mB4A+To5K) znr*xBg#bBoCo5rO(b=-?8Ck-e^*EM&YE89U0(wjyLVt@Zhq+>A#6^x(lFt(U*<{o* zI%Zz{#GL;4*>B^RyPfK23!&;fb%c6?I+bvll+KZN&HYWjN?44~E{7wW=Bt_X zM0J6FWs>{^@zrtP*Bho`(bPO1Cv2jKLv&g+l8X8b>nyqsHWScvDD1%S6c+2rA2|Ll zrSXA%E2d-?CBHOI;Rk8LV{^`cgaww=KW3CPz461&Q42k{Oz5MXT z${7S!tqTUlCj39_z8qt``?IsI@maH>z#`$^B+dZj`e-5H@*@*1*67aI!CT`w3gzCo zGQ9I4dX%^AJ9ORdWC~%x+%T$}f%fo7(D`FvAyl*4__MJSvVYdKI|62Z%$OdvaRy<* z6(ew+-=PJ%WP3!+ca;mME6Uhj>WjAoPu}S(kypAjGs@|4o~&3sa3#*bB;D{J;-ZV8 zM|4+US)*1AN-d|t@t+r|Ge|Dv>Wgktx+Fs-*jonL4aUQce&@k~FhhZ6u4t)#Xw3Ro zs9;qZ_`X+b$nE7rO0aS1IC|~kG?YSz$>%uk>>74UVD6M34@y!JYpK}|Ar74_EDF|s zPDV(!bb_=}ME}K`q|q5VrI(S&%*D@)%rgnNOO+G2PEAgZUTA7ye|5XO!$k*%0CWDU zvv;YJk*9BmcclWKdwCgt-9R|cF3g6{)fOxH{50PtnNf;ZnjJNlo<@C+>YU#4YO#WR zR%oW_`b9_ZMczPg8vI(xR*Yqm)SRIp@#9V8$k7cUdD{9KQ8hJ&?Ytf{+v8p^yjHR_ z99DBMlTBYyJRjzx0QQU~cfnUHvohVvQu8rW83{^mt^eZnZ|9XfN+fH`?dGf!P)Aj1 z2*%~1s~6tWg;5u>v$c!sg6J?atiVAXl0tJ7hgB?9{ms1tM zG*K5on9QumQ=ipy|G8KihG!Q|#Q!n~M23zXAfo_6d3iSq5oz9{a|Yq1qWtCu(4jT%}V`{9A!ZLRKeIDVpnkug-w6U%44 z)s*DVAnfJnIzu>U;a4XE8(x3RCNGqPWKwwU8DxL%We6IT%|uD5d;pX-+N9vCMkYY= zMFBVSvCW@iMlCkg0AuhDczoyijF7t@I?=;@n-}YRQRaF=MwT~}Dtq4;dAS79k5$J; z8t^T%zz1mq>EQUlY^r7iTmNb6Cwwrt?}*q$mU4diT6tM3r1oS06AUYtYim+raZDuI zqRs|ta3~Z0Iu{|71v3{VtsClGNmbPOk!3e-((3One6-)?R>{CX@nKmax7? z8wppLEbm()j=emuws-1I>(muL7(K9%$uC3B{bss)4An=SrK01E}j2%WJ}u>O|Zc6A95|x{cDBR=OuPPt7>#c72_o z;Ms!EE##92I=cmq=9^xG97_gacB%yVslt$~Vp`-^Qt~IqK`*sQkHdVHjQi_QjH}gE z9kUuEbW;7ob-xU7v!^0nC+8K&x7xnx%aSBRJAAq!NuBRo$;-OwW5u;s~^6X>w_ zCP2CZ;e#ui`x^w142RyBeCLc{^37#f^g`BDY)CGU$f&tsqaqBYYtp3PrIc64XIbq@2%sXjJD>ALDV7rzy2Lv%+glMwqO3x|$o}r}py9&uXfc z&pA;AHaCi4t+HY@e7~W?ti_kW5o#J?MT!gtXwy!Wiro*1;mJu7=k~&S?Vq61^6q0C zF93B3LfBKNgMTV7A~&iWfWNdsK1$kBZi*k#M*r1jFXapU_t(zsmVW1Pm|FCsq{^J9 z+{fZ^`~%6@?m}yFHBSF*Wl?T}XiYMTg#neMj%H<>^qLWYv4eKI@3uBN_^h`N_fPjb zWIQ%rn!~_>Q1H&WNnX}(BXL*ne1szsIe%a<5JqRp&rHT%9O!{WA7N}+FSgCL1EdoM^LknW_&imtM=jYoa(baJ^Lg`!Q4)Fb-GgQCh zGLt#-MahThn9*s4f?E3wHP^WxOub_mhdu~t>#rD^m+)dL>i{gF&y-HAziLcA_9HzZ4^zcz%MMyb{L zBi&4)^YsGL!s(I2l^J)`Z8-=yQ4-< zCN_I6d$nXGN83G(n08NE(|~lvp?-|eZtliiRcp%cwun(*F(dWJ&OPC}r)EO}mHl%p z$>HsIn~NeKl)StB=LhTNf{>Xs+uO%Rgg!^9(K`4gdPy|qcyJ%1$g%HC1InrrLHpk? zd1HT@x-87)5uF^zUtPkwuE_|)EDDO_c>t5rEycrof7~EXiQaHipF1Gpsags*eHoH6 z>UEG&y|Hx7Q~(CuKyihMAXa*0JDZ+M>flOjXses(h;PSCeI*%%GMesDqU6raNO~ zCRE={uCWrRQoT2}P#{3;A1 zD>p-Zsd4;~od`*=ovHttW=r60?YG2iXpuVhGj9TDBw!r=hU7^q-rvUOeBl)9AR&l% zb20A%)sd^u8luhe*Ft^GFA=ALzHzV5TJXLu!9quh+-(eK!Rm&sx9V_gX>F<>;&=RO zknx!C05Yh-I2?j9ui3^Nks2&&a;zTt%Sh9hCL*`dp|wm!6^BOpou1T-S4-ncDcOoW z4J2OvgH4fKJ>_@U8$YZ`1j2rBDh%@AXdLRvaxLP|f>g#y7H0OU42LB{S~_ zq0O$gfeGg?B5D+u0T%K3*BIe>;2` zyF)gmV0feM#Jq8d)pzN#LBRzVz8bZhE0Wnr)d)?^gv?LWdgzpjSeNT2O9>9n?xyBp z_}A84OUMxArssrR7gxdUZuEc)u`6MKLI&d8h3zgF8xaET|GJNP-$7quLB$xF7kyIz{XG1Fl;;IHVoIZ41`Kp0Dv@yRX$w;HF7C2jc-Pr z-Inw1;T;8yBI$Ec*r{4arjC}OK?dBRmtDsq0bYyXh*|GVPvOrYe5A4Jq86#S={z$- zH&oL<1i^6tsuN}4cCRGhbbL16P3#E%nqJI+*sBc&WQFp(n}Cm&)A^=Dhv zLIMp-uA>c&bN9a}$67Mv^zU{%vg02pSNEsd$uAXrov8@AgX(t-gSn>N8e=SX@^_eL z98@OD#p3xYQmGCs=*CZ2=%75xgmky6v^n!mdvc$6Ze`o>XY@~+NXI@o8#b=7z{i}ky~{BL_^ z!tVc|@XK*1P{~-fEDk;7o&4P!VycHob;zjB2vs+?$z#2(q@3qau4rjgfxLUw4<2f= zwm+dJfAign9EFVB{ySP4seL0**KHFZmzigy!{pm=bv@sK=Eza*BXoc3PQqwTPY|J& zOU`k|)+{d;&lMZX!MwFeX(vCOIsjR`$ep9nu>}h9cr>YL8@IGBVGuwQ)INu!@orn& zWayXTG+C>nX)v0m#4+?+f!|cSCOv;U4>8!@tqxvGs&OoD9cxPW-xx&Pyax{2R4JwU zfegxN5KdJI+Vy!h-OD<^W-+dAoV3et32=0;hsl5Hi%qJ1?;E&K{RS?H-{sF z*rjz*T9VpajnqLE(QMEyt?{n7tSyTfKBOptt>`_sHgVRE6^Mg$yGblE7Z}oabI92a zeMUb6=4jX_vMTcX@}mo0>3~}Qv510_j`>gkJ%{@t?AtNI1%?5$95*Vto9rQS_h6HY zCs**GgX&n^Q;Av`K+brNR7_SE@VP#wm(rOzuMiDM$=|tPe+KgjDbj4HDeP-%L9O=r zO-Ye*m(Pb9pPjZ3i(P@_KHW%@-e+NfLT&oS&i$Q)nssZEin;^?$9wmB15nNV00iCY z^4GNFI)m4wt8YqRlrr9DDwhL8_`WoDOD5=Lw zS$EjWnmZiNLvQY=nhQu}Z~eLM$Vc~_6b4>(~nXCCRMktLx)LcRvs`l$mx18Ym@KbAYB6U-GnD_cFLk;R7?d66mA7T zJ~eoE`C}0b+R?rSI!CQEcl_&B&(dl6lY9GKU*|JTo}f-trO{b&D>D6c4KlqlFZM%! zt5bsFcA?D#S`;FcmZQ=b5i?0As^$L7KN5Q(P6`W zBS)Rop@O;8HN?Z_!{htA0EV1pvF|BG)+I!6raWb2J-XR?b`K0d-JDbd#rE&bn%^g@ z#9UiS2rzDyH>&FF2Dmes4W3{`0m6KXGsnib&!L3IV}|th5WWl(0GXZvuJkP)-^U}j z4A_Z!q5#kCMU|itUaT?H{`w_tC-6yr`FCrv=Vy7h~V|H1C;j`}xQxJs`v0O1V zp6YNnQ1ZPWfX{e=#vJ?uzdiG52s$t|J#s8cS?y)sF*}AVbV9T*F`U^d7lz016xEVFS_bh?hoQ|RpT0Sla z?t{zI>EHG+lCD5fsrGeY5VH9<-tgWmD1cGWS2Fr#fLUkR!4};{FZQzbeZnlt5xZnY zrItHp2REEXdrL!8RJ=q|!`$JG0Sg!r$fNv?Xhd#`{@IOpenU+a9vnTDGM6 zH{rN>8c6B1ZBHgK^DEcj?pQnksW0gS6yxnl=m>etxeQxd=N-t_P3lyV`KFTI)|Co| zsvUjZ3!+POkPdDWOE;kru?5_?(8o-2r6 z>tB*ptn;wCeR}0mMaT_ZhR#{C9!1eSAMJkFM*Ql;N*OcZBIazI+n|SzWNEpKrvz_i z@i7V?ZHi`)>Lqxq`ytm3EMAH-x>5v>qRw(g%xh-(s+naLY`i~e;%Y@nBq~&GniSMs zy;&09J3j1%nuT0htA02xp-10oAlgv#fj?58N}lHH6DeCOMP9Jn4>Xy z{I>cA!QcxOkjB1nM84AXnOao`&1^Z$pFnlW!SoqAjs~Zi$U0R|4Yc*+&erA^eH)Xp zLLG_daIARjBH7eUIX5@KoeN%?Y%3KsY6mt390hgqI7_X<%=ao2JLOg3S4q&+X}T_C zg?M-1yPZ`@{4|Vf#U0T6&Rf*iUn^hOoy3PLv>hGhw*u*@tem36`%JV;&8yo|#32Da zU;Y4Qef?Zm1+?c@o9b4^oU^UFC8sA?vOmyR0@*m zt%&j{e^J1vh{p9artuVdK+EsI~SC6JPo+bra-MV^GLnQ!ANAZuh0@4 zbePSix_DiP_ZUyqwhu34vF422V}P5YWmP7D8(lLV<=c3YC+`aR4>pR(`6}WmL|$El zUG0Ga7}OUX`{%@7qsYJ8Y3hOlA(?$XRPOkIgiNVDCVr5kb7p+Kw)NZ(a^ zN%{JgWme%J%r$W_OmiTFlw_SxLP( zoQVzl*GT;+NH5REEP>MBj(=hTUf@`eQR%7Dv?fKOToEu`_@s)e-N{7b-PR)UbAOrY z!Tqr*0lM(W^roH3nv!71j%&q?s?N7TMM^p6(s3lT{!1#oH@SA~CjUMQR*!Jg$j#>1 zFGHsqcv@R=Chn=d!&@8Og?IC#;HA=alL{I08ZA21;B3*eQ$uQ&W8XC(|X2+Z`^yc3mtP* z!q&#@ZVha^!e~<5bKRtDj@F0DwCL6Y1~tBBhs3 z3H!zdBcnRrw@DGKi{vIdjiE|38or?H`wRo<6e;PWF4`aCJTOuw1 z|AZR3z7p;{mG>MgLhI2t39_YW*3|e+9QsamsYPZ`EpL$I^x5;UiSX7RFYCiLaQl`A z=&PsgKj5xj#)WV?j<+sg=)bPV@yITw6dpp=Ybr>}uCh6J)bWz>fn1BQy~qRu4E;st z;k_AO+WC4}@7SU(bVlGA2BUcctjywFPRU9!iwE=0>adv8#Td%yQxk(1wO|ot2c%-_ zbw)q}WWv=Tali=N1*vb`@Ek<^+t5{+zHlEF!JJp0d~jV!q-TB=q9{3Rj1zdtp+2yOzXcHG)}Ntiidf+5>}OH#--n1(cX#v2I#* z&p7+;>VRRaJ#X5bHB?to$St$^vc3IB5dsG4gtfx%5VZVO3{~nfFzcL=WzmWKUl8%1 z0l!>-h>$oOfK^6LY|TK{f|Zp1cCJb8sPN3JN3VfKdYlaD@m06}Mz;qvDdIk_vXSz> z706bNNO*VGs6NKK;%6w=^bB8Y9t$?IZc$F}8I%8{0)3~Mpd@N|O5#%gW8s~qprqvB zA4#39Uq!Ws&XL6)Rn*}cMO$=^jd6V+xRh&f*&LlY?qv!UlM zHtsoQHOl?Jmt8bHh#K)YlYtZ9e6kyxUUbbe6)K2v9L6?4Mu1m?ucNO-WM!7tLcp9Byj2YIB;O!yAs>5K6b=TDhgp^Cw zvm$O@`M-wCdE6c092rWvs6Ea&y(;|P;$>PQS>^LX!3!A3zE4p4%lAR3S|cg9J15oa zCPR{J}0G?otYP zLmo#wg{qSBe(7qYYD|GK%UYY!L0^>2)COi@U4eBhpGC|o$J)2KXol+h`P)>N&!k9~ zm{TqE7cS3%PbPEvq~VG)*HrV_LH2tAmZ?E5gKly~h)_%0g?MDtuDGmI(7DMnUq)wp zZA64VuQBTD**q&_2+!m&YiI>5`xORfl@imLl~Z7!hLe4;3o#d;>igNHQkk1=`u8J( z*rSH(7HUb3qa2g*g2kFJ2ZB?&zo}l=WdKck2B2SB4Z-kLYX~-Gy}|o>u`&ze-{dpd z;2emt>0IOr$0Rz4_JO75brIR3=5`-Z&COBVw8f4@yXJK%G*b{7*&L zc13FAopUhnTJFKqa{3)dqstc!zR>+@i>S(TH{I&GlfnXN^?xY^AE~k}=xqUgEjAIC;jZRCD?n?D%85KxCE$Q$w_(MPAtjLRh z04N_@lVlp=W%X9a^GJVR)ImpdIfA%&PK1oV^0^rqd7Hc7ZjDFLrhhIhh3Lu``xC-Q zO}e9avb{t!Hn#-Q+Mn>LS1&C=fJAM^?XunjL*IghO3xpaCFq%Rq@nfNHYZr!eUuH$ z<2J&;E85gy8qo8r>W5kV!)kS7Q|3gkVB+Zg{BW#0!X;00p?%&JLtvk5fmIUNL#^tq zO@_;FWU;;(o!HAdArqW^W7!YFB14eI ztZz^Xhy77~*MgvyV6EkJbbHeM@N-aB6Euy^fWq+5s%5CxNS~rO!uyM(JO<|_dKmyV zq@0@?+`8mNnagAUvb{jv{d&e@uRn1lW!H`F_|)F!w^55>0o|IU)(=GJa{mDBv()m7 zc)9X8Bjywtd8}p^r(B+Piev{N)C(2ga8-+BKi-A3H&)^zrYrN!;Fn1Y>3Q(scp?su z|0=S=%i63hlGmlMxpiUgQp|Sc{GhXC(FaW7DDI4R(y~~;8Uq=02`T=U8y_Yb7d=mmjO4b8|5tJq0F$PXVkG&nAEBX>D%&0_AGL0 zG0kFiFR}|K)g_Zr_-1(}oUi*5h)?Z=DQX20Z)~G_!T7^qKO;3Y9U1bji8^$%;cS>| zRf%+X$gafUZta2!ZO)|OiL}VZ1Jz$)iH#v)kW*l?oYL8LsH}Rw}q9Xo#Fd}ed$aRB);JY zjB8kuoUG3&{E*e^*+v`WkuR2g?`?eBu0MdwyYDwlR_?JM|5@H8Rp)4aSG%2>ui#JK z<*5J!dp+W?w`9y3M!S-dKZ)=q8iC3X3XaPkXh5LD`I7N@;sImUQXb1cp~<7q!0zW8 z2-7=t^ZU@dP%xqrcy{T4+RR%8yliv_+zwTs{;jczab0>AA+j?N?dX}&P0d44%ksMR z28T(_@+X@dbwpD;hEXX{TDqK0$<%7~Va-_e-0vM~X$v@ARVe@mZSh(T-c$2O(%;K> zP%<5g0^gZ4Z1*ny0N4t<57h3cWl$Blx`8hr+x zhdP0X0qTt83n^<5lc_`QZnflp&KC^OjAxKrAgyaJeKiKi85EyNg1A^ zSz8T5k~NJ|scFTAzoF5A^SE?*Z&OZk8=uBF%&o{fc}&}PBis4=5pYRezN?otOU_P& z9R>qmHyet0rl*35&*Stm^sz&1+^6i!Wbvj)l{i}Jk!is7o$VT+vPofO{g^*y*Ln3f zw5BtpZ!`CoFy3u!2p>r9v!@sgTeXxrcHwaJMqM~UV@Pseb-RY4D%*sK$hD}JWd+R7 zW;7x!Cq+JbL4bCn!MJ?8cq0T|YKL9OItOc7+CuQLNx_OZspTsZzOyl%{1s|Ns8ldN zp^2C1)k9;`XzMk;J#!PE@Ye*?!wmO?Xyu@~?CF12n#&I@km5#q^w5An2rpID84ZWE z$pJI$9l}8IQ!{9kH843g>d#?r=q`rJcrgDlfwzK5S)~_*%9uVP1CHxiZ=-^s)TpIJ z>ab&}FTun(DA7_OUG+VQ^yqW$f#U_;jBHaM8W=(N5HS5x89T=dY9V8A$49(4BYi6jeX*D?~fkP{B%(XSEZu4O?1 z4D+Z4edi$i=Lgf7$ZQsbBxWT?>9O#^Q#G|UIW>&rtkP?q05rHEYy##{3vpmds*Iarll6RD8Te$|iTRmnU3S92?(>thEZ8j1oI5H^M7-ns2py}_503$WuZ%1r?(O46AOIr=ekQl|v zu-Y6@p9X86rv%i0xx>`iWk&OuBoqA|J0w57On{aW(A-LxhM+(Wp!zhrI8zWFFpX5& zxXpE{nus0T(i$V8dw$;q?i}3Bv;mG?>FUKO6rL36QVZOlUkXn37w+4DKpq`R!q0M- zK}vPapmC@I?#IVUSmLD}T-eN#Oj}{t z@y{HP9i=vn-zW%gl6#!FFODTZtCT=U?7lT+ z8^u->a?!oZK@-0pxBr+Fkk;Wm7H!sO50-VL$&|1J1pa_v6i5ki2b@ zJ4*|@u#np>L(uju2|hxGQetYjA?y0Bhk&y?&_C-v;)s`emnxJMlwlz;xCkxi3d6=ZekZ_mVx+cept zWkEgsjTafhs5=-yHRl<_ij6C;Qw2gE*9|I`Xoaf&SW7Wg6T&LZVun!eELiYxDh+Y% zrP4x40ZD-=sTp*$x4KuMU9pkmA`sNLGSBZ~R!eWnEy4NLXU0E5t>5|U40b*ZI$Q*qT@k8Lt#B`X@x}bznFAdlB~phH-4}l<*`!G`l<3V}n-dt2k}A&II+i(HfVZHD z{`bby*nW{S#7edCUm-1iBy+7}&LE~YBEv4V!6WqXkE@!pMMSC+@~*wtCSG7nptfi zx6LT)>wh`K&Bv;*?HP|L-hw4CIh8Wx2t7>%V)K%pK8>Nt;a9;zqFI}iF!y=9`^0op z{uN(3f|>pkVqrDr@|Rr9VIs@MKLxyjD=WJw5fonaz68(ba%5d1Ih%y zWnPTb+jJJ%m8zs0tAkH6hxK#f=;dVW*h^DUMX%Id0*h|aMt)lZ3(mP~YM2IpPp?A( z?R<1d5O8*_A*Cd`_LyPhpI7T(cU^&a=^OUC#D*kBW-s;jo^+`DqD&CYZse&x6tjIDbQcEnpxakYGOhG>4~NBkdDCy{3MqNmMH!140b=l$-! z8enpLX*ut{Iw}n%5`FT|++How^HH|C#Ke-5b!hlqR)}7uLSNR7HN2eZsw}{{5?);o z%kug=OuWX2y+Tnr^uL28VS^UugK=DDf%gZ2L zRubgnSeMk{i&1w~=a3rrBfrja+S(m&<0@>Qr^>{U%+s6l zQcpceY9=2fjbv^gidg#c))4TX)(jzh<3l$~Z+kvg#gl80BzlA^SuZqBtr?A3{_f`h zwxWv=IW~P4AR<65{f0NH3@Os?ZfQh&T$tojy*MzQF3NdJj z%E#{kgC3h47)W$zUJM(jD$!Vrfw*I(x%19e2$>p47VKctKvv|Nvu zHylYLgOQKRBw%{%0Zry{HAGre{zzToe@UcfYjIiR$e+>m&z5zwDhbsokgjqV;8>BKhiV`(1v9yt@Ib!L@QwS;UGl*|G7r;tFwX@hr8)IVeOTE~~BhQDyf`G}*w}1{w zboVGI`&CiKJ94feEgpUY5Z?~g(fJl$K*$VHH@`~Fe(LT;DZ6+ttafA};-NrEw0N(3 zfUa#)FthTs87ieOZ`Gf!6I=yd3lRay|(gU$wTMo8`^|Wa7ePOEjFNjU}h^ z>3kpE=*G2hgCeBe3N`*>(>1o%2`k-`3=)uh;is@;$Hy2H_IwD%YR{wxB|V^=0+j*m zqb^LuF6~aKOKj}ePlXI^Wh$ya|I{;T5)xuq*$BV}cOJ@u;5!k+6g#{YDPJmC7HVk& z^5pQMSzoU)IdlaJ#-(EJH#vol)-4yIL*-90HF9{cpXkP9TE9sJohy~XgjMIVrVSaV zZLSLAhjBP?9JEn(Qag@)4M0fuK8$puOnarBcDuJSKxyb;Z=KD07s#Mzxi|n8jnEpm6bXF1V`>Oay&@--c5R=8 zt9U{tVs7QeG+0$Z01<)Lei&FCJQ~jHt0v21X3+c0g)4J@`YN4#+`l=q3AMSt?TT>L zPTIdjd~L@rHen@-`TcFobHU}FLI|TkS6F=Ico&v>QUZt_d!eCbVjRm0`>8Aj`2f>Cf$sim9aJrU4gZ+G(V-gOpeCtc_hElzPD`{<%=FEA zf*@6`ABpQ!LOq;NMR#^F)X;xn&aFcSfU?E7FasTjkZ=`dGDj{;({FsRH{@R*cUIG; zJAxiDSi~=I$(vfFal1yyoDNsv1#>XAjg0Y{W}aDnjE_OZZEVsoyL*Yv`g|nx66}S~ z$Yz{%!oHZr%oo5~#cyC8JOsw6d<(Ek;|tc;Qz7W!-gUIv$hN(8)I0@tei}pF5eXY~ z8V2?~>!A2ta-U|0fqGJ*%ZbIjhmmbLR@#P8Y@g)6m`p8&7vxAsr%^jx|J>#MpCn= zL0RR~)OM#dp4CwSIU-ShovV zbT?>MDv-}_UrKDNg*L5t#tMhu`-8gru)$pu<}=G~_iK$AP@C4wT4rQ3gqHd5SWN)7 zAs0wFpw?m}p-iO4W@Udr47qCzODdh0 zf+laaLHiw5*C9Bbg1b)~g7e0fgd*y11gkDN8UD~V?U5%5E%P^r5+WjmZ_Yz`~o6sbASG9;3OM!Hbn()*xCqTOaP)X8JWy!|?a z)T*(;p_$r~P0IUQcGQzCxpNbn|hlQ@JVDkp(s-raXeaO`2a7k0^! z!$#vK;x8S}#scF-fsjF5LLf&YKjT|kk{5T@*|ct!S!$o44FBlzjI7xttCEnnB%yBz zqmcSUg7>rcjkqOQ|Ew=p#BWW5L%37oqr;O7=$j zoSh6VUt}=4o9aLYT*cE+O7+5E-7Y_w3cAljTsPY5$bqAc04FX5a&1vV3p3KBsFRAz zpsNXov5UhZ3_X+MM@6j26cEivbsFnXYKURs^Fe+FA|J zFmS01)Fd^3FZ6F`4|qSYx$$pe{N=El?*GLaQle)U8qby0Aki@gvB>Q9V@#w!?$4@z z{23F;ET0_e+`EJt`7v@cq18qD?6Q(Jo~0nmW{YpaRLS@8eSiJWJl7_k#k%)mG@Y8* z(#ipjf4LKIwp;QSAo7ib?%hL~3_Mp(j&hID#2KCfoY6nw_&xVMDY1kTC)RF$XA$s><&C z3IFA}!`@KI@@r#BCR1wtqw2KF)OVl$?M^$D70)Vmx@OnH;R(xDSjzNY%9^{6O+^}z zuX4i7k}oLBQ{9@pW7p{GTpSxOcEmdlvZvj7i9fyrCN1&N*S??(g&Ks-*F~Ju2(Q3lEv4LuPlk z?~QY{Gv8rtXymM5XcTBd}T| zJ>C!F5kVdWT`{)r0fTmzP^42UGqxSGpJ}3QVrvY>9D;;?zc5(C`rl$qmZxAcNka5v zjMQf8pz*$}f+OUbZ2FNNB-+50xkWxaq|v<-QldP(E1460ts0A{lA4Q*O@;)yP=Tq8 zBaBn56e1(evozZDb6e)H-%+WDGR)-Zk5DBV-E z!ORiE682BP)J-jf56TZ6NHn-`T_k^|!ua4fV*yNhfH5{TJE(YCxdoHuQXyO>(jJ3; z$EzEER|RxW?P}!uXLq0pmr0fAc=2`6+AOs(>)U|M;c7Xw0eX3cAkjBl3_MWx4RGcV2k@FhY6)_lIGMKCxj zG6M;mWs#vFE~*u*9lk~z(zva=VXGt>)=Blu?^`Oa}S<5rc$JVGw|WKg{L}Mm`rqIyOx(@-B=o-&hIj)G_B1k&Il$PS_Hx9itc8(! z?Hta5$6N7&$>sY+n==FfVvQ;Yk0-&kb=31R!d%)Ia&tVFd0G3j>svU@=m z>T;a2E$G3>EbjQ=OVQoC>S|jl@pVmA1n6M|k3_3pg10_&gabx~$pi-0Em(-gb0=jU zKfDyFjQv$db*n>AO}$M ziCzIXv!})0DDxg~l%LU%^8%y0(n<3(qNqXb)83M$MRs&qq4<}#uN;D>C3Dmg^!XZf zoy5^dIBL>ZWT#n6qLAHl)U0c9&?AqiJW-GHNR~9N)Hk;uy?Q*+z}D~pGWRQoJazwy z5R`oKw#3I?i1j5kuec!jS8Eu2-dawFy4j&uiwC)3%|+&5nl`^9^!&{pLRXJW%3dLX zT8ErAn$;vKAK62dXFh(>_0tgHHx5@s0vB!KX1QJC0%zBsAj0!}VyO305(-LZKuEe) zsU01EIhmfG91Qa0-|1{Ph$SS|H>{eO(*xNI5q>USnAi6-O6bhchJ<3CKy@>>qX+NZ z^#ydjAmHRd_2Mfv@=505HherDUFP6GXKO0jm-%r|xH!P|5iQ zuqFCB6{GBVaJdIJz* zv`zQGws>|LbWit#Fpo{jiodusO0H2Y^8HB904MSqr^UJplKN(U2>5!o5@ha^TD6Ac zmn}iE@#IdQwYUksmUpwXt3$EL1%1%o%XD&N%b@Rt>M)#=&0gz48gtqUpTo-0W@qx;iUM7T9NwkHP&3G zn<&1)Gr*BK5tCZXkGQDC|6=}=tGLbRdKcZ)ql zWtD$QK1rAnfc4rl(gO^;D56M&sp4%nyc1*b%x!7J3XDa?N4NO8O~ zBf!FAPfUnIGcadj9~lhhnTdqHwNO+twmw@S+u*v4{NKz5n1;LtTar4rCT&P4%{v1) z5{*%go^#VF;SmWt|Lkh8WJ3i$1)^7B} zmP)VQwhOID$gDZbCHUj2y3E!5=d}qTBeUp!%Mk@`Q=N5cb9Dsmdxr9rEVpOT`^Y8W zNQ!NHjmY)Pl9RcW7m`LW(|=7iH_N_Po45$<13;8~9<}L}e=B3+o@Zf@PI&MDzHAqW zVjlm3RP$m4smd-EYh-Q`G{ml6%&nf6qP{50Momp;XOzX`zxBDB<@Gs;H4%ZBW>2CO zC&?#UPj|&1LYMazXLNwpZds#*sM9W{qm6JxT_Xu!K#=8l2*2K8W91B^DO#+C=jzDBM9YM^U zF2X@MC1`v&DH5Dp%7&sa4>su3&0v6!Y@~+3@ByEedUTqS~z7>15M4FiC@j zP@?o8*EhpdlgY}&+6*-^g(F1h41?`Z3!vldFqBp`D8K>9QuIJpbOxNh#9I&-Fs+Wy zp!yJdLkQNK>VvtZgkaZejLp4LK^9*7w9u_>%0#LOgGP4HsktNX>*Qptg0>&WF~P3* z*NXlGl54Bb#^3=in1fE(D(xC!p#3`uRE!C#jUV_NlB?|?TC1u~&!0;IdCwXKv6k@0 ztnE9wC)d1`@Wyy*)Bqi?F4(khk5E|Vfzm8{gw%egJpjoOKq9?r8h_`{(Mxn~-bLX- zG@>9yT;4yXW3WgxYv$aj*^#ibJVBmsCC0YfRQTxY^gvzTy$d1OgmwtLg6SBCwDC9{ z-H5D4$0OCjvlGb}s$8FOc+xf~ry|Um=E*J>OYK8oK}uxnRt5i1G#!#?^bm;EcU4^` z+Ibm5i2-2MYCR3LaQI6+Nakc24Y~HK1|%T=*_|YUl3YIoZ@n zS1NmJC7LVeN$$~H2*Yk%gJXJC(0zSrl~nKP84M+!V{DJA0Noc20A0c%V0eYq!g0B` zIz%H01)>q*=|?~oy)IE~!CeGxi+tFulDfT&LG8L02;}lyPu;6}uhDAeusZjt$Y9E~ z)&B-Hb5*ra;`cQ+t7eHD%U;Bg-#YcRr95W9wDda5`>4e(me+3|>5Ec4DwpLZ{D<)1 zPfzGsB>(k|$(R1Ktox6cZBX&L$OEf8P>R9bV|-@ zP~DMN)LdUFhs(*b>ru+sHq3-ht37C~JJhdBbuD;WwoJ#(%_|JqeyUk>Cgk(5mR>JG z{M$bb(LG4s`8?C`?6jNeo)f-;xp-n;)rXyjZ-6k@x?)<5SC?VL=Hlb32>4RJQ093% zL?8nu^NO9oD#nD({T2q=)>N#Qja*yVf$Ck>S7Vks3XnwPuy*_N0BQF%aM^Knp^wz}ekc~> zlL2RR1@Ai`MT-mz!2l{lH(ZMwJT1L#W#7j`h_+3YTQGLzTa4s#(Q|OZM6hKCfyTYdZjP2DaRT>>@yu97<{fDrf!S8 zEIhPCTNN%r32EcrKsqj=yT=VpBGFz~41H<#rFcc^lV}RHQGNNXM0^yHu;Y@iN?N0O zvzGO3k=YWpN(x2ve+YL=G^0A~KW-hS%j#wStyx^a0^MYH*{w9M!4yPGECNclDwj+T z?MmwA5)o*jx1~5opq>a>xCiF!nS=?#ShcpeEvgWU*%M)iX3;bxC8|c{@VdN}z|jS>vus6PflcbH`FqE>njvt)U= z^eqN>kJN6KU#)3vT?Hm=XP#Hr!(Y1z)V}Qix^sB_Wr6;IWaqj%-*9!6?}EXg$E_1$ zWM(%I+Pp0~s`mq`fAIi`^kw-@?CkR(en>NbJ=r5I>$PFzJ064(9C{KfV{NzOXw!?v zVw3bwbbx7%m}8TWY1fImvubY9M~Y0Z^Avx`+dbvXgm5Xa|2BKJGl__VKzjZJ?0q^x z%Kc_B{AK%E;qdeRiy_{G6TRjB7ya3OH!Plt?gnrUJ_p!cn1qWDRt1`FSM`zkKSDioi7aX!_!@%4leP1|vaI<+E}Fz>a)}pf&%Qp)xf| z^(X^l%g(OxM~VKtY)@-9Mj=mk9>#`Kw`buKTh*Y4aWKQ)obUXB?#C zW0hx2DvdY*-CsFEH&UqQ8025lMx}M=V;^{0{#9CaH6e(b-Yo5Esq~^$!hea7C()7W z+-KKS^kwyB9kcu$O3|_{o$+ zt@aoQkm#dEVY-+^r3YeyjmS}#-Mwm^bH-@YlSl7zI$zT$h(R*TiejGg05Mw-27#3g zdfz-VnrLjMOV3O&cMGo!zmjkxpJZ8378aMY3NmMZ_|gDf+a}CSO?ozG^tW@fI=n)? z7~Fmk6Q|#G!KG9s8zMf`a>F}w5B`025YlUZ^bq^7?jvQ1}5IrAP4@=VKZGVdoyC6$`ZT7smHo(;nt8@_Jcd( zt1`5%92Pb~A}}2@{v^-1sIyhhbFnh)-XjS=Jv7V!f&B*cl5G}s=dEQ>Z2zc22o*d8nIESUR8?|`!B%F1|009rlXf#E7x~K)^7b+k z%*@l18rU-bB?_HNZ4}$}*$A0gn%ypmW8tLHkR&YnM83W14UaDSslMepXLX7gxU9I- z0fqJr$yNFz!_Vgo*e}*2q3qo}%=khbG1WR65IU0)+p)`9GOyeZLY;3G12~0|Qr#|A zVCVOTKut8iK7-oHIhVRwZr?4ScMZuV#ea8H!f$pHbIT+o_p@L|qBndBvsbqnvRynt z^6&GZXby|q>Z(h8+?JI&=YH&=2lvb9f3g(sQNl*oV%FSf`hJ}yy4N(Q^8(ciG#tZ_ zlafz}KgTMOG!VL9r@d@<_x!~54~-!%$zpDnHfv54l7nX@f8@eDm^_O7QiS=sPw=l-ND7uO zVyob?od$^8_g$@fDoy&0gtrFX?!jj;(+6`O=;;PVRe2*B?~%0ibcgno3P6W?LhZlS z&@0b0bqV#)gvT(TCbRr~&a5bAPLH3>>BPsTjy?e|0w71D!HN;-srgxR zj)p3`!N2k^ukzcL@Cw(_av_kJnfbv<1i-{)2HQ(QWIM=gqvB*UpM)~EgfGp1-vXgY ziSC{BSAv=4Y?x7tMIC~ZgjByXpxM=u(B{wvAWB`PIZ&M$mDHJgfw)(62o_#+J~kD` zKO$gOTFk*{H|secmfn<^T>1vJ_#Hs-be-jmNpcKWb+(Z2O;jIU^B(0Uuu9L^-m8TS zn*)GYDs}nd&b%kgiz#TZ!E`&zu%(3MtL?S|$ zBbAB%<+_ZMtxy|R)evpSl&5HaVKt)W-Fz=E7)nm^eO_)rW3D>uR^$xIdiilgv!q6j zf>=K;WackX`Ix(CmS6eH4IXu`v(6{Ww@L2T)6rHX>Ds7v54DEKvF-vWW&2}YZ9>z`NZJ7g7e?m zvCv!q%1$0ds?&A2=SZ!8xLk5S{{%D3GQ)3IyFr9;-N|zD6_mJD-MrMJeP7*4v7{%q znC@n&@-aX2Ms@qZ)!-pq;d%|elv=LUhxEFqfqY~QP4^xWXrP|@v;;z()9;>lvpk&l z3P0EqbO;!F^q*aNj^djese^5!6q*N7pcXo44gN5X-L8*GaSu`7~Y0kxWBq8t*kc+ z$fvuA44S;(pn~44FLq+SrXdMQR7NCB((D@8mp1m*pa&^^7*_Rof1){<3IhbmbZAZu zGd!SJ$qqxTMrUuJ%_@I zI)R>Dh0*8flQu@?vx>|dl z4L0dO_7`ixLukgjj<2fgD|TWb5@~2s)hobce#|Rz2A4z>YAQ-M zp4D0K=A8-N7rc#c0Eebuf{}W>R$l#YZhvU)-^;+>Z~eb41;k$__qED!4rX4suE(}Q zJ+>@8*piLW}>#$aKR517* zisivgU){#H#+}g++iI~l{ydVp;B}&jNdDYG`0c?!L%T$^H$>#CmHk=V&ButOM4Cmk zW|p=0G)mlB>!q0(P&0I($h zXUuEL-mA4u%9)RG0-M55+W02&P|`5SC|ZLIUC0QnDVfsT|Ym z&Q*1mLoPSDbLuvXD1e>qiF|o8f=_*QzO3m@q*=^LGH=Ta4}NeShInm8Et0vlGx-Lq zJCk0`fKErwrH#x=KuKzru28Irt*>K|-cfCyBStE~@mmzp)QNyf&gX*ot6ZYv-s$7< zYeS!W#+PWGb`HX*v{+D~Vq#Ckm zZ(d`qL#mRGspBp4dSP!|XE3MBz`r6CWWJE5Bh!}pYIk781P^2xmBuxl_aD(xYh@pe z8EHe`D-LZuU&WyJF zE^W$T4hlFoUqOE~^R-I|HS_W#0oeT8v|H>U;!xVXG}j<5i55}EcO{g6MyLuggcd^? z+bO9!F(m+X%4T)?zKqoP|$%+9DaMK~jk>Tsxj^l&j@Y|HGWfs2alR zaH*WEz^GlP`+t_?A+r6V^C+9KNKitUN|Rxy+9sq#`6nN9(@L?0Tq)VVDqylCW0W3g z$^x8+`I%_njDzjfjY`ckJF?emb}<|p!;uGlHpBKMhhvAu)^5VK{oO>1bt#2De*mpT zZ$n?&J$8a@)js$)oec;%PKDV8J@V;Nhb(5aPd|f-tKFmmv!}&7$8vkaH+Hs~|0b29 zf1~#z9jldscE`1^iV>Ln1Pdb{V2IdY{tfSggsYYj>2ub$kZ$PrN-te=BCcIqmySPC z8{hw#1rP3@ErJJ<_v{DBOD9W4p_C*vBv&gPl4$7Zzqt14zf@%o%O{Vab!R#FaP-54 z=(1`|_HvY^mf(i<(Y1C^n3QH;D9XEyYokU>rIny-Lv zL}n(VzgtAPS=rQSoTa#quKtazYg3&b^m~kYI{wlRcVJz2-Hn)i9-5wiZ-#qzf5l8Gm;pMdGu;59 zboYn<5S{OXD~_@u<+*5oQ3@zRQswh|CLL-!E{CpJX0B(Fvce|4a&st+OQo7W{*9Il zOG@}ASeBj-UBP*Ya&Ee87l-ngjhAt8)ei^zGbf;HgTtKhuYC2z`T)Q zTn|9kyDz@4M8>=Mw&#vDJrj0UdLWaKlkYMhK|VKT1{nd=rrd{`><)?)Fd+W#Gl2$p z>lTb$lZ$mK9fyBCEg~XGxYxFN!s?U9F~RR5iko!a}2iy+Ppfewnl zOrkmt=n}2)rv3#qrY;q-1y)z?F^-&hVd65&De);CYenbMFsv);`ueqQ&}oG`XyO81 zhFDtR1L%5Yg)0Ouc?Up9%~MswEqt^G-Ua_;)K`hJt?%E0lutrZ@2i0x4MaM$$Qmsv zT{C$`qVieO3GP{$?hDSEV(@gL-b)tYlr`#Ff6uEhtkirn8_an|9a_s$f!>=alM+`` zw8lG_x4KR!8xCPk`?JD9hq8Br-WY0BsE!|{>M*Gi zv$j|Rp{=GGA<4Y5E(4TMLHlsFWc&iK|EP1v@~?Pxqse8P;H_fQ;V6d=UtBGnN7rHj z^d74Piz`B0PmiH1Y6tu4Vinzt;TN`p#gA_;p%V&hBq}&Et(MONiX|j(*|WHF)LE8R zA57B;Z>nFy`!V4zZj5S}0qt&pqxb*NM*4k4V85g(FcHQakWWT_GoKraQXlE~w}1kW zMZP4u~y0IGg{Ldb!O$d%Da$+Vw8=uhZ37w=!sveGt7O|M7VN+vtS)2UW zLuHg=e(!)P-%!~e-a6J>r&hbi8tz~x)P#9^Ln2?0*#ilQGTfyONy)I}Ga(p4@8jpk zTQh6nWck-JLpB}yCk)^es*Nv068iaMU}Dz}@|LR1cE31$uE&}8r;Z=4rBi?R$In2*(rgRr0UAbUDPv4m8Id_?qR z_ePbk&WX=M>XSwJ)K@7x?+z5vaIwL3a^-%BlN)qjTJ|XiovNoQ=kI0lzSOr4obO2q znGG!t(a5PXS$0qFo)v4Z^mARkv@z?%Q{3RbE-cHYrsML@cRZ~| zo=br4=SUlz?rIO!%-@7g_N46Z?1a8X{w1&WS4LzkZ`(o-H8#TVJyeYB&Kl4|2PS(( zkU8~(Vdp0K0Lf*w^(t;ZZ3u8y_k`kUgdm!Cip)< z=)D%{5bKwY>Y1E)A?i3UaJB4@$%~+Egjj!H2uo1PmTnIpNvPmYgOG;DFDf^hXz0-8 z&VdLImk_oC&ISQ9%h1UX8&WUit%r_sL+wZ{@(F6lL*~?J z-Vh=zMO85u(O`GlQ**SsSR%>?6>S!Kte%iCqh=_jx_QK-nbFhjNsxN`?vgoXDcxAI z0PLHYCDiSlL4U7%V@l3RJ_YN#Dr_LTxK5TTk6t|u5T&Q#`PwC<_EJwGh+c8mdqgBl z<*BFtxA#a=dp3iaBqX2bIg!~9N+HcsIW%*%RGm39VX9;q_7fZA(Ignh?09I_aLD^B z7D~~6+1Yfl1F)N^b)%gUMmy9u5U|(-@`%wXZ`8QA_vyBkbj=finVCqw9amVu+u zS&{tRj>E|j0R~MwZW~33WbE zlhZZRu%LQWp+u5OXqO84%2u-6E(Ou7Ox>PmjXg<~%)zVM;Yr=sPe;gg-byKJW!9OGDC650)Y2pjBs z{Fl6{bB=rgc{207>qCfDDm7smc>=QVzelS4TmHKo`xF1Z>Z`$-%uMIN-Dj!olpt`f`hDFD;J`QXSLk-p;y;+ilcL?^U*Yv4`tReEVK-ScZe zWB+HO^Sk^d^`=y##Lx8zBPr1?_%G4&We82Ko=&k#M-6);<9d+tPY^hl5}hkU?P9|P zT{TbL1iDp9iaI!@7bxYwyDx5hrPhhcpRx?0?A=W_w5 zdSK`3aTLbvjX5~(*z>2wft~1|8O>`A%4E?0zD3|kXv=26c~d=i`mHqL zzvgV3-n~RBJ2eM^k3^H+M8aVk^YpCwwV+gqZFI0@< z_2SH3MxiQ)eSl3wG_PpL}&&EJYhOCJdKGd5x5?&=90*E+tq zPrfj77&)miR~IMAitOxT(DS8@09MMr`;54b_NRi)1EJH#%4@ZMD>c8aflM^fW4Z{t z_FAcDciYsVoL>$0VeA|NU?=9)V@E`Q#k) z7p&x8rZEibcP~SX*gPlNB%={E%StGfdjCyAsc%5zxgWyL65Snw>%d_#-lHBJEUuPu zmzf6b?^#Y0tK4NVw@4E7xgQJMGU(Dds<6K}pgaOT0I9tDVJDp+spi~SqUT>A>KA0E zf`qHD40^2{Vz}lWx*KxIo@s#-qH4`dh&59VI5eazlIB<>Ba%+G3s&T#HvHMN0|Dbk zpz}n)FLM6jYDpao!=@WRiKI)R-B04amH)5yi6S&49P(ES>sIV40pfX z3T7ftkP!J9@*cDXd2l-8D@_sIIvZxAj8ab7Pq!+A@6`dfL+B3R*QoP9de3{e)eu99 zP&YlI-fqZ^`|r=`s=R6$Gy3qIMO+!1VSSk}HAChuLeYrwQmoLyt&nkS3i)LRte&R8 zpIr`>ushyiDv%tyfDy$_hYt1(^iPQHKVVl2`VulfEv^6|Gb$n9_$}PIcK(&R!qH$v zvqbOZ4AqRLoM%W|`IpZHnOIC+wo_(GS)K3pcQ4pr%n^u7ZOl*fzLg1x3Yl}&w+ zPUi~r>M_F~a$tbEP|6cm#0w~`2nCsT$SqSQ>4(8_z2b&1@ku`SHhsUFCjwCW*C`Pa?rFPfBpCF5Ur z6!+puiwBcTwHtlgxVZN&6ryrcfy4;P^us;h2bl`Om0XHg+4 zkk6y6aP5#djtx&X=6 z>O^zbOUTo*pCN{PGa!(RH=DUx`s@RK-+MsgQk8i_;)w0(ncS{wpj#`xJ1+<*l}ks( zNFBWn4o&mB8W*4>zsT9Nca8|(pGdAzq2MD^{X1$W^ahN;&hEO{hdx=^0M`|Sn&$74 z4jFjIvuT?DJuKw_)W@_*G@?Qo?RVhNpUE^;9Yn#2325jb&21$i=Ve`_Pw&ihu z?qY!bY*j)qfEZF8l3!Dl{s}4yETyW^1Mq@1`G~GPy#)cV>6QcR7lBdaRuOiVe39j$ zYzbW-?~T(=J=V3h6%0GOo-<6RF4dpE64&H9kG32{xIT{=%RGdF3_*_dHF}sCu-EUa z|LtsFM2AAp(}S|tA@9DO7^-(RTW5qOYe<8}SeYI~tNU&TG+)z-Bi>MX!{b<|nP7Ae}B_37S} zP4}KkH@f9Q4wXg!t}R-UgJ4CY!$@85VfbWd=QyG zwj^}qHmzmv3vfh1W@~qoS?_#UJXaBL9u&o()4mEs?=Ldo@5F0gBxa3k{tOwcs$wQ&0C)_A`hbR-^Y+{qIy%QXLY+n`?53ZsCzsaVwc;Y9u5# zr@j1GRM+CF*3>1lTyZBM`6O~fnh9n|XsG_22=3@f5Aw>TqW6)DvNHp!x6##-$(gCv zI}4KLnm3xxEG8j2skhUmDGxWr+~zGI<sTJx)T&ehV!r4iPqGSn+C z2xnR6MOilS_9}_3T%B$t9S1uXSVrj83D|kOl1*!_QA?Zy8mH>AOWgR>8VM`}_iZWv z4l3)=yEQ65Qzhi{&m6F%$w#HuuW+@j&RUq|8g& z*W@uxN;J(8>XXp9mhBT(`c&Dr58t{a{P*!XQl*>P`x{!`Hdp_zA$ROhCKxRKQ1Nw?|V%RK7&EU3wQNk67HGiXw>xMYBa`n1feqfIAaJErzW zzDVkuYe4N)2$gnC_FT{8%y%RNF7m41;sYObLvpZHNcrb*;8HJ@JLqwHqEn@We2Ug` z)Bm=#Siq6b8n|rhZiYV!M}nTh`_SyOhhU{Gtvx~KlQ-r27eI&psigrz-?L3>-O~>@ z8tBlLZAq9TzK1~iA2sxchLs@%`>k)sl`d_wqa4&Lvr=CnTO8983K%m{hh$@_%&q!d z-^y)0{b7=gE2uQ`;3@3C1w~YoR4R@r3CX8oO29e#J5nHho2eU}~!m4bktGjk9Ir!M>2P#gL48YltDul_|0qA94PDnIZJ%I5f z8pf&DLpn)W8PUy>uyfMWP>DphoQK)x$1oYN-d0hA^HO6NwnJeM+OSfgctB7=rp2h) z9Or#mh8_za%~$)CrIxkpkszbKyk$FOLz*-}C-W4Gx&mB=l|BkXJ(0gXt|P#a)cxzN zS*WT!hYi4;%QK>;%-ZZm?ZH_SLz9!6fTObEkWc-gOdh4g*e#IWvn6D%>JB#!41U07 zo>(TWB`U)%WxBx0(wCmF37Nnb$*96BG zr;{-k9=chs$2~^%ZUh1KT+{2;`YFj3tR4sT`tu-;0sAXk5Z7^4A)mu*9ChY>=U`oV zDe@9eJYrgk4l%HB2@ggA+%Phx2H68sdQ5-5>{?z9J|%d8cCSG2OQnLh;aKJ z|B^~r{dYV!KHLy^lJ7xb$`*_@BuLpI12Iy^8vv6mosuCL%+}11y)yzzqSf7`LvVmx z1!aO$LUE~fvTc7}Wjq9Xo3jivo=yh$;B)nn52Cc8(Pyb@V13bL46DU*5pz_!uw(0l zJ*X6mPLa$xJN_lJzrR6TJJu_yQ&+A5_U+mm^4aWo%~uBe><2oq1F@u#fPtRK2%`s5 z;@3B$e^EKecR7M4Qyzg`I^7_&N;#||3t6%DdBYXEIA$}usgeOx`*enszcB|?By@l= zBC%D{?1;f|C^^@lVr#CLH8)}i=wxR@!QOkh+UGNzEFBv515zm!$`Jagi^YZ=F_c4t z6zJ)lUjVUf>cZGz|3ICxHSECZ6I{Nqo(?&W2t+xAQcSAK(3uhvYFr&MOZuY#M#mYD zl4UgW__Gb+vNfsQG49GH!Gs;&6)~LV1*bV`fzYu-8taa?A5qfPV)qU~kqGhT*}mxd zK5P&j5~XZ59*q5vH9igVL>cr;#?9cgjKvytk zWl1hii3MBJ)me&EHG#{|l?*yx93cMQHH2yD94Kemn%r)dYhTd|Mfb88;pq)S{K&)b zTUh79@Gp#aQ={%u~Zm4xen0>}acyQ!+L&aooZg7BY z0CW)8nAx_p+qzDAJ$%PIsOS~3#*E7BRiaR$JLk<*v91N|!yO@INmx(^V{C5`V}WN> z4ZJCm5VxPp&&{$^CnE~Rg#6-6@T=ZHx1$%^wl-K{ALN8nQau14-C85UI!AY+^mlm@sq(Hl3FD%5T6nU#LeDINM0Z@=`T^L z8SV8N{U&d!$c8_;XU9yf6T%J>vE>tSvK$Tgb^|pbnPr8|d|h<-7x9}R8O(#rQk779 zwM}r36szQr7KgoCbrpq%Hq8OLXAi)pyW=Xs-F`sLyh7yo0UJF^8CJW?m@{uyL+GVn z4R^C_C_%JqxP?u2jhHA(eMcVFV6#X3ZEvg8LD@Dr>aoVW+<V7e@NIv=OQ7c#%HbF1uKMDQlX7HQ;N%|uHhOfr3UR95(B)Z!H@zCrRooqS~ z@xP`WwLCjy^Pj5Bntigs(`GdGe_a2cJtx9w))6Xg+0N*0(d$MJr0mGZP|l5AhP)lD zea!cqlSEtFedD6v zFl$y!)I*zvj=E#gUEfE2U6&+eamoB8A9VUF+h}HJ>ddJ86Kdc1;gVz89dJ=va)M z?a>(EnM(mqyLQO&l3qxu1r;F`Yb)rv*9RDObgJ4YhYHm4%nQR|Of{pvbmyV9)CEbE zCO4g`2Vb6LL`lE;vZKd?gy~a}lagBNo>6R8)ht*jk=P{*CxBIl>#aDLUR+c{1U)!d z9^Z9!GMBfar!uUIw^2JX-WA87-Au32xLWcqUkH+|;o9}(7rR;h8G-L1(VRre%4fj& z3a*wyXY(_TgMsXqDqNb5Pdcn=qvNgqV1E^Dbb1ZIq`PS!z_s3L$-uq{RJ{634V1$$ z81a}R1H9G^KDw6{DJ7X3r9fZii!h|m(GuNtf~304ZK0#xU8qX_$>(^i(VD&5=zqB@ zZOHn^kCw#%U1N%QTmwgD2{Uw2ZRIkfi9&N47^$7g0Ov%hW_0}u6qOkjST%5VV;MT% zBE7$)$*3wCOw&&zkXxyj93`L31LS4C>1tz4w+!IC$?HO#!s)=yy*hI;b4E;EMIo8R zMQA+;U#>*WpI@%E&@R2bHI%lKOe$dlqB2r@<;v z5Bd$dUr`MeBD}U7%)H&RUU%2LyeH`>kh13(w)YjaBssk^G=9qpbO%+;h&?pj0p&l! z!0N0?^h$WAg63Si-DS9_Ye;tip4kg~m&L{P!eDrS< zeEB|psCG*rh8pUPzO8#0ObaVMIg^2I`+H0Q%<2<`wnQU+>i9!Vi4s2B8pR^A(opZq zf$TNi2@!gvUBXF1-+pdfp7Jjbqu)58(Q_u5q2y9UVoq1>m6LgeJ&<&8M9m8FoEtd) z=k%G0)yXL*>U{EMSlOVA|3SQD?szs8R$3=d%)veN+0+n#3d~v(MzZL`I!x3OV&;B(_$yL|5S-P|!^Y_YpWcbBX+o=%LY2KW+_g--}NG(G5 zbgvEx)Vi9CCAD(PIp$H_;Awf=v&0!&ZVoy~7O9}@U^{k!%rx|ZdB#psWs&gJJtv%4 zzl|$#X6PMt9t&0cgSqRMGy z77mMa23y&k41{{T3PYB(wNRo1V|$hpj*9%J7Sp~dd6-i88nh<8l22^=J_&m@`>>-P z(fXTMw9zMNElNE*ZeZ>TI#y?2vZsYk^@Jt<4!V&8G?E(!+1#3QGN`4xH2wI;62rVB2!1@Mbzl>B| zHlU56oe!O**m?EJrxc6MZHQ}$en?Jc3CU;k0i&lU(|RRLn6d%FH1kR5v)b&o|I}X< zNWz!Je`U^p%xJRfSR<5v5Tw#?G0|i`fv|xCpGIw9-9y>ok8945K%JcrSnGAh{E%(Q z8;7QHvt0RHkTV09c4-c^+rP`zQn2pJgqR-Dzf{8L!bMk6%<86P|FDy+2Az!J>8KN@ z+3Fa{v~N!lNOZt>WapKKz;Ea{Y}?nly8!zm*!P2)7r;WP&HK(}mtqSf4FL8ZLaKs_ zrp5p#G(|+pOBQ`RuW+hoV_|%oBe5r|M1r!uCx39o(~V=eNv(j@{oIhsgo1ESNDShC zsZ-^IFb&!OZwZA7W6OLnxv~xqT%+%s0AJ}4`XI*r0{m*(PV?_rfKLR0;AKpy8M3xd?F%+}xNWA#}rS zm+ELD6)o$@iI^!-RKK=_)T>rhLHse095EZNND&R3-93FVK6^8w1Eu){{SbOq{Zo%Q zeVA-4mdILV@6BiQ{hiWAzDX_^9cgS)F()yAWIu4p(zin)zX5bp8j4>-6--_L4y%pO%8s0 z)kdW03JhZ+EQWD}r-aa5`a=vyGP&TFk3td#Zh?>H&H)Seob6F#^F+#1l~hnXrw8QN zlP0BqGP`?dsiI*oZ=iHbYS4z$*Mrz4uYhSE?*Z{WFshRJ_V1$cvC**F;&cHxyWV7C za;7Hkhbxp~TlYVE^f*q{rMq8nAn|N^O+v2pR4S0Y5Eo)#;{25x>CAelMni==(ng|x z5-ryq@oy%qxY7~1_Ov?N*`gcc;Zr+4`l*+IB}@72h47=R`_g3O%Tj@v4n?it{3A#?HTHGy$(!s<%6-Mf*Ff`@^@|1ut3U=WQ`hE?7qhktXA(f{U6p_cvm1p8pRU?c|a zh(+Oar~{zOa0A}g<0W;U1xl>ZggtlS6{>V^7M_?Z*uat{du4>jpPs5)1?H=hb#hIt zd2za=nxAz)GwE)~zmK~sXnX{D0p~*7;hsN`R1}u_L!0=%fT&}Guu#4wP0IG$4hE@A zOyvw#)dsi>uY};pXEzs$P5r2YE?c*nVY41pTP^apIm)J*+bP%~c?BkUei~{zrrxp3 zP|;waIeF?*_A#iv?rmVdp%nyDqa#Al>M|NEZ$p1p29^P<&O=Ce_C@jmFtM}4$6(cq zgKpFjjJ6qN@KGf1_pe5etPm|I0^`U~<-sHCCn(|Tt3bdnH^hV6<#IZ+2|BR_5?w1I z3zi~Ps8;6S(qD3l|kNtR1W=73Wrjg{k z8w%rmc@?g+IKIN1UK?p(#?0Kijv?yvD^?5WMZQ+G5D%8>25p#`C6xApFDrKK5y)o3 zi?{S8d@Y#iX-~*my^7;HAp^!q9sp6R9Yc`JP0Q2Td-W`b-#yweE9gHQn`YfZgN7cf z=W!)i=DRc-ePl)xg3R_ErLN=8(`0uWNsryD?HRS#^li#4eE`2q%0F%He{^w0-yKq~k{ zs+w?us+x_UlZOh|#{&3WhV58i5m4{r{Zi@U{z9@8s~!emC1h0?6_|bj6lSiz#(96` z{(}i=b%)G@{olsSakYrsKjmi-(6l=fu?TgXU#fbj?rX`~lWv?^?7*uj-{_T9_qq^` zKP=?38e)C98pR@+>peC2^78CfAn#mu&bF+3$R4>phq)Og3YjR-_&KEoC+Y*y72B(0 zR`kx!U;->aV@z2PYB$au|5{xc45*M2Nq;qVvz&3c7cZrNq%_$)3?b9ztRW-1<#%D~ zrf$its?ObgDI1JhOks;;sM4NorZpHx_SI;icJa@n9cYBy_)!qp*Xy2A&GI+7TE@0P z5lFiyD;8ww?HnV25e^Aluj=7uDRR?+*YJ*}W~*o*+ArnnVyrfgqq{*F3$uhhf%>Ft z^7%9gkepX*MI}qEVnamoqPUb_&D7OP8|Pj#%_zMwoBr2k-}r>7ri(%L16PQ>rBf66 zF{LUY+b_dLf=17kI^WVv)o~Uohvb-X45oQC+!))VjN`F`a%0hMZOG@$T*QskvZ~4b zgguad|Nqq`S@VB(sfMfNsm&o*OGtV@qQ~2?EqJw(%~DRQ#Yz2dAi*diGk zWCyjm)XR`E@{GX-uL_R(X7EQ6RyOc9^fr?9pOuF9NU}uLHbjQMnw!eM{-3L2AHsysD9ni_{k!9gld*}#Xx{5A8*&yFg2Ck9_lR`71#uO#sF79rN*1{aUWR2JgX-Xe zy|L(MLny}=3X|0I3k`9Ysee+oG;e^E{pN|C!=PnhsnzG!8bV3SGslr)|I5i+UmaYj zTOEb*@h;3M`8@W$VH@Yd%C44&?ZMz_Tl7)0ne?w~OGD5GIMl?Ho9T_|G8v*4K(vj3 za`x?G_)<&T7#!2}nwQg2u{|3m{S4~*7AYnlS?Q{K zh;`lFrJZ!m_ex#lh-T(o@b4rf%^YR$MfdHXdzB{8Kvb2y2+-SeV9r*`9`7^T7|b(2 zRE!M4p<8twI-eEU*?lr)ujT;k(Q5mnWHCRdJ*lsG{tYZX&mHM%2~Ja*Sw|Xdm7^_@ zosw_FB&ey3r9GPv)YT#0yly{v0U*giwMRPMGzLCWN8!{&Kh9kz-rH_u>f7wQ1PCF=cr z^)gGpyujJs7eLtVtH&}&{2q15T;7XuOeu=~ZS>xhnSVZ(&HEX>}fOAT~DO#^gY_n@9`tJTP3%OC-B(Oa%5F(G7zzOP0qpR+P( za_AyNq}^hcoocm!drD;T)5Ch>dlNIS01)?UpD|%4%?(KDI~#iN|6G+echAb*MR9dX zFndY|VMg|cnwAzS%qU05R3QQ*P~8O6>;Zr+U5+J0>2tbHaHv?3tCC~AzAl2E&Yh+W z{-${AaQJSZQKTWj5fJ~L3-3R?he-RSUQj#i1|_!NXz25Zi-bxl|FdMCQ;V`k20`QR zs`S+V&K3YilKN~D(CD$;2btK$;KmC9WOn=k*q`kQW((dF&|S4OpT%2^Vp8_|ZiwIi z1H!HC&k}AH=W39dBqaJ|D?!Ikszt65(|}96iS)1aFIwC6g8G8n7#w958>1ePl4$kk z^u@-unJ5qU)1K>2LnPU@5VJ@uu(bgk3`|N3*vlkZ7qR zyai?xa7A-ytDUD*2y_ShLgRGaVRSOc8kpXBU>HD>~Ruc}m0b*CIL+jQ+ zO%@58G>%1Fm%Gh541ZtTZOgonIa!gN0)ujkux(8~N)&qhd6`aqvXvp6*#JzZ{XfRu zIxMTDd;GS=#sd4Os3=&VqM)~dAT}x@1}b)UH^%YUf!*EREp~Sw6}!6?75#l?Ihz5_ z^L?-P{^P#(-cxH<&zdUx+|!oz@LaYg?@5fXuffPkv3-xh(8)e{Py$fOjr7ttaV88^ zw!-?`r}P$SrwwG_et!X-k)6*=-RNc7E^v0$P`dhL5Ze3I%c{EA;pQ~nK^@W2t4j)o zS~iA#mkJd^K3>=JvJJjE$kXN*VJYsrbztCD(Z{VAkwId#ol8Z^}P z-RaCEja((t_wR#VwrW2hS)9h@)i``+YDN;oQ8&wz#7X^<)N<5!`{KPyzHRsELiS-O zqayD3tWg6AcA17`*2BCe2uK(c2aS4CqEfBwEJisI$e$Cd#}2DSItRP<)m_yTgcCRH zh$v-Ccl@eeKq!^YKe52mcJJ@*1i%Ulmyj>d)!!`kxJ@m+)lKbEL7Aa-*nM#dKDBGd zw->%(^5iHx+`Ni4S^hH(8XZhgDRz@E`j3)@S$vQRdk!S&)Ru zEz{^)eDdwlm@L>V0T#A8n#Q>tjMQitx~W?4Hq1fpLq4Qf=FYIZJn26W(aWCdD=sXm zXWfDZXQb1vw08RjqV~HMzE86P7C=`wV;iOHF}0o5tw>MwUkSdY8VT0+zNUoR&X+hW zdDw57tr!|^RE}7Z7qf9t7jnb`%_MZ#L~BXTSVXyAyd?voM2MvQ9uoE!hWbl7XH!d5 zixMu{1*9FGp?FLMT|+F<=u^>3=vlWmGx!(qM&9Mel9VWGC3NaFSe*m|nxulGQz+|V zhxQI#Ob6xL>?9T}rVV*ImNW-jFMU9E|2>$t@2(AT5!VCc@ottOD89y`TMB>FX1~^C ze%><=R8?mJ3LhlM`c*$Ibgrdox#nsvHkGI=znXV||MHC?=8?1s+_`5r5+Jjna;MJ^ zW#6WaC$pRKo4RHN<_`Njn6|4)Yj=WF2|JvaS)1`nUwg(v_SQ-yg!&c-`s$_88$P&n zxz#yJWqZ+utmh!;;Fn~PuAX~Vg|X=XKc)t~EW8+jm8boax@blhEoF8lPFaXM6DP9? z7fFJ=wyjBs!S@6@tXDm#@Ng_h{QMw2{zgSF{DaTLJ7s@B{DIi`L}Q>)4Ig?Q^u$8( zq?;7${2HZD^JP{AIP5Kdwa!zv-$>*m_3j4>7Ff^x4Od(IJ)%#ebIKqZm@EgsQ1cSS z3}G|zS`y87(UrFD`n8lTvhF$7x_Be@Q^sSClZ2$hM-M8%3YYgdMJo!x3@t7pgAwo0 ztaWnN@v=po!Tk_`p3hChbVwse`+S~wBeZ6J{)bK-GTWN?m&_)((j!eI9MK7FHl`0l z1=(9AS^m#I3A5xZ8EeobL7hGRjH663ea|RhOlujUd_vHkdOiAYQa@e`-G{wfYQkCSc9o#U zv6OkKhP3f|bsySQC(ZwgqS%QPl?nH{!;{s%Su~fbj;;8lFKt_uqL`{4!pn?e)^d18 z&mPs+U6pS!Pw}O{kvHg8zP*)H7GBNYpsmP)dA*(zUDvrZe*@KcdTvgPt4U4K2MDCy zL<5`Yr_>vAV|628n!#5A{P*8*=SOdp<@)t#0VzB8fTdc3(^-CT=Y90|r>n9(V=>1M z!@-(lE?owkPD%{~rd>xhfg-Q@Dp)Gbq)LAi-1AmhELkXWhrztO7O8F#&y*M&PA${t zBj<^8foJ>TR`tg^0hP&j`2kQZonoa*Lbj8Z+bFLOQX#eSWIq^Me%sOnt&%i9fxJqq z7}?Caz%X}cbrscs0BghB5s69D$J17Gcn#wBJ^?H<@P#ev1}9>6xc>=#j$x|w)pVuL zKLTmF{OC35O#8OU;f*VNd$pGm@~!Fl&$_Ei@>p?(&;*tVtf$6}rB}mO0o9<#ry;Je z_Z%xV#0l*)`WV{LuNJ)={1Zt&dzHUg_uAvCHAT*!j6)jTw!UYJLmKj>y1JP?+Vkj4 zSqIeh%i8=27~{*O1)!l|Jz0{W=8lVAL|g2cHk`#WS-?=ERO`HK zvI9>luw^5nE>GfycAtyZGq=}C!m$%|8EMA*$idJ#`|0Xm`GCo+Ink%LZ^B%6-O~_P zeg?yJnKGVf%0~q}ZBKIJlC0`nfCYcQ@R~PIn8}_!M--V<+Gjy3#z&8F7VAV=-{d%Q zNtOGF=eBPN;MZ-e_&9llGkzb|#F@BH7bMI78RV5z`IGqMsv1Rfi5uM*bx&Pdm9ZacP1~|bn2xYtc4#iOL42e$9F|=jOkO=pdH`3~fs{EEUvIb^lL_xZ059nT z{}o(~2GQ#ekX2c#cjyciBv$wUI&OEe^BP!@rCe?lP)vj=4>r4WK^Yv!MX8dds zOUH8dQ&Eu=OY9RY?DijoT}{JcBPnsPqNTp3U(3X-ei;tLl!R8>xwobH@J%txiUPfM zeyTpZa!oBp(UMvIGI!{zUnM*{ZfY1CotiynS5NeAq6q~E9V~5*BX^)Q0x>nC7;8Gy zw~>dYLc`Rc_|v>DV7zQ;{Z0CN;1X2mm(5bmom0kAOS#k)XNHV@o;A>~yOmgND~7^* z8jKz?Sq`bl-K9!^A<*Z03k55xnL^3(tQ8V}KVd;M;Q^lH+u+Br%8%W#)Uc^9&Qq?= zu#fqcPffTL>CY(Id@J48>Wuj&$KM#73TIcIY7HaJ80=62C@osPq66ATN(BEZh@><} z`+S?81^ycWA4Da}h$?k}w7t3@;^E6mtIS}$+k1=eqdvmQ^6mSGSV){2LY67&WQC4X z+UP9J>!Ggs)1BL7GEl*g~&1 zv;44y(EZI=wXmlchfm(ebQZG*Mw&E#qGaNnQYES4Jehp*boes1qUT%U0__T;X(+bT!i)!Ox}48w;P-etBf!8sz-U|Lqm{?^IMAI5FBek zq~i)RNx!DhqcuBAGEcGr<o>A&3R(gn2{GCrA??bNpC3~4{pM(z<< z&cTTpU{yL@S;+csA2m4bJ~J z%o5}5yRH*FSr>#MHS2Sdj0hq)6^vGvPRsI@x>MGulU5LohCi~hLFk1CXZgG&U#|oh zme781aoa}E6q*o{aj9$=+|>6q)}$8sn|Q|(I(hYc+LqPjA%#%p+mbS>%nYHHd09k} zs^l+^I(K#Z2$&&~kSir5-;hqYTMOG*HOsdvFHv)6Hw!ZMn1ip5bQ`k*7LHOXu=`lc!wVpbZQ=(hHUBwyrT zsCB;+Xt+5QhZ3<-tA!=OOzDy(PYQE zRM6*LK|PuM{%$1(D{W9bomKFHs3!4}au({s=OM(WE?Blg+E}Aj$JFaXzTq#AvL?05 z30(VY$x%y%81RnDiKdn|&Q03JBIvghI45uvewcg{F+E>-2c1b-2KA7mCP9hG2;#y( zAT3*-_2Z7&IcoxL-`g4IM5}IwUC1u@*!V$ls_jn3?*OLODv{t7p z?psuTvS^x9);y&iTLHzr2(J69AY!#UnPsQDcef1RPIU^8l<-^FGGpgk5Kf(T4*Rj8hPTc#G%2r_D_Hri`}|jX`>9DJsXk$!0SrgfP78VcX^*<;upNPD zw6G$E2o_kE`uSnCd!smL_MuMr!{$f@^SW3*`2Di^hy|#;>U*Z<_?VnCBg+-{dKk zUh2rGBeX>E|EOjuHmB@9E|(q$-#`780(&$I*_v{=xvpiF8%;{S()}Iule~tGM9L;Q zTk1cDGT^czPdu$+Z{chMl^m2OgB6+J)(P;{IzQN~+H9mEcLSjZ5}$j`CA^?e^lsur zV9gv8)_ZeqE^%y%Ivf)Ko^MowpHJ1B|A#P11+BKDEetF-uEA2~HG71` z+2-5f-BxSnn|Vs#g?77T48aP0hSyBOw^a=G3{koqmRc+wTh$>i7Dr8S?puW75i(BP z;lp7?Z|Q#PTEU<*DFW;DlbX+_L9EQtFSV2X^0Yb4o*c7!Y3SMA61Ys)m$2@}hKZ4L z{Cl6j?P-(K4Dv+5tfwv&RX_rU*~jwuTo`0jZi?MUZx+XWt~F#{LO?2dbNEFm-LRs1z_?p(l{ z3Ytz2hfC&MK7Tg=dQGC~evEzoHq_y9!V_hQSfn)q4K5%HM6) zt2iS9SK6p%4~8>SuM<_ETVYom z(nJnkw#4JX?pAfq)BACVskBjh;0tc3NQ3e?>4Z#gE6<|GZXHm94mVOW@i2Cj_S;_! z!`dgG0r3)gTVvex#U!Qx8G05U*lE)( z0!P1dfZYp_Z1TTU(5bD(Lz1uDEGONXiFrul@^{ZG9z&162Iwq*wZPpv8v^Ss+f@oZ z-VMY`K5@MMmqX=|2&iO(RgDVCbuCijaNi2(_RrWi0Ym;$w1~w+E2dkiyB~wF1G}J6 ziSOW4GEeN4AA>sz0h_+4Isw0iydHL9G*R%Kqi*P!&nz~;Hb(ZZw1?rgpT%p|t)UA1 zuZU-^ltOoLA$W>&W3{a9o;zp^N3PlxWhc6QX8zPvPslJSY@+ zj)7;NX>Qmkr%(p{`!Ev{NtH3vWhXn0E9rjY*)J*4V`)s3NP;mQ&yxR`dd~p&kqQHFrA==SBBZ8f| z8Mv2k!fVn6IneuRu{mt}$_hXN-<4Ca=%G6Ybmm?is6H$dnU>U$Bq6LrPL3E%+>fHL zeITP09HR>~d$>UC_f{JsoQQc;Nl9`QAWMLPPkF+B)p#Ie= zhBpTr;&`czvJ3k0S2&HIt|h^76h-rD=)@G!>jk9xaCPQ@JjvgpE6{SJTD>#q6{Nj+ zt}P~JV*q2Y9n|-G94IdSxV$bgZY_{Xv6>qO>!*mNm=05)d1_FPPUm(R#PIM<0n~6~ z0Fu;(YD-|lXBN3%rcF|fcQ91CUx8<_R}@1bw__2 z|Av8kx%i;ubLdqD5-ZVvC%qjt7)?*4(ND`Vqt_mF0*r1gK+gOH7I_vczqoDg>N?-o zUf@dLvqi>IX$FEgPvRZUb!o3pS(4Q9@fZfllWggbrylE2DDrgX08t3p^;_vI0qnIQ z4tkt-zVbUyTbLK2)2WT&4aqFy)o1x&GnbdCG-xXZz%K&iwFUGjVH(8XSwYNBBiC~T zga&)*)C&cRv#0grNQUMK5~8j*H00XCcX)UM2mcilr_xZIzczof11c~%fmW=_RuDR&Zscm$o#1(X1)%wir zjem)5&!2hve<1yHgoCT*;09Ni?c_mv7UNWRLJ%79!O+ETvFQ!tSV1Pa>0@>T! z{fe1oo?|jq71~G6nk?Lps{FE0{f-=!@BmUFc}WA&IR?_dhfw0gjbQG04QQsT65T%K zGQ7KZ84f`Q8#c-=7mVrr6=p3^2OV}Ub8lTsXkWUORIOu_O4EDN1$pYN&W^cnM=#&U zUR9ha9-5};F!*KLaM-NLT4euD4b*>~a_GA6qnr`lvkW+-mg7?`rB(AAPP7~YBA10o zX!m&5CjfpK-JX21B|=iH;LI(DtXf-E4HMndyuTPo<*O4LTJ zOR<0c942Q~V5-`y#-`@=(J4^k_pv_PkcS}RCLyH?Ftv?7m2le7K038eBGB15JQJqP z?GpE3c>qw``LJX80}C_(P6vuzrST!LkG%`FftSgvEZj?LS(k#&jnM}b7zpA#<>-ta zH{YJ0p;(#LXxrQWAipL-o@(}is`8Y39(pL^E1Xj*C-!pS$;!+ct>slx!#?nIep%I+ z6@=Bcx_Yc9$PsqD*aaip=YC)PD&OKnVIzYnmZVETT~*3sP$r{u2(s`ATs`j&t4Ois z$Jc4nUK~Rg1dJ?Bu#Y^+U$vuk*xOPp9fK|>M5xcONJ^|lhxx2YNqq82J4AQI2Cem^ z(q_y1>(?}2adn6MnuG$VM#NuIuQwXPnUtqdmhvipq8QCOlobiRF~{m?fjHeVCFIGY zCc}T@mEM?hQ@1guI|=3H!il51ATzHs!P#Hcq6Xz>f@7EGgQZFru*{f1 zA2tj1dPi-{TwE|KiqWkk6WqH{0QF~Mx9<9-V39;cUXvuCgI!w(F(QqgFp>&BI#Hkh zHO#vl^S8g=2o1*GM%gv~D(Cg^vNc_eMCG(QrT0DcPhCUj8-0(|)@d<234iB7=XhQA zw6$AmN66w*JXqxuXn#0AJ0tb@j~JNb!z46&*;JR^Ts{*H_}<<6Rk}Z<*lACje*%>1 zxeDcJ?LJOS)X#yQwngVqUxVHwYJMN;sZk*6)!<&9Bse%ic^*FU-g7&Z%L>-)kW7Lum9hiC|O4A&d$BGKVbfr9Xjb+Ns1wiaK2<>10t?oq3h zxQJ;UZx zIihlg6O+IbQxH2;yW*cT0NgEgZCG7(p2FMs2wieDK`XnVJ=FHyR_`G9M0ccTZ!54C zo&eCo)NOsjyYiRa9W5_U;qxqG^I#OR`dpm`ai-vUuBXazgt67B+`0?0+I8_wy-boRaq9-#@?A=}T>mE&=zyUXuBv zeJlzN$buqUS_s3O{bvW=ZoaH8`f0D4QF+Op z6h%p}JNa=-=3>GY<4x|nH$MD~rWdL5$1(eil{F_s` z5~2Uruf4@?J9Ru+$FS78=KsCm#j48buw~wT)jXZ2I~ZTP3B0sZcX(`W0e#!v0@q{X<_K{o|lO#>oU zuJGazj&p=glQ2BooDI*T)5q>H6>_`y?{ypIZot1&vvtO>N*p3Th``yI(+dd9?o3%u zd3S#2k_88kq-;5vS52*yB|anxDG%5)MVyw+v*S8ogyoxDPoFHf05jie zLD-bnd%SF^N_*+5Ze~l&>WS<-Sa1-=C|V#GUjg2gvQok&G#oL=mX)!KFU(rLwuerA z=!jaEd@hs6>L=MKA>ZOmipsY*f8Mw1v25#@JpkoL6-cUV5Zd_wZ9Db?PC|Cyk;KS` zry%e#97@lfS`?$+uBwJ0=1NZsFEz?t(X_14_##(rKg7Ffak>yr!Ud#kdaHkRFV{|j zP}iThat3?1bohFmU$8VW`pUizTVHZ@=$8DPTl*FY_AUMv<9{qC@q@ES&((G)x2<0+ zi86Fcs4i%mbk+~4P5YF2gN(%icd@1UE4V>M$s93+ZvAMRfW7N0LEVqBoDU-1FWkeO zW0jWGU<@`aH5)#XBts8HPhNR^rpXT#knTjo)!$h_JM_vHPyIovZWqMRH6IQ!F-3&D)O|$9)ZnStv1F+ttv6N@;yP>S-%mh05uQ`u8mHt zHkNO>G7A5pRFCw)bV!Nm5tN;~2L3Y@Om7Ab%%KkLMV*=d&r^Tsih_>~V2s@lSW zI?FgutG~zg_J-U`Y4=iAoJFC`vlQR;(kpqrW81_``fm8jXen{1Hs{BOi*!264B%O! z$wTW_QPo@G@*lHkCFF1SMMFR7E!=tNCDb>?hIWpxYYDH19+HIl>3CXM%gNKWt)vB{ zB!urijPo=EyxeI?FNFYgawONqI+bH2BA(#Bma}8FA!TbVzQf5&4kMV{aD_J#osUJW zctL2^;p>a(O1oqN2V3gFv5)R!K9j$qNdxn&vZ$LcJ>zpMbHl>eg<4F;E-&hV!A74jQM54>uPI(#kFi}AOxW?GXo zB;~c|y2Wg7x@6fc&vYdiLHbdF&nCPU$s$uiC+;!P7iYoRg0_!iq9nynz?gLhwvf}I z7pmhky1uel*)<+)+t&r1BPgET7dI;X_G|=YQ{&o({`Di6u_woejDIG}gC zMDoq#v(*FUfxlpZI;}09oH~sjnJoS)yd$rzcI{*$BUNqvy1O*6j46|zMSG>|1NesZ z80P5Pat#kvt~EX;^(DcqB$CLdU4e}HsH(8bSjnY}=uGu67%s!ks=BOw2@a1km6{XQ zE)DApX^UO=v<-ZNV|}lFB`@3QR1|ymL{tz=-Fg$MFGB?^@y(|&lHVRr0@EkZ)XR&P z!Ktj_h-lQtOB;ReoN5hO$n~8b^={HfdZ>jEekx*+4fk zBKk;JS~H;>$!tcr@>*_8X7(UQ+O){ImsD=>vO&*JPN2ue6k1mWg=rR{agqF8>sM}W9?7X_R!Q~{PkvFep zvNn#SHB-GjW%G>1Ir1%M6>-5p*j-<%aY^tU8NgyUb!e5mzTIL;cP({AwS1eoppSlf z6r!l0b}CE4rG#Xee+Z#F-a0wnLm!?F464pIHHz+bn{mLN0ks&D1Qa6tS($cIZ(yKU z|7;Cifz%>@*Ak# zGjYHTE9z&?7&Kc_GadEyv@Kb0nWySoR}c&OosJD#3lWHH>R^ukdti%a*-&Y}DksuY zt8x>yQxh!W=Pr}k*%^|L8L`Axlx|>c{O4UOy^xQ2*;KbI1^=l{H&0ut6%;eyN-zeYVhk@ms7hP!Cp$-u_S1gWf2T8T+BMU03 zmv5GxF$pQwTN-JJIt_bYplD!9JiUWP4{t>+9rwbR4;`exv=$g1QrZ?Z7B)-VZvcnH z1;L3sXQNPx)TaAa+WSJN@}RwCZVP&8ZF~~c!?xNMdLLI;vR)v8x&)o@zl`4lPgtoQPaFRDTx$;}7eJB!kZXH8zbm0mCd) zrg4JHuP-8HFQNwnO@^YWcdtPsH9caHyW)4`<7TJ6DyoLsJbAD;hIx=pGs9bT3xTMX zg=K(1PNfl;D`cyoj=PP=FfA}43w-|0jyhBmS{3eDhAMdkd@lq@u#MQ}s|V{i(a@j_C?9Jkw! z+Vt6g|9reqVMKDXJ+lhxf~<3n+gORv5q{|$3F}>r4;Yt!T9_QN(8AixX!R8^9$qW! z`WI#=7;vSH9^oBW>J#;ny)rgSQEXQCi%Z`I4lY5D;*%8wIkhK^==DW%Sz@L*$KH;P;Eoi5k{9mMylVC#geAo5KjLD6=h~I%-NKb)W znBya^Xqvm8i#W|7!5Efq%-KVvNKK@foh4$_4PxbKYcMQ-zOQ0*we)45;^%oMQZguP zgj3Y>0gpK;4sAb*6>th`p8 z3}QEpOQI`v{j0iuvTxY>KcEdtT8oK-Y{gW9rHA4?b$MhtMP}~U#JGdE`s;QhvhH9> z%V~KKoUEUGIa4Rd z!kwQfU~~a;TE4PK@(rdh49w}&abneOTI0NL?~u2peK6Zmm#|Ed?|$2a4Ac6cpe~+; zTFz@Sv1ZY6I182XkucM4zo$)3zB!X1IaYEC)HnTz$OkRk423&uCdQ|cgof$h67S@! z<{RPm1)cLxUX z#k#MLuzzvSaMGSgo-ht+s%_cD9tXRIso}Y9O4ZtUT>phu@jqtp%!2g@w~4O@O8jMPC_O znj-?uH@T!~O)-$;mvbZg_#D8l9D(uASX#Tj8U4J?3k6lZ8~hs~?NrS@5+{2l@z9E#Dw6lhpER1Mc$EmO5OSCPXL5O3$7Cu?8Gdf*Cg&NGsi* z7*mCt1PSOKS<;(;ZX+j*b#nOcO9N_ts+ zbaw3TsK2cmjI~lYWNs~e&-4A#uyF)7VDsm1EMyCfuk6*y|@-1v?e(Ju+RD z9+_Rl+dc&9UP?lPGRQ*UkI{@xzv4SeF~5W;3VAwp4XKb_8jU)#!r*mt3quWVt=6^J z5;0O={w5u&McvYoYuDVE)pwb9mlH|fvwU7!Gk@t%?P4oz1bXsWxr=C|I}4Z!h`x*1 zLs{1nG&EebEjyC^;2QyQ8y5D@9E3mAmW=EMoQ)+f9~8hgqJ7y&wJD|J(1D) z9i4vB8K>jz^`^)ygqA%v+)f?#LNzAd+!oxaYRR`ef^AhR)qDJ5*^R9oF z(3uIxboa$wmDDK< z%U|4dZ%4a_?NL0wtRw!T%?gY_@0L3S%|8teA+Y6#Dj0AWk5O>iV=ZTuIufk7a z(mc+F3RxYP$)_yScDd~daQ=eB+TX>6_%y~t6Xj}Gn6OzO6by91TIEP?acmN0#)9@+ zntD^k>P**cfCUU*hZ{8L3WX$j7~ z_;9hjo>b~OfsNPs$51ji*Wr|}>xxhPK@CI&$BE&TDb;P5J)T31&oJkSxP{DjOLlluuuQ0xe2ljkJMni9*rC2;@?M?h~J!U z1$^RfoEs%ZIRIpqCh+OVcKH00##scF>i=3pLk_1Y>zhwR$ikJRx(*wlD|J<;eaN>F zwWwL1%wLD17ExyXIYL-!5d+DSUrMVpnIF-3@Z-M!sUX)zzU7ZT7FTL^EQX5goT{SE zeB&;PLK5Tz%mmF~ka=pqRkl+W;A=ft)NtTD9!2Up`H5Kna3OwBt$;@R+7GKZg5?uN zc-Dsq{jE+lzWtsM>1mt$;Ws7j2E~*fA&U8v6TD@$f2KW)wbV-Lh!rhj7F&0+K4#(t zJhWj4xEc|AKHgyMrn*bx)^bqSc?Fs4mm_n(Lr`l&0`y;oCJunxjZT-&0AIEH2bIyd z4K*+MZaL~{GhvV3X2tfTY5_K?ZN_TkYCtVT?_5WOP8K8LS`eB6{002wq3PpfDWxKqA$2&$o<0w>Y8JZ%~Lbm&XIX*-d(PHK4=_vCFwZ zo^d>h`@6hR6c;JfwP-2z-O32KjZe_{fqS(W+!m)FLj$QV`ADRq)xQl{M|+!o>E(_Bp?GMi$B`nAjT)Z8SmrE}ppoELN7(0vqbE!CcA)>{ z+oTN(Ih(K7SuRw|-&pf$$&!s(PULlk0Nwrgcr6aFN`t=OS=I$5_ylEIG(b{jD2Md= z3vmBp!b28)N5BF%+7x7j5%QGf%YjAN<5^KDL8W6qd81$C>2-Fhk|+5~n%kM6=T%r# zUhf{I0s+nJ`H@%oOFIOaHi6%x-#8a+`VW9KS>imIoq9`(fA_SR<&T9=hp_JJFVJ+R zu?IoLn=RPamc5Mzqj3%EshygQoKoXRn z&N-SUQ`=Ib!lSoy&Gg03kxrX^cM_lcXCHhT2OIM05&T)NdWncnfcWXQ!uVH77^&~t z0V3j*m{-}615h#$Na>jsAjKY4n*1#qJ~(Vzcg7W+Jgs3ty?;wGCzrMtw!6PCQ>LGq zF#>Oja0;$2y46I^&++P14^@?IBmyf5=FjH>WzDNRt$9uN<9xHXfzI}oz( z#HBot>AFK{JXJdoRw>Q-m>VzjtcOMO&o*@Dj+(~ zzR6Sa308lzO+&rjv=d5O)i;@smtkKP8ZD>-?}fIj#QtE~FfMxWCrGej~a)bnmx zjQP+kcnVu*^|mX`&PcmF;c~w0-C{< zJUvMUgWRo~1EQn>zKILroNYmjE55Ai$J)y~5P4P&NjDXW_Oc5x~gmW;o`@2dvlOW^scuQHyIm9vEy|tmr1Z|{*3Q! zzC9XLHzuJZL9EcV11RbB4m&huiu_Iphvl~vPt{9EcaqCcKf}^Ygdr<~-Q#W$IiRpp zOwvv%W@*tMe`9f^`fzm|o2;6Y*Ez23ndl2E$x6j0XrwQL&}L?~YOp=+?r{GLhgsc> zD>vY@yM>nj`mzqHPhH#@{NsX`ZRV#`YR;dUH*Lp?=P6K|;mt7^SWt4~06hNE*QiMJ zU)9CkH9#?!9+2>P(m_w#%8mp>G@8&uFI+8N%jyA#1-!6`d~GyhC2bsO3pdptg{;c! z(Y&UpwbOpY`L$XYnCb_}dGQ{i z%u!cV@6BrAKoH@$$CJT)F#gaxPt0+^Jdij&#~$5e(GPp|)5wH<^`!0Z&#&41^msR? zXeF{VX8Q;z{pdSPb98knO#WoD3>p&_%@%Y&N^LF6HVl}%KV8d8=q*^LKsOo>tP4P+ zB4I*9yl-lE(yL^F`rF`9u{?#|NfGwn)sSf^ar=YCVe)$5Bv83;www<6md%CN5Fhx! z`_KLgp$E(=se#+uFcBFYOIsU`e7sXDU?tV97h2_QQZM}~-}4o;Obg z?@FrtWl}d1t*K?{zaP)AbVBaU&9X80*$czn9bb+JOaG}l;YKSZoCEN3KcCFb)I#N= z$U^1-{OnIShEKB-{65{-8;@rm07X4RjYxgwqWbkEXzlG}N(cmwX?WVd^e+P9_o?8^ z^vbB9HPkX;GIO6&R`_(aJtz1?h1CVz5*T_Z>(($>WdTFErbC&khu|=ur4U8Fy{Jvi z$+IF+i4(yusrsOmeb+xAg1qwing6iot?4Hi@LezjI8Z&MVgE{6J-e8Lv(I}q*|q4ts`*#kWuq?$mW@fJGFfcDXQkf!v{OUTj6XBLsAzafXg z)tF{D7%LquYR!yZ#wl5DPZ&WE-x(mk?64zZof^aUvTaJ%C#Ecm(9N+V;7WSDhrahw z6vYQ3I62z`>59{dG0c(FT)BkhuT{zr3xb3BQU4W3_Kiu8k`Mam*TCRdvM&IoQCF2O z-vDXvgtp^kp5`M_*DsQ>qSkgl<_I!U9a-Smu?t-!CFIzdihG}XsEbq!V#ciN=N(}9K6 zaGw6bv&=lb5R1BU%w{FWWd?=5t8q03`KumZpFv6WfZYz4AKqJ+uIfGMT97ZQ)LM!{g z;zZD1qq76@L%fv_z>DNNsP48KF{1=`7##@YTV0kEoLYnscU=VpX4GaD#8&!eVodfK zbJremd5tU_yoT5xtP6QcHcf)I_(lh%jRNW{`KhUqczI2?zC1o*zT(U&sc27!JB!!> z|5P*_fJ+t=5HibZeHZs3wpk3rCwNXDA2B_0qIXOWFJQ+cxT$O|RSTpr7tP75{F&JY z^X-B<(jf1eR4mQD!{H02aW8u}xLw_4>SLEkS1Rx=S63eHJc}0SJg1h1x$8cLpjTG} zaIDf!*n93cI6_9k0iL%s%q@n3oHtd7&4rJowFyf>@uF#1x>RcCRj)eyN%e0V)`Wr! zQBVS8P`UX;G@qBz$NQQ?t0YyX_Zn~M5R)ibKEfbR>VVUwLD=qpvSYhlZ}nh^vi>~F zI8~7M($n6$#K9j0W0sXl?H*ej1`V17wDQ@5@L#GS8uI#PG+j7(0dX1q2)&W2Q8~g{ zyY6|}s;E;`Gq*rEM_t4^Z*>40Gt|DK&%eD`pz;$=Y;ia~(dm5qb>qukqwtK!E$s^R z|EzA~-8Qo$(NJmEbr)PLPx6;gZ7PY#ZON8=Yp+(aB~;t_b0z@-`8MG)Xdkr|y0x3} z1IMd+HiCsMQ7wC3Q(t(2PE5X}1HQnH5K2m1J&*WZh(3n3N@|O#%lNio;zk`tFgPzb z$U1_)Ek6b8^-T{fUQw~kU5z8w>!KM}1ycR@v6e%6?gz%u?Y1Q(k~(my56kji7DDgl zf#Kv^=j%W(Pb<}WJ(*p~;aP%9@BTsSO$Y{wPd36igPv0CN@9pI?E~;hi5q@+OY&s? z!V3Y=f*k!+0w|Wh&q0h%et&KSn$*Ut%|2@c5?fe z6-X_c)-i(Iu@x~5UsI7uDIbLC*+4tie16Wwbt-wm{pV8X_Qf2c`={NBUKN;uR@(`k zUNJ3ksa^#*r$i;-OE{E()oJX$0dieEchrbFo+4Y3bsY8Zw6snwdkt8|4q#;SpxH5; z@{Z+o!qW=K(aC!|Sp48pjM=`npOo@I=Y;izZ4V@`4J^U!G=@UzSa>ey3#&-J;%}h& zJ}+n#kp~p}p5avzl2(Ne63(W?Qzu4f^36^iK3!8?p)*abSKhAn$ezzyRHu$fE*Pir z-#3s2#}4In!UrF4Rr?*%gkq%Nj@_U zy^?w<`ZW8}mn^WUR(;XCUYsbwl_3+(HW*(zg?70poqBg{vv`EJrR%4b3IX zkyO2*+ZNc?p%Z#&csQ?)$52atM|0~eZMs_uZ+}(@@F~DvC#=W<$L24G)J}6pK2E%M zf<^&5H7wnimSTwF!APleK2)x>_PU2J*tHAb+=_VMxzrYvswBu?y&isq81m7#<2?z- zyi#-dhm`jqsjD+%_#UT#J99Om$rt{=nBV^f*ZrFRg1r>#nvV{%S9ACBVN%CLHi?Na z?}$&=dDNRpy(J++YX0=*v3c@E9)Ml zN2#tunRu>QaoVnXs0t`iK^?Y}^l3VXtoB2k9fhoVJcmI(o`Q!Y%hL`FuUFT%*r)n+ zII-I)WKdG&FQGa#H-lQ7d-dEU-I?n>!u3hZvVK>N%#$t@_Q0)(xKO(S43LC~cc*lg=FbSE1ZAhvE{ichlKOG;V?B=}7xg(7!uBLSk6N;+1<%s0d5h+v zR0n2Y*Xp%oEEK*#FD0ROuwVl|7=A7X+U$$7t~7o1vEZsTwpPMat~XK>HoStejI%E2 zup@e?$S;arcD7EU7J<(=WAZBJ8oakfaYW1@OYC=khul+cBbQajq9a@`S?aLM z*8@!6FJQJb%?!x>sUb30uL-Q|)(sD3jM{)R?h7TdZ^9Xq>h*3cIAVTDIA@y*7u}qV zs7Zf&^n@AwGtvEV0gjO3oF#rz)-2eS%;qnuBVvF17LDKDU6&}tMkK0$Kd&ZB!6AGz zucfBKgfrULM7Qs>yk<9#MudhMP{HH{bal2vT1=aYIBe+;uPrWe3yh~#oUQnI`ej&I zo`w}5%L^~+Gu6veeNRgPgdHZeaC7+i+yTqvOO}_n=COD%eir?r|rrlmMxfXMhpCtBLjij%J5Y{kfn>OOon~ZEeK+Ad{|`- z-+@Ljx-?$q7Si1~0N-NsF!1*{S+$s5DDjSB18Z5rnYJ#ZNVf)toB52t@dMmh8g`h@ zWN?N{win9?gM1ByZZj$N(i79++yo2zdJx7Y&neJZv?#{Lb2(WqJ*J`4!w^Kd17(tZ zD)y_j#)dj%#Y&cYg*`VOqf7@&e^Z`9rP)0lJKcz;5Y>-T!F(Jd@N=$K{ zsu95Q`O1LS^P%F1NI=W*rXU2p{fPw;>bkX|Z5)WWCZ;t*GOstq@p2!dOXOSE8(?zO zhYE0eWz^=5dz5HWIk}z(GX#()vjZyG&nYpFB}iW7y#})J8B~j8{?uhU>%)4}eRE}j zBs_OtQp(n=xxc2l(+hl7$0R^dWhm(UvI+t;O;w)Jdl7%U*CVj)(ufAE(tB0|qIl(G zR{tQ%@<(*2PWo|dv!zc>S5xjl=*;WD`S7V-Zdf2kaAuBw82E)_xaw>mbd|3O&>vl( zyC6XBUv8<9QiGTnx%&y#vgC|aUqN+n+>7nh*Zvx2wrN;N@ElIh^LXBV7>FX4c`!H~Cw14=s8rpQA?35-04;(O0z?^1On9B}=Bt zMVaKcP2(=-BfC@^sDevvOWORYI^oLaA4{dxI$Br5>~rbeQdrZw3J-yMDkBQB5r+lzd(GWFUG}wG6S5A?P~!R?i1HYV{N% zzrE2P4o@jQRR8+R{f(m~H;k1!LJ=Ql#wPhi@b z%!igzqVNv5N&MZnX)sFqK23a(ZA)Gw(^362gOnN-#0>f6hC0SARFym6hdWbDmdm!@ z+m(fAEn$`TM==&^ zl!&7|{%l`c17!lk8D>Zp5uC~D#=Y#BdvRo0c@$LmcqfhGQw<>Pzl$<567HXX1)Z;| zLF8q``h);!oe=E5ZovYt7W347UlLucMJ9`?$q)PLZ&IR~KlRDejzabHQ)|CFUbZM_ z7{qQp(o;L@KiI}lU};?`E&TEmEl~S4zNi%2UlA~BRs-CUlVFS5g;L;|?>h9KQ(Erj zsLyQ=E+;q)s1t6A!Jk>p9Dmp~%^<60Vf|!5?4G~35hbqjRmG`Yb(4GNHetT(0IEtL zkiSIV;N8WIMVBBSl^%50sU5)WB8azROczMe8DUD*4(lv6&r1Q2 zyH(tF`9Bt7C84Ie{osnaLL%cz1SiQV6h+cURvVJ#LHEb_KA#X0d3B8R$Hnv!|`hm~=5In5lniytGVZV}1*J4eKRI0>2!-iNV_P=ET@auB0$od;@X(6X!>-Ri9duHL0U4_e1!; zxVnaENS_watu<0RrxITMGym+|XSlWsOI}aFD&}lH<19+`CK{hF0v=kjiN76FC|1SC z67l0|fnLRBR9`z80{ixjMOt?skrcXBD>0%mLIvK!D>E3)+XbIhs$Z@4C#Sps)2#T3 zv$#Dk{JdvFF)U)qYK(x?foAWwVm=!m$ERsDDg-Cy6?BX2?1roM2=-3-yo6??lxmbv z_7Lz1UyaX9rQOvHR`U93SO@*|xF-qc{()mXmr;SF7V6%Nt#1u1z*EOQ!e6q;-@QMF zG+4ILB*;^rAZp%zcz~|db&O?A>z?H4!v_n}Ruw^0Q?;(3p$hIbC{~wXLy8OpfBXQb z$yRES`u6yvOWJ3$)a#P3#R=GF1*e^&5A>0IAGek#evkvD@cdLymV7y;#fB|=@|MVi zEb)Ef6Hj3cUc0Fdm9D5RP~Vmj<(8r|*5zkT`2ME4wjo&sYLR^M7x~0bwg@pGSifOGYo_+__=r9HG68RFA;8?8E&Y39~+J0=0_G zM9~VNhTlc|$dmjvRd<#g`{02MSk5TnU+=3Nb@(0QW#9`t-H}Bc!7z2=L$OqDMD+Qc zbsu9Ka;WgE#FjEFh2=kWqJ*>&$5A&mn{{bsakEK{;|-ex_qu2FqNIJL@P;L!?MhD# zvkrkk1)-bV2(!*55rcu7n5Pj7(OfIb9MzepDJve5ygDiDxaw{-l=c{8OjD+-PROo8 zFF|RMo)r(P5f$o={n)i3w?@PUH6eX)dtJi*B``{}oG{UW%2#%9cV;BK^ETHP)dZ(9 zAyaEatPxt*J8~>FK3L@k$$5!NWxH_`T)RHG{K=tEXac}d8x^;>EkG|^fS6{mj~jUaEFEay&F*q0>P9) zRF(1$s7o}7@l1~gxcgU*VPgK&*QYU85rh+d*VV&>4+4XA+1YC#%7YUY*KI3{jhZvX zp5WsF^z(Lgito(QSrPlQ@aa%>MckQC_~rOY*kal{2%mog_Ht|sjG@4?M_roLsxbU= z%^pZS_EWtiTU-23%l@q}=oDDw zZ>u_TL7rBgwVG^R$`ZO8J1s+cZPRt)5y!$=JM)B|f6Z{nF@(ua#<8{|Aih;Na(Q1j2xJs0v7YbsUVFZxWqp72gd< z)1@17SrUL6U9s2XzMK+pXAbPkZpAs4YS}V|3J#sCplcD0B#UZfgmHCtQj1j-DMSKJ zUG9oQrB3_p7?y{DXIEwqo%6cF=!MVf%vn2$A)v(1BWd+FIjJH4vVAPi9M#s-c4Av0 zrjveW!oF>QC(Sgp5GBsvlT@9&Y(jGk#AVt!r%22uQ?K-o`MUG?U}AW~ab32Y zMddzj+lawWGye8uM5RfO>SjnzoO(}LM0|4<#_>^>AbwlIw>uX=U8#%}Vph601o2xC z8i*Ec2XZzIg62|Hv7(l{Ag?dgG3Ym^l6hkpJuVmOrs7I{i?lTL3_XKcxsL9qTMiv*)-fAKY1m9HA0N{{Vol)T5`@OR0aD8TufOzO#bc7uzAh0AGqjZ0 zQuX7@nEsiN7&{Zm>0MIGgCRHsULLTck3agfRC;Jsv7{T!Iu!p|&T8_`;;46-{;%ca zvX?OqK<3WafLnPqRL+&WV!|gS%r5cuzEHJNqJelGgMekuG1w>FeyG}G)equ&BdRjk z3kTAny>L#l^g|UCxr}tr?@KrxX>59E1)HAIx*%&T0eflL()+8+r$7ae1k1|_JHr#7DOs~ zU*XB_6^3BMT+8&<>~h`H=3Su=C&?~W=F?{z=JO<#%4&zs4MSKPnhw#p^|dg1Ega4s zn2?^Q{}(FU3PD!Ok3_bl$?IyzYwJ2hn8k=VbS$stQ|uT;U+B$z&wXFcKk$U_d(TXQ zCs<`1fvwELFkV*yXn98@S!(Ih4imj=T`VE4+d^4xfR)W->orhj|~b&Sx01{Ho@wSyys`)72nn=|x1+v#O=Jmi%L>hjp6~p&Zdi zFiU;j4=sl;*;}9r`vrWaA$fZ9I62>5K$KTKnH!UDM{dP}KB!~?FWZtdXry_kiLm7L zMg7lkX0q<@xlD$Q$-=jOi!iuGQbZ){4=IK_Q)4|RIURt#f2q@TBukv-fMyEOq?*B* zk#keMJjJ;x!K8|~JiA)fg5b zh!z-4)JhsE^t>K^V*dbi_D&RIHFttPLl4$wXmrjpEcJJz>snuf@EOM8Uf$2-#6ICe z$U?7>Lac!-33DZX(JCh30*g3-X5G@)Tbp%f#M>D+kwEa7hnlCN_ z+z(3-tE=Hy?1S^uVcFXu#2-#$&jsAwt3|b7(6$%FLTFrB_RYCj5T5=TIwJ`mv%$8j zv!NMGzWy(eEqSdOfx_E*h$j8JApyr~lexnqnA&9%YUzuYr4GkEL;|El11GeKJQb;8 z0kpjh#Rlghp}4wW(KOV30tn0fvMR$252)J5k4l>rMKw#7Rsr;5(gxaatb*E%$X$d$ z)B?)Q|ST>OQSa-M3}v6dfGbsT6RIE z6=ke3{ze*2Uys!NVnQ`t-T5aFhuR|VHE>rWc$+zhSK^-XGBbMPhPJkx!;WhzbaDVFR@Lc?0H zK$Uw|z9tDRttY9Ux}&N4M_{t#b?p=!kyh#|IeC4y8!fP^03`cbCLcWXz@5!w?fas= z^CAY4MgH0rKwY0x_X(SC@-$3cj1=cvoYyAPfzNzPu;-;7O=NDs5J1A}p^F(7h!}`& zlBdOYX6SG7Du05tI3sYA+Vugpc4td8f4$bspRX)NXX1TxW^B6rpeBHf%BwrlX}=j4 zm`|yPi({_KsgnXE?rT_z-BTUtZ?h}`8t@rzprl)7SkEp#T1r&L;^!+^T9UZcR_xg7w+b) zD|IpJfcZ~j>Lww;w!YYpei`2o)eTv$g(+7%8h20bk8klFp4_t1s!zK5{vn)maWOC# znFB0q*1?z>FP1AbY#l?C`?DMop=;=zb(xS9Lv9f(gUQuBt+gMot2q3JbFP<8p}%eQ zhgwfu_**luiV7kr=HQy$8D+UF;AR=u zLoMZEjNNrUn6yiaB1_>6@rI5;QOxK7J$hCG8;xdy;>6X`lPnjSQ0eLIbSt=` zUvzx{E}Iw@m7nirEBh9ys1{1t`a(mDoVS-7p^Y|h)05FCyy~w?5N6rJ-)EY|Ro5?$OqpvmORuV=U`}&tFxTK*^zgKOZPJja%YuyfW?tQ5@?_TWs0XQ8{vM5iJ6m?2 z#fGS^J(>NV&G2DE$Ys7=|K(}Be4wE2R<>O3#P5%8(Pbkepwa!v!W_eJ0$drK4jv1T zB?=&>Hxt5+lJI!YJTzfYWBqEM0Ypeb-LtW{edclD9PlE8u3gukvscC)8yY?h6r0P< zBSK5i-|6vidglhh?KeS$hJdo2T6P9fAByEX0tEvz=YSp^k8myd8j10c#dKc;$83#~ z1GG%4c}m>b2}&nArk(SpyWl6*J*hRk!wjUo5}ac-Ptk1#2505`OY1N6i(I zWI-eShK!%neb?*ZnyT5kW=};@42sbQa&MIY{=I7~PE58jK6YVHR$RKCzUbm=Kssj^ zZq$r(|BtS-0IOo_`uI6up`wD_*xiAkN5?K~#O}Z@>@MtXuiahOj%#;ecXwR7`}^%R zGi>0#-^0V4U9)rj*IIk+7|3r701$yuH!d{$qxu0v|4(56BKO&T#&4UC=HA(uOF0{={S8V#4z)TO_+nz)AF{ZsqWEIwz#wCh59vBpF~-&jt0o$bA9(n zvLfFlB4i#gCPp;)K`cbpyPe6cG9;Iun~}ugy64xxKwiP%1CfIR?RLW*}PgD zMEJLYRix`6{H14S5a9llKFxE*e(mH^;3Bn3=hNGAiPw5iKe2wa$yI_gtxaE9m5g$9 zYwLzY2-m4F+HntRAhye-j+oUrb<>pO_p-As2pVRm503q5iT*sy4wEDs?<0f@svB&@ z+r41{mVQGSw@n}5tZ$OrSy}aCw8V9^7i_8F@0^@$QfmqBNvVcTQtnJaOdEmBb`C0w zT+!b$Kc{VacWQ@i$t-Dlvo^!M`yIWTaXIKYT#0fet0c4F;~H;KTv4n-H?pPhbHyk& z2*bx0*tI0Sda}NiL600{`dnR;^Wk?*=yV18?yGw6$%GROtf8MF{tb;F^eC64M=A1u zU6$b0!WM03hoQmGPfw8rJ3jvn7%KM8ix$XA6+2Ks|X^ZnSCB4MLP6pq+y^1q z+II$)rj>0e!z%?OI@23@F5Z>^|~zBioV z(p?&;W5e>IZ-_UeGL!0vyWz(?beOoVG=-!RhaW#Znh6X}4x}zVovVWL=h>KJyTNWc zykIKmyFC*|)Cb^K=VF9aVy>H#h;u_2qe**^jk~|H!DrzXrt8Bm+0&6sRrmV4D$jx| z|8S5&m4OW2?Idw{!<~X8zoWGwpWtsX*tS2oj1^W--Jx5e80=7ze7F^t%>Xe1S$AoY z1Hs(Y(~5Y$U2?HG^@Pf{?bYi+5QlQgeqy)7J4)M;r8XBR6pjS27In&D;tI%*{k{)l zDU(|neUFw^t&xthSw>>7=iP~xTOa|dTE9NHy7r1liISw#2LzfJ{A8*%=Tt(qHsN3r za{dpy;Lc=2Ph5s@C|!YWFT9<>>)s1$*S$NpoJ|Mv0c**0Z|CG3b-4mdb1%2sy0k?i zWNY#7J@?og9tU2MHVeAw$9JXKuCoovRPOgW6t9}7TdVKVS0SC5$vv7Ez*h7$HiTYv z&J)qZDq^C=Zuap=_WB_+P4SA|XMx(&F~1dN7U$nI@W-M%+-);}3CENZ6x76%wu$^b zq&BacZF`9PIGh903tCDgZ~5`?RHvNl&m3Tt7Uk*#DeY|&hSBR!Nhr24T3@S;O{~Nb zuebx^J91N`?nH4V>emU@tZs)X+ySZ9K0{0^$+V4NHF-4pd2&(MO@cVKOxcsl#@5Es zfa%;i$}RXVJ_h!JUd$0=rLzj}K6@`?xq>C8Ym=7Jr-vX0SwugP4ed3kFD$Co5kn3b&z&YrB5uPaL*#3TFAQ8ZAKl-_=(!=kukSyqR=OBe&tsnvqM zEhDkg?#Wt2>6@8_1E~dIpyk2TE_jhF#-?X4T3RK3&>@nSO7HJ?@0= z#}2B;jXP~nO+TtTN56Gr+~f`CGwC{;802=|D3d)sM&jDCr#zQtsB`KIYA}Imwtyi^ zv!pdQLDzroXKt#NbQ9yk6WCH5fonK$W;MHv{QrcEjG;xx!d!v5F8f2$3(&sA*K$qzOV)7Qk6 zBR7~1+Y33Kxnxsal&l){v)^qnmr*S^ULXX+Th)w~5B(K}u$~U`n(qYx=eIyPPwK}8 zbQfSU(ND&MnljjZ5KnD{jI)s*msUpE1|~De3{T!zv7#9TX7ykebDzHNP#qyM-~)V3 z>K8DP!e`clxfL1$1O5KUrm(JaBH>auCSf0jCz1M?dyLi9VTWoVSE_L_^;}L+{FB%6 zO4!b`(0xC1lM_w7H`AL%@3L@}d%c)-Ecj!CJ=-E$q|4~OG*TySQTse3w!FI6s#7z{ zJ$ix`Rh;`5cjHzq%I9M`&{uP_w_|2~U^roeru)0j6fVWCt93|8sf#@HuKRswt0QKQ zA!}o$L(kdpG8Xo=F_|!!F1lsC@p?DjD0?C1rOt#*?l}m-S1Ab!?g=LI)6&8$$Ni>v zuNI^yW$Jd>2it3uMI@x2diuu!_Noi{KW>+wZ z?X~ric%J1VwnG{tf;s;{DybVNF8QRw|I*QA{b={RJTQf`6f~&pYXv4o=IDJJ6?wxa zFlXn(HrI85yt~bVSfj;*1ugKS?^iv8X*I8&4;j^zKDd>rrd8)GiQsjGBcB$#Ix`Df zM*7EK@)%Yee5mhAM^oz>*N@Zu+iKH%^aT!;0SGNwC$-fWV^{)2WfM4D@i2I7@UcqF z^Qyq+{#iqQ#Rfiu!(BjK?N<+q@JK}sW{)mMvx4c#iE)}TGU|AfZ=U}G`;rgk=I;f! zeb)l2>s|@&v+XA}s1d+0y2V0W7&5}sYEie>klwf-uqx@M$)#zzxL#z3!+mLnwI}BH zKr8IG;=mI7;}fhvTy;m_deYS#0a;8CC-)8W+ntO(sD5}Pd)$$2V1obYD~H?_H?fMpRGJ)$J&*-kfo zU7Qy>EymDx+XwCXmZgVIZ9uXME~S;z)zvu}GBUIY*FYe7_LELG|JqYT!as^cduIa` zo0DHaSbHJuLz=>FWN(?iflF#75A$PK>XNvDRp{cG{UM8nEg)8pdWBg~t;!hv`{)a# zoD$^as)m#-k|!JEQMUlQ13$riBo{duA+C(k;2@{RIALkZPP2IWOy&kv()j^{g;wXt z+THfJw4=s29@YOIX+=l|QAVLd(SGdVivvQF^~FHfHXk%zyBIKt(J5EN*JPW9?~lHI z=5;WVRg{O`kR2tWck#~=vpSpukswLrBZT+AFnEs&z&FAY(6K@LBT=1ah!1_B7D4Vz zs~&MYWMop5sfEmIHb&(K6`y^@1XXMWbGzGNE2G;qE=jg@K{FRus_Jb4L~8@<^np?s zZ^Olj#Coqlc31O(ErpJx6D3R@!xn(rZ3pbI$_|)OgM@IJCT$&1P7x^VN%jB^&yE@f zP4<9|=yy*#82Im3$yvhE7a^A(RiTXw>MoFiN1zCa@Hi3&W>i0;)lH?=502P>8aP6` z1E#?&<+V>8ps?bdXx3lB@I+m%xHg2s`owiieZX2=7fM0Y8-}$0Ph$OU{^T}i&|%l7 zLB(@6!2Yj%BCA9#GN9oF+T(Vu1%w%e1U4TG#a56J5Jx+Z%vz|IB&RQCW$LCrC@J0{ zx#Qt2khL5j&2;K(Of;G3QP}-{G@AgHZlJYUxbk;;TE9@cZITFi++*2)6dR|M( z(qOj&7MB{iF0Fc(bn>WmqdwZY5~Qtk_jBo+ z(~`c+$`VA|_^2uBLRP&UVck9Ir#t6`T5{gzL|{l*rdkkOn@n`rjUzW$b4Xf@t2w4p@@5Jv(-$eBoCK+41ZiN~&Gg!&89@A8Pu^{Q7b*^NCwB7J4OlRe#1F3;!`Occ zhR~uVAb}LMsnzTLkx;g58I6{Tmr}>kD^_Y@d3~?^KZIU=FA_rDD0k6rg6@clJ^mXe z{U>{CH9z(4z^4PcR;&zNEoZnTgH_pWMj_rt-e=f_gl=laN-pv^ajL?9FeCeNQ}n68 z#Az+`tYx0etpC^aW_gR3>}jhea*J--do~AqR};{&d5dMINOcHZNH2aNCTuw}LB)v+ z2O~~A*YHV{qrR-JFR=N>yHHq~uexJ()){-qo@S#lB3;s?gw09C-(M2?qqJj5$tBA{ zl5FsREXt0dPY>+2nMcdFR73ipmnRCOxDtMXuEdqDPiM<@H{4hw5$Dxbi?pA~D)p44 z7&K~e{-}Ho1mv}{a7;;}A3w6A>niOs(z1yZ9T86gd zj~TF<^+qpPRk5{5m*+M8xf9_@c~p|-u#DRMZ90`b<#p=&V7m9L!AkXu;=C~oFl9?C z^D=5LrJeYj*tSkaCQicIu+1^{Nc;LQ9;-GdEb998z@AWQV*S%_{(tnp%kdj?Oj>>S zFx~-s;a|TYu3lp^Tg{U7A~fVhfrM}E{4J` zUMR=He|h>;D!!#E^^2+G*24l!A0&I($)=0USp@U8_QtK<*7AyKDpVZbxf9*zQx;4w ze>3LiPkAur=GXcdg@>x^dR42;682bVO?J;Ns{qkdIz`3b_J?gsUF!FMXIq0u#|{Z! z%U^aaPsE7DUJ%0%b$5)7Si1`*nE3 zaI|(g((2rybWEmpVznw*Gb1{G;e1vn9?bz#X3Z#uzBZXM>Z@H%H#Wg1@2V{n-Ae+Y zkz+|B_1o1~SSd|CUZo|?Qs1iW`<(o4D68q;;o6cU){he-;VR;7J_gf4t=Y-2^WTEj z8aC<3ogqQC=velYf~J+lPF?Wj6RR;=LZtocFO;XU3|Y4e%JtkjXC zE+xF-8)Xtl>kAF~8iCuA5Qwfxl`!t>S zD}Q}hq&H08*_kQIpItdos9rGoqdmz=cin9DEzaSO;H+oA^G2+Yu2$?9wF!EDh%Jip zRsd1*+A8(!N1Z_w*Zu}UR_wXT(bwNK{dJ@Wo#f#q`d++Q zx^WaolcOBu)!t^ynOkY^lFWvb}w z>#y@3;}gckUjVwh5Or#`>AU9!+Ob9*iP3EnSFKQ6PaLG~FG=6KB`ehdedzIxjCtd| zh$oL*MX-m3+svh=DdSNg-Cp^KfSfHai&AB|m6K&(V-n$AxLwVfqqcZ8`KY z3s9rsaMG-KBWnV>9n@aDSEizxUbtTz+v|x)BOR}{j4wEEzOybn@)nSN*}iZytD9_I zr&W^W0Z4Iu7^CD+CO`iq&Y<3~6N`Y<-hfys{9T*QLhWVi_p@@gc6tJm1G95r`0#kB zT8>wCvsBn~8S&{?iP%3yv7S$7Q2r@`clIDC_GMQ(RuZpEM1;6j?&xK?#M?9`1_-Ia z#zjIZRIP0;n6F&%H0uhy=|N>!P`^D0mNL(A?d)QU7|FnT@LJugND|!`pF#|Uc!js? zZzybElMlIn9kP{JIjAi&+!YC8W2asPCB6u>hsV7f3~FO+Tl(!%<~u3!;Cf&>ZU}=n zTJ70c+_5M2#AgBQvM2(-Dm>Ume1n~gTkmO9Twe2dY>%p^FKkXX;a4!EEZ)2yB1K48 zJ70K7>S9DR-Ae5TErl^k?F$rSwE8b##^+(KG%zx+C;MW9iXG3&PhWr2Bk5K3F@(M- z?>UjllJmfq=^4EAkd&N9l<(u@u4S)Y>+WnfV;76S>qq&!Mx7kOQhX?S&gLg8IGr>< zsZ|$AEU@bC%#VIm!-E}G{p(?@{sJ9LNL0v57xb=@ze~%;BZu3whP!8K#K9}Qt{RtG z4u;0dFYIbX=$Y`aqwD?5-Cj~;%hd?4d_Td=&x9z}AHpIT-DOwU-(fd7zN@r8-Q&B@ zL!t?Lq5C9w(#nd+mA{WQ!PhPC-f{vPXlmAA17>2Tw3Y4w;-R{v zh$lc9l!Lul=6&&+*OF#{A`b2U9=_>}Sl>%x@@^UXutftBhj*_2z(8~DrC}PL<`3I&@+R3{E zVxuGJt>dygt8Y)TlFo@UhQd>yj?c-&l9W567$f-lCEfI<3Zo!Np7f{o2M z39rsz;nJ=cof6w)S+3!0OI42{y4oH2kn(=R;s{QOhpuqHYk%*6ebfxl6fa z8@ulZ7Gz_2NL-Xp_h{I?mw^gh$>Fi#r&)i=_|kldSkahintVjwFCUr8SPCzWxaY}E zoWBOtxh?!+ofkDA>c;yjV;l{GEOjaVrL$G|lTOs-Olpvq z;x1wL?*)McZ+KIcAH0@)CRDet7^I@dQoDX_vDIV=s}^Xdlk3+j=B&vysJa(hxTm9E zy_@MqCn|c;f_`=kc8-l~LR99SFernMt-buQoz)(6nVk|v=W)X9jNi$Ph z5tF)7zs+MP_eH4y)TluEW#9%%+jJT1OSgX-p3HKIcT7L_Oo%IY8U&i`?K!u)GSzke zX4QGi3)n-EIuxFw2~B;JgAUWxxLFeHKi-8l$5exL4RuLyxxQ4W?j9=MCG%nQx_5MJ z{rIV1EzfbySisPZI^NbgO(ccOBg!?l5;m;fVZQM?h5pM^2;Stg^^R5DC>LbtTx2eJ zGi+gFJm4^Z1fB#GQ1UV0zMYqq5vwyIN0vA?LbJDtJaHJdE2?C5US;)Lo5 zI$LlluF#5y*c(+og%uGccFBt2ux53PQEQg5(ogg=@6;*TjT!TvmAz3v_QzIcdgk_1 zqUFbUw(f~lu5^T0mCdu<9$ zwwM3OHsR<-nL2k)<24dn!%*? zNQ)~pJ6dUnjUeyld4{`QJUX^@HJhz3{s2surh?#h_J_&5yjPM@=mg{GnYl2AX|{{#Q){vKY*6D(~dT)y3*_0@DX%vTC*Y8?}-mj~B>}et*o~W2kqI%C5B% z-QYR3ThS4*S}3wr10@fA3PQXgSE!nq-@JI1Yt=d0>sOD0<~60m!tTJrqqKK$QJV$r zZvq?f^9KeOl0w;4&H?P{Cu~-bTYWp^Kuqv^IKEwwWVPrakkpx8EKIs(YkEZtlh_{z z=*d@kiA^-kj;%Ed*z0#&%wtf*dt(KO_n&ZL1?yQhVx504ph6`RQnlT&s-oQWtOm2Z z)GA^w)%Kz{Ew4t8`?`^?=~Qs(e!eMyZNva9`I$Uo;;+^1cemb!p*-pjn8ZS`bif=K z&nk(f$D{1W{G~o7CfzQZ8su9zcT&CMtoeMj;Zz0ZS_AdA$bU~&$ZPVDFLB9tLSC0F z#WXi*QcE^QYa5fkYJ;fyOUj>R7|Fe>1p%;VN`MA`+ z@&&Y9$AT+Xh4h}lPD}DfY`#>tWyvKG!@6d(=Z$Zg(4xZcy)p099>dTQ3X_O)U z_y8*TS4!HOSls}ku`tgo3p-MZG4Kaxf(0F6;$tuv^u7$3bi|@?+fu$8WOcO;_0zEd zrzkhQI*KS^#a{4k3S~mNcc7x3PJKM6_w$anSIy`}B<$EM>-%^Mbmq0D4gOmp-;UID zrZ*YrBDHH~_|fce_Zx5EHd#Q>6lg=}8Z*F~n+R?)GU`xk;;CkM6D9`v(zDz2=T zgL#=cPv<-tVwI7jL^_(#8da@lt21)orEg1w39x^8gCeR;AV)Suk8NJXw-Uho4W zkxcgVF@Pbl>9Ai7kO3h>v2w2a8pgfxZBt+pRjYk5ya91iBMlXgb(;O{)6q-rorJ~{ z?`4dHXylP_Kv8sy>$RC)&wpo;R0Fjaap59Fn?7!PDTO<$mLuEqaigT*(!CTdDpDb!9TJwifRWcQU@C_E5jD z!DX?!_!mYRjE1ro1tBXW)4FO0_T}49-O2=vQTe;L+Gb`{x2Y{ll4MBPBp9~#!??vO zkIH{NVxcAeHY~Vza#<1GJR!3Xcckv4Pl4E9)N$KP8xYI~M>FPg!#`BjeGss^TMJeo zSykI+%jtE4>>@vPDIT#jUGDP|P|3yLkif*8R8k_Eori$GmZ3=Tru4RR(XWO6j08G~ zh<4DX$?x^(jY69kif<|Hy6AVsmFop{?zNO$+;j2hSD3bCy^h+b2VQR})nOyPl5^py zNU$$ozxuhnszUdo>z(}`M^zUYuy%^w4k;){4jOp7C@t!)?yRX7YO`jYskr8pglWz1 zGt#Poe2;!_kCv>>F8Hp(fW15#y^GWv$#T@gx<4HSTqn7`y@}<${0$B_I2{y`C$Y^G zGWcsh4NRYjW({j^<9Aq0TOp7nv+vmbtc&z6O!gzU*qGK4 z(zbHBMP;H1v63+@c&02*@!G+CytmqYHDVVykI|b^kas^Ms)|OD0xqyxEwYLw@M0QX z+ZGc*Y+i$%YrKC!s51^!*V5IK)$4Z@zG*6TP8d7CmCK7cWa_)SBy4UEu86g=yEAW1 zD*kyyIp%qpX;$H;SrB8-4P`8}8@=zh?J7H(&K}lw9NS0L%?gWB#)XD5!Q2ih`$6%U zk@_XZt17BUC_>oM{;g9O-x)SvJClgM?|zTkuL$DZ=|gDm$-ZPiK0gpoogtrEL_f#W zymK*{zvTl5@_IpGWereqP+iNH@sBr?ezh-1KHM2Hb{WTWJ}+gOanQ*Pm=o!>-P0M% zn3EC0@rT7i=5?ct?G?fP#l7)S!3ut7&sfO>Fy!|QEvoaF*@GjW=?+D1j2UAL_m#%;_yZ~Lsq5#8>5Hsw^)xpJp+DJL zfxISK#@u`ft%1sW4h?ElDh71oRLEk+jsUhncqjeOFm_tQ@C=wkueJEg69&~Ed~6L! zYo$xjBL*~7X)8C`?MAtig0{5Ii&nLLHxq;=2swWby7;&G0d+=8A7C%i2av4J3d1`x zsa?b^1(J(AW-JO|lkpg&nhq0}%z(rUBz*kw;OJJ8B9q4YyD~40=kJ;`5~EI~*sSu% z8^y=l>fn@I^qRK^lF8>S13I%hwoMUoS1-PV_~X-SRo0*qhwQ6~?5_LRYL>2n^rXI; zCUKK7TygwwEw-8+f<%`VsfIo|dQ!yK`jw}%563~mN-kPSO*o&8O8@KJjxJ4qjZyDN zxzLo_?@Q&3xJydj=$ajh6<4vZtQpEB{oex_HwoK*6+w`540D4-6X=b==Mcm3`!F{RcX9p9a+o77p4Qh|_^pgTp?((nWIF{f*xJ3*I1VLY_;f&ba2mjQT^y==Is^1f{sc@frEg5SFp9jl4q97#3dFyU4U4=I z&OjS}fJ!9iLQ3Yve@ZUmJuoX0Yo4Q-QOPi5%<98LobQ^|I(&@(=b+OZ!B}K4k?x%hd(drTGW<(;oWLL6NfcP!7y3`Cc zIiNg=eKQjgCnroHTU&Gsxp?K{)XLboj^OlMp|+i5eM#_E|jcvd&MedPf1%(`~bp54`**3{|^*#KNF%S}I5yN@6IT;&|2 z>5}J>`bsj;=s7zDCOFAJ!OgM#o`Fs?~yC=L%ttIf-_2B*S{HHB289jVujAc5gr;zO6lr>}Qn&ez%+Nhh@z0 zvzScW`ADJU$3fnPGAt#F)UXt0B@wSc8$aDh;vFxR)u%byfVZh9=;HhZ!AxN0ylUSe zns4nt4BnK{)ardX#?r4ZD^53RVdNMcZ6O=r9x15BMOX)f0#2`~M93J;uT&e-oBD+V zWoMv!343~vfp6S~;ZCkD`H`?pXMkk-@3g$Ddv?lwO9nS{0fUNaA4($iQBq0g7ebxI z6*q@X2jZ2-8Y35yJr_0D_8B7F_z`NVU5+vqrGuKjzoRF0VrQFp5Zv=>5yO)0Ce!89 z$>2#_SfoyzdN0CSds7vP1XziPANkCW9P`j4tq)jfmslI+=Duud|CQv@UhNFl1!>o4 zr9y~zdU=LIN4y`=li6&=ni0b!_CSA-r_<$GPr4WD?RI1uN}m?L1QXnUlfGJWjgA;` zD?g-Hv;#XhQ`tQpV**?`@75|9-uAdL16r4k(ccHL3bjXh=&i~+LhO!OR_F-rlKai{ z-PT8AH|azW`t_lo>((V0Xy=_3 zFwSSr0hPQ-YNZ?BtX{#ndK_FrR5Gt&U+B6LzIHrYBg9JvODyETtpPpYUQU$h$p$uFbu-b8YGvs(XI~ z<69^JBfaOmO&0f*HD%%`vRd6zH(IywhYPAR4BFDjO zo;$>N%+Caen;H$yI>9%W9Xr!bw_txc`szb@`00AM{EOoBnq*MI9q!om5ZNzP$2KKw z)HT@a-a7%X|L=tm1UbocR1jnN=^?txfjj{Fdd<#g3i)i*%4^p%P+IhLb}kY&sIFau zcHs+=5}_ZrM^FRtRvTrzD!;VZQm|(rridbxc3OSKLDKoCJ1_EOp+e#f>(HJ_O)z1J zbJ@+3ey%WWIEy~{|K<9=nggs44NJtd=bN#~T(`}ElP{z6o;gW-xS3c|=Uv2P~-W3~#oY)kw%ht*4 zCtw~SNq$Ex*DVSCce^B6y=_EI>vpzVE;$eVm%@(@Erq&Z&=DmE#lae{R!+Ao#U@}&y!!FxK8Wi21|GKWJw>MQqD5`iCa|ijDWYn=7%D4USrpYT z%>ekbg!TNu#O{L!EWGgI?o8}{BQ!g0rmw!Pb}*??6P*T8eLbcH8LhI(nD>YfvHyq9~UWMNZvQzPrj;X!n8-`qd!X zCM}xZ9i=ku2qqz&KV$%s?*$8Xoi!`f?TF&~e-}mdkzA$@}=L=Op!|sT1N7E4^_+$+tPb1KBn1 z4d>V0G;t{Dx&@I=`ZDrH} z@$=!&yPMFRr@aw(@zkJ9XhJI=xuTc6T4sfDi<#mQ<%vc0I&(?M;9d(=vb{P03cNrueh5HQf!=t+K$USKiwN=GGM zK4WOJ70$wf%$GdaRW{j8SQ|RLhFYdm$xr4kn_K2(c+^&l zyYWzfB%)}Md|aR5#hhKd`f)BPqGQT!c#f1I`~L6bkLK?>>j@M55kenG;!OGJrfCo9 zq1*c)YDqj~0)6^3h*9md&1Q4P6sjBj2m~TYKJKE&W%I+_q6yJ$A44yaUzF`N`eqru zH9IuNPe0eIES~Iz4TUC`e}&7p|CSj;-b*C$tPF#px*xV0|E5p!h%*e`z{~@(L~OL) zNTrjAtI5H5R@G`Iq)+vV-M4b=j+t<)0EI7LjFxYaCO~kzdV|oi;rpOsS7XSsW+utt z@6!z4fd?HqvUq^Wqa@yXe+y?luA)}dO}7DaRwJadRR&#m@u@BS zx~Lh!;@Ninel#hIE}DVDGrVPrvU@+clroZ3Z5^>R-)(je#hFJ$&+ibXRTrR^wo+BJ zmR8{M+*YGd_UX?3fu)pDvI*2uI+8yhS z?h~_H-za_h6hn8kD@Ck`=FP5gP~x#Wik$k&V#?vrUiG$mJkK{U^LIY7a$lPpE8W5JJjy)+wM$lV>uFswFg#Gfd@C!C zSu(bz*GZAx`**SKD-iF8*)gmNU9SokmA#U>31XiVZ}L=V&04abNsEyq%`udT>Un(; z{^~25W7Jwvl8gDVI{&1b`sMdy+H%Pr&`0yBq>SZ6wN=HlF$7Y*C7e*gs;ZqU&8~s& zYGHJH!lHZl$ib()R@FKN#rHEUN{jwbjZPbxf+lVRGk+UOA5{2hbAh@85oiHp5rOB_ zl>tLi*&UYiIo_?aDEHYCU?4@F-3&hd#Ngff3A1owX*kdw=lz5IdM5&`Bzw|=tz?bh zDrMVe#PB=inMEc0a#h6!Uyjap1qWjR+JSIQ;e=c0t`nE+Dase@V?~s!#jc^OrI|`k zaEwq{0a*W{kVWpMWM6I`u%FzMN=ih@N3}RHl9hpZmj4%GIgZ^mik9k7B#qaYP&R*HU^m|Vm_ibqcX3Cv0i zuS@!eo|M?@`|N>gSBCzJcNao`d=q}UT78G|`$b6a@qFm2QTP|hw;!I)X07QE;mSCa zYjqt2qFtWB;G@x23W|P=40MF)x(57qskNuIPoVZ_&i+=CSJOa+B&i?aY#)On-6X5} z*ME=X!>mm!BAH%^)@R2`lBQF@xmaAAcU<7-+P)0#xVOAeJM|ly*p?7l^JwyK27lM9 zQ*j~DferXjZVQ_WNPc<`njR<-fdL!;;)teCBMTa3yF>g@Td6R!DUhC&2v=~VzWR}& zg%z>87~Q1b{l0|G(Oz-2-|b?zoVwQVTE&D&PPM%u<@9lw7yBSEK2?Iyy@Rl`zETJ0 zKOUpKkJm9g@i&uItJ;it#Qf;6(eJpSQ(Bbo2Vblf*XW8(Et1zw6_=d7i4r!xy%#Fy zeS_Kdaymlhd9rxlGzsJ=i4zP5&QtUFSlb8UEo2=>?u3!p>Uqalz6AZl0>bvW*bv8VvC+OuFBR#b$jLrA9R&3qrb8#GX$f%WRT%G z>c^c8Ff#Fqfq3?xt*l1grU?=zkE3nLIdFXqc>5`+#BT%R)@nuqEU9^;tJ3JTEpg*p zF8!KtsjVHsTmH_mk=@}pupT&QkE(A%cyUN_atT$p$PAlAi*7bbZ#CiBSlV_;-6}6* z^z?FR2JclN)ShTCs3fl#!Kx3yYSZU&kfFJcv#&+nB6xk*d|H`tzn?41NSNE7v9OzL zi_WS>Lv@$c4aMKGF#yqC7iMq3FIeuDp~?96z%rO++%!OP;9jy%R*i?e(Jh@#b*c7w zA&=aPV$CZ9IJ;B|L|)#j4foz=w@BW|`vtbSa|ubrE00ATA<-?LsICqx@)xtV`#F#S z`?4cuo2|5)T17EmNjIj7O`;bIAyTLQ4F;=MwSic8nC8#4ZA>TlhP<|7H;p-3jmB(S z+#%vv%FvEa>kYqG`@-vgqHW)5Y4|agdJFAHnUqKVV_1e#qycP?gI(CFq6iJ z>t_esRb$n`BCcL>DDq5vSfA8X_RJ<9)hc46C8A>tTjm8{-i(ATZKLsY*prBf^>%1^ zP2`MhJWhBL$=N6QZLU%2>%$(03ou$qBDZOXOCA?5l0+;HI!wEowFM9Z?$bHam}QH5 zTduC3X~&JPP|1&tVC!Z;O)FwX8lchk7HYTW8-)MbEsR^<9@SW{C|Cimr@p5tS^fP1 zwkcNE zx=YxOP}suUcjhH*6hl(|c$)?Pi=ylBpmb3WFu65n49@3|DbPaG6mUt!gB$`$MZEFJtz^1pF); zRh!7WIeK?wDswgx-p;9YmwZkh$xGS;uqT%dx*KcAVAE{aLybj8{av{ZmScgt49%Kd zY^}fRaUH~GgEhcv%lxz)D`)~MX!D6_#@5;?*;Y~YEmr|NO#;6kU!RP~UF& z&<_UMdH@|J1J&^|gDkFn!SINx^ZQ!fy#pe_K$6JA-JM#c=|UfryAGI29e`-=rzXtE zVv~=g`)d?o;%ac(23^{LycT;aMeb=EDRK7G1Nh`f8pP+7qYtcs8rlX_v@J1=dF|IF z)-cL=lZ)nCNU9qC=KR2+S*>hw&kWi4_kA*XEIy6|U>k<;2Mt|@;Y{*}{fKlvwS3_gNLBhEX2po`CK)op|UlaV0B7qra&0*b6e+5U;892#I~G zKC_VKej>|zLRph_28G?(MVNXB@_eS6*FLR6)i%`t=ex@-VAgiThwY;ynB!DTfLC6- zo)TTymtX5Df$!gc$r55>wRYCUX?BzV{2loP~dFr5@a0|`(Ww(P) ztUoc}5t|;*sE%2ti%aLk{wGpT_DBUq?EF>`Tfvar$c63sStl7{Q^bjHjDApdL)~}i zjuP15E{2&Fh+bXLszfbSiaua)=Mj+TIveU*JY6^bPM=|0(=}L9aCn zvRRsBpM4HGYpKBq$PbU`*p=xR?h@|6;QYuij`(MBsGS_p_=OCx%^t%ivpp`jC@drI zJosdBCNR1&GIP~iT6#2x)+9DxG#A@-Hbb^otDW};A?Z%qog-n<6?6NivEAO=18JMz5@w$gyEgoC$>IB}TVcU! zW|Jq3FP4?QklSoK%SXHDCsVaLpIdP86(n(FwDW-0+oB|5MukM|bn3pwO7~(8=zf+C!3=USlqhwPN6}d* zn%i>|@n$_&N)$OVC=d@nyUzbt!E)Tt2@`N=@_NR_R zLveg~m_>%rUddob81w0!1E8y2!;)JW$S2&Ri2Uz0Sa3_AHdl4*n}QXmWDwUjb=jEY zB4L34P0YHj8;zotcwkd&@={sOg ztgZ!;uqU6}M2y{X*rttX1^`?59_~GOH|(Z<6PSL%5V*xTbz&qaHG_9Xt-0dV>~y zk9U*F#VST&_?VK`8>x?ql0|Gh^(BToZ{SUhKSaVpwomZlg@font6UNXZ$*>t9i45z za`lj-5*mfX7JOpY`G;DHPupGC9DQ|NnW;K;D8(}B^ju8lJEDL~t)e{K^RqT4VfxJI zq3!hZnt8M>)t?+}bJ<5-7GI`24{yR-rA#HJktJ=e7qN3Fw`?_7_`&cG9{nt6W4c-Z zO`X`1oaK$DPjI!LZ%YH6%e$Uk!&^>%Izqb+sck~;UG1#=f@}sLNjCn3){3jscH)&g z8c*fmo)~H6?mNh`njQK_?2TwO97wBCi!3|q5_OMF;!4<-M5Sx`xCOfoE*Im(+!r|i z>v>e{fHSol(Q`(K$LEppgxdQ zU?_cGx_Ptzl$J8MDb}6^HbY*J6o92U07d8(6S=SXc`$l$vf5|aaXt$A)3mm31ci%N zwsI9{L)*mr#2xXzAgmcPf)h0VvcP8YV`?YZ0olf1f@PU<`-I{mUH z13dA#x>Hd;k0w_;dM$_x3AD}66m+;SRS2nZAngC#FsM`BmB*SSbfUO&tGo2HMLnsf z4|c-Ny)ZrBrX8MH{!5dwdjs|}K@5Oo@ZmD#ePk}`(b%>SSE~dF>j?!=tt7TuKU~=c z+Z7)Z!l<75#pEokeFG-Ok3dxX>%upvsk~9{92tmf)RfX#NC(l`8)lI~-VO|Rz-qssg8g^hleZP<89r=%4uB0m*jn_ig+wUIi&{L&@$;cy$ps%+S0hFl0 zPHO>-GT^pkY*fg=5erav8~U2={uQHng6HG84hv|GpU zlx~LRmhIScD?dphZ-`Pf;QA+4#DdU$bS{G{A{#H2E{8_(i&3yz$v{O{>-O{e(32I_ zy+VU(K_zu}494!0p2R6OfT+`6=_9@2$=1`)Px7%7rXO5jO5g?#XI0G_aR8*;!JU#? zHAwn52yHW=GBf(VOk}U@^|aLA*gra1Nv<{+WVsfukB_nSL8kxYCz`*fXGSr~V^(l)jx@2A5gw(ET);?O?%EU$#UY6JIa z@yiyKugAh(XZj+eu3jI<@#l4#{ur4<@on<1x30TY``lOV`?*HVj$>t-vJ{0MolbsR ztJ0X^gW6aTZ~EBP&?6mdxy||{EOr`rNpE$xh4{mqzLuxKS6&pwPRmNAwOw#1v4+(RVxt zp+7&7{mlT8bYQjbodkZaMkgk+FkB8U-}xQ=lGUB7Y+`%pDjsoP z&=F56)L^`f2LvZsy+1-$f7BuG#L4K+-iK_4+)j=D_hmI9hGo1_OI-qUw-1TswWHf% z<^#)8tB{@G=&bsz&ib}_KweM)2S*#PMyA}ZZYAzJlltk7$hw{U3U%V$&8c~mRu}T? zSaE$lM&VJy#JhhI<+>>o@ce_gy3_!Y?VJ2J1O4uys?b&67}?Pj9xh}*=6fz)77m>G z4V=1r^4xx|rO7Z6R^Ji_B_;*xZ{LTB$+QG89b6E;JAXZkP12{i^K@a^smkA07fYBt z9@qF0*``YGhAsjDFQS8m3`Zo z=&^p-&420&wqARbg5F)3j(#rr0=^*xvM4^|c0+wHb!0*`sQbnZHo=5J%2$6UA{WnN zO#0KaAQ)5@wluTI7&bv?M{G+qwx6`oBi`G`@}t53g0hX~@QsZM#L~8>jUlsEx8lKW z{=)oO-?aye=5hvkPx%5?R#Q5CvASeOjeo4Z>K=ZNwS1zX`uDpLdu`llM4Rrvw#jF( zzCV*1NL`vNL1dMBMZc7;2aXQkr7@ScSH={!;0~9%b&bVdt0kGO`bjRbb5C6QP9mLs zgN$@ek-Ut)gh|)Q=Rm~knRNo2GwO`6V$*v-2NLlv!F@iGvsW+jL)OCl&I%@5lMy`lpYHm&4KD`#0s(Y2Cksd%H+hg5zA)?uUTDRj&kvvAE! zjyRTr)~^LF`#Y6EsoabFm{9XDec!1ri@WMt@F;cJ<7um4b=v`wVHFv?W$&}WpRXY4 z(#OK27d;zj95%^tV_+=pAN!1XGWxLT4?t!>;I_V5vHZRrB2aJ2NS}Kbl9=f)bCL#TDg8yZLC|lb6c?; z-!TeZW6}3Iaacw=OuUD0MCx4$tI^(0yk8xtkg&uGoOcT#ApfdLm$(Pu9epW~1FPFS z;jI06!?z78m@XszIVvRH)@=*15T2S*I1-!DZ(RrwXKt4O4zf2MT4Zhn_^f+r75}?p zZDW_oqx4m}?iifKHSMslCHj8!MtEoO08Djf%Uoe~X+jn37HZp!l-4vYq%3d9L-)NL zhm#E@(QpyDCw8W1#Q>S(eRg4)1a_| ziy03+WE&?F_TniNakw~-FOO_Qb$OJC>2*p5`P_5*dD}&5b^drkwwqiG;Fx7Bw1?JS z{#<(fcMU*X5c}WtV4G9-tIiv%uQPZ#yV2PLdoWrt0#Fk47^TXyj$PT8c&+*=B`eOs zxbrqHiZ!KidMjO~m0o;QX~BAI3MF6hrNq+2HeXGJBp07|P}rG5|FUt+o0{e?W3$aR zyI||mz{6YUoTcjgTYhckuR+}_fHWW8I6K_1@G?T$@c zgZjmNsEI}>hetL)xZbJj)avbsRxr1Fv@-2?dSgp!IJMMZ@)28e+x#Brjt~C?NB?>@ zQi{~;%~6e_&8;_b)`(Qlv2M~{yU0l*-TBJu%T;~!pjN*EEU(8v&;r+(uAO}9d z9-60WX&p0{{*ya(#D35yI6KndxYPiWk8+J(=X&)cj+?xzw|D6Q6_;!&Egr+c++`c% zCKZy^k(j&VGDobO#>(n{owG@o2$lWt##C4(haywTT&3%FO^eUqc`;-Q)m|&f@6?PxuP~F4m2Zg7>h7YOZG^3Z=w6SjnQ%h-^h@yUKrFye#AFZ7H+sa-(3wW&dWXqdl zt_N$gIiWu}MN-8-#EQ@x9Hpl6(DMrMR=Z|11u0S_y0IC4^o<6TM2XEgJ zJJ!oCyt2B{Pzw4TgaTP%JGxJllN-XvTjWQ$&MtqOO)ER0;)kUebLX#m{;m~aUlFOt z7`(%~qqB_v5(Zzaiw|nWSGd%r=S^97-IknMWwAL!itKhjOGmVy8V)nSHFp>+(m+Mbg!&n;6=tg|hR#A$1@FZDTgui*}kO}uX5h`>3LSB zn(t!GaI*9+AbvRgs6`Pm6Ta|weV&-d%4J{1Jbo^Z0za)-$yvr&IjQkTUK zZRWAH4&1Cy3I`yXKX}4<2yuZLZW7HF5d=poumt!kN#I7{O*KaDSKEE1ESgE zA?f5X?Pz5fXfo(UDiksbqpWUpZC_h!9-gdH#J%T|k!N&w!b z7N!OnT&dZzS&(5Yeu)*Gk}HN_I%vb>tnCLl^&A&K9Q5!Xu39Ql4cVv;^lV*B*z#OZ zQ%K-SW^zw*azRk*>Q?Pb^rPjKhQo}ih97%Kp073P(H)Ykc0+5<_V6!u&#Frb$G@%v zQnEm?8&<}*GRTr1ENpC-meMwpWMT7~Xl?}|%;uG$65VKVX-!7F^Go&gi>k0lgv?l8 z-GJrZ4l08AU46DeOid~L0B2|Q6+`Lgy;C2uoAetbnqglw>fHFLpDX_Grzm49kb_Z* zl)aq)>uyL%C)tZjKcZMjB|_#O1q(n!@v>5G+swJJDX32b4-aT=)hf6Q)6Hp}p-DOR z6@BU~#%RM*q``m-OzsQeq7@;r@>u)SW``A;24SUJNji@w zFqH2VX@^dyUA{L#g?QJyGpag5TzV@@kmBmDN%QebvuFW1{4obU$3>Pki!|h&HupexLV|R0#b>Ef*c3 zy(>3D=-Ml;ma$;}{oXQB6mJ~|E!RmddO-Kut6dM&y1jUlJr)K43kqrI!2!-Ui_8Wt zbjHY@GPZY%4$VSBIy7tEszXSVww<~cE*N4s4Cg{;%nJEMQ+9jH{+GlSm-p08s3JNk-&(tb5 zORr-D8MUpDMn-*H4a4K7I*sOdx*M%{b~M`XY{!2G{vAeFql^5ij&K-LoW>xduhB<5 z1C0Ul>o6uejWJGRm=kZPdW=+$(dywa#;KnP>M_X-P=V8xbEbOCa~gA<#vD6pf$2Ez zG?pmWCa1An{jPNyYn;YvJJm*~vB7p9avJ-c#!jcPO@-_*ga2_Fdra3p^XH)XbIfTR zHC+zlU#D^1XDjGOA$Vcao)lvDlPpme9kKG~AK8QJM z23&R;mjp*ufqE8j!&9nkXJX*@GM>b>i33N2vdvyGV#I#MM#DD(&{ zhPfHPRn%ZbPeVAL~3RQ7=#JyHH5yLmnY50eu!~rtOf-DDer6kYNlGa2?MR7Mb8h{`re^g$z1p(k$lmhQI2kUYWvCi)^(QzPrF?)z z1%Mq3C$L4Q0*fVJ;G|ANa1t(G0EfT?fwIDBELBNGz&Ikw7qH?mESMlb_Bs_X5G~tP zxWm|`ek}Q-{HL52I0Pr*0tQg2SPhf&D&V3S`atB#4Us`A)h#m^gxoV-4&#Z_K+>p4 zK?&e^<5W`1VZ1hj->Dw~2{Oi^praBacpS!0(>>MA7$tZaYLZo``Wb1u1tXvdsWL%@ zOfrL27T`n6m%q&LMS_oT0kcGft`U5!P)=lxdQ<(qa8p=5D^lWb6?{;zfv`b7o5@7N zAY8zXdc(qrDU?(IU;!d0%~%!SFfO?n=apY1j6;bQ0AogpczNn(%9lrOO8&rR%-AR9 z&vWzVrS0(7!Ga>C97rf%NoN=(L`o_AnSbStaw4Tn zUnN58Bav#N_c<&n6^P5mpGwu(XvEV21|3Fo{#%N#7bsIIU3+{T`R^>D4kVSsLZ8Fv zhlZ!#L@)umAx^^*R4Po=J7j^If!4FwsjxTBDXD=N;&`(12m%F*4g+yzNh*i2z!Fpr zC9+g*C{N%3rhEdIYs@$mZ$S_a>NLQiXn5dmr%JKgOa?Yp$^+`>km(ix0zO2T zigFlNMVSj1QkIHm#))(ShT#YlweG$Oumlw_R7p*01%ZI?m7Oz;9|{LmfXf zS}-`&O(}zeO;U0g!%fE$H)Fn=0Xj|m0X@W&pwO-rnz5S!^k%9TW}9!#wR2f4lMXZK zCR;|Wv|~3~f(q$n=eEbq*e>dx3IKe&bd(Aa{2fwZN7Un(8GAtxXUZthZ099VR32b< zOQ4RJH>2-~)@LV#4*<%@037PBuqFU}tKI>H4&$TwHpm2u>g^%!#!uxE1P*qWF#67P zJaVGX#Wp;`wMO+ueh`-4NQV?uXiUg|VtFeKvn*R8s&qaQs$2?*Su9^JkgCFzM9e3y zg7WN&Ue^GLRtKF>A+Q9YrHxV&S5}@5qcW0Gz3V^{Dl{q-)CTRf=$y6RFwlcC|DE}?y?i^7m%uW;A&w} z;055$%N*;B(>QIW4zpi!sCgF9RIgoAKL|om8dU%YeBd+$jLM6OD`0yos-r`R!-!yX zD9ureLlJ@k#UEz&T4w}>!xVj~KY%w%xkVhJJu+D}qoxZw9mX`%Yr-)y5-n*6Q6I2T zxLl$TaAcxc>5Fa# zxO5mmR0Ui%f3B&Y8|ncrRX8}hr(6%z<6k%9vHAs*Ojq5Mm^L%}E+i$4%EHpw9t)TewATTxP^NcWuQ+5!QGO_5V%9V_Njm8{;SQT#DKC*}F zr}>z@DKwgY9}_3%`+#Z{N5LSsv#MYwL- zJUoqwIHiSlAgo)s=|h=D?3$=w0I}4mgc(>vbrtD|pcKv2l8yq``KH?hrui~JL{F8OoC+|7q#1?GR1VnaD=DXq3xPL7~q5!~(MNL&HUPdy2fIIY6MB+ya zk^)XUCz-YVahkI@v6iTq5dv=I0&!Wrhz91)T#W|fFknZGSE z<3QmG%K~D)x<-YsGb0~}RRY8a9sw4UG(`L!w@Bgb(s7(LtG-ZICL3k;NquN4AP-r!= zGAe|be-O2r=G8|?21!soc{b!}3OolYXDdtKwH524Qau&Kl-j9aW@gkwevxlrs4s5Y zyf{3f#8XK}DykEBfxgJoKfx1fsE9g1X68IcIhfumA3EzI(*>56Dc3q@RP&-r2Vb)Q zhNRnSzOh&4fheH6O}|QKVN1jvb`G$2Ohp1LG*GknbCHdUz)XZ5lo56Ich`Ic+8*g< zymu;0iT3%z5_l><(LIrT4&$Af8qk9?M4yU{sCpvhz!-YGn{6Z!jHzT3McW*!$ZWh# zYXEDC=|=LYMDyJ&9aJomDn$JuYY;s^1#BR*b_vKF%5b<*gxy;8@_IYdZ5F_y3OsOT zXBg(DCWtbpIOb*?wIjfp%mc;VXr{fOf-ac}u3Of{aNSeD#njkM%}MGSB~kSff)It6 zIQ%Qb#OL2(85PqajFO7q7D#Etr_hiOv}M9)Pu7$vQ6t7QDJo&41d$R*+N5aKvyKR% z>i;puSF=h`rblzKrqZj)SiAoSq*lZP^WT<#_176y(R7V)%4}_zGh9LJ&y-EY4dh=- zqwvX^D2Jx3GIIzM!yN)_hErMM1urV*ukg8AL~(@t0W@MK5xl6BXJpp4Mg;>O5s6}W zRB<2zYYC_k8_7=f8;+NK%4eGrkBXd9e)R`(0FCXF`8!mE7yt)X!>#cqA|5+UdkJz& zIFroT9_qIv;sunTFA8AH%z=f*%Mh8H{ZOxhmESsxVJ4Z45-6${kwHLZoEZ#$%*>X# z8B1l724+>rZ1sas5_{re%OG#bBlC?FDiUEOl1RX!!VwaiR2U*;i~2n%5CdG`O2wOC zGjkAWMP1f$4VQ>~Vr6VGfEOGSG zju1sql*11y=#zSUF(bd5KTq70{j^l1NtrK5vr*^88>XB}!f!JE^NX1>lb9)e@MjD+ zk*LX7HbQdnum1AzpHEx`c@`E|31pQcf{CQ0ga(L-q&#jtDTAilBcK`pNz-PcW^-o7 z>S@o)9AcVmji9pQ+KEf)gi(c5FDs3jrK!|I!Yw37CZv^Rq7*v_;tbV~Ng7Pa&UuRI zL_C2%6=>;z5c(@w?*f_7j|Dr2DWycuLcpNHA&oX##{3Ot23wrknh(Tws8B7Y5L>Dg zL=@_vSqWxZU<$~g3W~5&!T+zEQjy52a1#j=GSmSTDwqR}%Kfh;q=2aMihKfjru(CE z056b+yt1T}!(0OsqkX6)q(G;7W2E{S>t@c%RKPSbmjFT*AWM zF1y)sN-UM?<(uj~rgh3K-0gwser*1{cT-v)I-!U#plGHO)L9l1CSiiQUnu%2{->Gf zkNGpf-5BOh=va4SjJq*XIY+C9`WtUM${5NOhsCy-5)oUTxn!YQnNNHSb1@-}$i~Q~ z6EiDwiA_z^vdX)5Xlap~wrngWhD;?Ji%P0e$YUcy`j4H)Se#s;Qe@rus!RnfU97$6lxe=SAir#F;vl{D3kw3*m;24RaM(R z3GeTFDwe09fbaxV6i`qB6>I@PrKyO31(7160t$i%A`lWv2qpAhQh+4%&U1Fg!44vr9F0h@30Z>Gmw)aTmT1DMd^?HP2tt%w3v{gC_9m#Olc3?|GjG7ww>^@(T# zm@nA!Zmc!4BZjUd-q7KI`2@$2t@8wz3>vu(Ky)9S^#QR_IDEdLqg6g^zM+SfHrx38 zjeml_a#5lS@_kBrA)A853{=ECqJS~eXDlN?tG<-!^$RX5iuVl^+Aq7d0#qFI8gcSL zD77*LHR`)Q$`oA9r9t>W)n4dzUDvol;j*0o^Dn4x$oH3S|83@&Yz6-+LSBqO&9H5g zV$zpirO57rOhGNard|^eg%tz?*8QD#qWLn#r1i060K&0>fai%0a!UAQ=a=y8h_R>H z-8r#TfpN6Mt2P4hpQ<4!)$BT@h=ABsYPq#d&yu`W-HF;K1}jzqVcupRsJa2`oszga zW-)AmcDh&|j#dDhDy?lmjRTEl*VkK}7PSF_&;bNX%Ix~i;1UDGCeXbY-W32WkwM|} zBu+Xcoh=8c!}Q9{SA@VSm_waCHd;>U=VLK!2g%Rkpg4$yX*K^jHU{Hb4*MCk7e(jP z;%l*D8$#37byf7kga8iazSALynQFaRhg+i`6l)MBAB2iBYqiz{TP`?nNH{$Q71%y! zus${uVi0zwB!9;D=X?di zL9nDixG{>aYMb!geCTE_D+HmoKl)faF@-wOss@V`mE^T^%2)UW(`s;hFhx~3CSeFJ zt>yyG0f2aP2&3G#)@7LrP6KUcJJxV|P4zl6#-weTFucw_ma7arF6yv)7p9cN2fgjLN```OYwVO46O48Y-#@4>v@aUelsl54XD$ zid5YwE2O7}1BL|5_Sem&4*)wj%E5urI8xt4yEJ#HA|D2OqM zGhG&}y=Z+^6;KyH<@G4M8K=2JK-`j$)(7bRFTQP}*#b4J-X)qX;%Su3tm&5UaXGv; zy|&?3+&$nfhIW-vPf#6u-_q`jvxJ?7%EG1i!a^K3@(AiZb+kuR!jW_!KH8LU*kQ*}%;n+9-m^8Dhy zN8K!m%s?+ucXY<_Om0Ux$qewtg>*bFD&uH>&2ss{3It?8Jzm;QU3G_yI@H66i2HH~SFEfDpLb6wC6)Bz^d zi^|!inW^b*e9vlqD|O76Qg^ajmQg?F}N!O{2CLy15n0&7e0? z;QDSIWH`pGZH8I7Qc3XaDDUrHK=3z4@8}siwKDRc_i!uVIm~|2{WB4Iq;9%VaTi*t z^x}g%^<2vX`jEAL8U=B4@rIB&M0SI_N)j_Hso?e;5qVw3|n$ZA##`b1-PPUH}xScBb zi81JOoj{-D&xsT0Q*DZ2??9lcPuSx!)rOwxKo8JatE>rTfw!3&e{)P?Z|Pmtd3sN@ z-XEt&i<&xH^|{fgxgkEC(B{d0butT@)bpwVw%4p@X4FDr@xdvBD<<+d(T%z z>`PVO!ReGynfYf7(NlRbEWiJx;ls{&X;=HmN3#GuCBQf+GqQ!9rA(B{3XO|?$2eU! zYwc>EZ`W+lRkHWT5cE*WtMToKQkgDm)3UCm6y4a+Slz(&xQej+Tk4zH6BBLY2g8pg z9zTc&K#4H^!;2OK)}1`XEgUNe|4y``s`fFOXq*o5xu6Yopt17jbZ{}@>V*xGys`=5B z0WW^&kg#rgTjrmc@b9jx7GO)wJS$EQ=`>ph*m0iD5aVMy^KeMGO58kYz4RruV?a9* z)I_N&f5Z8PzN4=9bg%~%m}UZ+BOTR)ifiGB!Gtkn7{)rh4|#@P`XlKQXt zDuZT|+P4SDlos2eQ%Pxmp`~QY2%Y-u%vX6b|H?Xl9o?ZLbl6)5{&qT{&O)r1NzsPQ zXeNbnV;+#2&_t0{)4@(#Hj$#A!k?!V7b&W2jPa^G6#=MDuoCcqZJ|h>k3bQ>3@i(y z#(=8CUe%IszdSD1>23YYF2mg<07fU!8dAy_msKg*u=K{n(fyd>1 z3iO|*Rsd-xJv$@`viQEg-bpZ*4vDoAQ=;=~bLTXeC+aiG7&EIT2XtoA`%Big8c}LX zTAHe8=kYrp&iIBS9gh4zqAqZmTnb`Si#3V9px#%KNi)Ksncf~!7E5rynNG2x#GnaI zL$}01;?n%9`Qv{H)RiElJgD%sT(q1Lr+{nx zGe9pH$7yInt!ZgGjaw21D2TmWUCE$$_3X3(jUYPpT?p0goi0GVghU`FW&V3(9tvue9IhQg$msdPnDXQT@?S5w^B znb z_s7)?2hcHFRd`TcPunU)ElrH54Q(^#Ne$Ed^_m(%&A&@0JOWvb$kj%}4I)<53YA>@Q10##geTwQGhA|?%B4>tLp!9M3}DB!p#rnuQ*7H6tWZ4JU} z(;&R2TnYEFiZ@05);O8NU@p|m3K46Mb9#gHPdah!QAs6<>E}PA`U`0es)0ZK8c@vt zqo_QXSajayhyFJ$+Nfbc&hZhrR>OZO${K`TxFpA*pD$Ws8IH?2KeWW~zDxXWCHH-& zhSD`xiQ#Ect?H*M%=RY&cWYBoUUgh>6C+IJjrjh?3Zg;{YT12_HegM`e zD2Gr|{3s|$1+&i`hRvmA5$%OFrCJhGLeTV6s0FCaP&2w@M$_$Oxye>t%&#Lb(+RFZ zxv?b|ecXmTV#lDwv?(x8qycAeaSO#@9t)6?#z7i;l^_BR7OX?;I(5A5#I=}O$31mZ zjnKgu8eP^ZSWQ%;(WL?;q!VyDy5_(ftF}bfNzuR=`DtY+0Y|K2v8 zdYZLC2&u84Fw9&%(|CG^61HI}Xh{a z^z`YPjMCE6ClKopv@o^L@QoxCWtpigB7xQt*iF-=e&vlv{R+0zh^6O7$$D| z7xmqgK3c?00Fu0z>j>a+UBH)QR(;$FVNX(NClbhyh)ui-L7HIt2|2Ru3f zt6SX_zTjT#oW(g>Zn zw}HgIDjwq4H71V(-(Jq6!TP`};N(bk!86C`9G`K$LA8^k=c3kG2>#E@#Apb~e#01h zVF&}0DN(wKX&U+YpO`unL)qx_x*Ia&60|?@TlF1zX@Jl+-w)?a#{nv zBkem`N;B=scQ@ByrHoi75dlDyHY;-bt8a9gNg4*>1SzQkMh&ZDb&k&@Yi>a5WV;kf z^Td$2z#MdDRL=KmgNm-|N0{yUKfz8 zkZa?fbi&o6MU9(X<}??f>6dyRbhX|-ZfmeJ#tR6b7hnUn-id+3bnESt%fa`K`*2jI zip1Zb9~Xfs!|UjfhJjA3zIY88r}l)`q#-462CyI`V+tiN$tbSCA26mWt~xknW(k+g zsLlilUn@auq#MeBTlsC<7{&kZ zx>o&)4^Ly2{PCq4uU^7_yuxdXS6-75rO)U1%9j_l;I*%~wic@8H|oA!QU@i|uKHe; z`VaUDcR%90Cg0e%OZeE-?!fikwxN$&o*F9PCa#Odlt{UyYr>sAJ68vu?5Q1S`Fr*h z_Z=$SKEY{w>h>9-0}mJt24C=0rvm~+;HMry2D9asL=c*lz_?;v@(me{lMK27Jd7d; z920sW392U7RFOV9;3XP~ZB^#vaeALKBHk-D3^WAnt_u-}3_yZF$s2sB2Etju>mGGH zn11#LLh9i-&5aXa)KECkZo+VIs{)$U zM%O$@do7O2JV@iN?2J-vC>%VxE62sHIoa!dCa7|H&CmjJsRZiRSi!q8_d~g&UGaT+ z%wfI?XrcdI9gScP+S6#j*+i_<%RZnE_)rWGJ^cq`KH%XcV*C+mK~r}d#jgx@=5HY? zx$C}yr$Ko;!k68)ypKUtE2Y#{&TA|Q{A&$em39H9P4Ec7Y-(r2dh`Wg z!s*a%5&1jMxjDz*^ZkQs+&$c?J3v?A)GKi*!(n)aig%&23r(4=^lwPAIxu%F8KwIo z)xfdVAgl2d|KbiZsR)j#UWZf!fy}9z#fSksF%EqXO%JjHIGN*-W)tRCN#>WR$;!lZ zje}QLl^GH+PA-w)E$VpI_xycjOCh!28w0?B@$!(W@Dq4S#N;LkPpIRm5Y{+UKtQ7s zK+n>L(P* z@y;RE7}zm$8Xc4ZHksoAY}5~dLSHezsR~3~Wu*`BBxI&aV0w{~?9J^SUN@BHcdtl!zn2Wy4E_s2lPcXv* zzHEWl_u;A~9Q9q??c$>^@cp7|u>l0UUJ{p<4m zsmJ|`wP}CEmnS~b&RzNgL_S8qOUv8}AF1che6=@_%G>$414rd){e!cR6`$Sn%q@7P zy$z5K6g6JZv678W$gF(3DKzysO#+s1Mi^VwBLVK!f3{BG%W5D9LPIRO z0hx~^1FOZayf%~a17Fd`BV=ZcC)tGu4c!(aFuWvWDk=fu4q7$l3zW=7RsvBJH3mR| zP+MDh9V<|P#Wof=`M0`942k72!~2Gm_SS6$s7A)^Wj4}8An*9HW0&esQnC^HV2vo9 zV{hGQW#f@k93YP0k(rCRs?8n>b)2iw08Hg+wyG{sIG?;+Z zZR+TC=z*5UjG(#c(wmbB!xDTX>0n|3hD8O^yPXI7#`zg}*kSWh1xC zao#QORsvM~zU@%*r116bK&ig{Q5>vz9c)KltU=qAj@>-e`~`Pe=`+6e)s0k>6agOo zj<^WO>Qp`}d4aDaFbP3o-cn(_&Zopc^H-)Qg60I7H7&RWS~1A(vS)8bNw3gkX0YjX zyVmN-xYZ5}h%hM(y)S;CHNHgIEbDp@T68@ah1ohmSO5$vQ|Gu*mg(5TN#5)@pn;Y76)@@;WbhfojWDZ>lbH7zjnp7=y(sfz5WN zqY2Db7cuY{4Z7w<2?Unu?sWRYGLAoe5f{W~KvLfweLiXFf}}-UTa4ppU0Rak=lL$> z+R{F9=WT}FvnDEI>=wQv=Qg^lw1>_o*q5t*haaPGm7t^*oE?-}mtg!e_8SZ-iLg{H zdG;my0X0U6`#aLL6W^UJRP?|-TpO7%0zoFLn00^MJ_p7<#GQ-9GR59+C<=##*-Cm{BpFCu{Kvs$rVi!I#R{<$gXY->9Zr zszcDhSMGh_0eJ3KCoC+%c{ZdBq=%!W>VYrVK?trS2dIl%P*r^(r#IUDd`kPI65Ac# zIM2;h`^LRB#(qDmfd?m$>;XJX-F?VPlb)b6t^Bi2 zLREaaG-G7#0wNZJgJfEL&;qefR#_a~?LAVqXI<1%v1w%dW^uyG07!^hmTwo2`Z~1| z(fyXkDek0X-0!=l1=5=1hdwHeLN(z-%3sMR+5&*et_tOEW6S)`$IX4*(#H-@+qzyX zt6n&bE$J=T0jC$Y!*JbBFjSk9J4#i30MQXv5D$r#!{Y?7)C(J#n*vxd;^gWGv;sI$ zl3-7rJp9k8TkwbShn=M!W9rhH^mlo*!W6+;SJxHNCD02cg(esm)e&C0BgPpWfCVU_ z{y|hB1SbKgIZ*|EYHiu+88tl@?UkJNGY7rnB`XjUMpn7vdr{o0`XkIvBjfhkUnjIj z2?MMcb7EN~7F-pLCMOlggcvvYIY~~Zw_bJ=(^+LI;Dc(J3gAU*y(GG>8j|`TJ1RxX zj5vi|1yA*+G`J!W__ zPVeY6Vm>AOIVU&%4CLx&O0EN7+-|=HO7q)6Upyb-!cbnH*1IggG-X{&ke$fwv$Per z#}_Q|B_Fi`Ni3L_Z8g*`la;bZyC#3zud^3)%vl+c}p>L>J(cbn7YHnfgEZ_UMd;r8gR>TnAmZWOs1ffTM9zJBo(K(6~G% z+K-PD*woBd6)(G$#`A1}O)gv5NdU@o+*oCOK;wmKg8OQ&MX!fGxef&r&b=-!W3s!g zHy0NeT4w}{_ecub^Ohv#N>3GXg8;p-tinRbgVO-CDBb&n_)9Y>!=9 zHut}{v{%b)x-lBF-eRJ=(N@{@og`?DejO_$@9A2fv-=&|>$q-S^gh+DDa%Zzm<#p; z%u|W)*`2&C5!>~8)@y3jv@9>_ke|vQAZUs8{wM28axNBG*^6Km@|KpPy;UP)z2?du zwYuH#`VqZbg8alT__b+Y*K$AeQPBETvmwZD-1-N<6kyAtn^XI}TVgMQKU3bubxbKR zC}&6CmjdX@yYbzFZzzv@XA*7vVcIMVK&HmSY7z$gm!U7UUt$g$?^KqA*0>k}p~7=0 zkQ1XW>DC+rNWji1wKewT?c=zYpSCe|rMiRb5?gj=V08hoqC-M^@uNYR3TTj!WHcFB zWy>%7J8X~jZnzb2iviDSB#=VCRKv@0dN(te{;g_IUx?d$g3bUCn6e(})C<@Sm=+lx zNGgu8-=i_iqW^@NB6w06a;4pqr>Ht1+OV~P-T>8E(S%BQZW&;uNv>N$(IvpPH+&nd-X;w87h{Zn!6qW?{mr9!-rE5Sp zAFHYAA3|z=z9024?Z{f(irS%IVYdon(jc|v&XO*7^tFuZ+I3)gO4{4w+sZT^P6maS^3-)@*)t|8M*A@P@o{D9RBEzB~`#p5dGuXxmm zBrxNw^?J++S#b4ayVff%qL(O15&QrXbPFdHhx&Hz@1gdE%~%UrCQ{miv9xINbJ^PM zKDVMXZ?#>x#;)L_7CY;OPjS`O@uBqgVyCT1NsJZF4(;*L8v|2M@5`Q^D61`$*PK2O(*kE9!Ho;$+-T?f<;7nJiY}_#_csb*F8!Dm(*vSwbLmG-hbqLS$&<4%zR(2i z`zS00gdWfwXp`lqTOG_8nA+H?kf$~edguqxKvtbT*tkGq_**egz-M^%e(Y6ldN8z~ z%1~DfxX4=ISX;>U76PK9Yx1oRnalAos7taYEu-neXy8H8 z!pQ+TFx7fLjG^n*c4LgWyw3Srp%9|-JT$Vn zRJ6&}kKI!8JAO({P>na-6uYe>KErJX{F7z7If1qKC?i^}!NRGiZYPfAg@(@P*qsKT znlR1$j(y`MJkf^Pp$XGNoNlFQybDiVpmjp-1p-*36Y~pg(%K~$h5OW;$|d|;k`>QZ zadH`SK@_Gm`w;*&=+HYtqE^Cvn&)brP%E#CYI^#?JrKRMw6(hB5e|htm@svk28t^f zh5s}maIDnVu-)RR@zHK!7^(F7wSevB=K&9a$G29bQ5M(7jgXzWgoi&D&b;sB27SUk9VsmXo7omPn zlk6F{84sjVaIyy|I;e4k98x+#xs5!S&m?%9&L>3gm^h7((+$qR_!QGeVqDTE^>ykQMQ?sQKO-|T_*Y3|e#ry2$zeAJVr93(TSBI20NWETU*iThy>?>h}E#XkL-pV^a6KI)AOcTdNaF9slRBdtR z1%EdC1-LNWA}?6E_~Ca9{#qgBe&hVawd{8s=#7aTe--1cT)&O66~K7~&c%Mp@lb{; z0M7RK8(VF2TVHuQA9sVm{_avCv46R?hmY_TdSs}TBWvP)hdKQc*bE|534K#1kPCAi zqsnp7qSlkG`A?fA7#ikOgEi){N0U28L(w`7x+F2^ z>z48h?y7sp0k?ZgE0kr-p>$Te&5%paW2lzcb}*VQ2l^6oadD6eu7)*>zF@NQyL29* zJIaXLH|{O)1dkE z?dyNVhzq$_Q!U1~d$-N!sdcQ_1qTA8t&+Jil3E}$sK)IgC(^!&g3{j+`Cbe%-|xt9(|jX9DQ4N)f_AD?SuOpDev z#}a)h;%!Rgq-aE$JX58~j_PSvAbWlp0NdFboMD#=@Vuh*Opn5~btPGi+ChD{CoA3- zLg3VZ*6eWKT))ZGTITEw52$AVEfd~*rQMADWpAt+!mq{Lfc4ENshOwPyulc8@#<}9 zqaX6j>MI-0StZkZ-L&+%@4L5`K(P0n^(CRJU70o%R_t|cewT6I_oY_jxH{iZN%4C~ z4{d#$KeyNhK7zAd;PcM8+*<+xx*fwM4yu=9cYMuR|`FmIj77IDge^I{cbkvq#Zxnu?b1m z2m*Skx5dqAYQ!{9; zhD)8Kf40fd@KYneJ6G}*eVBJfpE^-dr`A0Nt|_Z%)Z^T)DC8CcbNcPV0oZ_LE`y#G zKWcUl_FgD&$T2}`I!F?aaE8od{x^nzqW_>-*(m?TS)cj&cIxLTj=Z4YZG+QN`&Gpv z>9U<_ywOx)gx>ddw;9QrbUl(yl+C4ZTwijF%gWsQF_QYM!}lkAf9Bd&Ht#le0;PHb zU7NVCZ|LApv~KMl02JWSBAIrhf!!SX{+ES9{nVsm>?{FPhSz>^58B|fB?p%sqBhjj zR6m`n;+#pt3vD!hdr?8727bWH6o6|Jl>Lwor zrUO{0pc1`FCvbIZRMiAvUNCRXE-aTX#CfR505ThUkCq02^_0q~eHvX(=Y)!3In7$p z1(iW+??mgcxb;Tp)G^zBs)Am?r-6`~;idAJk};_YXgXFRkX%idv>Rf4477*jjN(es zh<<)fCox`D5+y6TiiI~-6HV9QsxoD88A>NgUE)jgCbglaqKK;D{`#7YhpPJNc-&)c zd`^-8>!}#?qBk!FV`}#LH0Z9`h6;x=y5-wyi}VvaI`OOGh0>wEXM=~6>h*m%%pc4= zdImbr=iK1+KWm55)r46XV=8b7G-)(|>dCM+8t4^3h0dpTU! ztNR&fp>|n3pxNZle82p$?wvSWA*m>zpt>#J__R1}l~8QU0@TsY#N0dXKG=4EK)P}2 z#a%E7mL6<4uIQei6lN^f8yiJDUZ+F}N=!90iO{|i5C|gAlZcTDXcMZKlA7xZov;;E z#zEuw9VoRD+Ceo?dP|7KPq-^80Q14#g=}~kq6;Vj%Exo>Ri|PasTz-Vs?&>cdLunE zuSa#5^LKM4+k4T5x(O;rr4qWYZbL)0x;i!8WC!X_@Unp;Bw~CXSJ&mJ!8ABlo#S|0XzLKg6hhQ!dtp#KFK;|EQ=@B!PFEK(ka&a2Gj$k92X(9gbMQfjB9aH4 zlQ~;M=IRiiC+pkd9A`lBd`_J&HH!ycRl{3WKmXkg1eaYvBF)5vX_%I0q0kN{Sw+dw zA7q9P^Zf|llG54cjg>cTEarZ+v%i8~c~e{4a9MY5L(FeQDEI5GDOd9cj>4~!bpdr~ zmisO+mFH7&!l2LfEv2_=Y*Ia@zjfQDIX>P65PzsbpMP=RQKq{QdWGZPY50d5+l?H7 zW{p4`*zTGzN*&3chqVcvx{NJD6+q5}vY>lRT`q^II9|ic2f&@1&YQFACL@zWqSHkh|q-FSY(r5hM z{S7hDHOwB;KDcc2TN0MTb+_$PO~VRGqibAQ%yUS&C&Ar`874eNZ9vros;iGFLS9sg zZ)ZX@oEE24O;P)vHMFKVlGC%IY=!J*H0>c=uWrg*Wf0iPGR! z)93?>)&!S_Ri0Nj>3LMu$3yg)CQbTv8;Jv$hW6lS(swlIy%^b#Jb6y=590ro$2J7< zcS3e6Wq2_1VLr~_Pw*{a!4k(%W_F}b)tw zHP@*Lmb6N%j@#pIwGWx>LC ztYUtQ{!jM6Xd#bT9(A)XjK1KWFL7Lk@0a;5=a#Q=jF)*Ajk8?2ELhOuh1HP?--~+J z_Q;=b?8t2cxABzc7COWCzTOfbQrncCqICYjQsMdB#^t|q+@5bS4h5xM+%qhkFMGJ+ z+%LcZ7w)6m0|4#cyw43TgWnET51O=o;}LPH@j&!TG|di6>G@2ZGd-UMQgN|>vl5?3 zo%8+EqG2>c2@qXVE^NDyN1%R9sGTaoXh0}+P6q++mq;Zy?r9haY=9n!uEqmVZ=T5) zx@j6z(o{NMjw=8fAZB8FQ|Gs%S+XPQOBfGKAb~Tho3Nw=^nuMxZpS(Q@9`oz02HvfA?fY<+yu3Z zbNKI84ZE`^IAO=<>|6u%p!&^jjsXH9bkdjs$f@vRyDQKO(e`qjh%yj#c(XSP=3VvK zIE)M!TZb`Yyf?M{K4j2ht{RMer|p9ls2)po>l{N^MEog-cK>`$WQ+T6U`a zQA!Ss*4urZnw|hZgxuPVBERRMCBb?P*F<&&r6RgOX<5b^uSMP59-Oj~_+}mv|DHB_ z7%e&itI@`u!W9Zn2&B8_usy4Tbi`nqph68n``j-|ptZ*A4~ZuD3%Uch>B0fq8Ur3r zR0*KwvhtIoV?vxx)9K>$;GlgPq`6@O+-PTx=9%iC;bm&MB1Wp!7#%o#Xs;a4V<57Z zBk~{;dIPITcZR7Z0hRNT?%+kWRFJ+?;`Kl^I$o-47Xaj2?LK!#X$V;b-BWklh`1fW zC!D9wQ92zEWzYgjp-EWoFg3L(%~jJcn|>r%X0M{SXpt*wZn7B52GE^B4`A;=N*{jZ9sh8vfdaTSH2((LIa5S2ZxhbA&l z&@BxLjZD}Sq$V`u_ZXRHNC0Ek(}F6;c=_M)akrinr*zp^1x;95wLCu+(X%B6g#zHXh85Av z`qJmDvc?>4yuRHY8Qf}I5UWbJl!@+8;jXCNt5X8g>{+7vbBi=2`MAnYSLf>UJ)sK* zf6d9*WoNGsAv{0dkMjLE-%s!j6YtZU7k2GS&H()^JGxDzy*w{jwSvS>H`bXsj zufNi>y(J60cBb}Ex1eqw6dqA1Z+P41(AmX%D6>im8b&W(7a+??~Npa9-6; zA-hAH{rM%Ex+yn0Fc~*M@+aVPoZ`&XPR~f%(_q7^>ZX?$|4Xmyr8V>5x~nwbH9ExS zzLF^e0rsw@&8WQ5C0Ri9tnu??t}U9Y5l<8|uuNFj>;bW8J90i50gE^)_@CLfu;H~3 zW?z4J#i(vmzF+3673+Gxuu9>@jP*sUxJ9jnyL>w<)bJyB zX+7*ZDfBa+wd!?CkhBrCU-A77Uy;}E_;!&&mE*NhTT|NBeSxkWC@VZDiCwaNH_q@F zxI(p5$6j&Tx7_WNJvBQhhX!26uqHS z4z!>&38QK{EflTuglHKPC&vdRakZldlS{}Qkg3(c)9Dg#(0NgHZL00kkO5CK>{gjp z#;6mvxzm~_sJiKWSMH%bK58rhLfd?h{e#^!kJQvOO_^K5ssOT{3$fRojrMBI1y%1` zP0|bYAYlenO2Q5?2_eIFvAQFJUYx@1Cz(S-@Q64aJKxY_qI7(muppU=M>3CXX`4zh zja|?`k{5`}ERZ#B;gx{?Acr3mKwO-1S<gMFA1S5Eiq zk7ySMs+lJmpo9e>&Sc8*sfU7hNx+tMaUd#ax^d3xW(B{U9-2r7*$HJV1kG}gT`#DJ zp!ga!T^H@3_U7Kjf#|9la8J9M>7laognDY|aGoRqG9bHZ|AW#B5ROhyhcvQ7YWo|$IePUG=Yp- z3d`yWQ+nS94v42^`r6bGm=Pzh@`Id<$0p#c8E&r|kE!-RyY0lRGB(f&TAwNTGr+xu zDKd$snuvkom&;&H`|qgm6l6D19qh$H;Sd;eEfWxTifJObc>T_|Z{h1?dbcZ?N-B;L zO?81!`MA87U<|bMLOQJIw&b~KOrcwD;cr)4G5Gec*nyFh6N2r|n`<+0UA~^8uv$q&tyOyIcg z4S2NmSo3ZC0w`Anm=#Qd&g9!_DPko~3K4KBNk|ra+tiL3z-eDUD-X|LGj94v$|Az6 zB^S^HudTDSccWTsvzi(xg3(!$eNYEyphDnr+4SLnet_x_zuJN1i!}=z(yjZ{?&au+ z+1YyRFh4sqWO&?rMS~sH>|3VpTM8u}D!FqV92{q#>d|T#GoZCtULdCl98?6{DnY#G zIMg2fOb~!dw7{s$Mo8zpO+* z0+A(%xM=onNTBa~&PeU{j_z+^#-jRfFy>o)1CZbK3`04--`c{TSYkbndc#%xnqT^S z9647CU2Hd08wqZfecC}fGEZY^lt%p3eSLRrFM!0~&~HgS-~^2$bt9I~k=#P*ycyGO z+YS=3GC_O@!kxJqD*dVf(dZoQxBxf)y-qg0tSrh%8?&UyNf_3gQs%@&a+bK-!60;b zZBP8;MhV`Qd!V5ROAf^L#`fo0iw^dbKq|p@+dN)VZqx(*Uhpo0sKTme9FhCRixjf5dCnV8;2 zKY$uUd8TGNuW$6@;`(|kfOvV6a$_q~SAZ-5XN9D3G}pO*sn0+faLtVy zR>D?jYCGYhj9ow5ZFM`Smc0tgn|@Zor@A;7SjM($f$GwE*{^Ue>lRm_ymC#A(~nvQ z-YkCB)jVPijz8kNCSP%<)LFgCq;%D8|l9Jxir9b+(Jqfq53V1u$ci4{oX{>@-&3lMJYVuUo`8TM#DnW{YR#gBq(1bTZv+D3@WzV`m z!{nLz?JQ@lL37~m0*wKL7wdeL(=V>bcQraDq6ufjyw}93?4UNVhAVlr+`2r&x-1k7 zZ6;1(RS9{k>!*@DYYr=jMYDxLL&Uz-*H!Ca>h0d6o4b%3{X_#3jP9$uFC!oZrGbj| ziYlVR;nC0I_Soj#B_~Ad7@c6rQ|hL=6Vx&(dZ!wW7xoKNUJ%{(#ZFQAvS`YB*^ zhDz5qK!rP@&6y#Pjv#BOU~isRO@Y=n1oB{r&emyelpoLCFLVtEPbZXG{6^15B_OM2 zecn*xo6*D7dDfsg@Smj32xTGuk8=W)30Jb;G%B{7S%{K8g(15aY{eF#Uvh7+50)E< zn|d4kJ0(Nl=h&9tvE=tTt}%2qj;p&a;H_&H>^iRhl;e6X350R&KjABC0~}p;K=*>w ztu4E=Um3K0juY_y;c{WfVI?JXc-&z)TmhS52TST|5V1~>7MMCI4NKJ31YSpwwkuBz zX|UIu9RScy&*br%k_ri$9YGu>gD!HBPM1Wv`iOA)++qTJUw*U)R^uVcq{qy%F_DD^ z3gf}fkYw^Br0GCpYOPPiRiD-={jV^kCVjQZ9e=ZK74Ph}VO3?_Cs$^`qZ$MDbTU02 zVOB&xu|02nF_Rdw<3cz>shu5Cn4*L%42dt$8Mwwi}BZXsY9kS2)UH2uiGc zZphpKuxILxcA%%i12!Y?E_*;l-@VZ{D^6hOX?tNGwYoYlTKnp(&#L)FV^5Pnlsdhe zDl5#GxHCPbS!tD(^59GfFPdun3OQ;&=2<@BsEi0s?uWH;secX-X9xm}@=5FK8gnvLAjCOD)06>Su{S%i2cSq$K4#23k zW8!4ojmsQV<4~PYpiUEYIyc=rfbX2TtEhTWS^?jMk}>;boYBz_J9wtWEbyL9M~wk{ zGn;MYiF4d4q5IQQdzUIaSu6cLpt4ceu1*AYkEz2bgab8mi$W;S>lD=v#NKrx0AN%d zACFFZ8G*ygO=)~448b0{s=ZT^v>L}feX6(N9h3QPCpp&*DwM#9WtwqOI3-T^Tf>CW zF32>Uv(yfN6N%@C*hM;BqQj-paE*~SRh6rf0ON<)n1%TOuo~bZo$kvFRN!})TJDMF z81j0*je4IcxO}on4wo>1?FU5bsa}zRO8^YM9do~@6K);sMikt*5|1$zS=$c_l*Z0e zdw4XV#4P3&#UJpn5Bj*M)5r^XYAwftZV&PlX!Q|rb-vH{5cldg;NRd+C5-o7d$p2< zwHN3rl-lh!Hij9sCE^ch|FNZdBP>6s?H6vBNA_1P>B7Qo-<6FeJz$%Z8lw#X6js@h zV*#$04wsiF53eDXeVR?CKqtUNSv@G7KDyu`x>pa=;fT0v%kqDquKsFY+Ag%x3KD}k zCl!(N%03&QHwLxVWri=Omi;-3gmu;%)405$F8o*b-OdLvZXw${x>>Ph)sK3d4jgI?1K9PaCk^0!nobl;2az^?@1Ql0kC?Q2xF z@`}oONOT<@C%}4aU%eF=$AH%1ljM22`p(p0TAQW{U4m!RCD3khtC^;%OP?p4a05im zpEtE@I@AqV@951OPuR{b>pCR$aGCAVUYCz`(x=r4VxQ9)-Bkn3pRcM6@YV39PB>#h zETpOyeJ*5Ug~2S^|9_O_-4P6n>8Sokf?V> zzUA{ruhU2oac6bALT0;VftjNM7E!c&69=qW~eu znQO1ktz|2Y8)Gh@so!8I!kc$SHBtCbi$V#_iS9?@^mrNje4F8FG*%rJ(EWQz*p6Pi z(Nd>r68Db%t}ttIXZ)V0Y(y{9lZDW_V8w%aHEjusXi>7jxftK_`5dJ$@ckm+rQOPZ zI=2esvppF68cj;>5uRg+IPyC#f8R&F_56qEx9Sx~uH{m&HfZ5VoXpVO&z?2K@RIT(CHJ;MxE)Lb)U`S-;6pn)@d zyzKvoz6o^Wj7AXKyE=Jn-0lg^0LyuW6G18&h&r4TV!AGTcX9Q=&MZMoK&sBEaRP_e z_4;6M=q3nqysc}ne-_r{@zr7zc+XbjTpbdfCSP61%=daSKMHEmPVuA zcI`Y_x#>2<`MIJK%*Bu&=KB%8Z7PsPEDazR=Db*Xc|J>7iRN0~SU{+j<;{@GxmO5W z!6hN^TP|(TqovBx8fkWgA=kE4>{AuL^cQXkMN^q~f3}}TedU!($}Qh2rPMxfmYsS4 zSaGcazhQCX;o8|tB~;b8B3yZ3T}=gHM(Zs`1^_y-T}=fft*}bwG>0Ki?ag9^eq%g< ztXZs3;)TX)Vo(y;nRf6%r7<9&7JBJ5)xP9;5#1GoWl^%4lOMScP^$n7*aDJ%WzwT< zdbt%<{1obgzGKFJ_ghT`@?LM!?5v)`mm&Uu*GODta)cN6ZPrtHhYX+u-yZJlRaiG2 ztQs41*^uP;Hh*ATxkoEMPAV-4!t?E*G)u-$xzyQq)>G9mquqgHdKnx*R6h`d1Mjwz zwOvsKAZs40r+_xhdr$DwjwcqpogIl{LSATJRdyYF~PwYJmuy&=cI8+F)j+b z!IV?%*SWYfR?lijdsnFs*u^Nw=^p3hKz>Gq@(0PB;GA6^s*Yk{&OS z6LlD)gSAxPs?vlwofan(-q}&c5W+ke_pR;@Ve)kXdSm%5xmw2U54%nUlN+ zY1YK?+hfSA3_OCi`^qGEqjTLD7vB$g zq}EV80k(Hnn&{jg26ZGgNjn=)u~zGb2CA1Ujz4c~y8Hf+_tQqnF1YO!z%GTWU7;|%Zt zJoWDrr(m`HqhjZ&I#C1A`Tz)4-tmdEV^)fK_NE!4*6nLhX<8*;gFJDqirM5Q3)!HSY{Y;G0kb}IW_CF|XjG?9+ zo3d0&z1mwtAY6dsr}&0``5Bi~Q{-{UtS#m9ckP||GG&2L9#P4y=%cK=l_`BYYg%Dk z)-aZp#%t22RnP4%RElKCy4#53ulUBNtTYw|e_vaL!L75Vl(+3E2j%VL)?Ivr&-M!- zf;==$9Ldc~VoZ5)5fG`uOkzPc-#`nD1`v-_$H|#$b5f?LqIpVstWFh(8NvX+r>h+W z6*R`|aA$g9&^DuK>Htd^UvcX9y^dJ~=}Pv77-GUh_iaOoM&dou51FAwTFHd=9*#!f zXcr7RXIwUQ%sw!*7e)dmJSW5)&_Fkaj;ITb_tuTKe=g-1rHVe|>;pYGs-Vv}JTj^$ zB@R2jbLx2vyu_(B=Vc%f_yOv=f)V9%a=8MrpK9m%0WiJ96gAPb5WA-CqLGor8>1aO z!D>()=#)6RCva>c7_(G^EN3@_H!oyCWVQ2P(O72KZdIxYOC-?y-=U}QNaR0JIMD_d zU#xrLm#F;b853Aqnv&GVc28kN1l1Dn?$r4^uAwvSICQh2t5Mm6V0>A)QC1x)TU6T# z#QQ5Y@DOe5Rxuv^b;pZB$do1ttBzNdr5iEEdk9}A!5cyvGP^lS=Ap-(-7+s##|PRM z;93O@K$W>tcXSHPQ!&f)Rd;+*4X^gB+IWqG*}*W+N2;OrA!O#~E4I=bDL&!yf<7wc zY!R2r-ib@N_Dc-2WorDcERp(=Rmjy*9fh=SSaM~K-zTf|yROM-D;op<&@F3nT$}H@ ze6>Ysq@9JKCe~68X#1j=&1l(zZx=g27u04L+tL_oe9NCk&d9QHcM0DWgDqr$7+R&u zXoq0zfT#eh!`rP%CC~?FYT`cvZHIdXO)fwMyLoq+i8`MalS%R>Ke$i}!PNx=Ccs=K z;b1TgFwCxs2@-2KMNlWB)1*~0(bUB4JR;CG-hR6*ils zdAP>R9onLff*A#4ZU3?>>F{#D!d0k~jiGj6^aQ>RRi$Mun`S38dp{NXYm}zU%(=LZ@Tbz3eT;mUue+>PVNUzhsN5fW-1tg6Thra^__#euE@HWoo*m%Z z#_J{iG<0)7SoV1OJ*CbqxwS`&<+gLFZ|oc$ahND?clYY&>|GVc1RuP?9Sxa4K}R~- z>|ot?hw1c7<5LQydtXK)-(>q0kKOZ1T