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,1428 @@
1
+ # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
2
+
3
+ """
4
+ Signal creation utilities
5
+ ========================
6
+
7
+ This module provides functions and parameter classes for creating new signals.
8
+
9
+ The module includes:
10
+
11
+ - `create_signal_from_param`: Factory function for creating SignalObj instances
12
+ from parameters
13
+ - `SignalTypes`: Enumeration of supported signal generation types
14
+ - `NewSignalParam` and subclasses: Parameter classes for signal generation
15
+ - Factory functions and registration utilities
16
+
17
+ These utilities support creating signals from various sources:
18
+ - Synthetic data (zeros, random distributions, analytical functions)
19
+ - Periodic functions (sine, cosine, square, etc.)
20
+ - Step functions, chirps, pulses
21
+ - Custom user-defined signals
22
+ """
23
+
24
+ # pylint: disable=invalid-name # Allows short reference names like x, y, ...
25
+ # pylint: disable=duplicate-code
26
+
27
+ from __future__ import annotations
28
+
29
+ import enum
30
+ from dataclasses import dataclass
31
+ from typing import Literal, Type
32
+
33
+ import guidata.dataset as gds
34
+ import numpy as np
35
+ import scipy.constants
36
+ import scipy.signal as sps
37
+
38
+ from sigima.config import _
39
+ from sigima.enums import SignalShape
40
+ from sigima.objects import base
41
+ from sigima.objects.signal.object import SignalObj
42
+ from sigima.tools.signal.pulse import GaussianModel, LorentzianModel, VoigtModel
43
+
44
+
45
+ def create_signal(
46
+ title: str,
47
+ x: np.ndarray | None = None,
48
+ y: np.ndarray | None = None,
49
+ dx: np.ndarray | None = None,
50
+ dy: np.ndarray | None = None,
51
+ metadata: dict | None = None,
52
+ units: tuple[str, str] | None = None,
53
+ labels: tuple[str, str] | None = None,
54
+ ) -> SignalObj:
55
+ """Create a new Signal object.
56
+
57
+ Args:
58
+ title: signal title
59
+ x: X data
60
+ y: Y data
61
+ dx: dX data (optional: error bars)
62
+ dy: dY data (optional: error bars)
63
+ metadata: signal metadata
64
+ units: X, Y units (tuple of strings)
65
+ labels: X, Y labels (tuple of strings)
66
+
67
+ Returns:
68
+ Signal object
69
+ """
70
+ assert isinstance(title, str)
71
+ signal = SignalObj(title=title)
72
+ signal.title = title
73
+ signal.set_xydata(x, y, dx=dx, dy=dy)
74
+ if units is not None:
75
+ signal.xunit, signal.yunit = units
76
+ if labels is not None:
77
+ signal.xlabel, signal.ylabel = labels
78
+ if metadata is not None:
79
+ signal.metadata.update(metadata)
80
+ return signal
81
+
82
+
83
+ class SignalTypes(gds.LabeledEnum):
84
+ """Signal types"""
85
+
86
+ #: Signal filled with zero
87
+ ZERO = "zero", _("Zero")
88
+ #: Random signal (normal distribution)
89
+ NORMAL_DISTRIBUTION = "normal_distribution", _("Normal distribution")
90
+ #: Random signal (Poisson distribution)
91
+ POISSON_DISTRIBUTION = "poisson_distribution", _("Poisson distribution")
92
+ #: Random signal (uniform distribution)
93
+ UNIFORM_DISTRIBUTION = "uniform_distribution", _("Uniform distribution")
94
+ #: Gaussian function
95
+ GAUSS = "gauss", _("Gaussian")
96
+ #: Lorentzian function
97
+ LORENTZ = "lorentz", _("Lorentzian")
98
+ #: Voigt function
99
+ VOIGT = "voigt", _("Voigt")
100
+ #: Planck function
101
+ PLANCK = "planck", _("Blackbody (Planck)")
102
+ #: Sinusoid
103
+ SINE = "sine", _("Sine")
104
+ #: Cosinusoid
105
+ COSINE = "cosine", _("Cosine")
106
+ #: Sawtooth function
107
+ SAWTOOTH = "sawtooth", _("Sawtooth")
108
+ #: Triangle function
109
+ TRIANGLE = "triangle", _("Triangle")
110
+ #: Square function
111
+ SQUARE = "square", _("Square")
112
+ #: Cardinal sine
113
+ SINC = "sinc", _("Cardinal sine")
114
+ #: Linear chirp
115
+ LINEARCHIRP = "linearchirp", _("Linear chirp")
116
+ #: Step function
117
+ STEP = "step", _("Step")
118
+ #: Exponential function
119
+ EXPONENTIAL = "exponential", _("Exponential")
120
+ #: Logistic function
121
+ LOGISTIC = "logistic", _("Logistic")
122
+ #: Pulse function
123
+ PULSE = "pulse", _("Pulse")
124
+ #: Step pulse function (with configurable rise time)
125
+ STEP_PULSE = "step_pulse", _("Step pulse")
126
+ #: Square pulse function (with configurable rise/fall times)
127
+ SQUARE_PULSE = "square_pulse", _("Square pulse")
128
+ #: Polynomial function
129
+ POLYNOMIAL = "polynomial", _("Polynomial")
130
+ #: Custom function
131
+ CUSTOM = "custom", _("Custom")
132
+
133
+
134
+ DEFAULT_TITLE = _("Untitled signal")
135
+
136
+
137
+ class NewSignalParam(gds.DataSet):
138
+ """New signal dataset.
139
+
140
+ Subclasses can optionally implement a ``generate_title()`` method to provide
141
+ automatic title generation based on their parameters. This method should return
142
+ a string containing the generated title, or an empty string if no title can be
143
+ generated.
144
+
145
+ Example::
146
+
147
+ def generate_title(self) -> str:
148
+ '''Generate a title based on current parameters.'''
149
+ return f"MySignal(param1={self.param1},param2={self.param2})"
150
+ """
151
+
152
+ title = gds.StringItem(_("Title"), default=DEFAULT_TITLE)
153
+ size = gds.IntItem(
154
+ _("N<sub>points</sub>"),
155
+ help=_("Total number of points in the signal"),
156
+ min=1,
157
+ default=500,
158
+ )
159
+ xmin = gds.FloatItem("x<sub>min</sub>", default=-10.0)
160
+ xmax = gds.FloatItem("x<sub>max</sub>", default=10.0).set_prop("display", col=1)
161
+ xlabel = gds.StringItem(_("X label"), default="")
162
+ xunit = gds.StringItem(_("X unit"), default="").set_prop("display", col=1)
163
+ ylabel = gds.StringItem(_("Y label"), default="")
164
+ yunit = gds.StringItem(_("Y unit"), default="").set_prop("display", col=1)
165
+
166
+ # As it is the last item of the dataset, the separator will be hidden if no other
167
+ # items are present after it (i.e. when derived classes do not add any new items
168
+ # or when the NewSignalParam class is used alone).
169
+ sep = gds.SeparatorItem()
170
+
171
+ def generate_x_data(self) -> np.ndarray:
172
+ """Generate x data based on current parameters."""
173
+ return np.linspace(self.xmin, self.xmax, self.size)
174
+
175
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
176
+ """Compute 1D data based on current parameters.
177
+
178
+ Returns:
179
+ Tuple of (x, y) arrays
180
+ """
181
+ return self.generate_x_data(), np.zeros(self.size)
182
+
183
+
184
+ SIGNAL_TYPE_PARAM_CLASSES = {}
185
+
186
+
187
+ def register_signal_parameters_class(stype: SignalTypes, param_class) -> None:
188
+ """Register a parameters class for a given signal type.
189
+
190
+ Args:
191
+ stype: signal type
192
+ param_class: parameters class
193
+ """
194
+ SIGNAL_TYPE_PARAM_CLASSES[stype] = param_class
195
+
196
+
197
+ def __get_signal_parameters_class(stype: SignalTypes) -> Type[NewSignalParam]:
198
+ """Get parameters class for a given signal type.
199
+
200
+ Args:
201
+ stype: signal type
202
+
203
+ Returns:
204
+ Parameters class
205
+
206
+ Raises:
207
+ ValueError: if no parameters class is registered for the given signal type
208
+ """
209
+ try:
210
+ return SIGNAL_TYPE_PARAM_CLASSES[stype]
211
+ except KeyError as exc:
212
+ raise ValueError(
213
+ f"Image type {stype} has no parameters class registered"
214
+ ) from exc
215
+
216
+
217
+ def check_all_signal_parameters_classes() -> None:
218
+ """Check all registered parameters classes."""
219
+ for stype, param_class in SIGNAL_TYPE_PARAM_CLASSES.items():
220
+ assert __get_signal_parameters_class(stype) is param_class
221
+
222
+
223
+ def create_signal_parameters(
224
+ stype: SignalTypes,
225
+ title: str | None = None,
226
+ size: int | None = None,
227
+ xmin: float | None = None,
228
+ xmax: float | None = None,
229
+ xlabel: str | None = None,
230
+ ylabel: str | None = None,
231
+ xunit: str | None = None,
232
+ yunit: str | None = None,
233
+ **kwargs: dict,
234
+ ) -> NewSignalParam:
235
+ """Create parameters for a given signal type.
236
+
237
+ Args:
238
+ stype: signal type
239
+ title: signal title
240
+ size: signal size (number of points)
241
+ xmin: minimum x value
242
+ xmax: maximum x value
243
+ xlabel: x axis label
244
+ ylabel: y axis label
245
+ xunit: x axis unit
246
+ yunit: y axis unit
247
+ **kwargs: additional parameters (specific to the signal type)
248
+
249
+ Returns:
250
+ Parameters object for the given signal type
251
+ """
252
+ pclass = __get_signal_parameters_class(stype)
253
+ p = pclass.create(**kwargs)
254
+ if title is not None:
255
+ p.title = title
256
+ if size is not None:
257
+ p.size = size
258
+ if xmin is not None:
259
+ p.xmin = xmin
260
+ if xmax is not None:
261
+ p.xmax = xmax
262
+ if xlabel is not None:
263
+ p.xlabel = xlabel
264
+ if ylabel is not None:
265
+ p.ylabel = ylabel
266
+ if xunit is not None:
267
+ p.xunit = xunit
268
+ if yunit is not None:
269
+ p.yunit = yunit
270
+ return p
271
+
272
+
273
+ class ZeroParam(NewSignalParam, title=_("Zero")):
274
+ """Parameters for zero signal."""
275
+
276
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
277
+ """Compute 1D data based on current parameters.
278
+
279
+ Returns:
280
+ Tuple of (x, y) arrays.
281
+ """
282
+ x = self.generate_x_data()
283
+ return x, np.zeros_like(x)
284
+
285
+
286
+ register_signal_parameters_class(SignalTypes.ZERO, ZeroParam)
287
+
288
+
289
+ class UniformDistribution1DParam(
290
+ NewSignalParam, base.UniformDistributionParam, title=_("Uniform distribution")
291
+ ):
292
+ """Uniform-distribution signal parameters."""
293
+
294
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
295
+ """Compute 1D data based on current parameters.
296
+
297
+ Returns:
298
+ Tuple of (x, y) arrays.
299
+ """
300
+ x = self.generate_x_data()
301
+ rng = np.random.default_rng(self.seed)
302
+ assert self.vmin is not None
303
+ assert self.vmax is not None
304
+ y = self.vmin + rng.random(len(x)) * (self.vmax - self.vmin)
305
+ return x, y
306
+
307
+
308
+ register_signal_parameters_class(
309
+ SignalTypes.UNIFORM_DISTRIBUTION, UniformDistribution1DParam
310
+ )
311
+
312
+
313
+ class NormalDistribution1DParam(
314
+ NewSignalParam, base.NormalDistributionParam, title=_("Normal distribution")
315
+ ):
316
+ """Normal-distribution signal parameters."""
317
+
318
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
319
+ """Compute 1D data based on current parameters.
320
+
321
+ Returns:
322
+ Tuple of (x, y) arrays.
323
+ """
324
+ x = self.generate_x_data()
325
+ rng = np.random.default_rng(self.seed)
326
+ assert self.mu is not None
327
+ assert self.sigma is not None
328
+ y = rng.normal(self.mu, self.sigma, len(x))
329
+ return x, y
330
+
331
+
332
+ register_signal_parameters_class(
333
+ SignalTypes.NORMAL_DISTRIBUTION, NormalDistribution1DParam
334
+ )
335
+
336
+
337
+ class PoissonDistribution1DParam(
338
+ NewSignalParam, base.PoissonDistributionParam, title=_("Poisson distribution")
339
+ ):
340
+ """Poisson-distribution signal parameters."""
341
+
342
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
343
+ """Compute 1D data based on current parameters.
344
+
345
+ Returns:
346
+ Tuple of (x, y) arrays.
347
+ """
348
+ x = self.generate_x_data()
349
+ rng = np.random.default_rng(self.seed)
350
+ assert self.lam is not None
351
+ y = rng.poisson(lam=self.lam, size=len(x))
352
+ return x, y
353
+
354
+
355
+ register_signal_parameters_class(
356
+ SignalTypes.POISSON_DISTRIBUTION, PoissonDistribution1DParam
357
+ )
358
+
359
+
360
+ class BaseGaussLorentzVoigtParam(NewSignalParam):
361
+ """Base parameters for Gaussian, Lorentzian and Voigt functions"""
362
+
363
+ STYPE: Type[SignalTypes] | None = None
364
+
365
+ a = gds.FloatItem("A", default=1.0)
366
+ y0 = gds.FloatItem("y<sub>0</sub>", default=0.0).set_pos(col=1)
367
+ sigma = gds.FloatItem("σ", default=1.0)
368
+ mu = gds.FloatItem("μ", default=0.0).set_pos(col=1)
369
+
370
+ def generate_title(self) -> str:
371
+ """Generate a title based on current parameters."""
372
+ assert isinstance(self.STYPE, SignalTypes)
373
+ return (
374
+ f"{self.STYPE.name.lower()}(A={self.a:.3g},σ={self.sigma:.3g},"
375
+ f"μ={self.mu:.3g},y0={self.y0:.3g})"
376
+ )
377
+
378
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
379
+ """Compute 1D data based on current parameters.
380
+
381
+ Returns:
382
+ Tuple of (x, y) arrays
383
+ """
384
+ x = self.generate_x_data()
385
+ func = {
386
+ SignalTypes.GAUSS: GaussianModel.func,
387
+ SignalTypes.LORENTZ: LorentzianModel.func,
388
+ SignalTypes.VOIGT: VoigtModel.func,
389
+ }[self.STYPE]
390
+ y = func(x, self.a, self.sigma, self.mu, self.y0)
391
+ return x, y
392
+
393
+ def get_expected_features(
394
+ self, start_ratio: float = 0.1, stop_ratio: float = 0.9
395
+ ) -> ExpectedFeatures:
396
+ """Calculate expected pulse features for this signal.
397
+
398
+ Args:
399
+ start_ratio: Start ratio for rise time calculation
400
+ stop_ratio: Stop ratio for rise time calculation
401
+
402
+ Returns:
403
+ ExpectedFeatures dataclass with all expected values
404
+ """
405
+ if self.a is None or self.sigma is None:
406
+ raise ValueError("Parameters 'a' and 'sigma' must be set")
407
+ if self.a == 0 or self.sigma <= 0:
408
+ raise ValueError("Parameter 'a' must be non-zero and 'sigma' positive")
409
+
410
+ polarity = 1 if self.a > 0 else -1
411
+
412
+ # For Gaussian: peak amplitude is a / (sigma * sqrt(2*pi))
413
+ # This gives the actual maximum value of the Gaussian function
414
+ amplitude = abs(self.a) / (self.sigma * np.sqrt(2 * np.pi))
415
+
416
+ if self.STYPE == SignalTypes.GAUSS:
417
+ # Gaussian rise time: t_r = 2.563 * sigma (10% to 90%)
418
+ rise_time = 2.563 * self.sigma
419
+ elif self.STYPE == SignalTypes.LORENTZ:
420
+ # Lorentzian rise time: 2*sigma*sqrt(1/start_ratio - 1/stop_ratio)
421
+ rise_time = 2 * self.sigma * np.sqrt(1 / start_ratio - 1 / stop_ratio)
422
+ elif self.STYPE == SignalTypes.VOIGT:
423
+ # Voigt rise time: approximate as Gaussian for simplicity
424
+ rise_time = 2.563 * self.sigma
425
+ else:
426
+ raise ValueError(f"Unsupported signal type: {self.STYPE}")
427
+
428
+ # For Gaussian signals centered at mu
429
+ x_center = self.mu if self.mu is not None else 0.0
430
+
431
+ # Gaussian-specific calculations
432
+ if self.STYPE == SignalTypes.GAUSS:
433
+ # Time at 50% amplitude (FWHM calculation)
434
+ fwhm = 2.355 * self.sigma # Full Width at Half Maximum for Gaussian
435
+ # x50 is the 50% crossing on the rise (left side of peak)
436
+ x50 = x_center - self.sigma * np.sqrt(-2 * np.log(0.5)) # ~0.833σ
437
+
438
+ # Rise time from left 20% to left 80% (one-sided)
439
+ # For amplitude ratios: x = mu ± sigma * sqrt(-2 * ln(ratio))
440
+ t_20_left = x_center - self.sigma * np.sqrt(-2 * np.log(0.2)) # ~1.794σ
441
+ t_80_left = x_center - self.sigma * np.sqrt(-2 * np.log(0.8)) # ~0.668σ
442
+ actual_rise_time = abs(t_80_left - t_20_left)
443
+
444
+ # Fall time (symmetric for Gaussian)
445
+ fall_time = actual_rise_time
446
+
447
+ # Foot duration: For Gaussian, use approximation based on sigma
448
+ # Since Gaussian has no true flat foot, this is an approximation
449
+ foot_duration = 1.5 * self.sigma # Empirically derived approximation
450
+
451
+ else:
452
+ # For Lorentzian and Voigt, use approximations
453
+ x50 = x_center
454
+ actual_rise_time = rise_time # Use calculated rise_time
455
+ fall_time = rise_time
456
+ if self.STYPE == SignalTypes.LORENTZ:
457
+ fwhm = 2 * self.sigma
458
+ else:
459
+ fwhm = 2.355 * self.sigma
460
+ foot_duration = 2 * self.sigma # Approximation
461
+
462
+ return ExpectedFeatures(
463
+ signal_shape=SignalShape.SQUARE,
464
+ polarity=polarity,
465
+ amplitude=amplitude,
466
+ rise_time=actual_rise_time,
467
+ offset=self.y0 if self.y0 is not None else 0.0,
468
+ x50=x50,
469
+ x100=x_center, # Maximum is at center for Gaussian
470
+ foot_duration=foot_duration,
471
+ fall_time=fall_time,
472
+ fwhm=fwhm,
473
+ )
474
+
475
+ def get_feature_tolerances(self) -> FeatureTolerances:
476
+ """Get absolute tolerance values for pulse feature validation.
477
+
478
+ Returns:
479
+ FeatureTolerances dataclass with adjusted tolerances for Gaussian signals
480
+ """
481
+ # Gaussian signals may need slightly more relaxed tolerances due to smoothness
482
+ return FeatureTolerances(
483
+ rise_time=0.3, # Slightly higher tolerance for Gaussian rise time
484
+ fall_time=0.3, # Match rise time tolerance
485
+ x100=0.1, # Tighter tolerance for maximum position (should be exact)
486
+ fwhm=0.2, # Reasonable tolerance for FWHM
487
+ )
488
+
489
+ def get_crossing_time(self, edge: Literal["rise", "fall"], ratio: float) -> float:
490
+ """Get the theoretical crossing time for the specified edge and ratio.
491
+
492
+ Args:
493
+ edge: Which edge to calculate ("rise" or "fall")
494
+ ratio: Crossing ratio (0.0 to 1.0)
495
+
496
+ Returns:
497
+ Theoretical crossing time for the specified edge and ratio
498
+ """
499
+ if self.a is None or self.sigma is None or self.mu is None:
500
+ raise ValueError("Parameters 'a', 'sigma', and 'mu' must be set")
501
+ if self.a == 0 or self.sigma <= 0:
502
+ raise ValueError("Parameter 'a' must be non-zero and 'sigma' positive")
503
+ if not 0.0 < ratio < 1.0:
504
+ raise ValueError("Ratio must be between 0.0 and 1.0")
505
+
506
+ if self.STYPE != SignalTypes.GAUSS:
507
+ raise NotImplementedError(
508
+ "Crossing time calculation is only implemented for Gaussian signals"
509
+ )
510
+
511
+ # For Gaussian: x = mu ± sigma * sqrt(-2 * ln(ratio))
512
+ delta_x = self.sigma * np.sqrt(-2 * np.log(ratio))
513
+ if edge == "rise":
514
+ return self.mu - delta_x
515
+ if edge == "fall":
516
+ return self.mu + delta_x
517
+ raise ValueError("Edge must be 'rise' or 'fall'")
518
+
519
+
520
+ class GaussParam(
521
+ BaseGaussLorentzVoigtParam,
522
+ title=_("Gaussian"),
523
+ comment="y = y<sub>0</sub> + "
524
+ "A/(σ √(2π)) exp(-((x - μ)<sup>2</sup>) / (2 σ<sup>2</sup>))",
525
+ ):
526
+ """Parameters for Gaussian function."""
527
+
528
+ STYPE = SignalTypes.GAUSS
529
+
530
+
531
+ register_signal_parameters_class(SignalTypes.GAUSS, GaussParam)
532
+
533
+
534
+ class LorentzParam(
535
+ BaseGaussLorentzVoigtParam,
536
+ title=_("Lorentzian"),
537
+ comment="y = y<sub>0</sub> + A/(π σ (1 + ((x - μ)/σ)<sup>2</sup>))",
538
+ ):
539
+ """Parameters for Lorentzian function."""
540
+
541
+ STYPE = SignalTypes.LORENTZ
542
+
543
+
544
+ register_signal_parameters_class(SignalTypes.LORENTZ, LorentzParam)
545
+
546
+
547
+ class VoigtParam(
548
+ BaseGaussLorentzVoigtParam,
549
+ title=_("Voigt"),
550
+ comment="y = y<sub>0</sub> + "
551
+ "A Re[exp(-z<sup>2</sup>) erfc(-j z)] / (σ √(2π)), "
552
+ "with z = (x - μ - j σ) / (σ √2)",
553
+ ):
554
+ """Parameters for Voigt function."""
555
+
556
+ STYPE = SignalTypes.VOIGT
557
+
558
+
559
+ register_signal_parameters_class(SignalTypes.VOIGT, VoigtParam)
560
+
561
+
562
+ class PlanckParam(
563
+ NewSignalParam,
564
+ title=_("Blackbody (Planck)"),
565
+ comment="y = (2 h c<sup>2</sup>) / "
566
+ "(λ<sup>5</sup> (exp(h c / (λ k<sub>B</sub> T)) - 1))",
567
+ ):
568
+ """Planck radiation law."""
569
+
570
+ xmin = gds.FloatItem(
571
+ "λ<sub>min</sub>", default=1e-7, unit="m", min=0.0, nonzero=True
572
+ )
573
+ xmax = gds.FloatItem(
574
+ "λ<sub>max</sub>", default=1e-4, unit="m", min=0.0, nonzero=True
575
+ ).set_prop("display", col=1)
576
+ T = gds.FloatItem(
577
+ "T", default=293.0, unit="K", min=0.0, nonzero=True, help=_("Temperature")
578
+ )
579
+
580
+ def generate_title(self) -> str:
581
+ """Generate a title based on current parameters.
582
+
583
+ Returns:
584
+ Title string.
585
+ """
586
+ return f"planck(T={self.T:.3g}K)"
587
+
588
+ @classmethod
589
+ def func(cls, wavelength: np.ndarray, temperature: float) -> np.ndarray:
590
+ """Compute the Planck function.
591
+
592
+ Args:
593
+ wavelength: Wavelength (m).
594
+ T: Temperature (K).
595
+
596
+ Returns:
597
+ Spectral radiance (W m<sup>-2</sup> sr<sup>-1</sup> Hz<sup>-1</sup>).
598
+ """
599
+ h = scipy.constants.h # Planck constant (J·s)
600
+ c = scipy.constants.c # Speed of light (m/s)
601
+ k = scipy.constants.k # Boltzmann constant (J/K)
602
+ c1 = 2 * h * c**2
603
+ c2 = (h * c) / k
604
+ denom = np.exp(c2 / (wavelength * temperature)) - 1.0
605
+ spectral_radiance = c1 / (wavelength**5 * (denom))
606
+ return spectral_radiance
607
+
608
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
609
+ """Compute 1D data based on current parameters.
610
+
611
+ Returns:
612
+ Tuple of (wavelength, spectral radiance) arrays.
613
+ """
614
+ wavelength = self.generate_x_data()
615
+ assert self.T is not None
616
+ y = self.func(wavelength, self.T)
617
+ return wavelength, y
618
+
619
+
620
+ register_signal_parameters_class(SignalTypes.PLANCK, PlanckParam)
621
+
622
+
623
+ class FreqUnits(enum.Enum):
624
+ """Frequency units"""
625
+
626
+ HZ = "Hz"
627
+ KHZ = "kHz"
628
+ MHZ = "MHz"
629
+ GHZ = "GHz"
630
+
631
+ @classmethod
632
+ def convert_in_hz(cls, value, unit):
633
+ """Convert value in Hz"""
634
+ factor = {cls.HZ: 1, cls.KHZ: 1e3, cls.MHZ: 1e6, cls.GHZ: 1e9}.get(unit)
635
+ if factor is None:
636
+ raise ValueError(f"Unknown unit: {unit}")
637
+ return value * factor
638
+
639
+
640
+ class BasePeriodicParam(NewSignalParam):
641
+ """Parameters for periodic functions"""
642
+
643
+ STYPE: Type[SignalTypes] | None = None
644
+
645
+ def get_frequency_in_hz(self):
646
+ """Return frequency in Hz"""
647
+ return FreqUnits.convert_in_hz(self.freq, self.freq_unit)
648
+
649
+ # Redefining some parameters with more appropriate defaults
650
+ xunit = gds.StringItem(_("X unit"), default="s")
651
+
652
+ a = gds.FloatItem("A", default=1.0)
653
+ offset = gds.FloatItem("y<sub>0</sub>", default=0.0).set_pos(col=1)
654
+ freq = gds.FloatItem("f", default=1.0)
655
+ freq_unit = gds.ChoiceItem(_("Unit"), FreqUnits, default=FreqUnits.HZ).set_pos(
656
+ col=1
657
+ )
658
+ phase = gds.FloatItem("φ", default=0.0, unit="°")
659
+
660
+ def generate_title(self) -> str:
661
+ """Generate a title based on current parameters."""
662
+ assert isinstance(self.STYPE, SignalTypes)
663
+ freq_hz = self.get_frequency_in_hz()
664
+ title = (
665
+ f"{self.STYPE.name.lower()}(f={freq_hz:.3g}Hz,"
666
+ f"A={self.a:.3g},y0={self.offset:.3g},φ={self.phase:.3g}°)"
667
+ )
668
+ return title
669
+
670
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
671
+ """Compute 1D data based on current parameters.
672
+
673
+ Returns:
674
+ Tuple of (x, y) arrays
675
+ """
676
+ x = self.generate_x_data()
677
+ func = {
678
+ SignalTypes.SINE: np.sin,
679
+ SignalTypes.COSINE: np.cos,
680
+ SignalTypes.SAWTOOTH: sps.sawtooth,
681
+ SignalTypes.TRIANGLE: triangle_func,
682
+ SignalTypes.SQUARE: sps.square,
683
+ SignalTypes.SINC: np.sinc,
684
+ }[self.STYPE]
685
+ freq = self.get_frequency_in_hz()
686
+ y = self.a * func(2 * np.pi * freq * x + np.deg2rad(self.phase)) + self.offset
687
+ return x, y
688
+
689
+
690
+ class SineParam(
691
+ BasePeriodicParam, title=_("Sine"), comment="y = y<sub>0</sub> + A sin(2π f x + φ)"
692
+ ):
693
+ """Parameters for sine function."""
694
+
695
+ STYPE = SignalTypes.SINE
696
+
697
+
698
+ register_signal_parameters_class(SignalTypes.SINE, SineParam)
699
+
700
+
701
+ class CosineParam(
702
+ BasePeriodicParam,
703
+ title=_("Cosine"),
704
+ comment="y = y<sub>0</sub> + A cos(2π f x + φ)",
705
+ ):
706
+ """Parameters for cosine function."""
707
+
708
+ STYPE = SignalTypes.COSINE
709
+
710
+
711
+ register_signal_parameters_class(SignalTypes.COSINE, CosineParam)
712
+
713
+
714
+ class SawtoothParam(
715
+ BasePeriodicParam,
716
+ title=_("Sawtooth"),
717
+ comment="y = y<sub>0</sub> + A (2 (f x + φ/(2π) - |f x + φ/(2π) + 1/2|))",
718
+ ):
719
+ """Parameters for sawtooth function."""
720
+
721
+ STYPE = SignalTypes.SAWTOOTH
722
+
723
+
724
+ register_signal_parameters_class(SignalTypes.SAWTOOTH, SawtoothParam)
725
+
726
+
727
+ class TriangleParam(
728
+ BasePeriodicParam,
729
+ title=_("Triangle"),
730
+ comment="y = y<sub>0</sub> + A sawtooth(2π f x + φ, width=0.5)",
731
+ ):
732
+ """Parameters for triangle function."""
733
+
734
+ STYPE = SignalTypes.TRIANGLE
735
+
736
+
737
+ register_signal_parameters_class(SignalTypes.TRIANGLE, TriangleParam)
738
+
739
+
740
+ class SquareParam(
741
+ BasePeriodicParam,
742
+ title=_("Square"),
743
+ comment="y = y<sub>0</sub> + A sgn(sin(2π f x + φ))",
744
+ ):
745
+ """Parameters for square function."""
746
+
747
+ STYPE = SignalTypes.SQUARE
748
+
749
+
750
+ register_signal_parameters_class(SignalTypes.SQUARE, SquareParam)
751
+
752
+
753
+ class SincParam(
754
+ BasePeriodicParam,
755
+ title=_("Cardinal sine"),
756
+ comment="y = y<sub>0</sub> + A sinc(f x + φ)",
757
+ ):
758
+ """Parameters for cardinal sine function."""
759
+
760
+ STYPE = SignalTypes.SINC
761
+
762
+
763
+ register_signal_parameters_class(SignalTypes.SINC, SincParam)
764
+
765
+
766
+ class LinearChirpParam(
767
+ NewSignalParam,
768
+ title=_("Linear chirp"),
769
+ comment="y = y<sub>0</sub> + a sin(φ<sub>0</sub> "
770
+ "+ 2π (f<sub>0</sub> x + 0.5 k x²))",
771
+ ):
772
+ """Linear chirp function."""
773
+
774
+ a = gds.FloatItem("A", default=1.0, help=_("Amplitude"))
775
+ phi0 = gds.FloatItem(
776
+ "φ<sub>0</sub>", default=0.0, help=_("Initial phase")
777
+ ).set_prop("display", col=1)
778
+ k = gds.FloatItem("k", default=1.0, help=_("Chirp rate (f<sup>-2</sup>)"))
779
+ offset = gds.FloatItem(
780
+ "y<sub>0</sub>", default=0.0, help=_("Vertical offset")
781
+ ).set_prop("display", col=1)
782
+ f0 = gds.FloatItem("f<sub>0</sub>", default=1.0, help=_("Initial frequency (Hz)"))
783
+
784
+ def generate_title(self) -> str:
785
+ """Generate a title based on current parameters.
786
+
787
+ Returns:
788
+ Title string.
789
+ """
790
+ return (
791
+ f"chirp(A={self.a:.3g},"
792
+ f"k={self.k:.3g},"
793
+ f"f0={self.f0:.3g},"
794
+ f"φ0={self.phi0:.3g},"
795
+ f"y0={self.offset:.3g})"
796
+ )
797
+
798
+ @classmethod
799
+ def func(
800
+ cls, x: np.ndarray, a: float, k: float, f0: float, phi0: float, offset: float
801
+ ) -> np.ndarray:
802
+ """Compute the linear chirp function.
803
+
804
+ Args:
805
+ x: X data array.
806
+ a: Amplitude.
807
+ k: Chirp rate (s<sup>-2</sup>).
808
+ f0: Initial frequency (Hz).
809
+ phi0: Initial phase.
810
+ offset: Vertical offset.
811
+
812
+ Returns:
813
+ Y data array computed using the chirp function.
814
+ """
815
+ phase = phi0 + 2 * np.pi * (f0 * x + 0.5 * k * x**2)
816
+ return offset + a * np.sin(phase)
817
+
818
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
819
+ """Compute 1D data based on current parameters.
820
+
821
+ Returns:
822
+ Tuple of (x, y) arrays.
823
+ """
824
+ assert self.a is not None
825
+ assert self.k is not None
826
+ assert self.f0 is not None
827
+ assert self.phi0 is not None
828
+ assert self.offset is not None
829
+ x = self.generate_x_data()
830
+ y = self.func(x, self.a, self.k, self.f0, self.phi0, self.offset)
831
+ return x, y
832
+
833
+
834
+ register_signal_parameters_class(SignalTypes.LINEARCHIRP, LinearChirpParam)
835
+
836
+
837
+ class StepParam(NewSignalParam, title=_("Step")):
838
+ """Parameters for step function."""
839
+
840
+ a1 = gds.FloatItem("A<sub>1</sub>", default=0.0)
841
+ a2 = gds.FloatItem("A<sub>2</sub>", default=1.0).set_pos(col=1)
842
+ x0 = gds.FloatItem("x<sub>0</sub>", default=0.0)
843
+
844
+ def generate_title(self) -> str:
845
+ """Generate a title based on current parameters."""
846
+ return f"step(a1={self.a1:.3g},a2={self.a2:.3g},x0={self.x0:.3g})"
847
+
848
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
849
+ """Compute 1D data based on current parameters.
850
+
851
+ Returns:
852
+ Tuple of (x, y) arrays
853
+ """
854
+ x = self.generate_x_data()
855
+ y = np.ones_like(x) * self.a1
856
+ y[x > self.x0] = self.a2
857
+ return x, y
858
+
859
+
860
+ register_signal_parameters_class(SignalTypes.STEP, StepParam)
861
+
862
+
863
+ class ExponentialParam(
864
+ NewSignalParam, title=_("Exponential"), comment="y = A exp(B x) + y<sub>0</sub>"
865
+ ):
866
+ """Parameters for exponential function."""
867
+
868
+ a = gds.FloatItem("A", default=1.0)
869
+ offset = gds.FloatItem("y<sub>0</sub>", default=0.0)
870
+ exponent = gds.FloatItem("B", default=1.0)
871
+
872
+ def generate_title(self) -> str:
873
+ """Generate a title based on current parameters."""
874
+ return f"exponential(A={self.a:.3g},B={self.exponent:.3g},y0={self.offset:.3g})"
875
+
876
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
877
+ """Compute 1D data based on current parameters.
878
+
879
+ Returns:
880
+ Tuple of (x, y) arrays
881
+ """
882
+ x = self.generate_x_data()
883
+ y = self.a * np.exp(self.exponent * x) + self.offset
884
+ return x, y
885
+
886
+
887
+ register_signal_parameters_class(SignalTypes.EXPONENTIAL, ExponentialParam)
888
+
889
+
890
+ class LogisticParam(
891
+ NewSignalParam,
892
+ title=_("Logistic"),
893
+ comment="y = y<sub>0</sub> + A / (1 + exp(-k (x - x<sub>0</sub>)))",
894
+ ):
895
+ """Logistic function."""
896
+
897
+ a = gds.FloatItem("A", default=1.0, help=_("Amplitude"))
898
+ x0 = gds.FloatItem(
899
+ "x<sub>0</sub>", default=0.0, help=_("Horizontal offset")
900
+ ).set_prop("display", col=1)
901
+ k = gds.FloatItem("k", default=1.0, help=_("Growth or decay rate"))
902
+ offset = gds.FloatItem(
903
+ "y<sub>0</sub>", default=0.0, help=_("Vertical offset")
904
+ ).set_prop("display", col=1)
905
+
906
+ def generate_title(self) -> str:
907
+ """Generate a title based on current parameters.
908
+
909
+ Returns:
910
+ Title string.
911
+ """
912
+ return (
913
+ f"logistic(A={self.a:.3g},"
914
+ f"k={self.k:.3g},"
915
+ f"x0={self.x0:.3g},"
916
+ f"y0={self.offset:.3g})"
917
+ )
918
+
919
+ @classmethod
920
+ def func(
921
+ cls, x: np.ndarray, a: float, k: float, x0: float, offset: float
922
+ ) -> np.ndarray:
923
+ """Compute the logistic function.
924
+
925
+ Args:
926
+ x: X data array.
927
+ a: Amplitude.
928
+ k: Growth or decay rate.
929
+ x0: Horizontal offset.
930
+ offset: Vertical offset.
931
+
932
+ Returns:
933
+ Y data array computed using the logistic function.
934
+ """
935
+ return offset + a / (1.0 + np.exp(-k * (x - x0)))
936
+
937
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
938
+ """Compute 1D data based on current parameters.
939
+
940
+ Returns:
941
+ Tuple of (x, y) arrays.
942
+ """
943
+ assert self.a is not None
944
+ assert self.k is not None
945
+ assert self.x0 is not None
946
+ assert self.offset is not None
947
+ x = self.generate_x_data()
948
+ y = self.func(x, self.a, self.k, self.x0, self.offset)
949
+ return x, y
950
+
951
+
952
+ register_signal_parameters_class(SignalTypes.LOGISTIC, LogisticParam)
953
+
954
+
955
+ class PulseParam(NewSignalParam, title=_("Pulse")):
956
+ """Parameters for pulse function."""
957
+
958
+ amp = gds.FloatItem("Amplitude", default=1.0)
959
+ start = gds.FloatItem(_("Start"), default=0.0).set_pos(col=1)
960
+ offset = gds.FloatItem(_("Offset"), default=10.0)
961
+ stop = gds.FloatItem(_("End"), default=5.0).set_pos(col=1)
962
+
963
+ def generate_title(self) -> str:
964
+ """Generate a title based on current parameters."""
965
+ return (
966
+ f"pulse(start={self.start:.3g},stop={self.stop:.3g},"
967
+ f"offset={self.offset:.3g},amp={self.amp:.3g})"
968
+ )
969
+
970
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
971
+ """Compute 1D data based on current parameters.
972
+
973
+ Returns:
974
+ Tuple of (x, y) arrays
975
+ """
976
+ x = self.generate_x_data()
977
+ y = np.full_like(x, self.offset)
978
+ y[(x >= self.start) & (x <= self.stop)] += self.amp
979
+ return x, y
980
+
981
+
982
+ register_signal_parameters_class(SignalTypes.PULSE, PulseParam)
983
+
984
+
985
+ @dataclass
986
+ class ExpectedFeatures:
987
+ """Expected pulse feature values for validation."""
988
+
989
+ signal_shape: SignalShape
990
+ polarity: int
991
+ amplitude: float
992
+ rise_time: float # Rise time between specified ratios
993
+ offset: float
994
+ x50: float
995
+ x100: float # Time at 100% amplitude (maximum)
996
+ foot_duration: float
997
+ fall_time: float | None = None # Fall time between specified ratios
998
+ fwhm: float | None = None
999
+
1000
+
1001
+ @dataclass
1002
+ class FeatureTolerances:
1003
+ """Absolute tolerance values for pulse feature validation."""
1004
+
1005
+ polarity: float = 1e-8
1006
+ amplitude: float = 0.5
1007
+ rise_time: float = 0.2
1008
+ offset: float = 0.5
1009
+ x50: float = 0.1
1010
+ x100: float = 0.6 # Tolerance for time at 100% amplitude
1011
+ foot_duration: float = 0.5
1012
+ fall_time: float = 1.0
1013
+ fwhm: float = 0.5
1014
+
1015
+
1016
+ class BasePulseParam(NewSignalParam):
1017
+ """Base class for pulse signal parameters."""
1018
+
1019
+ SEED = 0
1020
+
1021
+ # Redefine NewSignalParam parameters with more appropriate defaults
1022
+ xmin = gds.FloatItem(_("Start time"), default=0.0)
1023
+ xmax = gds.FloatItem(_("End time"), default=10.0)
1024
+ size = gds.IntItem(_("Number of points"), default=1000, min=1)
1025
+
1026
+ # Specific pulse parameters
1027
+ offset = gds.FloatItem(_("Initial value"), default=0.0)
1028
+ amplitude = gds.FloatItem(_("Amplitude"), default=5.0).set_pos(col=1)
1029
+ noise_amplitude = gds.FloatItem(_("Noise amplitude"), default=0.2, min=0.0)
1030
+ x_rise_start = gds.FloatItem(_("Rise start time"), default=3.0, min=0.0)
1031
+ total_rise_time = gds.FloatItem(_("Total rise time"), default=2.0, min=0.0).set_pos(
1032
+ col=1
1033
+ )
1034
+
1035
+ def get_crossing_time(self, edge: Literal["rise", "fall"], ratio: float) -> float:
1036
+ """Get the theoretical crossing time for the specified edge and ratio.
1037
+
1038
+ Args:
1039
+ edge: Which edge to calculate ("rise" or "fall")
1040
+ ratio: Crossing ratio (0.0 to 1.0)
1041
+
1042
+ Returns:
1043
+ Theoretical crossing time for the specified edge and ratio
1044
+ """
1045
+ if edge == "rise":
1046
+ return self.x_rise_start + ratio * self.total_rise_time
1047
+ raise NotImplementedError(
1048
+ "Fall edge crossing time not implemented for this signal type"
1049
+ )
1050
+
1051
+ def get_expected_features(
1052
+ self, start_ratio: float = 0.1, stop_ratio: float = 0.9
1053
+ ) -> ExpectedFeatures:
1054
+ """Calculate expected pulse features for this signal.
1055
+
1056
+ Args:
1057
+ start_ratio: Start ratio for rise time calculation
1058
+ stop_ratio: Stop ratio for rise time calculation
1059
+
1060
+ Returns:
1061
+ ExpectedFeatures dataclass with all expected values
1062
+ """
1063
+ y_end_value = self.offset + self.amplitude
1064
+ return ExpectedFeatures(
1065
+ signal_shape=SignalShape.STEP,
1066
+ polarity=1 if y_end_value > self.offset else -1,
1067
+ amplitude=abs(y_end_value - self.offset),
1068
+ rise_time=(stop_ratio - start_ratio) * self.total_rise_time,
1069
+ offset=self.offset,
1070
+ x50=self.x_rise_start + 0.5 * self.total_rise_time,
1071
+ x100=self.x_rise_start + self.total_rise_time,
1072
+ foot_duration=self.x_rise_start - self.xmin,
1073
+ )
1074
+
1075
+ def get_feature_tolerances(self) -> FeatureTolerances:
1076
+ """Get absolute tolerance values for pulse feature validation.
1077
+
1078
+ Returns:
1079
+ FeatureTolerances dataclass with default tolerance values
1080
+ """
1081
+ return FeatureTolerances()
1082
+
1083
+
1084
+ class StepPulseParam(BasePulseParam, title=_("Step pulse with noise")):
1085
+ """Parameters for generating step signals with configurable rise time."""
1086
+
1087
+ def generate_title(self) -> str:
1088
+ """Generate a title based on current parameters."""
1089
+ return (
1090
+ f"step_pulse(rise_time={self.total_rise_time:.3g},"
1091
+ f"x_start={self.x_rise_start:.3g},offset={self.offset:.3g},"
1092
+ f"amp={self.amplitude:.3g})"
1093
+ )
1094
+
1095
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
1096
+ """Generate a noisy step signal with a linear rise.
1097
+
1098
+ The function creates a time vector and generates a signal that starts at
1099
+ `offset`, rises linearly to `offset + amplitude` starting at `x_rise_start` over
1100
+ a duration of `total_rise_time`, and remains at the final value afterwards.
1101
+ Gaussian noise is added to the signal.
1102
+
1103
+ Returns:
1104
+ Tuple containing the time vector and noisy step signal.
1105
+ """
1106
+ # time vector
1107
+ x = self.generate_x_data()
1108
+
1109
+ # Calculate final value from offset and amplitude
1110
+ y_final = self.offset + self.amplitude
1111
+
1112
+ # creating the signal
1113
+ rise_end_time = self.x_rise_start + self.total_rise_time
1114
+ y = np.piecewise(
1115
+ x,
1116
+ [
1117
+ x < self.x_rise_start,
1118
+ (x >= self.x_rise_start) & (x < rise_end_time),
1119
+ x >= rise_end_time,
1120
+ ],
1121
+ [
1122
+ self.offset,
1123
+ lambda t: (
1124
+ self.offset
1125
+ + (y_final - self.offset)
1126
+ * (t - self.x_rise_start)
1127
+ / self.total_rise_time
1128
+ ),
1129
+ y_final,
1130
+ ],
1131
+ )
1132
+ rdg = np.random.default_rng(self.SEED)
1133
+ noise = rdg.normal(0, self.noise_amplitude, size=len(y))
1134
+ y_noisy = y + noise
1135
+
1136
+ return x, y_noisy
1137
+
1138
+
1139
+ register_signal_parameters_class(SignalTypes.STEP_PULSE, StepPulseParam)
1140
+
1141
+
1142
+ class SquarePulseParam(BasePulseParam, title=_("Square pulse with noise")):
1143
+ """Parameters for generating square signals with configurable rise/fall times."""
1144
+
1145
+ # Redefine NewSignalParam parameters with more appropriate defaults
1146
+ xmax = gds.FloatItem(_("End time"), default=20.0)
1147
+
1148
+ # Specific square pulse parameters
1149
+ fwhm = gds.FloatItem(_("Full Width at Half Maximum"), default=5.5, min=0.0)
1150
+ total_fall_time = gds.FloatItem(_("Total fall time"), default=5.0, min=0.0).set_pos(
1151
+ col=1
1152
+ )
1153
+
1154
+ @property
1155
+ def square_duration(self) -> float:
1156
+ """Calculate the square duration from FWHM and total rise/fall times."""
1157
+ return self.fwhm - 0.5 * self.total_rise_time - 0.5 * self.total_fall_time
1158
+
1159
+ def get_plateau_range(self) -> tuple[float, float]:
1160
+ """Get the theoretical plateau range (start, end) for the square signal.
1161
+
1162
+ Returns:
1163
+ Tuple with (start, end) times of the plateau
1164
+ """
1165
+ return (
1166
+ self.x_rise_start + self.total_rise_time,
1167
+ self.x_rise_start + self.total_rise_time + self.square_duration,
1168
+ )
1169
+
1170
+ def get_crossing_time(self, edge: Literal["rise", "fall"], ratio: float) -> float:
1171
+ """Get the theoretical crossing time for the specified edge and ratio.
1172
+
1173
+ Args:
1174
+ edge: Which edge to calculate ("rise" or "fall")
1175
+ ratio: Crossing ratio (0.0 to 1.0)
1176
+
1177
+ Returns:
1178
+ Theoretical crossing time for the specified edge and ratio
1179
+ """
1180
+ if edge == "rise":
1181
+ return super().get_crossing_time(edge, ratio)
1182
+ if edge == "fall":
1183
+ t_start_fall = (
1184
+ self.x_rise_start + self.total_rise_time + self.square_duration
1185
+ )
1186
+ return t_start_fall + ratio * self.total_fall_time
1187
+ raise ValueError("edge must be 'rise' or 'fall'")
1188
+
1189
+ def get_expected_features(
1190
+ self, start_ratio: float = 0.1, stop_ratio: float = 0.9
1191
+ ) -> ExpectedFeatures:
1192
+ """Calculate expected pulse features for this signal.
1193
+
1194
+ Args:
1195
+ start_ratio: Start ratio for rise time calculation
1196
+ stop_ratio: Stop ratio for rise time calculation
1197
+
1198
+ Returns:
1199
+ ExpectedFeatures dataclass with all expected values
1200
+ """
1201
+ features = super().get_expected_features(start_ratio, stop_ratio)
1202
+ features.signal_shape = SignalShape.SQUARE
1203
+ features.fall_time = np.abs(stop_ratio - start_ratio) * self.total_fall_time
1204
+ features.fwhm = self.fwhm
1205
+ return features
1206
+
1207
+ def get_feature_tolerances(self) -> FeatureTolerances:
1208
+ """Get absolute tolerance values for square signal feature validation.
1209
+
1210
+ Returns:
1211
+ FeatureTolerances dataclass with square-specific tolerance values
1212
+ """
1213
+ return FeatureTolerances(
1214
+ x100=0.8, # Looser tolerance for square signals
1215
+ )
1216
+
1217
+ def generate_title(self) -> str:
1218
+ """Generate a title based on current parameters."""
1219
+ return (
1220
+ f"square_pulse(rise_time={self.total_rise_time:.3g},"
1221
+ f"fall_time={self.total_fall_time:.3g},"
1222
+ f"fwhm={self.fwhm:.3g},offset={self.offset:.3g},"
1223
+ f"amp={self.amplitude:.3g})"
1224
+ )
1225
+
1226
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
1227
+ """Generate a synthetic square-like signal with configurable parameters.
1228
+
1229
+ Generates a synthetic square-like signal with configurable rise, plateau,
1230
+ and fall times, and adds Gaussian noise.
1231
+
1232
+ Returns:
1233
+ Tuple containing the time vector and noisy square signal.
1234
+ """
1235
+ # time vector
1236
+ x = self.generate_x_data()
1237
+
1238
+ # Calculate high value from offset and amplitude
1239
+ y_high = self.offset + self.amplitude
1240
+
1241
+ x_rise_end = self.x_rise_start + self.total_rise_time
1242
+ x_start_fall = self.x_rise_start + self.total_rise_time + self.square_duration
1243
+ # creating the signal
1244
+ y = np.piecewise(
1245
+ x,
1246
+ [
1247
+ x < self.x_rise_start,
1248
+ (x >= self.x_rise_start) & (x < x_rise_end),
1249
+ (x >= x_rise_end) & (x < x_start_fall),
1250
+ (x >= x_start_fall) & (x < x_start_fall + self.total_fall_time),
1251
+ x >= self.total_fall_time + x_start_fall,
1252
+ ],
1253
+ [
1254
+ self.offset,
1255
+ lambda t: (
1256
+ self.offset
1257
+ + (y_high - self.offset)
1258
+ * (t - self.x_rise_start)
1259
+ / self.total_rise_time
1260
+ ),
1261
+ y_high,
1262
+ lambda t: y_high
1263
+ - (y_high - self.offset) * (t - x_start_fall) / self.total_fall_time,
1264
+ self.offset,
1265
+ ],
1266
+ )
1267
+ rdg = np.random.default_rng(self.SEED)
1268
+ noise = rdg.normal(0, self.noise_amplitude, size=len(y))
1269
+ y_noisy = y + noise
1270
+
1271
+ return x, y_noisy
1272
+
1273
+
1274
+ register_signal_parameters_class(SignalTypes.SQUARE_PULSE, SquarePulseParam)
1275
+
1276
+
1277
+ class PolyParam(NewSignalParam, title=_("Polynomial")):
1278
+ """Parameters for polynomial function."""
1279
+
1280
+ a0 = gds.FloatItem("a0", default=1.0)
1281
+ a3 = gds.FloatItem("a3", default=0.0).set_pos(col=1)
1282
+ a1 = gds.FloatItem("a1", default=1.0)
1283
+ a4 = gds.FloatItem("a4", default=0.0).set_pos(col=1)
1284
+ a2 = gds.FloatItem("a2", default=0.0)
1285
+ a5 = gds.FloatItem("a5", default=0.0).set_pos(col=1)
1286
+
1287
+ def generate_title(self) -> str:
1288
+ """Generate a title based on current parameters."""
1289
+ return (
1290
+ f"polynomial(a0={self.a0:.3g},a1={self.a1:.3g},a2={self.a2:.3g},"
1291
+ f"a3={self.a3:.3g},a4={self.a4:.3g},a5={self.a5:.3g})"
1292
+ )
1293
+
1294
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
1295
+ """Compute 1D data based on current parameters.
1296
+
1297
+ Returns:
1298
+ Tuple of (x, y) arrays
1299
+ """
1300
+ x = self.generate_x_data()
1301
+ y = np.polyval([self.a5, self.a4, self.a3, self.a2, self.a1, self.a0], x)
1302
+ return x, y
1303
+
1304
+
1305
+ register_signal_parameters_class(SignalTypes.POLYNOMIAL, PolyParam)
1306
+
1307
+
1308
+ class CustomSignalParam(NewSignalParam, title=_("Custom signal")):
1309
+ """Parameters for custom signal (e.g. manually defined experimental data)."""
1310
+
1311
+ size = gds.IntItem(_("N<sub>points</sub>"), default=10).set_prop(
1312
+ "display", active=False
1313
+ )
1314
+ xmin = gds.FloatItem("x<sub>min</sub>", default=0.0).set_prop(
1315
+ "display", active=False
1316
+ )
1317
+ xmax = gds.FloatItem("x<sub>max</sub>", default=1.0).set_prop(
1318
+ "display", active=False, col=1
1319
+ )
1320
+
1321
+ xyarray = gds.FloatArrayItem(
1322
+ "XY Values",
1323
+ format="%g",
1324
+ )
1325
+
1326
+ def setup_array(
1327
+ self,
1328
+ size: int | None = None,
1329
+ xmin: float | None = None,
1330
+ xmax: float | None = None,
1331
+ ) -> None:
1332
+ """Setup the xyarray from size, xmin and xmax (use the current values is not
1333
+ provided)
1334
+
1335
+ Args:
1336
+ size: xyarray size (default: None)
1337
+ xmin: X min (default: None)
1338
+ xmax: X max (default: None)
1339
+ """
1340
+ self.size = size or self.size
1341
+ self.xmin = xmin or self.xmin
1342
+ self.xmax = xmax or self.xmax
1343
+ x_arr = np.linspace(self.xmin, self.xmax, self.size) # type: ignore
1344
+ self.xyarray = np.vstack((x_arr, x_arr)).T
1345
+
1346
+ def generate_title(self) -> str:
1347
+ """Generate a title based on current parameters."""
1348
+ return f"custom(size={self.size})"
1349
+
1350
+ def generate_1d_data(self) -> tuple[np.ndarray, np.ndarray]:
1351
+ """Compute 1D data based on current parameters.
1352
+
1353
+ Returns:
1354
+ Tuple of (x, y) arrays
1355
+ """
1356
+ self.setup_array(size=self.size, xmin=self.xmin, xmax=self.xmax)
1357
+ x, y = self.xyarray.T
1358
+ return x, y
1359
+
1360
+
1361
+ register_signal_parameters_class(SignalTypes.CUSTOM, CustomSignalParam)
1362
+
1363
+
1364
+ check_all_signal_parameters_classes()
1365
+
1366
+
1367
+ def triangle_func(xarr: np.ndarray) -> np.ndarray:
1368
+ """Triangle function
1369
+
1370
+ Args:
1371
+ xarr: x data
1372
+ """
1373
+ # ignore warning, as type hint is not handled properly in upstream library
1374
+ return sps.sawtooth(xarr, width=0.5) # type: ignore[no-untyped-def]
1375
+
1376
+
1377
+ SIG_NB = 0
1378
+
1379
+
1380
+ def get_next_signal_number() -> int:
1381
+ """Get the next signal number.
1382
+
1383
+ This function is used to keep track of the number of signals created.
1384
+ It is typically used to generate unique titles for new signals.
1385
+
1386
+ Returns:
1387
+ int: new signal number
1388
+ """
1389
+ global SIG_NB # pylint: disable=global-statement
1390
+ SIG_NB += 1
1391
+ return SIG_NB
1392
+
1393
+
1394
+ def create_signal_from_param(param: NewSignalParam) -> SignalObj:
1395
+ """Create a new Signal object from parameters.
1396
+
1397
+ Args:
1398
+ param: new signal parameters
1399
+
1400
+ Returns:
1401
+ Signal object
1402
+
1403
+ Raises:
1404
+ NotImplementedError: if the signal type is not supported
1405
+ """
1406
+ # Generate data first, as some `generate_title()` methods may depend on it:
1407
+ x, y = param.generate_1d_data()
1408
+ # Check if user has customized the title or left it as default/empty
1409
+ use_generated_title = not param.title or param.title == DEFAULT_TITLE
1410
+ if use_generated_title:
1411
+ # Try to generate a descriptive title
1412
+ gen_title = getattr(param, "generate_title", lambda: "")()
1413
+ if gen_title:
1414
+ title = gen_title
1415
+ else:
1416
+ # No generated title available, use default with number
1417
+ title = f"{DEFAULT_TITLE} {get_next_signal_number():d}"
1418
+ else:
1419
+ # User has set a custom title, use it as-is
1420
+ title = param.title
1421
+ signal = create_signal(
1422
+ title,
1423
+ x,
1424
+ y,
1425
+ units=(param.xunit, param.yunit),
1426
+ labels=(param.xlabel, param.ylabel),
1427
+ )
1428
+ return signal