sigima 0.0.1.dev0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sigima/__init__.py +142 -2
- sigima/client/__init__.py +105 -0
- sigima/client/base.py +780 -0
- sigima/client/remote.py +469 -0
- sigima/client/stub.py +814 -0
- sigima/client/utils.py +90 -0
- sigima/config.py +444 -0
- sigima/data/logo/Sigima.svg +135 -0
- sigima/data/tests/annotations.json +798 -0
- sigima/data/tests/curve_fitting/exponential_fit.txt +511 -0
- sigima/data/tests/curve_fitting/gaussian_fit.txt +100 -0
- sigima/data/tests/curve_fitting/piecewiseexponential_fit.txt +1022 -0
- sigima/data/tests/curve_fitting/polynomial_fit.txt +100 -0
- sigima/data/tests/curve_fitting/twohalfgaussian_fit.txt +1000 -0
- sigima/data/tests/curve_formats/bandwidth.txt +201 -0
- sigima/data/tests/curve_formats/boxcar.npy +0 -0
- sigima/data/tests/curve_formats/datetime.txt +1001 -0
- sigima/data/tests/curve_formats/dynamic_parameters.txt +4000 -0
- sigima/data/tests/curve_formats/fw1e2.txt +301 -0
- sigima/data/tests/curve_formats/fwhm.txt +319 -0
- sigima/data/tests/curve_formats/multiple_curves.csv +29 -0
- sigima/data/tests/curve_formats/noised_saw.mat +0 -0
- sigima/data/tests/curve_formats/oscilloscope.csv +111 -0
- sigima/data/tests/curve_formats/other/other2/recursive2.txt +5 -0
- sigima/data/tests/curve_formats/other/recursive1.txt +5 -0
- sigima/data/tests/curve_formats/paracetamol.npy +0 -0
- sigima/data/tests/curve_formats/paracetamol.txt +1010 -0
- sigima/data/tests/curve_formats/paracetamol_dx_dy.csv +1000 -0
- sigima/data/tests/curve_formats/paracetamol_dy.csv +1001 -0
- sigima/data/tests/curve_formats/pulse1.npy +0 -0
- sigima/data/tests/curve_formats/pulse2.npy +0 -0
- sigima/data/tests/curve_formats/simple.txt +5 -0
- sigima/data/tests/curve_formats/spectrum.mca +2139 -0
- sigima/data/tests/curve_formats/square2.npy +0 -0
- sigima/data/tests/curve_formats/step.npy +0 -0
- sigima/data/tests/fabry-perot1.jpg +0 -0
- sigima/data/tests/fabry-perot2.jpg +0 -0
- sigima/data/tests/flower.npy +0 -0
- sigima/data/tests/image_formats/NF 180338201.scor-data +11003 -0
- sigima/data/tests/image_formats/binary_image.npy +0 -0
- sigima/data/tests/image_formats/binary_image.png +0 -0
- sigima/data/tests/image_formats/centroid_test.npy +0 -0
- sigima/data/tests/image_formats/coordinated_text/complex_image.txt +10011 -0
- sigima/data/tests/image_formats/coordinated_text/complex_ref_image.txt +10010 -0
- sigima/data/tests/image_formats/coordinated_text/image.txt +15 -0
- sigima/data/tests/image_formats/coordinated_text/image2.txt +14 -0
- sigima/data/tests/image_formats/coordinated_text/image_no_unit_no_label.txt +14 -0
- sigima/data/tests/image_formats/coordinated_text/image_with_nan.txt +15 -0
- sigima/data/tests/image_formats/coordinated_text/image_with_unit.txt +14 -0
- sigima/data/tests/image_formats/fiber.csv +480 -0
- sigima/data/tests/image_formats/fiber.jpg +0 -0
- sigima/data/tests/image_formats/fiber.png +0 -0
- sigima/data/tests/image_formats/fiber.txt +480 -0
- sigima/data/tests/image_formats/gaussian_spot_with_noise.npy +0 -0
- sigima/data/tests/image_formats/mr-brain.dcm +0 -0
- sigima/data/tests/image_formats/noised_gaussian.mat +0 -0
- sigima/data/tests/image_formats/sif_reader/nd_lum_image_no_glue.sif +0 -0
- sigima/data/tests/image_formats/sif_reader/raman1.sif +0 -0
- sigima/data/tests/image_formats/tiling.txt +10 -0
- sigima/data/tests/image_formats/uint16.tiff +0 -0
- sigima/data/tests/image_formats/uint8.tiff +0 -0
- sigima/data/tests/laser_beam/TEM00_z_13.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_18.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_23.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_30.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_35.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_40.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_45.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_50.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_55.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_60.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_65.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_70.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_75.jpg +0 -0
- sigima/data/tests/laser_beam/TEM00_z_80.jpg +0 -0
- sigima/enums.py +195 -0
- sigima/io/__init__.py +123 -0
- sigima/io/base.py +311 -0
- sigima/io/common/__init__.py +5 -0
- sigima/io/common/basename.py +164 -0
- sigima/io/common/converters.py +189 -0
- sigima/io/common/objmeta.py +181 -0
- sigima/io/common/textreader.py +58 -0
- sigima/io/convenience.py +157 -0
- sigima/io/enums.py +17 -0
- sigima/io/ftlab.py +395 -0
- sigima/io/image/__init__.py +9 -0
- sigima/io/image/base.py +177 -0
- sigima/io/image/formats.py +1016 -0
- sigima/io/image/funcs.py +414 -0
- sigima/io/signal/__init__.py +9 -0
- sigima/io/signal/base.py +129 -0
- sigima/io/signal/formats.py +290 -0
- sigima/io/signal/funcs.py +723 -0
- sigima/objects/__init__.py +260 -0
- sigima/objects/base.py +937 -0
- sigima/objects/image/__init__.py +88 -0
- sigima/objects/image/creation.py +556 -0
- sigima/objects/image/object.py +524 -0
- sigima/objects/image/roi.py +904 -0
- sigima/objects/scalar/__init__.py +57 -0
- sigima/objects/scalar/common.py +215 -0
- sigima/objects/scalar/geometry.py +502 -0
- sigima/objects/scalar/table.py +784 -0
- sigima/objects/shape.py +290 -0
- sigima/objects/signal/__init__.py +133 -0
- sigima/objects/signal/constants.py +27 -0
- sigima/objects/signal/creation.py +1428 -0
- sigima/objects/signal/object.py +444 -0
- sigima/objects/signal/roi.py +274 -0
- sigima/params.py +405 -0
- sigima/proc/__init__.py +96 -0
- sigima/proc/base.py +381 -0
- sigima/proc/decorator.py +330 -0
- sigima/proc/image/__init__.py +513 -0
- sigima/proc/image/arithmetic.py +335 -0
- sigima/proc/image/base.py +260 -0
- sigima/proc/image/detection.py +519 -0
- sigima/proc/image/edges.py +329 -0
- sigima/proc/image/exposure.py +406 -0
- sigima/proc/image/extraction.py +458 -0
- sigima/proc/image/filtering.py +219 -0
- sigima/proc/image/fourier.py +147 -0
- sigima/proc/image/geometry.py +661 -0
- sigima/proc/image/mathops.py +340 -0
- sigima/proc/image/measurement.py +195 -0
- sigima/proc/image/morphology.py +155 -0
- sigima/proc/image/noise.py +107 -0
- sigima/proc/image/preprocessing.py +182 -0
- sigima/proc/image/restoration.py +235 -0
- sigima/proc/image/threshold.py +217 -0
- sigima/proc/image/transformations.py +393 -0
- sigima/proc/signal/__init__.py +376 -0
- sigima/proc/signal/analysis.py +206 -0
- sigima/proc/signal/arithmetic.py +551 -0
- sigima/proc/signal/base.py +262 -0
- sigima/proc/signal/extraction.py +60 -0
- sigima/proc/signal/features.py +310 -0
- sigima/proc/signal/filtering.py +484 -0
- sigima/proc/signal/fitting.py +276 -0
- sigima/proc/signal/fourier.py +259 -0
- sigima/proc/signal/mathops.py +420 -0
- sigima/proc/signal/processing.py +580 -0
- sigima/proc/signal/stability.py +175 -0
- sigima/proc/title_formatting.py +227 -0
- sigima/proc/validation.py +272 -0
- sigima/tests/__init__.py +7 -0
- sigima/tests/common/__init__.py +0 -0
- sigima/tests/common/arithmeticparam_unit_test.py +26 -0
- sigima/tests/common/basename_unit_test.py +126 -0
- sigima/tests/common/client_unit_test.py +412 -0
- sigima/tests/common/converters_unit_test.py +77 -0
- sigima/tests/common/decorator_unit_test.py +176 -0
- sigima/tests/common/examples_unit_test.py +104 -0
- sigima/tests/common/kernel_normalization_unit_test.py +242 -0
- sigima/tests/common/roi_basic_unit_test.py +73 -0
- sigima/tests/common/roi_geometry_unit_test.py +171 -0
- sigima/tests/common/scalar_builder_unit_test.py +142 -0
- sigima/tests/common/scalar_unit_test.py +991 -0
- sigima/tests/common/shape_unit_test.py +183 -0
- sigima/tests/common/stat_unit_test.py +138 -0
- sigima/tests/common/title_formatting_unit_test.py +338 -0
- sigima/tests/common/tools_coordinates_unit_test.py +60 -0
- sigima/tests/common/transformations_unit_test.py +178 -0
- sigima/tests/common/validation_unit_test.py +205 -0
- sigima/tests/conftest.py +129 -0
- sigima/tests/data.py +998 -0
- sigima/tests/env.py +280 -0
- sigima/tests/guiutils.py +163 -0
- sigima/tests/helpers.py +532 -0
- sigima/tests/image/__init__.py +28 -0
- sigima/tests/image/binning_unit_test.py +128 -0
- sigima/tests/image/blob_detection_unit_test.py +312 -0
- sigima/tests/image/centroid_unit_test.py +170 -0
- sigima/tests/image/check_2d_array_unit_test.py +63 -0
- sigima/tests/image/contour_unit_test.py +172 -0
- sigima/tests/image/convolution_unit_test.py +178 -0
- sigima/tests/image/datatype_unit_test.py +67 -0
- sigima/tests/image/edges_unit_test.py +155 -0
- sigima/tests/image/enclosingcircle_unit_test.py +88 -0
- sigima/tests/image/exposure_unit_test.py +223 -0
- sigima/tests/image/fft2d_unit_test.py +189 -0
- sigima/tests/image/filtering_unit_test.py +166 -0
- sigima/tests/image/geometry_unit_test.py +654 -0
- sigima/tests/image/hough_circle_unit_test.py +147 -0
- sigima/tests/image/imageobj_unit_test.py +737 -0
- sigima/tests/image/morphology_unit_test.py +71 -0
- sigima/tests/image/noise_unit_test.py +57 -0
- sigima/tests/image/offset_correction_unit_test.py +72 -0
- sigima/tests/image/operation_unit_test.py +518 -0
- sigima/tests/image/peak2d_limits_unit_test.py +41 -0
- sigima/tests/image/peak2d_unit_test.py +133 -0
- sigima/tests/image/profile_unit_test.py +159 -0
- sigima/tests/image/projections_unit_test.py +121 -0
- sigima/tests/image/restoration_unit_test.py +141 -0
- sigima/tests/image/roi2dparam_unit_test.py +53 -0
- sigima/tests/image/roi_advanced_unit_test.py +588 -0
- sigima/tests/image/roi_grid_unit_test.py +279 -0
- sigima/tests/image/spectrum2d_unit_test.py +40 -0
- sigima/tests/image/threshold_unit_test.py +91 -0
- sigima/tests/io/__init__.py +0 -0
- sigima/tests/io/addnewformat_unit_test.py +125 -0
- sigima/tests/io/convenience_funcs_unit_test.py +470 -0
- sigima/tests/io/coordinated_text_format_unit_test.py +495 -0
- sigima/tests/io/datetime_csv_unit_test.py +198 -0
- sigima/tests/io/imageio_formats_test.py +41 -0
- sigima/tests/io/ioregistry_unit_test.py +69 -0
- sigima/tests/io/objmeta_unit_test.py +87 -0
- sigima/tests/io/readobj_unit_test.py +130 -0
- sigima/tests/io/readwriteobj_unit_test.py +67 -0
- sigima/tests/signal/__init__.py +0 -0
- sigima/tests/signal/analysis_unit_test.py +135 -0
- sigima/tests/signal/check_1d_arrays_unit_test.py +169 -0
- sigima/tests/signal/convolution_unit_test.py +404 -0
- sigima/tests/signal/datetime_unit_test.py +176 -0
- sigima/tests/signal/fft1d_unit_test.py +303 -0
- sigima/tests/signal/filters_unit_test.py +403 -0
- sigima/tests/signal/fitting_unit_test.py +929 -0
- sigima/tests/signal/fwhm_unit_test.py +111 -0
- sigima/tests/signal/noise_unit_test.py +128 -0
- sigima/tests/signal/offset_correction_unit_test.py +34 -0
- sigima/tests/signal/operation_unit_test.py +489 -0
- sigima/tests/signal/peakdetection_unit_test.py +145 -0
- sigima/tests/signal/processing_unit_test.py +657 -0
- sigima/tests/signal/pulse/__init__.py +112 -0
- sigima/tests/signal/pulse/crossing_times_unit_test.py +123 -0
- sigima/tests/signal/pulse/plateau_detection_unit_test.py +102 -0
- sigima/tests/signal/pulse/pulse_unit_test.py +1824 -0
- sigima/tests/signal/roi_advanced_unit_test.py +392 -0
- sigima/tests/signal/signalobj_unit_test.py +603 -0
- sigima/tests/signal/stability_unit_test.py +431 -0
- sigima/tests/signal/uncertainty_unit_test.py +611 -0
- sigima/tests/vistools.py +1030 -0
- sigima/tools/__init__.py +59 -0
- sigima/tools/checks.py +290 -0
- sigima/tools/coordinates.py +308 -0
- sigima/tools/datatypes.py +26 -0
- sigima/tools/image/__init__.py +97 -0
- sigima/tools/image/detection.py +451 -0
- sigima/tools/image/exposure.py +77 -0
- sigima/tools/image/extraction.py +48 -0
- sigima/tools/image/fourier.py +260 -0
- sigima/tools/image/geometry.py +190 -0
- sigima/tools/image/preprocessing.py +165 -0
- sigima/tools/signal/__init__.py +86 -0
- sigima/tools/signal/dynamic.py +254 -0
- sigima/tools/signal/features.py +135 -0
- sigima/tools/signal/filtering.py +171 -0
- sigima/tools/signal/fitting.py +1171 -0
- sigima/tools/signal/fourier.py +466 -0
- sigima/tools/signal/interpolation.py +70 -0
- sigima/tools/signal/peakdetection.py +126 -0
- sigima/tools/signal/pulse.py +1626 -0
- sigima/tools/signal/scaling.py +50 -0
- sigima/tools/signal/stability.py +258 -0
- sigima/tools/signal/windowing.py +90 -0
- sigima/worker.py +79 -0
- sigima-1.0.0.dist-info/METADATA +233 -0
- sigima-1.0.0.dist-info/RECORD +262 -0
- {sigima-0.0.1.dev0.dist-info → sigima-1.0.0.dist-info}/licenses/LICENSE +29 -29
- sigima-0.0.1.dev0.dist-info/METADATA +0 -60
- sigima-0.0.1.dev0.dist-info/RECORD +0 -6
- {sigima-0.0.1.dev0.dist-info → sigima-1.0.0.dist-info}/WHEEL +0 -0
- {sigima-0.0.1.dev0.dist-info → sigima-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1824 @@
|
|
|
1
|
+
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Unit tests for the `sigima.tools.signal.pulse` module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import warnings
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Generator, Literal
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from sigima.enums import SignalShape
|
|
18
|
+
from sigima.objects import create_signal
|
|
19
|
+
from sigima.objects.signal import (
|
|
20
|
+
ExpectedFeatures,
|
|
21
|
+
FeatureTolerances,
|
|
22
|
+
GaussParam,
|
|
23
|
+
SquarePulseParam,
|
|
24
|
+
StepPulseParam,
|
|
25
|
+
)
|
|
26
|
+
from sigima.proc.signal import PulseFeaturesParam, extract_pulse_features
|
|
27
|
+
from sigima.tests import guiutils
|
|
28
|
+
from sigima.tests.data import get_test_signal
|
|
29
|
+
from sigima.tests.helpers import check_scalar_result
|
|
30
|
+
from sigima.tests.signal.pulse import (
|
|
31
|
+
view_baseline_plateau_and_curve,
|
|
32
|
+
view_pulse_features,
|
|
33
|
+
)
|
|
34
|
+
from sigima.tools.signal import filtering, pulse
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class PulseTestData:
|
|
39
|
+
"""Container for pulse test data with metadata."""
|
|
40
|
+
|
|
41
|
+
x: np.ndarray
|
|
42
|
+
y: np.ndarray
|
|
43
|
+
signal_type: Literal["step", "square", "gaussian"]
|
|
44
|
+
is_generated: bool
|
|
45
|
+
description: str
|
|
46
|
+
expected_features: ExpectedFeatures | None = None
|
|
47
|
+
tolerances: FeatureTolerances | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def iterate_square_pulse_data() -> Generator[tuple[np.ndarray, np.ndarray], None, None]:
|
|
51
|
+
"""Iterate over real square pulse data for testing."""
|
|
52
|
+
for basename in ("boxcar.npy", "square2.npy"):
|
|
53
|
+
obj = get_test_signal(basename)
|
|
54
|
+
yield obj.x, obj.y
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def iterate_step_pulse_data() -> Generator[tuple[np.ndarray, np.ndarray], None, None]:
|
|
58
|
+
"""Iterate over real step pulse data for testing."""
|
|
59
|
+
for basename in ("step.npy",):
|
|
60
|
+
obj = get_test_signal(basename)
|
|
61
|
+
yield obj.x, obj.y
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def iterate_all_step_test_data(
|
|
65
|
+
start_ratio: float = 0.1, stop_ratio: float = 0.9
|
|
66
|
+
) -> Generator[PulseTestData, None, None]:
|
|
67
|
+
"""Iterate over all step pulse test data (generated and real).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
start_ratio: Start ratio for feature calculation
|
|
71
|
+
stop_ratio: Stop ratio for feature calculation
|
|
72
|
+
|
|
73
|
+
Yields:
|
|
74
|
+
PulseTestData objects with both generated and real step signals
|
|
75
|
+
"""
|
|
76
|
+
# Generated step data
|
|
77
|
+
params = create_test_step_params()
|
|
78
|
+
x, y = params.generate_1d_data()
|
|
79
|
+
yield PulseTestData(
|
|
80
|
+
x=x,
|
|
81
|
+
y=y,
|
|
82
|
+
signal_type="step",
|
|
83
|
+
is_generated=True,
|
|
84
|
+
description="Generated step signal",
|
|
85
|
+
expected_features=params.get_expected_features(start_ratio, stop_ratio),
|
|
86
|
+
tolerances=params.get_feature_tolerances(),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Real step data
|
|
90
|
+
for idx, (x, y) in enumerate(iterate_step_pulse_data(), 1):
|
|
91
|
+
yield PulseTestData(
|
|
92
|
+
x=x,
|
|
93
|
+
y=y,
|
|
94
|
+
signal_type="step",
|
|
95
|
+
is_generated=False,
|
|
96
|
+
description=f"Real step signal #{idx}",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def iterate_all_square_test_data(
|
|
101
|
+
start_ratio: float = 0.1, stop_ratio: float = 0.9
|
|
102
|
+
) -> Generator[PulseTestData, None, None]:
|
|
103
|
+
"""Iterate over all square pulse test data (generated and real).
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
start_ratio: Start ratio for feature calculation
|
|
107
|
+
stop_ratio: Stop ratio for feature calculation
|
|
108
|
+
|
|
109
|
+
Yields:
|
|
110
|
+
PulseTestData objects with both generated and real square signals
|
|
111
|
+
"""
|
|
112
|
+
# Generated square data
|
|
113
|
+
params = create_test_square_params()
|
|
114
|
+
x, y = params.generate_1d_data()
|
|
115
|
+
yield PulseTestData(
|
|
116
|
+
x=x,
|
|
117
|
+
y=y,
|
|
118
|
+
signal_type="square",
|
|
119
|
+
is_generated=True,
|
|
120
|
+
description="Generated square signal",
|
|
121
|
+
expected_features=params.get_expected_features(start_ratio, stop_ratio),
|
|
122
|
+
tolerances=params.get_feature_tolerances(),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Real square data
|
|
126
|
+
for idx, (x, y) in enumerate(iterate_square_pulse_data(), 1):
|
|
127
|
+
yield PulseTestData(
|
|
128
|
+
x=x,
|
|
129
|
+
y=y,
|
|
130
|
+
signal_type="square",
|
|
131
|
+
is_generated=False,
|
|
132
|
+
description=f"Real square signal #{idx}",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def iterate_all_gaussian_test_data(
|
|
137
|
+
start_ratio: float = 0.1, stop_ratio: float = 0.9
|
|
138
|
+
) -> Generator[PulseTestData, None, None]:
|
|
139
|
+
"""Iterate over all Gaussian pulse test data (generated only).
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
start_ratio: Start ratio for feature calculation
|
|
143
|
+
stop_ratio: Stop ratio for feature calculation
|
|
144
|
+
|
|
145
|
+
Yields:
|
|
146
|
+
PulseTestData objects with generated Gaussian signals
|
|
147
|
+
"""
|
|
148
|
+
# Generated Gaussian data
|
|
149
|
+
params = create_test_gaussian_params()
|
|
150
|
+
x, y = params.generate_1d_data()
|
|
151
|
+
yield PulseTestData(
|
|
152
|
+
x=x,
|
|
153
|
+
y=y,
|
|
154
|
+
signal_type="gaussian",
|
|
155
|
+
is_generated=True,
|
|
156
|
+
description="Generated Gaussian signal",
|
|
157
|
+
expected_features=params.get_expected_features(start_ratio, stop_ratio),
|
|
158
|
+
tolerances=params.get_feature_tolerances(),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def create_test_gaussian_params() -> GaussParam:
|
|
163
|
+
"""Create GaussParam with explicit test values."""
|
|
164
|
+
params = GaussParam()
|
|
165
|
+
# Explicit values to ensure test stability
|
|
166
|
+
params.xmin = -10.0
|
|
167
|
+
params.xmax = 10.0
|
|
168
|
+
params.size = 1000
|
|
169
|
+
params.a = 5.0
|
|
170
|
+
params.y0 = 0.0
|
|
171
|
+
params.sigma = 2.0
|
|
172
|
+
params.mu = 0.0
|
|
173
|
+
return params
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_test_step_params() -> StepPulseParam:
|
|
177
|
+
"""Create StepPulseParam with explicit test values."""
|
|
178
|
+
params = StepPulseParam()
|
|
179
|
+
# Explicit values to ensure test stability
|
|
180
|
+
params.xmin = 0.0
|
|
181
|
+
params.xmax = 10.0
|
|
182
|
+
params.size = 1000
|
|
183
|
+
params.offset = 0.0
|
|
184
|
+
params.amplitude = 5.0
|
|
185
|
+
params.noise_amplitude = 0.2
|
|
186
|
+
params.x_rise_start = 3.0
|
|
187
|
+
params.total_rise_time = 2.0
|
|
188
|
+
return params
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def create_test_square_params() -> SquarePulseParam:
|
|
192
|
+
"""Create SquarePulseParam with explicit test values."""
|
|
193
|
+
params = SquarePulseParam()
|
|
194
|
+
# Explicit values to ensure test stability
|
|
195
|
+
params.xmin = 0.0
|
|
196
|
+
params.xmax = 20.0
|
|
197
|
+
params.size = 1000
|
|
198
|
+
params.offset = 0.0
|
|
199
|
+
params.amplitude = 5.0
|
|
200
|
+
params.noise_amplitude = 0.2
|
|
201
|
+
params.x_rise_start = 3.0
|
|
202
|
+
params.total_rise_time = 2.0
|
|
203
|
+
params.fwhm = 5.5
|
|
204
|
+
params.total_fall_time = 5.0
|
|
205
|
+
return params
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class AnalysisParams:
|
|
210
|
+
"""Parameters for pulse analysis."""
|
|
211
|
+
|
|
212
|
+
start_ratio: float = 0.1
|
|
213
|
+
stop_ratio: float = 0.9
|
|
214
|
+
start_range: tuple[float, float] = (0.0, 3.0)
|
|
215
|
+
end_range: tuple[float, float] = (6.0, 8.0)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _test_shape_recognition_case(
|
|
219
|
+
signal_type: Literal["step", "square", "gaussian"],
|
|
220
|
+
expected_shape: SignalShape,
|
|
221
|
+
y_initial: float,
|
|
222
|
+
y_final_or_high: float,
|
|
223
|
+
start_range: tuple[float, float] | None = None,
|
|
224
|
+
end_range: tuple[float, float] | None = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Helper function to test shape recognition for different signal configurations.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
signal_type: Signal shape type
|
|
230
|
+
expected_shape: Expected SignalShape result
|
|
231
|
+
y_initial: Initial signal value
|
|
232
|
+
y_final_or_high: Final value (step) or high value (square)
|
|
233
|
+
start_range: Start baseline range for shape recognition (optional)
|
|
234
|
+
end_range: End baseline range for shape recognition (optional)
|
|
235
|
+
"""
|
|
236
|
+
# Generate signal
|
|
237
|
+
if signal_type == "step":
|
|
238
|
+
step_params = create_test_step_params()
|
|
239
|
+
step_params.offset = y_initial
|
|
240
|
+
step_params.amplitude = y_final_or_high - y_initial
|
|
241
|
+
x, y_noisy = step_params.generate_1d_data()
|
|
242
|
+
elif signal_type == "square":
|
|
243
|
+
square_params = create_test_square_params()
|
|
244
|
+
square_params.offset = y_initial
|
|
245
|
+
square_params.amplitude = y_final_or_high - y_initial
|
|
246
|
+
x, y_noisy = square_params.generate_1d_data()
|
|
247
|
+
else: # gaussian
|
|
248
|
+
gaussian_params = create_test_gaussian_params()
|
|
249
|
+
gaussian_params.y0 = y_initial
|
|
250
|
+
gaussian_params.a = y_final_or_high - y_initial
|
|
251
|
+
x, y_noisy = gaussian_params.generate_1d_data()
|
|
252
|
+
|
|
253
|
+
# Create title
|
|
254
|
+
polarity_desc = "positive" if y_final_or_high > y_initial else "negative"
|
|
255
|
+
title = f"{signal_type.capitalize()}, {polarity_desc} polarity | Shape recognition"
|
|
256
|
+
if start_range is None:
|
|
257
|
+
title += " (auto-detection)"
|
|
258
|
+
|
|
259
|
+
# Test shape recognition
|
|
260
|
+
if start_range is not None and end_range is not None:
|
|
261
|
+
shape = pulse.heuristically_recognize_shape(x, y_noisy, start_range, end_range)
|
|
262
|
+
else:
|
|
263
|
+
shape = pulse.heuristically_recognize_shape(x, y_noisy)
|
|
264
|
+
|
|
265
|
+
assert shape == expected_shape, f"Expected {expected_shape}, got {shape}"
|
|
266
|
+
guiutils.view_curves_if_gui([[x, y_noisy]], title=f"{title}: {shape}")
|
|
267
|
+
|
|
268
|
+
# Test auto-detection if requested and ranges were provided
|
|
269
|
+
if start_range is not None:
|
|
270
|
+
shape_auto = pulse.heuristically_recognize_shape(x, y_noisy)
|
|
271
|
+
assert shape_auto == expected_shape, (
|
|
272
|
+
f"Auto-detection: Expected {expected_shape}, got {shape_auto}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _test_shape_recognition_with_data(
|
|
277
|
+
test_data: PulseTestData,
|
|
278
|
+
expected_shape: SignalShape,
|
|
279
|
+
start_range: tuple[float, float] | None = None,
|
|
280
|
+
end_range: tuple[float, float] | None = None,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Test shape recognition using PulseTestData.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
test_data: Test data container
|
|
286
|
+
expected_shape: Expected SignalShape result
|
|
287
|
+
start_range: Start baseline range for shape recognition (optional)
|
|
288
|
+
end_range: End baseline range for shape recognition (optional)
|
|
289
|
+
"""
|
|
290
|
+
x, y = test_data.x, test_data.y
|
|
291
|
+
title = f"{test_data.description} | Shape recognition"
|
|
292
|
+
|
|
293
|
+
# Test shape recognition
|
|
294
|
+
if start_range is not None and end_range is not None:
|
|
295
|
+
shape = pulse.heuristically_recognize_shape(x, y, start_range, end_range)
|
|
296
|
+
title += " (with ranges)"
|
|
297
|
+
else:
|
|
298
|
+
shape = pulse.heuristically_recognize_shape(x, y)
|
|
299
|
+
title += " (auto-detection)"
|
|
300
|
+
|
|
301
|
+
assert shape == expected_shape, f"Expected {expected_shape}, got {shape}"
|
|
302
|
+
guiutils.view_curves_if_gui([[x, y]], title=f"{title}: {shape}")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_heuristically_recognize_shape() -> None:
|
|
306
|
+
"""Unit test for the `pulse.heuristically_recognize_shape` function.
|
|
307
|
+
|
|
308
|
+
This test verifies that the function correctly identifies the shape of various
|
|
309
|
+
noisy signals (step and square) generated with different parameters. It checks the
|
|
310
|
+
recognition both with and without specifying regions of interest.
|
|
311
|
+
|
|
312
|
+
Test cases:
|
|
313
|
+
- Step signal with default parameters.
|
|
314
|
+
- Step signal with specified regions.
|
|
315
|
+
- Square signal with default parameters.
|
|
316
|
+
- Step signal with custom initial and final values.
|
|
317
|
+
- Square signal with custom initial and high values.
|
|
318
|
+
|
|
319
|
+
"""
|
|
320
|
+
tsc = _test_shape_recognition_case
|
|
321
|
+
# Step signals with positive polarity
|
|
322
|
+
tsc("step", SignalShape.STEP, 0.0, 5.0, (0.0, 2.0), (4.0, 8.0))
|
|
323
|
+
# Step signals with negative polarity
|
|
324
|
+
tsc("step", SignalShape.STEP, 5.0, 2.0, (0.0, 2.0), (4.0, 8.0))
|
|
325
|
+
# Square signals with positive polarity
|
|
326
|
+
tsc("square", SignalShape.SQUARE, 0.0, 5.0, (0.0, 2.0), (12.0, 14.0))
|
|
327
|
+
# Square signals with negative polarity
|
|
328
|
+
tsc("square", SignalShape.SQUARE, 5.0, 2.0, (0.0, 2.0), (12.0, 14.0))
|
|
329
|
+
# Gaussian signals with positive polarity
|
|
330
|
+
tsc("gaussian", SignalShape.SQUARE, 0.0, 5.0)
|
|
331
|
+
|
|
332
|
+
# Test with real data
|
|
333
|
+
for test_data in iterate_all_step_test_data():
|
|
334
|
+
if not test_data.is_generated:
|
|
335
|
+
_test_shape_recognition_with_data(test_data, SignalShape.STEP)
|
|
336
|
+
|
|
337
|
+
for test_data in iterate_all_square_test_data():
|
|
338
|
+
if not test_data.is_generated:
|
|
339
|
+
_test_shape_recognition_with_data(test_data, SignalShape.SQUARE)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _test_polarity_detection_case(
|
|
343
|
+
signal_type: Literal["step", "square", "gaussian"],
|
|
344
|
+
polarity_desc: str,
|
|
345
|
+
expected_polarity: int,
|
|
346
|
+
y_initial: float,
|
|
347
|
+
y_final_or_high: float,
|
|
348
|
+
start_range: tuple[float, float] | None = None,
|
|
349
|
+
end_range: tuple[float, float] | None = None,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Helper function to test polarity detection for different signal configurations.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
signal_type: Signal shape type
|
|
355
|
+
polarity_desc: Description of polarity ("positive" or "negative")
|
|
356
|
+
expected_polarity: Expected polarity result (1 or -1)
|
|
357
|
+
y_initial: Initial signal value
|
|
358
|
+
y_final_or_high: Final value (step) or high value (square)
|
|
359
|
+
start_range: Start baseline range for polarity detection (optional)
|
|
360
|
+
end_range: End baseline range for polarity detection (optional)
|
|
361
|
+
"""
|
|
362
|
+
# Generate signal
|
|
363
|
+
if signal_type == "step":
|
|
364
|
+
step_params = create_test_step_params()
|
|
365
|
+
step_params.offset = y_initial
|
|
366
|
+
step_params.amplitude = y_final_or_high - y_initial
|
|
367
|
+
x, y_noisy = step_params.generate_1d_data()
|
|
368
|
+
elif signal_type == "square":
|
|
369
|
+
square_params = create_test_square_params()
|
|
370
|
+
square_params.offset = y_initial
|
|
371
|
+
square_params.amplitude = y_final_or_high - y_initial
|
|
372
|
+
x, y_noisy = square_params.generate_1d_data()
|
|
373
|
+
else: # gaussian
|
|
374
|
+
gaussian_params = create_test_gaussian_params()
|
|
375
|
+
gaussian_params.y0 = y_initial
|
|
376
|
+
gaussian_params.a = y_final_or_high - y_initial
|
|
377
|
+
x, y_noisy = gaussian_params.generate_1d_data()
|
|
378
|
+
|
|
379
|
+
# Create title
|
|
380
|
+
title = f"{signal_type}, detection {polarity_desc} polarity"
|
|
381
|
+
if start_range is None:
|
|
382
|
+
title += " (auto)"
|
|
383
|
+
|
|
384
|
+
# Test polarity detection
|
|
385
|
+
if start_range is not None and end_range is not None:
|
|
386
|
+
polarity = pulse.detect_polarity(x, y_noisy, start_range, end_range)
|
|
387
|
+
else:
|
|
388
|
+
polarity = pulse.detect_polarity(x, y_noisy)
|
|
389
|
+
|
|
390
|
+
check_scalar_result(title, polarity, expected_polarity)
|
|
391
|
+
guiutils.view_curves_if_gui([[x, y_noisy]], title=f"{title}: {polarity}")
|
|
392
|
+
|
|
393
|
+
# Test auto-detection if requested and ranges were provided
|
|
394
|
+
if start_range is not None:
|
|
395
|
+
polarity_auto = pulse.detect_polarity(x, y_noisy)
|
|
396
|
+
check_scalar_result(f"{title} (auto)", polarity_auto, expected_polarity)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _test_polarity_detection_with_data(
|
|
400
|
+
test_data: PulseTestData,
|
|
401
|
+
start_range: tuple[float, float] | None = None,
|
|
402
|
+
end_range: tuple[float, float] | None = None,
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Test polarity detection using PulseTestData.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
test_data: Test data container
|
|
408
|
+
start_range: Start baseline range for polarity detection (optional)
|
|
409
|
+
end_range: End baseline range for polarity detection (optional)
|
|
410
|
+
"""
|
|
411
|
+
x, y = test_data.x, test_data.y
|
|
412
|
+
title = f"{test_data.description} | Polarity detection"
|
|
413
|
+
|
|
414
|
+
# Test polarity detection
|
|
415
|
+
if start_range is not None and end_range is not None:
|
|
416
|
+
polarity = pulse.detect_polarity(x, y, start_range, end_range)
|
|
417
|
+
title += " (with ranges)"
|
|
418
|
+
else:
|
|
419
|
+
polarity = pulse.detect_polarity(x, y)
|
|
420
|
+
title += " (auto-detection)"
|
|
421
|
+
|
|
422
|
+
# For real data, we just verify it returns a valid polarity
|
|
423
|
+
assert polarity in (1, -1), f"Expected polarity to be 1 or -1, got {polarity}"
|
|
424
|
+
guiutils.view_curves_if_gui([[x, y]], title=f"{title}: {polarity}")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_detect_polarity() -> None:
|
|
428
|
+
"""Unit test for the `pulse.detect_polarity` function.
|
|
429
|
+
|
|
430
|
+
This test verifies the correct detection of signal polarity for both step and
|
|
431
|
+
square signals, with various initial and final values, and using different detection
|
|
432
|
+
intervals.
|
|
433
|
+
|
|
434
|
+
Test cases covered:
|
|
435
|
+
- Positive polarity detection for step and square signals.
|
|
436
|
+
- Negative polarity detection for step and square signals with inverted amplitude.
|
|
437
|
+
- Detection with and without explicit interval arguments.
|
|
438
|
+
"""
|
|
439
|
+
tpdc = _test_polarity_detection_case
|
|
440
|
+
# Step signals with positive polarity
|
|
441
|
+
tpdc("step", "positive", 1, 0.0, 5.0, (0.0, 2.0), (4.0, 8.0))
|
|
442
|
+
# Step signals with negative polarity
|
|
443
|
+
tpdc("step", "negative", -1, 5.0, 2.0, (0.0, 2.0), (4.0, 8.0))
|
|
444
|
+
# Square signals with positive polarity
|
|
445
|
+
tpdc("square", "positive", 1, 0.0, 5.0, (0.0, 2.0), (12.0, 14.0))
|
|
446
|
+
# Square signals with negative polarity
|
|
447
|
+
tpdc("square", "negative", -1, 5.0, 2.0, (0.0, 2.0), (12.0, 14.0))
|
|
448
|
+
# Gaussian signals with positive polarity (use baseline ranges at extremes)
|
|
449
|
+
tpdc("gaussian", "positive", 1, 0.0, 5.0, (-9.0, -7.0), (7.0, 9.0))
|
|
450
|
+
# Gaussian signals with negative polarity
|
|
451
|
+
tpdc("gaussian", "negative", -1, 5.0, 2.0, (-9.0, -7.0), (7.0, 9.0))
|
|
452
|
+
|
|
453
|
+
# Test with real data
|
|
454
|
+
for test_data in iterate_all_step_test_data():
|
|
455
|
+
if not test_data.is_generated:
|
|
456
|
+
_test_polarity_detection_with_data(test_data)
|
|
457
|
+
|
|
458
|
+
for test_data in iterate_all_square_test_data():
|
|
459
|
+
if not test_data.is_generated:
|
|
460
|
+
_test_polarity_detection_with_data(test_data)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _test_amplitude_case(
|
|
464
|
+
signal_type: Literal["step", "square", "gaussian"],
|
|
465
|
+
polarity_desc: str,
|
|
466
|
+
y_initial: float,
|
|
467
|
+
y_final_or_high: float,
|
|
468
|
+
start_range: tuple[float, float],
|
|
469
|
+
end_range: tuple[float, float],
|
|
470
|
+
plateau_range: tuple[float, float] | None = None,
|
|
471
|
+
atol: float = 0.2,
|
|
472
|
+
rtol: float = 0.1,
|
|
473
|
+
) -> None:
|
|
474
|
+
"""Helper function to test amplitude calculation for different signal configs.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
signal_type: Signal shape type
|
|
478
|
+
polarity_desc: Description of polarity ("positive" or "negative")
|
|
479
|
+
y_initial: Initial signal value
|
|
480
|
+
y_final_or_high: Final value (step) or high value (square)
|
|
481
|
+
start_range: Start baseline range for amplitude calculation
|
|
482
|
+
end_range: End baseline range for amplitude calculation
|
|
483
|
+
plateau_range: Plateau range for square signals (optional)
|
|
484
|
+
atol: Absolute tolerance for amplitude comparison
|
|
485
|
+
rtol: Relative tolerance for auto-detection comparison
|
|
486
|
+
"""
|
|
487
|
+
# Generate signal and calculate expected amplitude
|
|
488
|
+
if signal_type == "step":
|
|
489
|
+
step_params = create_test_step_params()
|
|
490
|
+
step_params.offset = y_initial
|
|
491
|
+
step_params.amplitude = y_final_or_high - y_initial
|
|
492
|
+
x, y_noisy = step_params.generate_1d_data()
|
|
493
|
+
expected_features = step_params.get_expected_features()
|
|
494
|
+
expected_amp = expected_features.amplitude
|
|
495
|
+
elif signal_type == "square":
|
|
496
|
+
square_params = create_test_square_params()
|
|
497
|
+
square_params.offset = y_initial
|
|
498
|
+
square_params.amplitude = y_final_or_high - y_initial
|
|
499
|
+
x, y_noisy = square_params.generate_1d_data()
|
|
500
|
+
expected_features = square_params.get_expected_features()
|
|
501
|
+
expected_amp = expected_features.amplitude
|
|
502
|
+
else: # gaussian
|
|
503
|
+
gaussian_params = create_test_gaussian_params()
|
|
504
|
+
gaussian_params.y0 = y_initial
|
|
505
|
+
gaussian_params.a = y_final_or_high - y_initial
|
|
506
|
+
x, y_noisy = gaussian_params.generate_1d_data()
|
|
507
|
+
expected_features = gaussian_params.get_expected_features()
|
|
508
|
+
expected_amp = expected_features.amplitude
|
|
509
|
+
|
|
510
|
+
# Create title
|
|
511
|
+
title = (
|
|
512
|
+
f"{signal_type.capitalize()}, {polarity_desc} polarity | "
|
|
513
|
+
f"Get {signal_type} amplitude"
|
|
514
|
+
)
|
|
515
|
+
if plateau_range is None:
|
|
516
|
+
title += " (without plateau)"
|
|
517
|
+
|
|
518
|
+
# Test with explicit ranges
|
|
519
|
+
if plateau_range is not None:
|
|
520
|
+
amp = pulse.get_amplitude(x, y_noisy, start_range, end_range, plateau_range)
|
|
521
|
+
else:
|
|
522
|
+
amp = pulse.get_amplitude(x, y_noisy, start_range, end_range)
|
|
523
|
+
|
|
524
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
525
|
+
if qt_app is not None:
|
|
526
|
+
view_baseline_plateau_and_curve(
|
|
527
|
+
x,
|
|
528
|
+
y_noisy,
|
|
529
|
+
f"{title}: {amp:.3f}",
|
|
530
|
+
signal_type,
|
|
531
|
+
start_range,
|
|
532
|
+
end_range,
|
|
533
|
+
plateau_range,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
check_scalar_result(title, amp, expected_amp, atol=atol)
|
|
537
|
+
|
|
538
|
+
# Test auto-detection
|
|
539
|
+
amplitude_auto = pulse.get_amplitude(x, y_noisy)
|
|
540
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
541
|
+
if qt_app is not None:
|
|
542
|
+
view_baseline_plateau_and_curve(
|
|
543
|
+
x,
|
|
544
|
+
y_noisy,
|
|
545
|
+
f"{title}: {amp:.3f} (auto)",
|
|
546
|
+
signal_type,
|
|
547
|
+
pulse.get_start_range(x),
|
|
548
|
+
pulse.get_end_range(x),
|
|
549
|
+
pulse.get_plateau_range(x, y_noisy, expected_features.polarity),
|
|
550
|
+
)
|
|
551
|
+
check_scalar_result(f"{title} (auto)", amplitude_auto, expected_amp, rtol=rtol)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _test_amplitude_with_data(
|
|
555
|
+
test_data: PulseTestData,
|
|
556
|
+
start_range: tuple[float, float] | None = None,
|
|
557
|
+
end_range: tuple[float, float] | None = None,
|
|
558
|
+
plateau_range: tuple[float, float] | None = None,
|
|
559
|
+
) -> None:
|
|
560
|
+
"""Test amplitude calculation using PulseTestData.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
test_data: Test data container
|
|
564
|
+
start_range: Start baseline range (optional)
|
|
565
|
+
end_range: End baseline range (optional)
|
|
566
|
+
plateau_range: Plateau range for square signals (optional)
|
|
567
|
+
"""
|
|
568
|
+
x, y = test_data.x, test_data.y
|
|
569
|
+
title = f"{test_data.description} | Amplitude calculation"
|
|
570
|
+
|
|
571
|
+
# Calculate amplitude
|
|
572
|
+
if start_range is not None and end_range is not None:
|
|
573
|
+
if plateau_range is not None:
|
|
574
|
+
amp = pulse.get_amplitude(x, y, start_range, end_range, plateau_range)
|
|
575
|
+
else:
|
|
576
|
+
amp = pulse.get_amplitude(x, y, start_range, end_range)
|
|
577
|
+
title += " (with ranges)"
|
|
578
|
+
else:
|
|
579
|
+
amp = pulse.get_amplitude(x, y)
|
|
580
|
+
title += " (auto-detection)"
|
|
581
|
+
|
|
582
|
+
# For real data, just verify we get a reasonable value
|
|
583
|
+
assert amp > 0, f"Expected positive amplitude, got {amp}"
|
|
584
|
+
|
|
585
|
+
# Check against expected if available
|
|
586
|
+
if test_data.expected_features is not None:
|
|
587
|
+
check_scalar_result(
|
|
588
|
+
title,
|
|
589
|
+
amp,
|
|
590
|
+
test_data.expected_features.amplitude,
|
|
591
|
+
atol=test_data.tolerances.amplitude if test_data.tolerances else 0.2,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
595
|
+
if qt_app is not None:
|
|
596
|
+
view_baseline_plateau_and_curve(
|
|
597
|
+
x,
|
|
598
|
+
y,
|
|
599
|
+
f"{title}: {amp:.3f}",
|
|
600
|
+
test_data.signal_type,
|
|
601
|
+
start_range or pulse.get_start_range(x),
|
|
602
|
+
end_range or pulse.get_end_range(x),
|
|
603
|
+
plateau_range,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def test_get_amplitude() -> None:
|
|
608
|
+
"""Unit test for the `pulse.get_amplitude` function.
|
|
609
|
+
|
|
610
|
+
This test verifies the correct calculation of the amplitude of step and square
|
|
611
|
+
signals, both with and without specified regions of interest. It checks the
|
|
612
|
+
amplitude for both positive and negative polarities using theoretical calculations.
|
|
613
|
+
|
|
614
|
+
Test cases:
|
|
615
|
+
- Step signal with positive polarity.
|
|
616
|
+
- Step signal with negative polarity.
|
|
617
|
+
- Square signal with positive polarity.
|
|
618
|
+
- Square signal with negative polarity.
|
|
619
|
+
- Gaussian signal with positive polarity.
|
|
620
|
+
- Gaussian signal with negative polarity.
|
|
621
|
+
|
|
622
|
+
- Step signal with custom initial and final values.
|
|
623
|
+
- Square signal with custom initial and high values.
|
|
624
|
+
"""
|
|
625
|
+
tac = _test_amplitude_case
|
|
626
|
+
# Step signals
|
|
627
|
+
tac("step", "positive", 0.0, 5.0, (0.0, 2.0), (6.0, 8.0))
|
|
628
|
+
tac("step", "negative", 5.0, 2.0, (0.0, 2.0), (6.0, 8.0))
|
|
629
|
+
# Square signals with plateau
|
|
630
|
+
tac("square", "positive", 0.0, 5.0, (0.0, 2.0), (12.0, 14.0), (5.5, 6.5))
|
|
631
|
+
tac("square", "negative", 5.0, 2.0, (0.0, 2.0), (12.0, 14.0), (5.5, 6.5), rtol=0.25)
|
|
632
|
+
# Square signals without plateau (auto-detected plateau)
|
|
633
|
+
tac("square", "positive", 0.0, 5.0, (0.0, 2.0), (12.0, 14.0), atol=0.7)
|
|
634
|
+
tac("square", "negative", 5.0, 2.0, (0.0, 2.0), (12.0, 14.0), atol=0.7, rtol=0.25)
|
|
635
|
+
# Gaussian signals
|
|
636
|
+
tac("gaussian", "positive", 0.0, 5.0, (-9.0, -7.0), (7.0, 9.0), atol=0.6)
|
|
637
|
+
tac("gaussian", "negative", 5.0, 2.0, (-9.0, -7.0), (7.0, 9.0), atol=0.6)
|
|
638
|
+
|
|
639
|
+
# Test with real data (auto-detection only, as we don't know optimal ranges)
|
|
640
|
+
for test_data in iterate_all_step_test_data():
|
|
641
|
+
if not test_data.is_generated:
|
|
642
|
+
_test_amplitude_with_data(test_data)
|
|
643
|
+
|
|
644
|
+
for test_data in iterate_all_square_test_data():
|
|
645
|
+
if not test_data.is_generated:
|
|
646
|
+
_test_amplitude_with_data(test_data)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _test_crossing_ratio_time_case(
|
|
650
|
+
signal_type: Literal["step", "square", "gaussian"],
|
|
651
|
+
polarity_desc: str,
|
|
652
|
+
y_initial: float,
|
|
653
|
+
y_final_or_high: float,
|
|
654
|
+
start_range: tuple[float, float],
|
|
655
|
+
end_range: tuple[float, float],
|
|
656
|
+
ratio: float,
|
|
657
|
+
edge: Literal["rise", "fall"] = "rise",
|
|
658
|
+
atol: float = 0.1,
|
|
659
|
+
rtol: float = 0.1,
|
|
660
|
+
) -> None:
|
|
661
|
+
"""Helper function to test crossing ratio time for different signal configurations.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
signal_type: Signal shape type
|
|
665
|
+
polarity_desc: Description of polarity ("positive" or "negative")
|
|
666
|
+
y_initial: Initial signal value
|
|
667
|
+
y_final_or_high: Final value (step) or high value (square)
|
|
668
|
+
start_range: Start baseline range for crossing time calculation
|
|
669
|
+
end_range: End baseline range for crossing time calculation
|
|
670
|
+
ratio: Crossing ratio (0.0 to 1.0)
|
|
671
|
+
edge: Which edge to calculate for square signals
|
|
672
|
+
atol: Absolute tolerance for crossing time comparison
|
|
673
|
+
rtol: Relative tolerance for auto-detection comparison
|
|
674
|
+
"""
|
|
675
|
+
# Generate signal and calculate expected crossing time
|
|
676
|
+
if signal_type == "step":
|
|
677
|
+
step_params = create_test_step_params()
|
|
678
|
+
step_params.offset = y_initial
|
|
679
|
+
step_params.amplitude = y_final_or_high - y_initial
|
|
680
|
+
x, y_noisy = step_params.generate_1d_data()
|
|
681
|
+
# Calculate crossing time for the specific ratio
|
|
682
|
+
expected_ct = step_params.get_crossing_time("rise", ratio)
|
|
683
|
+
elif signal_type == "square":
|
|
684
|
+
square_params = create_test_square_params()
|
|
685
|
+
square_params.offset = y_initial
|
|
686
|
+
square_params.amplitude = y_final_or_high - y_initial
|
|
687
|
+
x, y_noisy = square_params.generate_1d_data()
|
|
688
|
+
# For square signals, calculate crossing time based on edge and ratio
|
|
689
|
+
expected_ct = square_params.get_crossing_time(edge, ratio)
|
|
690
|
+
else: # gaussian
|
|
691
|
+
gaussian_params = create_test_gaussian_params()
|
|
692
|
+
gaussian_params.y0 = y_initial
|
|
693
|
+
gaussian_params.a = y_final_or_high - y_initial
|
|
694
|
+
x, y_noisy = gaussian_params.generate_1d_data()
|
|
695
|
+
# Calculate crossing time for the specific ratio
|
|
696
|
+
expected_ct = gaussian_params.get_crossing_time("rise", ratio)
|
|
697
|
+
|
|
698
|
+
# Create title
|
|
699
|
+
title = (
|
|
700
|
+
f"{signal_type.capitalize()}, {polarity_desc} polarity | "
|
|
701
|
+
f"Get crossing time at {ratio:.1%}"
|
|
702
|
+
)
|
|
703
|
+
if signal_type == "square":
|
|
704
|
+
title += f" ({edge} edge)"
|
|
705
|
+
|
|
706
|
+
# Using the same denoise algorithm as in `extract_pulse_features`
|
|
707
|
+
y_noisy = filtering.denoise_preserve_shape(y_noisy)[0]
|
|
708
|
+
|
|
709
|
+
# Test with explicit ranges
|
|
710
|
+
ct = pulse.find_crossing_at_ratio(x, y_noisy, ratio, start_range, end_range)
|
|
711
|
+
check_scalar_result(title, ct, expected_ct, atol=atol)
|
|
712
|
+
|
|
713
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
714
|
+
if qt_app is not None:
|
|
715
|
+
# polarity = pulse.detect_polarity(x, y_noisy, start_range, end_range)
|
|
716
|
+
# plateau_range = pulse.get_plateau_range(x, y_noisy, polarity)
|
|
717
|
+
view_baseline_plateau_and_curve(
|
|
718
|
+
x,
|
|
719
|
+
y_noisy,
|
|
720
|
+
f"{title}: {ct:.3f}",
|
|
721
|
+
signal_type,
|
|
722
|
+
start_range,
|
|
723
|
+
end_range,
|
|
724
|
+
plateau_range=None,
|
|
725
|
+
vcursors={f"Crossing at {ratio:.1%}": ct},
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
# Test auto-detection
|
|
729
|
+
ct_auto = pulse.find_crossing_at_ratio(x, y_noisy, ratio)
|
|
730
|
+
check_scalar_result(f"{title} (auto)", ct_auto, expected_ct, rtol=rtol, atol=atol)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
@pytest.mark.parametrize("ratio", [0.2, 0.5, 0.8])
|
|
734
|
+
def test_get_crossing_ratio_time(ratio: float) -> None:
|
|
735
|
+
"""Unit test for the `pulse.find_crossing_at_ratio` function.
|
|
736
|
+
|
|
737
|
+
This test verifies the correct calculation of the crossing time at a given ratio
|
|
738
|
+
for both positive and negative polarity step signals using theoretical calculations
|
|
739
|
+
based on the signal generation parameters.
|
|
740
|
+
|
|
741
|
+
Test cases:
|
|
742
|
+
- Step signal with positive polarity.
|
|
743
|
+
- Step signal with negative polarity.
|
|
744
|
+
"""
|
|
745
|
+
tcrtc = _test_crossing_ratio_time_case
|
|
746
|
+
|
|
747
|
+
tcrtc("step", "positive", 0.0, 5.0, (0.0, 2.0), (6.0, 8.0), ratio)
|
|
748
|
+
tcrtc("step", "negative", 5.0, 2.0, (0.0, 2.0), (6.0, 8.0), ratio)
|
|
749
|
+
# Gaussian signals (test that functions work, even if results are less meaningful)
|
|
750
|
+
tcrtc("gaussian", "positive", 0.0, 5.0, (-9.0, -7.0), (7.0, 9.0), ratio, atol=1.0)
|
|
751
|
+
tcrtc("gaussian", "negative", 5.0, 2.0, (-9.0, -7.0), (7.0, 9.0), ratio, atol=1.0)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _test_rise_time_case(
|
|
755
|
+
signal_type: Literal["step", "square", "gaussian"],
|
|
756
|
+
polarity_desc: Literal["positive", "negative"],
|
|
757
|
+
y_initial: float,
|
|
758
|
+
y_final_or_high: float,
|
|
759
|
+
start_range: tuple[float, float],
|
|
760
|
+
end_range: tuple[float, float],
|
|
761
|
+
start_ratio: float,
|
|
762
|
+
stop_ratio: float,
|
|
763
|
+
noise_amplitude: float = 0.1,
|
|
764
|
+
atol: float = 0.1,
|
|
765
|
+
rtol: float = 0.1,
|
|
766
|
+
) -> None:
|
|
767
|
+
"""Helper function to test step rise time for different signal configurations.
|
|
768
|
+
|
|
769
|
+
Args:
|
|
770
|
+
signal_type: Signal shape type
|
|
771
|
+
polarity_desc: Description of polarity
|
|
772
|
+
y_initial: Initial signal value
|
|
773
|
+
y_final_or_high: Final value (step) or high value (square)
|
|
774
|
+
start_range: Start baseline range for rise time calculation
|
|
775
|
+
end_range: End baseline range for rise time calculation
|
|
776
|
+
start_ratio: Starting amplitude ratio for rise time measurement
|
|
777
|
+
stop_ratio: Stopping amplitude ratio (e.g., 0.8 for 80%)
|
|
778
|
+
noise_amplitude: Noise level for signal generation
|
|
779
|
+
atol: Absolute tolerance for rise time comparison
|
|
780
|
+
rtol: Relative tolerance for auto-detection comparison
|
|
781
|
+
"""
|
|
782
|
+
rise_or_fall = "Rise" if polarity_desc == "positive" else "Fall"
|
|
783
|
+
|
|
784
|
+
if noise_amplitude == 0.0:
|
|
785
|
+
atol /= 10.0 # Tighter check for clean signals
|
|
786
|
+
|
|
787
|
+
# Generate signal and calculate expected rise time
|
|
788
|
+
if signal_type == "step":
|
|
789
|
+
step_params = create_test_step_params()
|
|
790
|
+
step_params.offset = y_initial
|
|
791
|
+
step_params.amplitude = y_final_or_high - y_initial
|
|
792
|
+
step_params.noise_amplitude = noise_amplitude
|
|
793
|
+
x, y_noisy = step_params.generate_1d_data()
|
|
794
|
+
expected_features = step_params.get_expected_features(start_ratio, stop_ratio)
|
|
795
|
+
elif signal_type == "square":
|
|
796
|
+
square_params = create_test_square_params()
|
|
797
|
+
square_params.offset = y_initial
|
|
798
|
+
square_params.amplitude = y_final_or_high - y_initial
|
|
799
|
+
square_params.noise_amplitude = noise_amplitude
|
|
800
|
+
x, y_noisy = square_params.generate_1d_data()
|
|
801
|
+
expected_features = square_params.get_expected_features(start_ratio, stop_ratio)
|
|
802
|
+
else: # gaussian
|
|
803
|
+
gaussian_params = create_test_gaussian_params()
|
|
804
|
+
gaussian_params.y0 = y_initial
|
|
805
|
+
gaussian_params.a = y_final_or_high - y_initial
|
|
806
|
+
x, y_noisy = gaussian_params.generate_1d_data()
|
|
807
|
+
expected_features = gaussian_params.get_expected_features(
|
|
808
|
+
start_ratio, stop_ratio
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
# Create title
|
|
812
|
+
noise_desc = "clean" if noise_amplitude == 0 else "noisy"
|
|
813
|
+
title = (
|
|
814
|
+
f"{signal_type.capitalize()}, {polarity_desc} polarity | "
|
|
815
|
+
f"Get {rise_or_fall.lower()} time ({noise_desc})"
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# Test with explicit ranges
|
|
819
|
+
rise_time = pulse.get_rise_time(
|
|
820
|
+
x, y_noisy, start_ratio, stop_ratio, start_range, end_range
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
824
|
+
if qt_app is not None:
|
|
825
|
+
# pylint: disable=import-outside-toplevel
|
|
826
|
+
from sigima.tests import vistools
|
|
827
|
+
|
|
828
|
+
ct1 = pulse.find_crossing_at_ratio(
|
|
829
|
+
x, y_noisy, start_ratio, start_range, end_range
|
|
830
|
+
)
|
|
831
|
+
ct2 = pulse.find_crossing_at_ratio(
|
|
832
|
+
x, y_noisy, stop_ratio, start_range, end_range
|
|
833
|
+
)
|
|
834
|
+
item = vistools.create_range(
|
|
835
|
+
"h",
|
|
836
|
+
ct1,
|
|
837
|
+
ct2,
|
|
838
|
+
f"{rise_or_fall} time {start_ratio:.0%}-"
|
|
839
|
+
f"{stop_ratio:.0%} = {rise_time:.3f}",
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
view_baseline_plateau_and_curve(
|
|
843
|
+
x,
|
|
844
|
+
y_noisy,
|
|
845
|
+
f"{title}: {rise_time:.3f}",
|
|
846
|
+
signal_type,
|
|
847
|
+
start_range,
|
|
848
|
+
end_range,
|
|
849
|
+
plateau_range=None,
|
|
850
|
+
other_items=[item],
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
check_scalar_result(title, rise_time, expected_features.rise_time, atol=atol)
|
|
854
|
+
|
|
855
|
+
# Test auto-detection
|
|
856
|
+
rise_time_auto = pulse.get_rise_time(
|
|
857
|
+
x, y_noisy, start_ratio=start_ratio, stop_ratio=stop_ratio
|
|
858
|
+
)
|
|
859
|
+
check_scalar_result(
|
|
860
|
+
f"{title} (auto)", rise_time_auto, expected_features.rise_time, rtol=rtol
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@pytest.mark.parametrize("noise_amplitude", [0.1, 0.0])
|
|
865
|
+
def test_get_rise_time(noise_amplitude: float) -> None:
|
|
866
|
+
"""Unit test for the `pulse.get_rise_time` function.
|
|
867
|
+
|
|
868
|
+
This test verifies the correct calculation of the rise time for step signals with
|
|
869
|
+
both positive and negative polarity using theoretical calculations based on
|
|
870
|
+
signal generation parameters.
|
|
871
|
+
|
|
872
|
+
Test cases (including noisy and clean signals):
|
|
873
|
+
- Step signal with positive polarity (20%-80% rise time).
|
|
874
|
+
- Step signal with negative polarity (20%-80% rise time).
|
|
875
|
+
"""
|
|
876
|
+
trtc = _test_rise_time_case
|
|
877
|
+
# Standard 20%-80% rise time parameters
|
|
878
|
+
start_ratio, stop_ratio = 0.2, 0.8
|
|
879
|
+
|
|
880
|
+
# Step signals with positive polarity
|
|
881
|
+
na = noise_amplitude
|
|
882
|
+
trtc("step", "positive", 0.0, 5.0, (0, 2), (6, 8), start_ratio, stop_ratio, na)
|
|
883
|
+
trtc("step", "negative", 5.0, 2.0, (0, 2), (6, 8), start_ratio, stop_ratio, na)
|
|
884
|
+
# Gaussian signals (test that functions work, even if results are less meaningful)
|
|
885
|
+
trtc(
|
|
886
|
+
"gaussian",
|
|
887
|
+
"positive",
|
|
888
|
+
0.0,
|
|
889
|
+
5.0,
|
|
890
|
+
(-9.0, -7.0),
|
|
891
|
+
(7.0, 9.0),
|
|
892
|
+
start_ratio,
|
|
893
|
+
stop_ratio,
|
|
894
|
+
na,
|
|
895
|
+
atol=1.0,
|
|
896
|
+
)
|
|
897
|
+
trtc(
|
|
898
|
+
"gaussian",
|
|
899
|
+
"negative",
|
|
900
|
+
5.0,
|
|
901
|
+
2.0,
|
|
902
|
+
(-9.0, -7.0),
|
|
903
|
+
(7.0, 9.0),
|
|
904
|
+
start_ratio,
|
|
905
|
+
stop_ratio,
|
|
906
|
+
na,
|
|
907
|
+
atol=1.0,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
# Test with real data (only for noise_amplitude=0.1 to avoid duplication)
|
|
911
|
+
if noise_amplitude == 0.1:
|
|
912
|
+
for test_data in iterate_all_step_test_data():
|
|
913
|
+
if not test_data.is_generated:
|
|
914
|
+
_test_rise_time_with_data(test_data, start_ratio, stop_ratio)
|
|
915
|
+
|
|
916
|
+
for test_data in iterate_all_square_test_data():
|
|
917
|
+
if not test_data.is_generated:
|
|
918
|
+
_test_rise_time_with_data(test_data, start_ratio, stop_ratio)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _test_rise_time_with_data(
|
|
922
|
+
test_data: PulseTestData,
|
|
923
|
+
start_ratio: float,
|
|
924
|
+
stop_ratio: float,
|
|
925
|
+
start_range: tuple[float, float] | None = None,
|
|
926
|
+
end_range: tuple[float, float] | None = None,
|
|
927
|
+
) -> None:
|
|
928
|
+
"""Test rise time calculation using PulseTestData.
|
|
929
|
+
|
|
930
|
+
Args:
|
|
931
|
+
test_data: Test data container
|
|
932
|
+
start_ratio: Starting amplitude ratio for rise time measurement
|
|
933
|
+
stop_ratio: Stopping amplitude ratio for rise time measurement
|
|
934
|
+
start_range: Start baseline range (optional)
|
|
935
|
+
end_range: End baseline range (optional)
|
|
936
|
+
"""
|
|
937
|
+
x, y = test_data.x, test_data.y
|
|
938
|
+
title = f"{test_data.description} | Rise time"
|
|
939
|
+
|
|
940
|
+
# Calculate rise time
|
|
941
|
+
if start_range is not None and end_range is not None:
|
|
942
|
+
rise_time = pulse.get_rise_time(
|
|
943
|
+
x, y, start_ratio, stop_ratio, start_range, end_range
|
|
944
|
+
)
|
|
945
|
+
title += " (with ranges)"
|
|
946
|
+
else:
|
|
947
|
+
rise_time = pulse.get_rise_time(x, y, start_ratio, stop_ratio)
|
|
948
|
+
title += " (auto-detection)"
|
|
949
|
+
|
|
950
|
+
# For real data, just verify we get a reasonable value
|
|
951
|
+
assert rise_time > 0, f"Expected positive rise time, got {rise_time}"
|
|
952
|
+
|
|
953
|
+
# Check against expected if available
|
|
954
|
+
if test_data.expected_features is not None:
|
|
955
|
+
check_scalar_result(
|
|
956
|
+
title,
|
|
957
|
+
rise_time,
|
|
958
|
+
test_data.expected_features.rise_time,
|
|
959
|
+
atol=test_data.tolerances.rise_time if test_data.tolerances else 0.2,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
963
|
+
if qt_app is not None:
|
|
964
|
+
# pylint: disable=import-outside-toplevel
|
|
965
|
+
from sigima.tests import vistools
|
|
966
|
+
|
|
967
|
+
sr = start_range or pulse.get_start_range(x)
|
|
968
|
+
er = end_range or pulse.get_end_range(x)
|
|
969
|
+
ct1 = pulse.find_crossing_at_ratio(x, y, start_ratio, sr, er)
|
|
970
|
+
ct2 = pulse.find_crossing_at_ratio(x, y, stop_ratio, sr, er)
|
|
971
|
+
item = vistools.create_range(
|
|
972
|
+
"h",
|
|
973
|
+
ct1,
|
|
974
|
+
ct2,
|
|
975
|
+
f"Rise time {start_ratio:.0%}-{stop_ratio:.0%} = {rise_time:.3f}",
|
|
976
|
+
)
|
|
977
|
+
view_baseline_plateau_and_curve(
|
|
978
|
+
x,
|
|
979
|
+
y,
|
|
980
|
+
f"{title}: {rise_time:.3f}",
|
|
981
|
+
test_data.signal_type,
|
|
982
|
+
sr,
|
|
983
|
+
er,
|
|
984
|
+
plateau_range=None,
|
|
985
|
+
other_items=[item],
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# pylint: disable=too-many-positional-arguments
|
|
990
|
+
def _test_fall_time_case(
|
|
991
|
+
signal_type: Literal["square", "gaussian"],
|
|
992
|
+
polarity_desc: Literal["positive", "negative"],
|
|
993
|
+
y_initial: float,
|
|
994
|
+
y_final_or_high: float,
|
|
995
|
+
start_range: tuple[float, float],
|
|
996
|
+
end_range: tuple[float, float],
|
|
997
|
+
plateau_range: tuple[float, float],
|
|
998
|
+
start_ratio: float,
|
|
999
|
+
stop_ratio: float,
|
|
1000
|
+
noise_amplitude: float = 0.1,
|
|
1001
|
+
atol: float = 0.1,
|
|
1002
|
+
rtol: float = 0.1,
|
|
1003
|
+
) -> None:
|
|
1004
|
+
"""Helper function to test fall time for different signal configurations.
|
|
1005
|
+
|
|
1006
|
+
Args:
|
|
1007
|
+
signal_type: Type of signal ("square" or "gaussian")
|
|
1008
|
+
polarity_desc: Description of polarity
|
|
1009
|
+
y_initial: Initial signal value
|
|
1010
|
+
y_final_or_high: Final value (step) or high value (square)
|
|
1011
|
+
start_range: Start baseline range for fall time calculation
|
|
1012
|
+
end_range: End baseline range for fall time calculation
|
|
1013
|
+
plateau_range: Plateau range for square signals
|
|
1014
|
+
start_ratio: Starting amplitude ratio for fall time measurement
|
|
1015
|
+
stop_ratio: Stopping amplitude ratio (e.g., 0.8 for 80%)
|
|
1016
|
+
noise_amplitude: Noise level for signal generation
|
|
1017
|
+
atol: Absolute tolerance for fall time comparison
|
|
1018
|
+
rtol: Relative tolerance for auto-detection comparison
|
|
1019
|
+
"""
|
|
1020
|
+
if noise_amplitude == 0.0:
|
|
1021
|
+
atol /= 10.0 # Tighter check for clean signals
|
|
1022
|
+
|
|
1023
|
+
# Generate signal and calculate expected fall time
|
|
1024
|
+
if signal_type == "square":
|
|
1025
|
+
square_params = create_test_square_params()
|
|
1026
|
+
square_params.offset = y_initial
|
|
1027
|
+
square_params.amplitude = y_final_or_high - y_initial
|
|
1028
|
+
square_params.noise_amplitude = noise_amplitude
|
|
1029
|
+
x, y_noisy = square_params.generate_1d_data()
|
|
1030
|
+
expected_features = square_params.get_expected_features(start_ratio, stop_ratio)
|
|
1031
|
+
else: # gaussian
|
|
1032
|
+
gaussian_params = create_test_gaussian_params()
|
|
1033
|
+
gaussian_params.y0 = y_initial
|
|
1034
|
+
gaussian_params.a = y_final_or_high - y_initial
|
|
1035
|
+
x, y_noisy = gaussian_params.generate_1d_data()
|
|
1036
|
+
expected_features = gaussian_params.get_expected_features(
|
|
1037
|
+
start_ratio, stop_ratio
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
# Create title
|
|
1041
|
+
noise_desc = "clean" if noise_amplitude == 0 else "noisy"
|
|
1042
|
+
signal_desc = signal_type.capitalize()
|
|
1043
|
+
title = f"{signal_desc}, {polarity_desc} polarity | Get fall time ({noise_desc})"
|
|
1044
|
+
|
|
1045
|
+
# Using the same denoise algorithm as in `extract_pulse_features`
|
|
1046
|
+
y_noisy = filtering.denoise_preserve_shape(y_noisy)[0]
|
|
1047
|
+
|
|
1048
|
+
# Test with explicit ranges
|
|
1049
|
+
fall_time = pulse.get_fall_time(
|
|
1050
|
+
x, y_noisy, start_ratio, stop_ratio, plateau_range, end_range
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1054
|
+
if qt_app is not None:
|
|
1055
|
+
# pylint: disable=import-outside-toplevel
|
|
1056
|
+
from sigima.tests import vistools
|
|
1057
|
+
|
|
1058
|
+
ct1 = pulse.find_crossing_at_ratio(
|
|
1059
|
+
x, y_noisy[::-1], start_ratio, start_range, end_range
|
|
1060
|
+
)
|
|
1061
|
+
ct1 = x[-1] - ct1 # Adjust for reversed x
|
|
1062
|
+
ct2 = pulse.find_crossing_at_ratio(
|
|
1063
|
+
x, y_noisy[::-1], stop_ratio, start_range, end_range
|
|
1064
|
+
)
|
|
1065
|
+
ct2 = x[-1] - ct2 # Adjust for reversed x
|
|
1066
|
+
item = vistools.create_range(
|
|
1067
|
+
"h",
|
|
1068
|
+
ct1,
|
|
1069
|
+
ct2,
|
|
1070
|
+
f"Fall time {start_ratio:.0%}-{stop_ratio:.0%} = {fall_time:.3f}",
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
view_baseline_plateau_and_curve(
|
|
1074
|
+
x,
|
|
1075
|
+
y_noisy,
|
|
1076
|
+
f"{title}: {fall_time:.3f}",
|
|
1077
|
+
signal_type,
|
|
1078
|
+
start_range,
|
|
1079
|
+
end_range,
|
|
1080
|
+
plateau_range=plateau_range,
|
|
1081
|
+
other_items=[item],
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
check_scalar_result(
|
|
1085
|
+
f"Get fall time ({noise_desc})",
|
|
1086
|
+
fall_time,
|
|
1087
|
+
expected_features.fall_time,
|
|
1088
|
+
atol=atol,
|
|
1089
|
+
rtol=rtol,
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
@pytest.mark.parametrize("noise_amplitude", [0.1, 0.0])
|
|
1094
|
+
def test_get_fall_time(noise_amplitude: float) -> None:
|
|
1095
|
+
"""Unit test for the `pulse.get_fall_time` function.
|
|
1096
|
+
|
|
1097
|
+
This test verifies the correct calculation of the fall time for signals with
|
|
1098
|
+
both positive and negative polarity using theoretical calculations based on
|
|
1099
|
+
signal generation parameters.
|
|
1100
|
+
|
|
1101
|
+
Test cases (including noisy and clean signals):
|
|
1102
|
+
- Square signal with positive polarity (20%-80% fall time).
|
|
1103
|
+
- Square signal with negative polarity (20%-80% fall time).
|
|
1104
|
+
- Gaussian signal with positive polarity (function test only).
|
|
1105
|
+
- Gaussian signal with negative polarity (function test only).
|
|
1106
|
+
"""
|
|
1107
|
+
tftc = _test_fall_time_case
|
|
1108
|
+
|
|
1109
|
+
# Square signals with plateau
|
|
1110
|
+
na = noise_amplitude
|
|
1111
|
+
tftc(
|
|
1112
|
+
"square",
|
|
1113
|
+
"positive",
|
|
1114
|
+
0.0,
|
|
1115
|
+
5.0,
|
|
1116
|
+
(0.0, 2.0),
|
|
1117
|
+
(12.0, 14.0),
|
|
1118
|
+
(5.5, 6.5),
|
|
1119
|
+
0.8,
|
|
1120
|
+
0.2,
|
|
1121
|
+
na,
|
|
1122
|
+
)
|
|
1123
|
+
tftc(
|
|
1124
|
+
"square",
|
|
1125
|
+
"negative",
|
|
1126
|
+
5.0,
|
|
1127
|
+
2.0,
|
|
1128
|
+
(0.0, 2.0),
|
|
1129
|
+
(12.0, 14.0),
|
|
1130
|
+
(5.5, 6.5),
|
|
1131
|
+
0.8,
|
|
1132
|
+
0.2,
|
|
1133
|
+
na,
|
|
1134
|
+
)
|
|
1135
|
+
# Gaussian signals (test that functions work, even if results are less meaningful)
|
|
1136
|
+
tftc(
|
|
1137
|
+
"gaussian",
|
|
1138
|
+
"positive",
|
|
1139
|
+
0.0,
|
|
1140
|
+
5.0,
|
|
1141
|
+
(-9.0, -7.0),
|
|
1142
|
+
(7.0, 9.0),
|
|
1143
|
+
(-1.0, 1.0),
|
|
1144
|
+
0.8,
|
|
1145
|
+
0.2,
|
|
1146
|
+
na,
|
|
1147
|
+
atol=1.0,
|
|
1148
|
+
)
|
|
1149
|
+
tftc(
|
|
1150
|
+
"gaussian",
|
|
1151
|
+
"negative",
|
|
1152
|
+
5.0,
|
|
1153
|
+
2.0,
|
|
1154
|
+
(-9.0, -7.0),
|
|
1155
|
+
(7.0, 9.0),
|
|
1156
|
+
(-1.0, 1.0),
|
|
1157
|
+
0.8,
|
|
1158
|
+
0.2,
|
|
1159
|
+
na,
|
|
1160
|
+
atol=1.0,
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
# Test with real data (only for noise_amplitude=0.1 to avoid duplication)
|
|
1164
|
+
if noise_amplitude == 0.1:
|
|
1165
|
+
for test_data in iterate_all_square_test_data():
|
|
1166
|
+
if not test_data.is_generated:
|
|
1167
|
+
_test_fall_time_with_data(test_data, 0.8, 0.2)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _test_fall_time_with_data(
|
|
1171
|
+
test_data: PulseTestData,
|
|
1172
|
+
start_ratio: float,
|
|
1173
|
+
stop_ratio: float,
|
|
1174
|
+
start_range: tuple[float, float] | None = None,
|
|
1175
|
+
end_range: tuple[float, float] | None = None,
|
|
1176
|
+
plateau_range: tuple[float, float] | None = None,
|
|
1177
|
+
) -> None:
|
|
1178
|
+
"""Test fall time calculation using PulseTestData.
|
|
1179
|
+
|
|
1180
|
+
Args:
|
|
1181
|
+
test_data: Test data container
|
|
1182
|
+
start_ratio: Starting amplitude ratio for fall time measurement
|
|
1183
|
+
stop_ratio: Stopping amplitude ratio for fall time measurement
|
|
1184
|
+
start_range: Start baseline range (optional)
|
|
1185
|
+
end_range: End baseline range (optional)
|
|
1186
|
+
plateau_range: Plateau range (optional)
|
|
1187
|
+
"""
|
|
1188
|
+
x, y = test_data.x, test_data.y
|
|
1189
|
+
title = f"{test_data.description} | Fall time"
|
|
1190
|
+
|
|
1191
|
+
# Using the same denoise algorithm as in `extract_pulse_features`
|
|
1192
|
+
y = filtering.denoise_preserve_shape(y)[0]
|
|
1193
|
+
|
|
1194
|
+
# Calculate fall time
|
|
1195
|
+
if plateau_range is not None and end_range is not None:
|
|
1196
|
+
fall_time = pulse.get_fall_time(
|
|
1197
|
+
x, y, start_ratio, stop_ratio, plateau_range, end_range
|
|
1198
|
+
)
|
|
1199
|
+
title += " (with ranges)"
|
|
1200
|
+
else:
|
|
1201
|
+
# Auto-detect ranges
|
|
1202
|
+
sr = start_range or pulse.get_start_range(x)
|
|
1203
|
+
er = end_range or pulse.get_end_range(x)
|
|
1204
|
+
polarity = pulse.detect_polarity(x, y, sr, er)
|
|
1205
|
+
pr = plateau_range or pulse.get_plateau_range(x, y, polarity)
|
|
1206
|
+
fall_time = pulse.get_fall_time(x, y, start_ratio, stop_ratio, pr, er)
|
|
1207
|
+
title += " (auto-detection)"
|
|
1208
|
+
|
|
1209
|
+
# For real data, fall_time might be None for some signals
|
|
1210
|
+
if fall_time is None:
|
|
1211
|
+
# This is acceptable for some real data
|
|
1212
|
+
return
|
|
1213
|
+
|
|
1214
|
+
# Verify we get a reasonable value
|
|
1215
|
+
assert fall_time > 0, f"Expected positive fall time, got {fall_time}"
|
|
1216
|
+
|
|
1217
|
+
# Check against expected if available
|
|
1218
|
+
if test_data.expected_features is not None:
|
|
1219
|
+
check_scalar_result(
|
|
1220
|
+
title,
|
|
1221
|
+
fall_time,
|
|
1222
|
+
test_data.expected_features.fall_time,
|
|
1223
|
+
atol=test_data.tolerances.fall_time if test_data.tolerances else 0.2,
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1226
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1227
|
+
if qt_app is not None:
|
|
1228
|
+
# pylint: disable=import-outside-toplevel
|
|
1229
|
+
from sigima.tests import vistools
|
|
1230
|
+
|
|
1231
|
+
sr = start_range or pulse.get_start_range(x)
|
|
1232
|
+
er = end_range or pulse.get_end_range(x)
|
|
1233
|
+
polarity = pulse.detect_polarity(x, y, sr, er)
|
|
1234
|
+
pr = plateau_range or pulse.get_plateau_range(x, y, polarity)
|
|
1235
|
+
|
|
1236
|
+
ct1 = pulse.find_crossing_at_ratio(x, y[::-1], start_ratio, sr, er)
|
|
1237
|
+
ct1 = x[-1] - ct1
|
|
1238
|
+
ct2 = pulse.find_crossing_at_ratio(x, y[::-1], stop_ratio, sr, er)
|
|
1239
|
+
ct2 = x[-1] - ct2
|
|
1240
|
+
|
|
1241
|
+
item = vistools.create_range(
|
|
1242
|
+
"h",
|
|
1243
|
+
ct1,
|
|
1244
|
+
ct2,
|
|
1245
|
+
f"Fall time {start_ratio:.0%}-{stop_ratio:.0%} = {fall_time:.3f}",
|
|
1246
|
+
)
|
|
1247
|
+
view_baseline_plateau_and_curve(
|
|
1248
|
+
x,
|
|
1249
|
+
y,
|
|
1250
|
+
f"{title}: {fall_time:.3f}",
|
|
1251
|
+
test_data.signal_type,
|
|
1252
|
+
sr,
|
|
1253
|
+
er,
|
|
1254
|
+
plateau_range=pr,
|
|
1255
|
+
other_items=[item],
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def test_heuristically_find_rise_start_time() -> None:
|
|
1260
|
+
"""Unit test for the `pulse.heuristically_find_rise_start_time` function.
|
|
1261
|
+
|
|
1262
|
+
This test verifies that the function correctly identifies the end time of the foot
|
|
1263
|
+
(baseline) region in a step signal with a sharp rise, ensuring accurate detection
|
|
1264
|
+
even in the presence of noise.
|
|
1265
|
+
"""
|
|
1266
|
+
# Generate a signal with baseline until t=3, then rising from t=3 to t=5
|
|
1267
|
+
step_params = create_test_step_params()
|
|
1268
|
+
x, y = step_params.generate_1d_data()
|
|
1269
|
+
# Use proper baseline range that doesn't include the rising portion
|
|
1270
|
+
time = pulse.heuristically_find_rise_start_time(x, y, (0, 2.5))
|
|
1271
|
+
if time is not None:
|
|
1272
|
+
# Expected time should be x_rise_start (3.0) - the start of the rise
|
|
1273
|
+
# This is when the foot (baseline) region ends
|
|
1274
|
+
expected_foot_end_time = step_params.x_rise_start
|
|
1275
|
+
check_scalar_result(
|
|
1276
|
+
"heuristically find foot end time",
|
|
1277
|
+
time,
|
|
1278
|
+
expected_foot_end_time,
|
|
1279
|
+
atol=0.2, # Allow reasonable tolerance for noisy signals
|
|
1280
|
+
)
|
|
1281
|
+
else:
|
|
1282
|
+
# If the function returns None, that's unexpected for this signal
|
|
1283
|
+
pytest.fail(
|
|
1284
|
+
"heuristically_find_rise_start_time returned None for a clear step signal"
|
|
1285
|
+
)
|
|
1286
|
+
time_str = f"{time:.3f}" if time is not None else "None"
|
|
1287
|
+
guiutils.view_curves_if_gui([[x, y]], title=f"Rise start time = {time_str}")
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def test_get_rise_start_time() -> None:
|
|
1291
|
+
"""Unit test for the `pulse.get_rise_start_time ` function."""
|
|
1292
|
+
# Generate a step signal with a sharp rise at t=5
|
|
1293
|
+
step_params = create_test_step_params()
|
|
1294
|
+
x, y = step_params.generate_1d_data()
|
|
1295
|
+
|
|
1296
|
+
# Use start_range before the step, end_range after
|
|
1297
|
+
start_range, end_range, threshold = (0, 2), (6, 8), 0.1
|
|
1298
|
+
|
|
1299
|
+
x0 = pulse.get_rise_start_time(x, y, start_range, end_range, threshold=threshold)
|
|
1300
|
+
foot_duration = x0 - x[0] # Since x[0] = 0.0 in this case
|
|
1301
|
+
|
|
1302
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1303
|
+
if qt_app is not None:
|
|
1304
|
+
# polarity = pulse.detect_polarity(x, y_noisy, start_range, end_range)
|
|
1305
|
+
# plateau_range = pulse.get_plateau_range(x, y_noisy, polarity)
|
|
1306
|
+
title = f"Foot duration={foot_duration:.3f}, x_end={x0:.3f}, "
|
|
1307
|
+
title += f"threshold={threshold:.3f}"
|
|
1308
|
+
view_baseline_plateau_and_curve(
|
|
1309
|
+
x,
|
|
1310
|
+
y,
|
|
1311
|
+
title,
|
|
1312
|
+
"step",
|
|
1313
|
+
start_range,
|
|
1314
|
+
end_range,
|
|
1315
|
+
plateau_range=None,
|
|
1316
|
+
vcursors={"Foot duration end": x0},
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
check_scalar_result("foot_info x_end", x0, step_params.x_rise_start, atol=0.2)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def __check_features(
|
|
1323
|
+
features: pulse.PulseFeatures,
|
|
1324
|
+
expected: ExpectedFeatures,
|
|
1325
|
+
tolerances: FeatureTolerances,
|
|
1326
|
+
) -> None:
|
|
1327
|
+
"""Helper function to validate extracted pulse features against expected values.
|
|
1328
|
+
|
|
1329
|
+
Args:
|
|
1330
|
+
features: Extracted pulse features.
|
|
1331
|
+
expected: Expected feature values for validation.
|
|
1332
|
+
tolerances: Tolerance values for each feature.
|
|
1333
|
+
"""
|
|
1334
|
+
signal_shape = features.signal_shape
|
|
1335
|
+
# Get signal shape string for error messages (handle both string and enum)
|
|
1336
|
+
shape_str = signal_shape if isinstance(signal_shape, str) else signal_shape.value
|
|
1337
|
+
# Validate numerical features
|
|
1338
|
+
for field in dataclasses.fields(features):
|
|
1339
|
+
value = getattr(features, field.name)
|
|
1340
|
+
expected_value = getattr(expected, field.name, None)
|
|
1341
|
+
if expected_value is None:
|
|
1342
|
+
continue # Skip fields without expected values
|
|
1343
|
+
tolerance = getattr(tolerances, field.name, None)
|
|
1344
|
+
if tolerance is None:
|
|
1345
|
+
assert value == expected_value, (
|
|
1346
|
+
f"[{shape_str}] {field.name}: Expected {expected_value}, got {value}"
|
|
1347
|
+
)
|
|
1348
|
+
else:
|
|
1349
|
+
check_scalar_result(
|
|
1350
|
+
f"[{shape_str}] {field.name}",
|
|
1351
|
+
value,
|
|
1352
|
+
expected_value,
|
|
1353
|
+
atol=tolerance,
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def _extract_and_validate_step_features(
|
|
1358
|
+
x: np.ndarray,
|
|
1359
|
+
y: np.ndarray,
|
|
1360
|
+
analysis: AnalysisParams,
|
|
1361
|
+
expected: ExpectedFeatures,
|
|
1362
|
+
signal_params: StepPulseParam,
|
|
1363
|
+
) -> pulse.PulseFeatures:
|
|
1364
|
+
"""Helper function to extract and validate step signal features.
|
|
1365
|
+
|
|
1366
|
+
Args:
|
|
1367
|
+
x: X data array
|
|
1368
|
+
y: Y data array
|
|
1369
|
+
analysis: Analysis parameters for pulse feature extraction
|
|
1370
|
+
expected: Expected feature values for validation
|
|
1371
|
+
signal_params: Step signal parameters for tolerance calculation
|
|
1372
|
+
|
|
1373
|
+
Returns:
|
|
1374
|
+
Extracted pulse features
|
|
1375
|
+
"""
|
|
1376
|
+
# Extract features while ignoring FWHM warnings for noisy signals
|
|
1377
|
+
with warnings.catch_warnings():
|
|
1378
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
1379
|
+
features = pulse.extract_pulse_features(
|
|
1380
|
+
x,
|
|
1381
|
+
y,
|
|
1382
|
+
analysis.start_range,
|
|
1383
|
+
analysis.end_range,
|
|
1384
|
+
analysis.start_ratio,
|
|
1385
|
+
analysis.stop_ratio,
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
# Visualize results if GUI is available
|
|
1389
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1390
|
+
if qt_app is not None:
|
|
1391
|
+
view_pulse_features(
|
|
1392
|
+
x, y, "Step signal feature extraction", "step", features
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
# Validate that we got the correct type
|
|
1396
|
+
assert isinstance(features, pulse.PulseFeatures), (
|
|
1397
|
+
f"Expected PulseFeatures, got {type(features)}"
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
# Validate signal shape
|
|
1401
|
+
assert features.signal_shape == SignalShape.STEP, (
|
|
1402
|
+
f"Expected signal_shape to be STEP, but got {features.signal_shape}"
|
|
1403
|
+
)
|
|
1404
|
+
|
|
1405
|
+
# Get tolerance values
|
|
1406
|
+
tolerances = signal_params.get_feature_tolerances()
|
|
1407
|
+
|
|
1408
|
+
# Validate numerical features
|
|
1409
|
+
__check_features(features, expected, tolerances)
|
|
1410
|
+
|
|
1411
|
+
# Validate that step-specific features are None
|
|
1412
|
+
assert features.fall_time is None, (
|
|
1413
|
+
f"Expected fall_time to be None for step signal, but got {features.fall_time}"
|
|
1414
|
+
)
|
|
1415
|
+
assert features.fwhm is None, (
|
|
1416
|
+
f"Expected fwhm to be None for step signal, but got {features.fwhm}"
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
return features
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _extract_and_validate_step_features_from_data(
|
|
1423
|
+
test_data: PulseTestData,
|
|
1424
|
+
) -> pulse.PulseFeatures:
|
|
1425
|
+
"""Helper function to extract and validate step signal features from test data.
|
|
1426
|
+
|
|
1427
|
+
Args:
|
|
1428
|
+
test_data: Test data container
|
|
1429
|
+
|
|
1430
|
+
Returns:
|
|
1431
|
+
Extracted pulse features
|
|
1432
|
+
"""
|
|
1433
|
+
x, y = test_data.x, test_data.y
|
|
1434
|
+
|
|
1435
|
+
# Auto-detect ranges
|
|
1436
|
+
start_range = pulse.get_start_range(x)
|
|
1437
|
+
end_range = pulse.get_end_range(x)
|
|
1438
|
+
|
|
1439
|
+
# Extract features
|
|
1440
|
+
with warnings.catch_warnings():
|
|
1441
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
1442
|
+
features = pulse.extract_pulse_features(x, y, start_range, end_range)
|
|
1443
|
+
|
|
1444
|
+
# Visualize results if GUI is available
|
|
1445
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1446
|
+
if qt_app is not None:
|
|
1447
|
+
view_pulse_features(
|
|
1448
|
+
x, y, f"{test_data.description} | Feature extraction", "step", features
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
# Validate that we got the correct type
|
|
1452
|
+
assert isinstance(features, pulse.PulseFeatures), (
|
|
1453
|
+
f"Expected PulseFeatures, got {type(features)}"
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
# Validate signal shape
|
|
1457
|
+
assert features.signal_shape == SignalShape.STEP, (
|
|
1458
|
+
f"Expected signal_shape to be STEP, but got {features.signal_shape}"
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
# If we have expected features, validate against them
|
|
1462
|
+
if test_data.expected_features is not None and test_data.tolerances is not None:
|
|
1463
|
+
__check_features(features, test_data.expected_features, test_data.tolerances)
|
|
1464
|
+
|
|
1465
|
+
return features
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _extract_and_validate_square_features(
|
|
1469
|
+
x: np.ndarray,
|
|
1470
|
+
y: np.ndarray,
|
|
1471
|
+
analysis: AnalysisParams,
|
|
1472
|
+
expected: ExpectedFeatures,
|
|
1473
|
+
signal_params: SquarePulseParam,
|
|
1474
|
+
) -> pulse.PulseFeatures:
|
|
1475
|
+
"""Helper function to extract and validate square signal features.
|
|
1476
|
+
|
|
1477
|
+
Args:
|
|
1478
|
+
x: X data array
|
|
1479
|
+
y: Y data array
|
|
1480
|
+
analysis: Analysis parameters for pulse feature extraction
|
|
1481
|
+
expected: Expected feature values for validation
|
|
1482
|
+
signal_params: Square signal parameters for tolerance calculation
|
|
1483
|
+
|
|
1484
|
+
Returns:
|
|
1485
|
+
Extracted pulse features
|
|
1486
|
+
"""
|
|
1487
|
+
# Extract features while ignoring FWHM warnings for noisy signals
|
|
1488
|
+
with warnings.catch_warnings():
|
|
1489
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
1490
|
+
features = pulse.extract_pulse_features(
|
|
1491
|
+
x,
|
|
1492
|
+
y,
|
|
1493
|
+
analysis.start_range,
|
|
1494
|
+
analysis.end_range,
|
|
1495
|
+
analysis.start_ratio,
|
|
1496
|
+
analysis.stop_ratio,
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
# Visualize results if GUI is available
|
|
1500
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1501
|
+
if qt_app is not None:
|
|
1502
|
+
view_pulse_features(
|
|
1503
|
+
x, y, "Square signal feature extraction", "square", features
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
# Validate that we got the correct type
|
|
1507
|
+
assert isinstance(features, pulse.PulseFeatures), (
|
|
1508
|
+
f"Expected PulseFeatures, got {type(features)}"
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
# Validate signal shape
|
|
1512
|
+
assert features.signal_shape == SignalShape.SQUARE, (
|
|
1513
|
+
f"Expected signal_shape to be SQUARE, but got {features.signal_shape}"
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
# Get tolerance values
|
|
1517
|
+
tolerances = signal_params.get_feature_tolerances()
|
|
1518
|
+
|
|
1519
|
+
# Validate numerical features
|
|
1520
|
+
__check_features(features, expected, tolerances)
|
|
1521
|
+
|
|
1522
|
+
return features
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def _extract_and_validate_square_features_from_data(
|
|
1526
|
+
test_data: PulseTestData,
|
|
1527
|
+
) -> pulse.PulseFeatures:
|
|
1528
|
+
"""Helper function to extract and validate square signal features from test data.
|
|
1529
|
+
|
|
1530
|
+
Args:
|
|
1531
|
+
test_data: Test data container
|
|
1532
|
+
|
|
1533
|
+
Returns:
|
|
1534
|
+
Extracted pulse features
|
|
1535
|
+
"""
|
|
1536
|
+
x, y = test_data.x, test_data.y
|
|
1537
|
+
|
|
1538
|
+
# Auto-detect ranges
|
|
1539
|
+
start_range = pulse.get_start_range(x)
|
|
1540
|
+
end_range = pulse.get_end_range(x)
|
|
1541
|
+
|
|
1542
|
+
# Extract features
|
|
1543
|
+
with warnings.catch_warnings():
|
|
1544
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
1545
|
+
features = pulse.extract_pulse_features(x, y, start_range, end_range)
|
|
1546
|
+
|
|
1547
|
+
# Visualize results if GUI is available
|
|
1548
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1549
|
+
if qt_app is not None:
|
|
1550
|
+
view_pulse_features(
|
|
1551
|
+
x,
|
|
1552
|
+
y,
|
|
1553
|
+
f"{test_data.description} | Feature extraction",
|
|
1554
|
+
"square",
|
|
1555
|
+
features,
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
# Validate that we got the correct type
|
|
1559
|
+
assert isinstance(features, pulse.PulseFeatures), (
|
|
1560
|
+
f"Expected PulseFeatures, got {type(features)}"
|
|
1561
|
+
)
|
|
1562
|
+
|
|
1563
|
+
# Validate signal shape
|
|
1564
|
+
assert features.signal_shape == SignalShape.SQUARE, (
|
|
1565
|
+
f"Expected signal_shape to be SQUARE, but got {features.signal_shape}"
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
# If we have expected features, validate against them
|
|
1569
|
+
if test_data.expected_features is not None and test_data.tolerances is not None:
|
|
1570
|
+
__check_features(features, test_data.expected_features, test_data.tolerances)
|
|
1571
|
+
|
|
1572
|
+
return features
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _extract_and_validate_gaussian_features(
|
|
1576
|
+
x: np.ndarray,
|
|
1577
|
+
y: np.ndarray,
|
|
1578
|
+
analysis: AnalysisParams,
|
|
1579
|
+
expected: ExpectedFeatures,
|
|
1580
|
+
signal_params: GaussParam,
|
|
1581
|
+
) -> pulse.PulseFeatures:
|
|
1582
|
+
"""Helper function to extract and validate Gaussian signal features.
|
|
1583
|
+
|
|
1584
|
+
Args:
|
|
1585
|
+
x: X data array
|
|
1586
|
+
y: Y data array
|
|
1587
|
+
analysis: Analysis parameters for pulse feature extraction
|
|
1588
|
+
expected: Expected feature values for validation
|
|
1589
|
+
signal_params: Gaussian signal parameters for tolerance calculation
|
|
1590
|
+
|
|
1591
|
+
Returns:
|
|
1592
|
+
Extracted pulse features
|
|
1593
|
+
"""
|
|
1594
|
+
# Extract features while ignoring FWHM warnings for noisy signals
|
|
1595
|
+
with warnings.catch_warnings():
|
|
1596
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
1597
|
+
features = pulse.extract_pulse_features(
|
|
1598
|
+
x,
|
|
1599
|
+
y,
|
|
1600
|
+
analysis.start_range,
|
|
1601
|
+
analysis.end_range,
|
|
1602
|
+
analysis.start_ratio,
|
|
1603
|
+
analysis.stop_ratio,
|
|
1604
|
+
)
|
|
1605
|
+
|
|
1606
|
+
# Visualize results if GUI is available
|
|
1607
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1608
|
+
if qt_app is not None:
|
|
1609
|
+
view_pulse_features(
|
|
1610
|
+
x, y, "Gaussian signal feature extraction", "gaussian", features
|
|
1611
|
+
)
|
|
1612
|
+
|
|
1613
|
+
# Validate that we got the correct type
|
|
1614
|
+
assert isinstance(features, pulse.PulseFeatures), (
|
|
1615
|
+
f"Expected PulseFeatures, got {type(features)}"
|
|
1616
|
+
)
|
|
1617
|
+
|
|
1618
|
+
# Validate signal shape (Gaussian is recognized as SQUARE)
|
|
1619
|
+
assert features.signal_shape == SignalShape.SQUARE, (
|
|
1620
|
+
f"Expected signal_shape to be SQUARE, but got {features.signal_shape}"
|
|
1621
|
+
)
|
|
1622
|
+
|
|
1623
|
+
# Get tolerance values
|
|
1624
|
+
tolerances = signal_params.get_feature_tolerances()
|
|
1625
|
+
|
|
1626
|
+
# Validate numerical features
|
|
1627
|
+
__check_features(features, expected, tolerances)
|
|
1628
|
+
|
|
1629
|
+
return features
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def test_step_feature_extraction() -> None:
|
|
1633
|
+
"""Test feature extraction for step signals.
|
|
1634
|
+
|
|
1635
|
+
Validates that pulse feature extraction correctly identifies and measures
|
|
1636
|
+
all relevant parameters for a step signal, including polarity, amplitude,
|
|
1637
|
+
rise time, timing features, and baseline characteristics.
|
|
1638
|
+
"""
|
|
1639
|
+
# Define signal parameters
|
|
1640
|
+
signal_params = create_test_step_params()
|
|
1641
|
+
|
|
1642
|
+
# Define analysis parameters
|
|
1643
|
+
analysis = AnalysisParams()
|
|
1644
|
+
|
|
1645
|
+
# Calculate expected values
|
|
1646
|
+
expected = signal_params.get_expected_features(
|
|
1647
|
+
start_ratio=analysis.start_ratio,
|
|
1648
|
+
stop_ratio=analysis.stop_ratio,
|
|
1649
|
+
)
|
|
1650
|
+
|
|
1651
|
+
# Generate test signal
|
|
1652
|
+
x, y = signal_params.generate_1d_data()
|
|
1653
|
+
|
|
1654
|
+
# Extract and validate features
|
|
1655
|
+
_extract_and_validate_step_features(x, y, analysis, expected, signal_params)
|
|
1656
|
+
|
|
1657
|
+
# Test with real data
|
|
1658
|
+
for test_data in iterate_all_step_test_data():
|
|
1659
|
+
if not test_data.is_generated:
|
|
1660
|
+
_extract_and_validate_step_features_from_data(test_data)
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def test_square_feature_extraction() -> None:
|
|
1664
|
+
"""Test feature extraction for square signals.
|
|
1665
|
+
|
|
1666
|
+
Validates that pulse feature extraction correctly identifies and measures
|
|
1667
|
+
all relevant parameters for a square signal, including polarity, amplitude,
|
|
1668
|
+
rise/fall times, FWHM, timing features, and baseline characteristics.
|
|
1669
|
+
"""
|
|
1670
|
+
# Define signal parameters with custom ranges for square signal
|
|
1671
|
+
signal_params = create_test_square_params()
|
|
1672
|
+
|
|
1673
|
+
# Define analysis parameters with custom ranges for square signal
|
|
1674
|
+
analysis = AnalysisParams(
|
|
1675
|
+
start_range=(0.0, 2.5),
|
|
1676
|
+
end_range=(15.0, 17.0),
|
|
1677
|
+
)
|
|
1678
|
+
|
|
1679
|
+
# Calculate expected values
|
|
1680
|
+
expected = signal_params.get_expected_features(
|
|
1681
|
+
start_ratio=analysis.start_ratio,
|
|
1682
|
+
stop_ratio=analysis.stop_ratio,
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
# Generate test signal
|
|
1686
|
+
x, y = signal_params.generate_1d_data()
|
|
1687
|
+
|
|
1688
|
+
# Extract and validate features
|
|
1689
|
+
_extract_and_validate_square_features(x, y, analysis, expected, signal_params)
|
|
1690
|
+
|
|
1691
|
+
# Test with real data
|
|
1692
|
+
for test_data in iterate_all_square_test_data():
|
|
1693
|
+
if not test_data.is_generated:
|
|
1694
|
+
_extract_and_validate_square_features_from_data(test_data)
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def test_gaussian_feature_extraction() -> None:
|
|
1698
|
+
"""Test feature extraction for Gaussian signals.
|
|
1699
|
+
|
|
1700
|
+
Validates that pulse feature extraction correctly identifies and measures
|
|
1701
|
+
all relevant parameters for a Gaussian signal, including polarity, amplitude,
|
|
1702
|
+
rise/fall times, timing features, and baseline characteristics using the
|
|
1703
|
+
improved Gaussian-aware algorithms.
|
|
1704
|
+
"""
|
|
1705
|
+
# Define signal parameters with appropriate ranges for Gaussian signal
|
|
1706
|
+
signal_params = create_test_gaussian_params()
|
|
1707
|
+
|
|
1708
|
+
# Define analysis parameters with ranges suitable for Gaussian signal
|
|
1709
|
+
analysis = AnalysisParams(
|
|
1710
|
+
start_range=(-9.0, -7.0),
|
|
1711
|
+
end_range=(7.0, 9.0),
|
|
1712
|
+
start_ratio=0.2, # 20%
|
|
1713
|
+
stop_ratio=0.8, # 80%
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
# Calculate expected values
|
|
1717
|
+
expected = signal_params.get_expected_features(
|
|
1718
|
+
start_ratio=analysis.start_ratio,
|
|
1719
|
+
stop_ratio=analysis.stop_ratio,
|
|
1720
|
+
)
|
|
1721
|
+
|
|
1722
|
+
# Generate test signal
|
|
1723
|
+
x, y = signal_params.generate_1d_data()
|
|
1724
|
+
|
|
1725
|
+
# Extract and validate features
|
|
1726
|
+
_extract_and_validate_gaussian_features(x, y, analysis, expected, signal_params)
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
@pytest.mark.validation
|
|
1730
|
+
def test_signal_extract_pulse_features() -> None:
|
|
1731
|
+
"""Validation test for extract_pulse_features computation function.
|
|
1732
|
+
|
|
1733
|
+
Tests the extract_pulse_features function for both step and square signals,
|
|
1734
|
+
validating that all computed parameters match expected theoretical values.
|
|
1735
|
+
"""
|
|
1736
|
+
# Test STEP signal feature extraction
|
|
1737
|
+
step_params = create_test_step_params()
|
|
1738
|
+
x_step, y_step = step_params.generate_1d_data()
|
|
1739
|
+
sig_step = create_signal("Test Step Signal", x_step, y_step)
|
|
1740
|
+
|
|
1741
|
+
# Define step analysis parameters
|
|
1742
|
+
p_step = PulseFeaturesParam()
|
|
1743
|
+
p_step.xstartmin = 0.0
|
|
1744
|
+
p_step.xstartmax = 3.0
|
|
1745
|
+
p_step.xendmin = 6.0
|
|
1746
|
+
p_step.xendmax = 8.0
|
|
1747
|
+
p_step.reference_levels = (10, 90)
|
|
1748
|
+
|
|
1749
|
+
# Calculate expected step features using the DataSet method
|
|
1750
|
+
start_ratio, stop_ratio = p_step.reference_levels
|
|
1751
|
+
expected_step = step_params.get_expected_features(
|
|
1752
|
+
start_ratio / 100.0, stop_ratio / 100.0
|
|
1753
|
+
)
|
|
1754
|
+
tolerances_step = step_params.get_feature_tolerances()
|
|
1755
|
+
|
|
1756
|
+
# Extract and validate step features
|
|
1757
|
+
table_step = extract_pulse_features(sig_step, p_step)
|
|
1758
|
+
tdict_step = table_step.as_dict()
|
|
1759
|
+
features_step = pulse.PulseFeatures(**tdict_step)
|
|
1760
|
+
__check_features(features_step, expected_step, tolerances_step)
|
|
1761
|
+
|
|
1762
|
+
# Visualize results if GUI is available
|
|
1763
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1764
|
+
if qt_app is not None:
|
|
1765
|
+
view_pulse_features(
|
|
1766
|
+
x_step, y_step, "Step signal feature extraction", "step", features_step
|
|
1767
|
+
)
|
|
1768
|
+
|
|
1769
|
+
# Test SQUARE signal feature extraction
|
|
1770
|
+
square_params = create_test_square_params()
|
|
1771
|
+
x_square, y_square = square_params.generate_1d_data()
|
|
1772
|
+
sig_square = create_signal("Test Square Signal", x_square, y_square)
|
|
1773
|
+
|
|
1774
|
+
# Define square analysis parameters
|
|
1775
|
+
p_square = PulseFeaturesParam()
|
|
1776
|
+
p_square.xstartmin = 0
|
|
1777
|
+
p_square.xstartmax = 2.5
|
|
1778
|
+
p_square.xendmin = 15
|
|
1779
|
+
p_square.xendmax = 17
|
|
1780
|
+
p_square.reference_levels = (10, 90)
|
|
1781
|
+
|
|
1782
|
+
# Calculate expected square features using the DataSet method
|
|
1783
|
+
start_ratio, stop_ratio = p_square.reference_levels
|
|
1784
|
+
expected_square = square_params.get_expected_features(
|
|
1785
|
+
start_ratio / 100.0, stop_ratio / 100.0
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
# Extract and validate square features
|
|
1789
|
+
table_square = extract_pulse_features(sig_square, p_square)
|
|
1790
|
+
tdict_square = table_square.as_dict()
|
|
1791
|
+
features_square = pulse.PulseFeatures(**tdict_square)
|
|
1792
|
+
tolerances_square = square_params.get_feature_tolerances()
|
|
1793
|
+
__check_features(features_square, expected_square, tolerances_square)
|
|
1794
|
+
|
|
1795
|
+
# Visualize results if GUI is available
|
|
1796
|
+
with guiutils.lazy_qt_app_context() as qt_app:
|
|
1797
|
+
if qt_app is not None:
|
|
1798
|
+
view_pulse_features(
|
|
1799
|
+
x_square,
|
|
1800
|
+
y_square,
|
|
1801
|
+
"Square signal feature extraction",
|
|
1802
|
+
"square",
|
|
1803
|
+
features_square,
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
if __name__ == "__main__":
|
|
1808
|
+
guiutils.enable_gui()
|
|
1809
|
+
# test_heuristically_recognize_shape()
|
|
1810
|
+
# test_detect_polarity()
|
|
1811
|
+
test_get_amplitude()
|
|
1812
|
+
test_get_crossing_ratio_time(0.2)
|
|
1813
|
+
test_get_crossing_ratio_time(0.5)
|
|
1814
|
+
test_get_crossing_ratio_time(0.8)
|
|
1815
|
+
test_get_rise_time(0.1)
|
|
1816
|
+
test_get_rise_time(0.0)
|
|
1817
|
+
test_get_fall_time(0.1)
|
|
1818
|
+
test_get_fall_time(0.0)
|
|
1819
|
+
test_heuristically_find_rise_start_time()
|
|
1820
|
+
test_get_rise_start_time()
|
|
1821
|
+
test_step_feature_extraction()
|
|
1822
|
+
test_square_feature_extraction()
|
|
1823
|
+
test_gaussian_feature_extraction()
|
|
1824
|
+
test_signal_extract_pulse_features()
|