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.
Files changed (264) hide show
  1. sigima/__init__.py +142 -2
  2. sigima/client/__init__.py +105 -0
  3. sigima/client/base.py +780 -0
  4. sigima/client/remote.py +469 -0
  5. sigima/client/stub.py +814 -0
  6. sigima/client/utils.py +90 -0
  7. sigima/config.py +444 -0
  8. sigima/data/logo/Sigima.svg +135 -0
  9. sigima/data/tests/annotations.json +798 -0
  10. sigima/data/tests/curve_fitting/exponential_fit.txt +511 -0
  11. sigima/data/tests/curve_fitting/gaussian_fit.txt +100 -0
  12. sigima/data/tests/curve_fitting/piecewiseexponential_fit.txt +1022 -0
  13. sigima/data/tests/curve_fitting/polynomial_fit.txt +100 -0
  14. sigima/data/tests/curve_fitting/twohalfgaussian_fit.txt +1000 -0
  15. sigima/data/tests/curve_formats/bandwidth.txt +201 -0
  16. sigima/data/tests/curve_formats/boxcar.npy +0 -0
  17. sigima/data/tests/curve_formats/datetime.txt +1001 -0
  18. sigima/data/tests/curve_formats/dynamic_parameters.txt +4000 -0
  19. sigima/data/tests/curve_formats/fw1e2.txt +301 -0
  20. sigima/data/tests/curve_formats/fwhm.txt +319 -0
  21. sigima/data/tests/curve_formats/multiple_curves.csv +29 -0
  22. sigima/data/tests/curve_formats/noised_saw.mat +0 -0
  23. sigima/data/tests/curve_formats/oscilloscope.csv +111 -0
  24. sigima/data/tests/curve_formats/other/other2/recursive2.txt +5 -0
  25. sigima/data/tests/curve_formats/other/recursive1.txt +5 -0
  26. sigima/data/tests/curve_formats/paracetamol.npy +0 -0
  27. sigima/data/tests/curve_formats/paracetamol.txt +1010 -0
  28. sigima/data/tests/curve_formats/paracetamol_dx_dy.csv +1000 -0
  29. sigima/data/tests/curve_formats/paracetamol_dy.csv +1001 -0
  30. sigima/data/tests/curve_formats/pulse1.npy +0 -0
  31. sigima/data/tests/curve_formats/pulse2.npy +0 -0
  32. sigima/data/tests/curve_formats/simple.txt +5 -0
  33. sigima/data/tests/curve_formats/spectrum.mca +2139 -0
  34. sigima/data/tests/curve_formats/square2.npy +0 -0
  35. sigima/data/tests/curve_formats/step.npy +0 -0
  36. sigima/data/tests/fabry-perot1.jpg +0 -0
  37. sigima/data/tests/fabry-perot2.jpg +0 -0
  38. sigima/data/tests/flower.npy +0 -0
  39. sigima/data/tests/image_formats/NF 180338201.scor-data +11003 -0
  40. sigima/data/tests/image_formats/binary_image.npy +0 -0
  41. sigima/data/tests/image_formats/binary_image.png +0 -0
  42. sigima/data/tests/image_formats/centroid_test.npy +0 -0
  43. sigima/data/tests/image_formats/coordinated_text/complex_image.txt +10011 -0
  44. sigima/data/tests/image_formats/coordinated_text/complex_ref_image.txt +10010 -0
  45. sigima/data/tests/image_formats/coordinated_text/image.txt +15 -0
  46. sigima/data/tests/image_formats/coordinated_text/image2.txt +14 -0
  47. sigima/data/tests/image_formats/coordinated_text/image_no_unit_no_label.txt +14 -0
  48. sigima/data/tests/image_formats/coordinated_text/image_with_nan.txt +15 -0
  49. sigima/data/tests/image_formats/coordinated_text/image_with_unit.txt +14 -0
  50. sigima/data/tests/image_formats/fiber.csv +480 -0
  51. sigima/data/tests/image_formats/fiber.jpg +0 -0
  52. sigima/data/tests/image_formats/fiber.png +0 -0
  53. sigima/data/tests/image_formats/fiber.txt +480 -0
  54. sigima/data/tests/image_formats/gaussian_spot_with_noise.npy +0 -0
  55. sigima/data/tests/image_formats/mr-brain.dcm +0 -0
  56. sigima/data/tests/image_formats/noised_gaussian.mat +0 -0
  57. sigima/data/tests/image_formats/sif_reader/nd_lum_image_no_glue.sif +0 -0
  58. sigima/data/tests/image_formats/sif_reader/raman1.sif +0 -0
  59. sigima/data/tests/image_formats/tiling.txt +10 -0
  60. sigima/data/tests/image_formats/uint16.tiff +0 -0
  61. sigima/data/tests/image_formats/uint8.tiff +0 -0
  62. sigima/data/tests/laser_beam/TEM00_z_13.jpg +0 -0
  63. sigima/data/tests/laser_beam/TEM00_z_18.jpg +0 -0
  64. sigima/data/tests/laser_beam/TEM00_z_23.jpg +0 -0
  65. sigima/data/tests/laser_beam/TEM00_z_30.jpg +0 -0
  66. sigima/data/tests/laser_beam/TEM00_z_35.jpg +0 -0
  67. sigima/data/tests/laser_beam/TEM00_z_40.jpg +0 -0
  68. sigima/data/tests/laser_beam/TEM00_z_45.jpg +0 -0
  69. sigima/data/tests/laser_beam/TEM00_z_50.jpg +0 -0
  70. sigima/data/tests/laser_beam/TEM00_z_55.jpg +0 -0
  71. sigima/data/tests/laser_beam/TEM00_z_60.jpg +0 -0
  72. sigima/data/tests/laser_beam/TEM00_z_65.jpg +0 -0
  73. sigima/data/tests/laser_beam/TEM00_z_70.jpg +0 -0
  74. sigima/data/tests/laser_beam/TEM00_z_75.jpg +0 -0
  75. sigima/data/tests/laser_beam/TEM00_z_80.jpg +0 -0
  76. sigima/enums.py +195 -0
  77. sigima/io/__init__.py +123 -0
  78. sigima/io/base.py +311 -0
  79. sigima/io/common/__init__.py +5 -0
  80. sigima/io/common/basename.py +164 -0
  81. sigima/io/common/converters.py +189 -0
  82. sigima/io/common/objmeta.py +181 -0
  83. sigima/io/common/textreader.py +58 -0
  84. sigima/io/convenience.py +157 -0
  85. sigima/io/enums.py +17 -0
  86. sigima/io/ftlab.py +395 -0
  87. sigima/io/image/__init__.py +9 -0
  88. sigima/io/image/base.py +177 -0
  89. sigima/io/image/formats.py +1016 -0
  90. sigima/io/image/funcs.py +414 -0
  91. sigima/io/signal/__init__.py +9 -0
  92. sigima/io/signal/base.py +129 -0
  93. sigima/io/signal/formats.py +290 -0
  94. sigima/io/signal/funcs.py +723 -0
  95. sigima/objects/__init__.py +260 -0
  96. sigima/objects/base.py +937 -0
  97. sigima/objects/image/__init__.py +88 -0
  98. sigima/objects/image/creation.py +556 -0
  99. sigima/objects/image/object.py +524 -0
  100. sigima/objects/image/roi.py +904 -0
  101. sigima/objects/scalar/__init__.py +57 -0
  102. sigima/objects/scalar/common.py +215 -0
  103. sigima/objects/scalar/geometry.py +502 -0
  104. sigima/objects/scalar/table.py +784 -0
  105. sigima/objects/shape.py +290 -0
  106. sigima/objects/signal/__init__.py +133 -0
  107. sigima/objects/signal/constants.py +27 -0
  108. sigima/objects/signal/creation.py +1428 -0
  109. sigima/objects/signal/object.py +444 -0
  110. sigima/objects/signal/roi.py +274 -0
  111. sigima/params.py +405 -0
  112. sigima/proc/__init__.py +96 -0
  113. sigima/proc/base.py +381 -0
  114. sigima/proc/decorator.py +330 -0
  115. sigima/proc/image/__init__.py +513 -0
  116. sigima/proc/image/arithmetic.py +335 -0
  117. sigima/proc/image/base.py +260 -0
  118. sigima/proc/image/detection.py +519 -0
  119. sigima/proc/image/edges.py +329 -0
  120. sigima/proc/image/exposure.py +406 -0
  121. sigima/proc/image/extraction.py +458 -0
  122. sigima/proc/image/filtering.py +219 -0
  123. sigima/proc/image/fourier.py +147 -0
  124. sigima/proc/image/geometry.py +661 -0
  125. sigima/proc/image/mathops.py +340 -0
  126. sigima/proc/image/measurement.py +195 -0
  127. sigima/proc/image/morphology.py +155 -0
  128. sigima/proc/image/noise.py +107 -0
  129. sigima/proc/image/preprocessing.py +182 -0
  130. sigima/proc/image/restoration.py +235 -0
  131. sigima/proc/image/threshold.py +217 -0
  132. sigima/proc/image/transformations.py +393 -0
  133. sigima/proc/signal/__init__.py +376 -0
  134. sigima/proc/signal/analysis.py +206 -0
  135. sigima/proc/signal/arithmetic.py +551 -0
  136. sigima/proc/signal/base.py +262 -0
  137. sigima/proc/signal/extraction.py +60 -0
  138. sigima/proc/signal/features.py +310 -0
  139. sigima/proc/signal/filtering.py +484 -0
  140. sigima/proc/signal/fitting.py +276 -0
  141. sigima/proc/signal/fourier.py +259 -0
  142. sigima/proc/signal/mathops.py +420 -0
  143. sigima/proc/signal/processing.py +580 -0
  144. sigima/proc/signal/stability.py +175 -0
  145. sigima/proc/title_formatting.py +227 -0
  146. sigima/proc/validation.py +272 -0
  147. sigima/tests/__init__.py +7 -0
  148. sigima/tests/common/__init__.py +0 -0
  149. sigima/tests/common/arithmeticparam_unit_test.py +26 -0
  150. sigima/tests/common/basename_unit_test.py +126 -0
  151. sigima/tests/common/client_unit_test.py +412 -0
  152. sigima/tests/common/converters_unit_test.py +77 -0
  153. sigima/tests/common/decorator_unit_test.py +176 -0
  154. sigima/tests/common/examples_unit_test.py +104 -0
  155. sigima/tests/common/kernel_normalization_unit_test.py +242 -0
  156. sigima/tests/common/roi_basic_unit_test.py +73 -0
  157. sigima/tests/common/roi_geometry_unit_test.py +171 -0
  158. sigima/tests/common/scalar_builder_unit_test.py +142 -0
  159. sigima/tests/common/scalar_unit_test.py +991 -0
  160. sigima/tests/common/shape_unit_test.py +183 -0
  161. sigima/tests/common/stat_unit_test.py +138 -0
  162. sigima/tests/common/title_formatting_unit_test.py +338 -0
  163. sigima/tests/common/tools_coordinates_unit_test.py +60 -0
  164. sigima/tests/common/transformations_unit_test.py +178 -0
  165. sigima/tests/common/validation_unit_test.py +205 -0
  166. sigima/tests/conftest.py +129 -0
  167. sigima/tests/data.py +998 -0
  168. sigima/tests/env.py +280 -0
  169. sigima/tests/guiutils.py +163 -0
  170. sigima/tests/helpers.py +532 -0
  171. sigima/tests/image/__init__.py +28 -0
  172. sigima/tests/image/binning_unit_test.py +128 -0
  173. sigima/tests/image/blob_detection_unit_test.py +312 -0
  174. sigima/tests/image/centroid_unit_test.py +170 -0
  175. sigima/tests/image/check_2d_array_unit_test.py +63 -0
  176. sigima/tests/image/contour_unit_test.py +172 -0
  177. sigima/tests/image/convolution_unit_test.py +178 -0
  178. sigima/tests/image/datatype_unit_test.py +67 -0
  179. sigima/tests/image/edges_unit_test.py +155 -0
  180. sigima/tests/image/enclosingcircle_unit_test.py +88 -0
  181. sigima/tests/image/exposure_unit_test.py +223 -0
  182. sigima/tests/image/fft2d_unit_test.py +189 -0
  183. sigima/tests/image/filtering_unit_test.py +166 -0
  184. sigima/tests/image/geometry_unit_test.py +654 -0
  185. sigima/tests/image/hough_circle_unit_test.py +147 -0
  186. sigima/tests/image/imageobj_unit_test.py +737 -0
  187. sigima/tests/image/morphology_unit_test.py +71 -0
  188. sigima/tests/image/noise_unit_test.py +57 -0
  189. sigima/tests/image/offset_correction_unit_test.py +72 -0
  190. sigima/tests/image/operation_unit_test.py +518 -0
  191. sigima/tests/image/peak2d_limits_unit_test.py +41 -0
  192. sigima/tests/image/peak2d_unit_test.py +133 -0
  193. sigima/tests/image/profile_unit_test.py +159 -0
  194. sigima/tests/image/projections_unit_test.py +121 -0
  195. sigima/tests/image/restoration_unit_test.py +141 -0
  196. sigima/tests/image/roi2dparam_unit_test.py +53 -0
  197. sigima/tests/image/roi_advanced_unit_test.py +588 -0
  198. sigima/tests/image/roi_grid_unit_test.py +279 -0
  199. sigima/tests/image/spectrum2d_unit_test.py +40 -0
  200. sigima/tests/image/threshold_unit_test.py +91 -0
  201. sigima/tests/io/__init__.py +0 -0
  202. sigima/tests/io/addnewformat_unit_test.py +125 -0
  203. sigima/tests/io/convenience_funcs_unit_test.py +470 -0
  204. sigima/tests/io/coordinated_text_format_unit_test.py +495 -0
  205. sigima/tests/io/datetime_csv_unit_test.py +198 -0
  206. sigima/tests/io/imageio_formats_test.py +41 -0
  207. sigima/tests/io/ioregistry_unit_test.py +69 -0
  208. sigima/tests/io/objmeta_unit_test.py +87 -0
  209. sigima/tests/io/readobj_unit_test.py +130 -0
  210. sigima/tests/io/readwriteobj_unit_test.py +67 -0
  211. sigima/tests/signal/__init__.py +0 -0
  212. sigima/tests/signal/analysis_unit_test.py +135 -0
  213. sigima/tests/signal/check_1d_arrays_unit_test.py +169 -0
  214. sigima/tests/signal/convolution_unit_test.py +404 -0
  215. sigima/tests/signal/datetime_unit_test.py +176 -0
  216. sigima/tests/signal/fft1d_unit_test.py +303 -0
  217. sigima/tests/signal/filters_unit_test.py +403 -0
  218. sigima/tests/signal/fitting_unit_test.py +929 -0
  219. sigima/tests/signal/fwhm_unit_test.py +111 -0
  220. sigima/tests/signal/noise_unit_test.py +128 -0
  221. sigima/tests/signal/offset_correction_unit_test.py +34 -0
  222. sigima/tests/signal/operation_unit_test.py +489 -0
  223. sigima/tests/signal/peakdetection_unit_test.py +145 -0
  224. sigima/tests/signal/processing_unit_test.py +657 -0
  225. sigima/tests/signal/pulse/__init__.py +112 -0
  226. sigima/tests/signal/pulse/crossing_times_unit_test.py +123 -0
  227. sigima/tests/signal/pulse/plateau_detection_unit_test.py +102 -0
  228. sigima/tests/signal/pulse/pulse_unit_test.py +1824 -0
  229. sigima/tests/signal/roi_advanced_unit_test.py +392 -0
  230. sigima/tests/signal/signalobj_unit_test.py +603 -0
  231. sigima/tests/signal/stability_unit_test.py +431 -0
  232. sigima/tests/signal/uncertainty_unit_test.py +611 -0
  233. sigima/tests/vistools.py +1030 -0
  234. sigima/tools/__init__.py +59 -0
  235. sigima/tools/checks.py +290 -0
  236. sigima/tools/coordinates.py +308 -0
  237. sigima/tools/datatypes.py +26 -0
  238. sigima/tools/image/__init__.py +97 -0
  239. sigima/tools/image/detection.py +451 -0
  240. sigima/tools/image/exposure.py +77 -0
  241. sigima/tools/image/extraction.py +48 -0
  242. sigima/tools/image/fourier.py +260 -0
  243. sigima/tools/image/geometry.py +190 -0
  244. sigima/tools/image/preprocessing.py +165 -0
  245. sigima/tools/signal/__init__.py +86 -0
  246. sigima/tools/signal/dynamic.py +254 -0
  247. sigima/tools/signal/features.py +135 -0
  248. sigima/tools/signal/filtering.py +171 -0
  249. sigima/tools/signal/fitting.py +1171 -0
  250. sigima/tools/signal/fourier.py +466 -0
  251. sigima/tools/signal/interpolation.py +70 -0
  252. sigima/tools/signal/peakdetection.py +126 -0
  253. sigima/tools/signal/pulse.py +1626 -0
  254. sigima/tools/signal/scaling.py +50 -0
  255. sigima/tools/signal/stability.py +258 -0
  256. sigima/tools/signal/windowing.py +90 -0
  257. sigima/worker.py +79 -0
  258. sigima-1.0.0.dist-info/METADATA +233 -0
  259. sigima-1.0.0.dist-info/RECORD +262 -0
  260. {sigima-0.0.1.dev0.dist-info → sigima-1.0.0.dist-info}/licenses/LICENSE +29 -29
  261. sigima-0.0.1.dev0.dist-info/METADATA +0 -60
  262. sigima-0.0.1.dev0.dist-info/RECORD +0 -6
  263. {sigima-0.0.1.dev0.dist-info → sigima-1.0.0.dist-info}/WHEEL +0 -0
  264. {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()