1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311
|
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
"""
Unit tests for signal analysis features
---------------------------------------
Features from the "Analysis" menu are covered by this test.
The "Analysis" menu contains functions to compute signal properties like
bandwidth, ENOB, etc.
"""
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
# pylint: disable=duplicate-code
from __future__ import annotations
import numpy as np
import pytest
import sigima.objects
import sigima.params
import sigima.proc.signal
from sigima.tests import guiutils
from sigima.tests.data import get_test_signal
from sigima.tests.helpers import check_scalar_result
@pytest.mark.validation
def test_signal_bandwidth_3db() -> None:
"""Validation test for the bandwidth computation."""
obj = get_test_signal("bandwidth.txt")
geometry = sigima.proc.signal.bandwidth_3db(obj)
assert geometry is not None, "Bandwidth computation failed."
with guiutils.lazy_qt_app_context() as qt_app:
if qt_app is not None:
# pylint: disable=import-outside-toplevel
from plotpy.builder import make
from sigima.tests import vistools
x0, y0, x1, y1 = geometry.coords[0]
x, y = obj.xydata
vistools.view_curve_items(
[
make.mcurve(x.real, y.real, label=obj.title),
vistools.create_signal_segment(x0, y0, x1, y1, "Bandwidth@-3dB"),
],
title="Bandwidth@-3dB",
)
length = geometry.segments_lengths()[0]
check_scalar_result("Bandwidth@-3dB", length, 38.99301975103714)
p1 = sigima.params.AbscissaParam.create(x=length)
geometry_result = sigima.proc.signal.y_at_x(obj, p1)
assert geometry_result is not None, "Y at X computation failed"
_, y_val = geometry_result.value # Get (x, y) tuple from .value property
check_scalar_result("Value@cutoff", y_val, np.max(obj.y) - 3.0)
@pytest.mark.validation
def test_dynamic_parameters() -> None:
"""Validation test for dynamic parameters computation."""
obj = get_test_signal("dynamic_parameters.txt")
param = sigima.params.DynamicParam.create(full_scale=1.0)
table = sigima.proc.signal.dynamic_parameters(obj, param)
assert table is not None, "Dynamic parameters computation failed"
tdict = table.as_dict()
check_scalar_result("ENOB", tdict["enob"], 5.1, rtol=0.001)
check_scalar_result("SINAD", tdict["sinad"], 32.49, rtol=0.001)
check_scalar_result("THD", tdict["thd"], -30.18, rtol=0.001)
check_scalar_result("SFDR", tdict["sfdr"], 34.03, rtol=0.001)
check_scalar_result("Freq", tdict["freq"], 49998377.464, rtol=0.001)
check_scalar_result("SNR", tdict["snr"], 101.52, rtol=0.001)
@pytest.mark.validation
def test_signal_sampling_rate_period() -> None:
"""Validation test for the sampling rate and period computation."""
obj = get_test_signal("dynamic_parameters.txt")
table = sigima.proc.signal.sampling_rate_period(obj)
assert table is not None, "Sampling rate and period computation failed"
check_scalar_result("Sampling rate", table["fs"][0], 1.0e10, rtol=0.001)
check_scalar_result("Period", table["T"][0], 1.0e-10, rtol=0.001)
@pytest.mark.validation
def test_signal_contrast() -> None:
"""Validation test for the contrast computation."""
obj = get_test_signal("fw1e2.txt")
table = sigima.proc.signal.contrast(obj)
assert table is not None, "Contrast computation failed"
check_scalar_result("Contrast", table["contrast"][0], 0.825, rtol=0.001)
@pytest.mark.validation
def test_signal_x_at_minmax() -> None:
"""Validation test for the x value at min/max computation."""
obj = get_test_signal("fw1e2.txt")
table = sigima.proc.signal.x_at_minmax(obj)
assert table is not None, "X at min/max computation failed"
check_scalar_result("X@Ymin", table["X@Ymin"][0], 0.803, rtol=0.001)
check_scalar_result("X@Ymax", table["X@Ymax"][0], 5.184, rtol=0.001)
@pytest.mark.validation
def test_signal_x_at_y() -> None:
"""Validation test for the abscissa finding computation."""
obj = sigima.objects.create_signal_from_param(sigima.objects.StepParam.create())
if obj is None:
raise ValueError("Failed to create test signal")
param = sigima.proc.signal.OrdinateParam.create(y=0.5)
geometry = sigima.proc.signal.x_at_y(obj, param)
assert geometry is not None, "X at Y computation failed"
x_val, _ = geometry.value # Get (x, y) tuple from .value property
check_scalar_result("x|y=0.5", x_val, 0.0)
@pytest.mark.validation
def test_signal_y_at_x() -> None:
"""Validation test for the ordinate finding computation."""
param = sigima.objects.TriangleParam.create(xmin=0.0, xmax=10.0, size=101)
obj = sigima.objects.create_signal_from_param(param)
if obj is None:
raise ValueError("Failed to create test signal")
param = sigima.proc.signal.AbscissaParam.create(x=2.5)
geometry = sigima.proc.signal.y_at_x(obj, param)
assert geometry is not None, "Y at X computation failed"
_, y_val = geometry.value # Get (x, y) tuple from .value property
check_scalar_result("y|x=2.5", y_val, 1.0)
def test_x_at_y_geometry_result() -> None:
"""Test that x_at_y returns a GeometryResult with MARKER kind."""
# Create a simple sine wave signal
x = np.linspace(0, 2 * np.pi, 100)
y = np.sin(x)
obj = sigima.objects.SignalObj()
obj.set_xydata(x, y)
# Find x where y = 0.5
param = sigima.params.OrdinateParam.create(y=0.5)
result = sigima.proc.signal.x_at_y(obj, param)
# Check it's a GeometryResult with MARKER kind
assert result is not None, "x_at_y returned None"
assert hasattr(result, "kind"), "Result should have 'kind' attribute"
assert result.kind == sigima.objects.KindShape.MARKER, (
f"Expected MARKER kind, got {result.kind}"
)
# Check coords shape (should be N×2 for MARKER)
assert result.coords.shape[1] == 2, (
f"MARKER coords should be N×2, got {result.coords.shape}"
)
# Check .value property returns (x, y) tuple
x_val, y_val = result.value
assert isinstance(x_val, float), "x value should be a float"
assert isinstance(y_val, float), "y value should be a float"
assert abs(y_val - 0.5) < 1e-10, f"Y value should be 0.5, got {y_val}"
# Verify the x coordinate is reasonable (between 0 and pi/2 for sin(x) = 0.5)
assert 0 < x_val < np.pi / 2, f"X value {x_val} out of expected range"
def test_y_at_x_geometry_result() -> None:
"""Test that y_at_x returns a GeometryResult with MARKER kind."""
# Create a simple quadratic signal
x = np.linspace(-5, 5, 101)
y = x**2
obj = sigima.objects.SignalObj()
obj.set_xydata(x, y)
# Find y where x = 3.0
param = sigima.params.AbscissaParam.create(x=3.0)
result = sigima.proc.signal.y_at_x(obj, param)
# Check it's a GeometryResult with MARKER kind
assert result is not None, "y_at_x returned None"
assert hasattr(result, "kind"), "Result should have 'kind' attribute"
assert result.kind == sigima.objects.KindShape.MARKER, (
f"Expected MARKER kind, got {result.kind}"
)
# Check coords shape (should be N×2 for MARKER)
assert result.coords.shape[1] == 2, (
f"MARKER coords should be N×2, got {result.coords.shape}"
)
# Check .value property returns (x, y) tuple
x_val, y_val = result.value
assert isinstance(x_val, float), "x value should be a float"
assert isinstance(y_val, float), "y value should be a float"
assert abs(x_val - 3.0) < 1e-10, f"X value should be 3.0, got {x_val}"
# Verify the y coordinate is correct (should be 9.0 for x^2 at x=3)
assert abs(y_val - 9.0) < 0.1, f"Y value should be ~9.0, got {y_val}"
def test_geometry_result_value_property() -> None:
"""Test the .value property for POINT, MARKER, and SEGMENT shapes."""
# Test POINT
point_result = sigima.objects.GeometryResult.from_coords(
title="Test Point",
kind=sigima.objects.KindShape.POINT,
coords=np.array([[1.5, 2.5]]),
)
x, y = point_result.value
assert abs(x - 1.5) < 1e-10, f"POINT x should be 1.5, got {x}"
assert abs(y - 2.5) < 1e-10, f"POINT y should be 2.5, got {y}"
# Test MARKER
marker_result = sigima.objects.GeometryResult.from_coords(
title="Test Marker",
kind=sigima.objects.KindShape.MARKER,
coords=np.array([[3.0, 4.0]]),
)
x, y = marker_result.value
assert abs(x - 3.0) < 1e-10, f"MARKER x should be 3.0, got {x}"
assert abs(y - 4.0) < 1e-10, f"MARKER y should be 4.0, got {y}"
# Test SEGMENT (3-4-5 triangle)
segment_result = sigima.objects.GeometryResult.from_coords(
title="Test Segment",
kind=sigima.objects.KindShape.SEGMENT,
coords=np.array([[0.0, 0.0, 3.0, 4.0]]),
)
length = segment_result.value
assert isinstance(length, float), "SEGMENT value should be a float"
assert abs(length - 5.0) < 1e-10, f"SEGMENT length should be 5.0, got {length}"
# Test error for unsupported shape (CIRCLE)
circle_result = sigima.objects.GeometryResult.from_coords(
title="Test Circle",
kind=sigima.objects.KindShape.CIRCLE,
coords=np.array([[0.0, 0.0, 1.0]]),
)
with pytest.raises(ValueError, match="value property only valid for"):
_ = circle_result.value
# Test error for multiple rows
multi_result = sigima.objects.GeometryResult.from_coords(
title="Multiple Points",
kind=sigima.objects.KindShape.POINT,
coords=np.array([[1.0, 2.0], [3.0, 4.0]]),
)
with pytest.raises(ValueError, match="single-row results"):
_ = multi_result.value
def test_x_at_y_cross_marker_coordinates() -> None:
"""Test that x_at_y returns both x and y coordinates for cross marker display."""
# Create a test signal
x = np.linspace(0, 10, 100)
y = 2 * x + 1 # Linear signal: y = 2x + 1
obj = sigima.objects.SignalObj()
obj.set_xydata(x, y)
# Find x where y = 5.0 (should be x = 2.0)
param = sigima.params.OrdinateParam.create(y=5.0)
result = sigima.proc.signal.x_at_y(obj, param)
assert result is not None
# Check that we have both coordinates (not NaN)
coords = result.coords[0]
x_coord, y_coord = coords
assert not np.isnan(x_coord), "X coordinate should not be NaN"
assert not np.isnan(y_coord), "Y coordinate should not be NaN"
# Verify values are correct
check_scalar_result("x_at_y x-coordinate", x_coord, 2.0, rtol=0.01)
check_scalar_result("x_at_y y-coordinate", y_coord, 5.0, rtol=0.01)
def test_y_at_x_cross_marker_coordinates() -> None:
"""Test that y_at_x returns both x and y coordinates for cross marker display."""
# Create a test signal
x = np.linspace(0, 10, 100)
y = x**2 # Quadratic signal: y = x^2
obj = sigima.objects.SignalObj()
obj.set_xydata(x, y)
# Find y where x = 3.0 (should be y = 9.0)
param = sigima.params.AbscissaParam.create(x=3.0)
result = sigima.proc.signal.y_at_x(obj, param)
assert result is not None
# Check that we have both coordinates (not NaN)
coords = result.coords[0]
x_coord, y_coord = coords
assert not np.isnan(x_coord), "X coordinate should not be NaN"
assert not np.isnan(y_coord), "Y coordinate should not be NaN"
# Verify values are correct
check_scalar_result("y_at_x x-coordinate", x_coord, 3.0, rtol=0.01)
check_scalar_result("y_at_x y-coordinate", y_coord, 9.0, rtol=0.01)
if __name__ == "__main__":
guiutils.enable_gui()
test_signal_bandwidth_3db()
test_dynamic_parameters()
test_signal_sampling_rate_period()
test_signal_contrast()
test_signal_x_at_minmax()
test_signal_x_at_y()
test_signal_y_at_x()
test_x_at_y_geometry_result()
test_y_at_x_geometry_result()
test_geometry_result_value_property()
test_x_at_y_cross_marker_coordinates()
test_y_at_x_cross_marker_coordinates()
|