teareduce 0.3.6__py3-none-any.whl → 0.4.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.
teareduce/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  #
2
- # Copyright 2023-2024 Universidad Complutense de Madrid
2
+ # Copyright 2023-2025 Universidad Complutense de Madrid
3
3
  #
4
4
  # This file is part of teareduce
5
5
  #
@@ -13,7 +13,9 @@ from .cosmicrays import cr2images, apply_cr2images_ccddata, crmedian
13
13
  from .ctext import ctext
14
14
  from .draw_rectangle import draw_rectangle
15
15
  from .elapsed_time import elapsed_time
16
+ from .elapsed_time import elapsed_time_since
16
17
  from .imshow import imshow
18
+ from .imshow import imshowme
17
19
  from .numsplines import AdaptiveLSQUnivariateSpline
18
20
  from .peaks_spectrum import find_peaks_spectrum, refine_peaks_spectrum
19
21
  from .polfit import polfit_residuals, polfit_residuals_with_sigma_rejection
@@ -21,8 +23,10 @@ from .robust_std import robust_std
21
23
  from .sdistortion import fit_sdistortion
22
24
  from .sliceregion import SliceRegion1D, SliceRegion2D
23
25
  from .statsummary import ifc_statsummary, statsummary
26
+ from .simulateccdexposure import SimulateCCDExposure
24
27
  from .version import version
25
28
  from .wavecal import TeaWaveCalibration, apply_wavecal_ccddata
29
+ from .write_array_to_fits import write_array_to_fits
26
30
  from .zscale import zscale
27
31
 
28
32
  __version__ = version
teareduce/elapsed_time.py CHANGED
@@ -1,6 +1,6 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  #
3
- # Copyright 2024 Universidad Complutense de Madrid
3
+ # Copyright 2024-2025 Universidad Complutense de Madrid
4
4
  #
5
5
  # This file is part of teareduce
6
6
  #
@@ -8,6 +8,7 @@
8
8
  # License-Filename: LICENSE.txt
9
9
  #
10
10
 
11
+ from datetime import datetime
11
12
  import platform
12
13
  import sys
13
14
  from .version import version
@@ -39,3 +40,18 @@ def elapsed_time(time_ini, time_end, osinfo=True):
39
40
  print(f"Initial time.....: {time_ini}")
40
41
  print(f"Final time.......: {time_end}")
41
42
  print(f"Elapsed time.....: {time_end - time_ini}")
43
+
44
+
45
+ def elapsed_time_since(time_ini, osinfo=True):
46
+ """Display elapsed time and OS info since a given time.
47
+
48
+ Parameters
49
+ ----------
50
+ time_ini : datetime instance
51
+ Initial time.
52
+ osinfo : bool
53
+ If True, display OS info.
54
+ """
55
+
56
+ time_end = datetime.now()
57
+ elapsed_time(time_ini, time_end)
teareduce/imshow.py CHANGED
@@ -1,5 +1,5 @@
1
1
  #
2
- # Copyright 2022-2024 Universidad Complutense de Madrid
2
+ # Copyright 2022-2025 Universidad Complutense de Madrid
3
3
  #
4
4
  # This file is part of teareduce
5
5
  #
@@ -7,12 +7,37 @@
7
7
  # License-Filename: LICENSE.txt
8
8
  #
9
9
 
10
- import astropy.units as u
10
+ from astropy.units import Unit
11
+ from matplotlib import pyplot as plt
12
+ from matplotlib.figure import Figure
13
+ from matplotlib.axes import Axes
11
14
  from mpl_toolkits.axes_grid1 import make_axes_locatable
12
15
 
13
16
 
17
+ def imshowme(data, **kwargs):
18
+ """Simple execution of teareduce.imshow.
19
+
20
+ Parameters
21
+ ----------
22
+ data : numpy.ndarray
23
+ 2D array to be displayed
24
+
25
+ Returns
26
+ -------
27
+ fig : matplotlib.figure.Figure
28
+ Instance of Figure.
29
+ ax : matplotlib.axes.Axes
30
+ Instance of Axes.
31
+ img : matplotlib AxesImage
32
+ Instance returned by ax.imshow()
33
+ """
34
+ fig, ax = plt.subplots()
35
+ img = imshow(fig=fig, ax=ax, data=data, **kwargs)
36
+ return fig, ax, img
37
+
38
+
14
39
  def imshow(fig=None, ax=None, data=None,
15
- crpix1=1, crval1=None, cdelt1=None, cunit1=None, cunitx=u.Angstrom,
40
+ crpix1=1, crval1=None, cdelt1=None, cunit1=None, cunitx=Unit('Angstrom'),
16
41
  xlabel=None, ylabel=None, title=None,
17
42
  colorbar=True, cblabel='Number of counts',
18
43
  **kwargs):
@@ -25,9 +50,9 @@ def imshow(fig=None, ax=None, data=None,
25
50
  Parameters
26
51
  ----------
27
52
  fig : matplotlib.figure.Figure
28
- Figure object.
53
+ Instance of Figure.
29
54
  ax : matplotlib.axes.Axes
30
- Axes object.
55
+ Instance of Axes.
31
56
  data : numpy array
32
57
  2D array to be displayed.
33
58
  crpix1 : astropy.units.Quantity
@@ -63,6 +88,13 @@ def imshow(fig=None, ax=None, data=None,
63
88
 
64
89
  """
65
90
 
91
+ # protections
92
+ if not isinstance(fig, Figure):
93
+ raise ValueError("Unexpected 'fig' argument")
94
+ if not isinstance(ax, Axes):
95
+ raise ValueError("Unexpected 'ax' argument")
96
+
97
+ # default labels
66
98
  if xlabel is None:
67
99
  xlabel = 'X axis (array index)'
68
100
 
@@ -77,8 +109,9 @@ def imshow(fig=None, ax=None, data=None,
77
109
  naxis2, naxis1 = data.shape
78
110
  xmin, xmax = -0.5, naxis1 - 0.5
79
111
  ymin, ymax = -0.5, naxis2 - 0.5
80
- xminwv = crval1 + (xmin * u.pixel - crpix1 + 1 * u.pixel) * cdelt1
81
- xmaxwv = crval1 + (xmax * u.pixel - crpix1 + 1 * u.pixel) * cdelt1
112
+ u_pixel = Unit('pixel')
113
+ xminwv = crval1 + (xmin * u_pixel - crpix1 + 1 * u_pixel) * cdelt1
114
+ xmaxwv = crval1 + (xmax * u_pixel - crpix1 + 1 * u_pixel) * cdelt1
82
115
  extent = [xminwv.to(cunitx).value, xmaxwv.to(cunitx).value, ymin, ymax]
83
116
  xlabel = f'Wavelength ({cunitx})'
84
117
  aspect = 'auto'
@@ -114,3 +147,5 @@ def imshow(fig=None, ax=None, data=None,
114
147
  divider = make_axes_locatable(ax)
115
148
  cax = divider.append_axes("right", size="5%", pad=0.05)
116
149
  fig.colorbar(img, cax=cax, label=cblabel)
150
+
151
+ return img
@@ -0,0 +1,639 @@
1
+ #
2
+ # Copyright 2025 Universidad Complutense de Madrid
3
+ #
4
+ # This file is part of teareduce
5
+ #
6
+ # SPDX-License-Identifier: GPL-3.0+
7
+ # License-Filename: LICENSE.txt
8
+ #
9
+ from astropy.io import fits
10
+ from astropy.units import Unit
11
+ from astropy.units import Quantity
12
+ import numpy as np
13
+
14
+ from .sliceregion import SliceRegion2D
15
+ from .imshow import imshowme
16
+
17
+ VALID_BITPIX = {8: np.uint8, 16: np.uint16, 32: np.uint32, 64: np.uint64}
18
+ VALID_PARAMETERS = ["bias", "gain", "readout_noise", "dark", "flatfield", "data_model"]
19
+ VALID_IMAGE_TYPES = ["bias", "dark", "object"]
20
+ VALID_METHODS = ["poisson", "gaussian"]
21
+
22
+
23
+ class ImageParameter:
24
+ """Auxiliary class to hold image parameters.
25
+
26
+ Attributes
27
+ ----------
28
+ name : str
29
+ Parameter name.
30
+ value : int or float
31
+ Value of the parameter (without units).
32
+ unit : astropy.units.Unit or None
33
+ Expected unit of the parameter.
34
+ dtype : type
35
+ Expected type of the parameter value.
36
+ """
37
+ def __init__(self, name, quantity, expected_unit, expected_type):
38
+ """Initialize the class attributes.
39
+
40
+ Parameters
41
+ ----------
42
+ name : str
43
+ Parameter name.
44
+ quantity : astropy.units.Quantity or float
45
+ Parameter value (with units when required)
46
+ expected_unit : astropy.units.Unit or None
47
+ Expected unit of the parameter.
48
+ expected_type : type
49
+ Expected type of the parameter value.
50
+ """
51
+ if expected_unit is not None:
52
+ if not isinstance(quantity, Quantity):
53
+ raise TypeError(f"{name} must be a Quantity: {expected_unit=}")
54
+ if quantity.unit != expected_unit:
55
+ raise ValueError(f"{quantity.unit=} != {expected_unit=}")
56
+ self.value = quantity.value
57
+ else:
58
+ if isinstance(quantity, Quantity):
59
+ raise TypeError(f"{quantity=} should not be a Quantity but a float or a numpy array")
60
+ self.value = quantity
61
+ self.name = name
62
+ self.unit = expected_unit
63
+ self.dtype = expected_type
64
+
65
+
66
+ class SimulatedCCDResult:
67
+ """Result of executing SimulateCCDExposure.run().
68
+
69
+ Auxiliary class to store the result and all the relevant
70
+ parameters employed when generating the simulated CCD image.
71
+
72
+ Attributes
73
+ ----------
74
+ data : numpy.ndarray or None
75
+ Data array with the result of the simulated CCD exposure.
76
+ unit : astropy.units.Unit
77
+ Units of the simulated CCD exposure.
78
+ imgtype : str
79
+ Type of image to be generated. It must be one of
80
+ VALID_IMAGE_TYPES.
81
+ method : str
82
+ Method used to generate the simulated CCD image.
83
+ It must be one of VALID_METHODS.
84
+ origin : SimulateCCDExposure
85
+ Instance of SimulateCCDExposure employed to generate
86
+ the simulated CCD image.
87
+
88
+ Methods
89
+ -------
90
+ imshow(**kwargs)
91
+ Display simulated CCD image using tea.imshow().
92
+ """
93
+
94
+ def __init__(self, data, unit, imgtype, method, origin, seed):
95
+ """
96
+ Initialize the class attributes.
97
+
98
+ Parameters
99
+ ----------
100
+ data : numpy.ndarray or None
101
+ Data array with the result of the simulated CCD exposure.
102
+ unit : astropy.units.Unit
103
+ Units of the simulated CCD exposure.
104
+ imgtype : str
105
+ Type of image to be generated. It must be one of
106
+ VALID_IMAGE_TYPES.
107
+ method : str
108
+ Method used to generate the simulated CCD image.
109
+ It must be one of VALID_METHODS.
110
+ origin : SimulateCCDExposure
111
+ Instance of SimulateCCDExposure employed to generate
112
+ the simulated CCD image.
113
+ seed : int or None
114
+ Random number generator seed.
115
+ """
116
+ self.data = data
117
+ self.unit = unit
118
+ self.imgtype = imgtype
119
+ self.method = method
120
+ self.origin = origin
121
+ self.seed = seed
122
+
123
+ def __repr__(self):
124
+ output = f'{self.__class__.__name__}(\n'
125
+ output += f' data={self.data!r},\n'
126
+ output += f' unit={self.unit!r},\n'
127
+ output += f' imgtype={self.imgtype!r},\n'
128
+ output += f' method={self.method!r},\n'
129
+ output += f' origin={self.origin!r},\n'
130
+ output += f' seed={self.seed!r},\n'
131
+ output += ')'
132
+ return output
133
+
134
+ def imshowme(self, **kwargs):
135
+ """Plot simulated CCD image
136
+
137
+ This function simplys call teareduce.imshowme()
138
+
139
+ Parameters
140
+ ----------
141
+ **kwargs : dict
142
+ Additional keyword arguments to be passed to
143
+ teareduce.imshowme().
144
+ """
145
+ return imshowme(self.data, **kwargs)
146
+
147
+ def writeto(self, filename, overwrite=False, save_params=False):
148
+ """Write simulated result to FITS file.
149
+
150
+ Parameters
151
+ ----------
152
+ filename : path-like or file-like
153
+ File to write to.
154
+ overwrite : bool, optional
155
+ If True, overwrite the output file if it exists. Raises an
156
+ OSError if False and the output file exists.
157
+ save_params : bool, optional
158
+ If True, save the simulated CCD parameters to a file.
159
+ """
160
+
161
+ hdu_data = fits.PrimaryHDU(self.data)
162
+ hdu_data.header["UNIT"] = str(self.unit)
163
+ hdu_data.header["IMGTYPE"] = self.imgtype
164
+ hdu_data.header["METHOD"] = self.method
165
+
166
+ if save_params:
167
+ hdu_bias = fits.ImageHDU(self.origin.bias.data, name='BIAS')
168
+ hdu_bias.header["UNIT"] = str(self.origin.bias.unit)
169
+
170
+ hdu_gain = fits.ImageHDU(self.origin.gain.data, name='GAIN')
171
+ hdu_gain.header["UNIT"] = str(self.origin.gain.unit)
172
+
173
+ hdu_readout_noise = fits.ImageHDU(self.origin.readout_noise.data, name='RNOISE')
174
+ hdu_readout_noise.header["UNIT"] = str(self.origin.readout_noise.unit)
175
+
176
+ hdu_dark = fits.ImageHDU(self.origin.dark.data, name='DARK')
177
+ hdu_dark.header["UNIT"] = str(self.origin.dark.unit)
178
+
179
+ hdu_flatfield = fits.ImageHDU(self.origin.flatfield.data, name='FLATFIELD')
180
+
181
+ hdu_data_model = fits.ImageHDU(self.origin.data_model.data, name='DATAMODEL')
182
+ hdu_data_model.header["UNIT"] = str(self.origin.data_model.unit)
183
+
184
+ hdul = fits.HDUList([hdu_data, hdu_bias, hdu_gain, hdu_readout_noise,
185
+ hdu_dark, hdu_flatfield, hdu_data_model])
186
+ else:
187
+ hdul = fits.HDUList([hdu_data])
188
+
189
+ hdul.writeto(filename, overwrite=overwrite)
190
+
191
+
192
+ class SimulateCCDExposure:
193
+ """Simulated image generator from first principles.
194
+
195
+ CCD exposures are simulated making use of basic CCD parameters,
196
+ such as gain, readout noise, bias, dark and flat field.
197
+ A data model can also be employed to simulate more realising
198
+ CCD exposures.
199
+
200
+ The saturated pixels in 'data_model' are returned as 2**bitpix - 1
201
+ in the simulated image (for instance, 65535 when bitpix=16).
202
+
203
+ By initializing the seed of the random number generator
204
+ when instantiating this class, there is in principle no need to
205
+ use another seed for the run() method. This is useful for
206
+ generating reproducible sets of consecutive exposures. In any case,
207
+ it is also possible to provide a particular seed to the run()
208
+ method in order to initialize the execution of single exposures.
209
+
210
+ Attributes
211
+ ----------
212
+ naxis1 : int
213
+ NAXIS1 value.
214
+ naxis2 : int
215
+ NAXIS2 value.
216
+ bitpix : int
217
+ BITPIX value.
218
+ bias : Quantity
219
+ Numpy array with the detector bias level (ADU).
220
+ gain : Quantity
221
+ Numpy array with the detector gain (electrons/ADU).
222
+ readout_noise : Quantity
223
+ Numpy array with the readout noise (ADU).
224
+ dark : Quantity
225
+ Numpy array with the total dark current (ADU) for each pixel.
226
+ These numbers should not be the dark current rate (ADU/s).
227
+ The provided numbers must correspond to the total dark current
228
+ since the exposure time is not defined.
229
+ flatfield : numpy.ndarray
230
+ Numpy array with the pixel to pixel sensitivity (without units).
231
+ data_model : Quantity
232
+ Numpy array with the model of the source to be simulated (ADU).
233
+ seed : int or None
234
+ Random number generator seed.
235
+ _rng : np.random.RandomState
236
+ Random number generator.
237
+
238
+ Methods
239
+ -------
240
+ set_constant(parameter, value, region)
241
+ Set the value of a particular CCD parameter to a constant
242
+ value. The parameter can be any of VALID_PARAMETERS. The
243
+ constant value can be employed for all pixels or in a specific
244
+ region.
245
+ set_array2d(parameter, value, region)
246
+ Set the value of a particular CCD parameter to a 2D array.
247
+ The parameter can be any of VALID_PARAMETERS. The 2D array
248
+ may correspond to the whole simulated array or to a specific
249
+ region.
250
+ run(imgtype, seed, method)
251
+ Execute the generation of the simulated CCD exposure of
252
+ type 'imgtype', where 'imgtype' is one of VALID_IMAGE_TYPES.
253
+ The signal can be generated using either method: Poisson
254
+ or Gaussian. It is possible to set the seed in order to
255
+ initialize the random number generator.
256
+
257
+ """
258
+
259
+ def __init__(self,
260
+ naxis1=None,
261
+ naxis2=None,
262
+ bitpix=None,
263
+ bias=np.nan * Unit('adu'),
264
+ gain=np.nan * Unit('electron') / Unit('adu'),
265
+ readout_noise=np.nan * Unit('adu'),
266
+ dark=np.nan * Unit('adu'),
267
+ flatfield=np.nan,
268
+ data_model=np.nan * Unit('adu'),
269
+ seed=None,):
270
+ """Initialize the class attributes.
271
+
272
+ The simulated array dimensions are mandatory. If any additional
273
+ parameter is not provided, all the pixel values are set to NaN.
274
+ The values of each parameter can be subsequently modified using
275
+ methods that allow these values to be changed in specific regions
276
+ of the CCD.
277
+
278
+ The input parameters must be a Quantity whose value is either
279
+ a single number, which is expanded to fill the numpy.array,
280
+ or a numpy.array with the expected shape (NAXIS2, NAXIS1).
281
+
282
+ Parameters
283
+ ----------
284
+ naxis1 : int
285
+ NAXIS1 value.
286
+ naxis2 : int
287
+ NAXIS2 value.
288
+ bitpix : int
289
+ BITPIX value.
290
+ bias : Quantity
291
+ Detector bias level (ADU).
292
+ gain : Quantity
293
+ Detector gain (electrons/ADU).
294
+ readout_noise : Quantity
295
+ Readout noise (ADU).
296
+ dark : Quantity
297
+ Total dark current (ADU). This number should not be the
298
+ dark current rate (ADU/s). The provided number must
299
+ be the total dark current since the exposure time is not
300
+ defined.
301
+ flatfield : float or numpy.ndarray
302
+ Pixel to pixel sensitivity (without units).
303
+ data_model : Quantity
304
+ Model of the source to be simulated (ADU).
305
+ seed : int or None
306
+ Random number generator seed.
307
+ """
308
+ # protections
309
+ if naxis1 is None or naxis2 is None:
310
+ raise RuntimeError("Basic image parameters (naxis1, naxis2) must be provided")
311
+ if not isinstance(naxis1, int):
312
+ raise ValueError(f"{naxis1=} must be an integer")
313
+ if not isinstance(naxis2, int):
314
+ raise ValueError(f"{naxis2=} must be an integer")
315
+ if naxis1 < 0 or naxis2 < 0:
316
+ raise ValueError(f"Both {naxis1=} and {naxis2=} must be positive")
317
+ if bitpix is None:
318
+ raise ValueError(f"{bitpix=} must be provided")
319
+ if not isinstance(bitpix, int):
320
+ raise ValueError(f"{bitpix=} must be an integer")
321
+ if bitpix not in VALID_BITPIX.keys():
322
+ raise ValueError(f"BITPIX {bitpix} must be {VALID_BITPIX.keys()=}")
323
+
324
+ # image shape
325
+ self.naxis1 = naxis1
326
+ self.naxis2 = naxis2
327
+ self.bitpix = bitpix
328
+ self.bias = None
329
+ self.gain = None
330
+ self.readout_noise = None
331
+ self.dark = None
332
+ self.flatfield = None
333
+ self.data_model = None
334
+ self.seed = seed
335
+ self._rng = np.random.default_rng(seed)
336
+
337
+ # check that each input parameter is a Quantity with the expected units,
338
+ parameter_list = [
339
+ ImageParameter(
340
+ name="bias",
341
+ quantity=bias,
342
+ expected_unit=Unit('adu'),
343
+ expected_type=int),
344
+ ImageParameter(
345
+ name="gain",
346
+ quantity=gain,
347
+ expected_unit=Unit('electron') / Unit('adu'),
348
+ expected_type=float),
349
+ ImageParameter(
350
+ name="readout_noise",
351
+ quantity=readout_noise,
352
+ expected_unit=Unit('adu'),
353
+ expected_type=float),
354
+ ImageParameter(
355
+ name="dark",
356
+ quantity=dark,
357
+ expected_unit=Unit('adu'),
358
+ expected_type=float),
359
+ ImageParameter(
360
+ name="flatfield",
361
+ quantity=flatfield,
362
+ expected_unit=None,
363
+ expected_type=float),
364
+ ImageParameter(
365
+ name="data_model",
366
+ quantity=data_model,
367
+ expected_unit=Unit('adu'),
368
+ expected_type=float),
369
+ ]
370
+ # check that the parameter values are defined either as a
371
+ # single number (integer or float) or as a numpy.array with
372
+ # the expected shape
373
+ for p in parameter_list:
374
+ if isinstance(p.value, (int, float)):
375
+ # constant value for the full array
376
+ if p.unit is None:
377
+ setattr(self, p.name, np.full(shape=(naxis2, naxis1), fill_value=float(p.value)))
378
+ else:
379
+ setattr(self, p.name, np.full(shape=(naxis2, naxis1), fill_value=p.value) * p.unit)
380
+ elif isinstance(p.value, np.ndarray):
381
+ if p.value.ndim != 2:
382
+ raise ValueError(f"Unexpected number of dimensions {p.value.ndim} for {p.name}")
383
+ if not isinstance(p.value[0, 0], (int, float)):
384
+ raise ValueError(f"Unexpected value {p.value[0, 0]} for {p.name}: it should be an int or float")
385
+ naxis2_, naxis1_ = p.value.shape
386
+ if naxis1_ == naxis1 and naxis2_ == naxis2:
387
+ # array of the expected shape
388
+ if p.unit is None:
389
+ setattr(self, p.name, p.value)
390
+ else:
391
+ setattr(self, p.name, p.value * p.unit)
392
+ else:
393
+ msg = (f"Parameter {p.name}: NAXIS1={naxis1_}, NAXIS2={naxis2_} "
394
+ f"are not compatible with expected values NAXIS1={naxis1}, NAXIS2={naxis2}")
395
+ raise ValueError(msg)
396
+ else:
397
+ raise ValueError(f"Unexpected {p.name=} with {type(p.value)=}")
398
+
399
+ def __repr__(self):
400
+ output = f'{self.__class__.__name__}(\n'
401
+ output += f' naxis1={self.naxis1},\n'
402
+ output += f' naxis2={self.naxis2},\n'
403
+ output += f' bitpix={self.bitpix},\n'
404
+ for parameter in VALID_PARAMETERS:
405
+ output += f' {parameter}={getattr(self, parameter)!r},\n'
406
+ output += f' seed={self.seed},\n'
407
+ output += ')'
408
+ return output
409
+
410
+ def _precheck_set_function(self, parameter, quantity, region):
411
+ """Auxiliary function to check function inputs.
412
+
413
+ This function checks whether the parameters provided to
414
+ the functions in charge of defining pixels values are correct.
415
+
416
+ Parameters
417
+ ----------
418
+ parameter : str
419
+ CCD parameter to set. It must be any of VALID_PARAMETERS.
420
+ quantity : Quantity
421
+ Float or numpy.array with units (except for flatfield).
422
+ region : SliceRegion2D or None
423
+ Region in which to define de parameter. When it is None, it
424
+ indicates that 'value' should be set for all pixels.
425
+
426
+ Returns
427
+ -------
428
+ region : SliceRegion2D
429
+ Updated region in which to define de parameter. When the input
430
+ value is None, the returned region will be the full frame.
431
+ """
432
+ full_frame = SliceRegion2D(f"[1:{self.naxis1}, 1:{self.naxis2}]", mode='fits')
433
+ # protections
434
+ if parameter not in VALID_PARAMETERS:
435
+ raise RuntimeError(f"Invalid {parameter=}\n{VALID_PARAMETERS=}")
436
+ if region is None:
437
+ region = full_frame
438
+ else:
439
+ if isinstance(region, SliceRegion2D):
440
+ # check region is within NAXIS1, NAXIS2 rectangle
441
+ if not region.within(full_frame):
442
+ raise RuntimeError(f"Region {region=} outside of frame {full_frame=}")
443
+ else:
444
+ raise TypeError(f"The parameter {region=} must be an instance of SliceRegion2D")
445
+
446
+ if parameter == "gain":
447
+ expected_units = Unit('electron') / Unit('adu')
448
+ elif parameter == "flatfield":
449
+ expected_units = None
450
+ else:
451
+ expected_units = Unit('adu')
452
+ if expected_units is not None:
453
+ if not isinstance(quantity, Quantity):
454
+ raise TypeError(f"{parameter} must be a Quantity: {expected_units=}")
455
+ if quantity.unit != expected_units:
456
+ raise ValueError(f"{quantity.unit=} != {expected_units=}")
457
+
458
+ return region
459
+
460
+ def set_constant(self, parameter, constant, region=None):
461
+ """
462
+ Set the value of a particular parameter to a constant value.
463
+
464
+ The parameter can be any of VALID_PARAMETERS. The constant
465
+ value can be employed for all pixels or in a specific region.
466
+
467
+ Parameters
468
+ ----------
469
+ parameter : str
470
+ CCD parameter to set. It must be any of VALID_PARAMETERS.
471
+ constant : Quantity or float
472
+ Constant value for the parameter.
473
+ region : SliceRegion2D or None
474
+ Region in which to define de parameter. When it is None, it
475
+ indicates that 'value' should be set for all pixels.
476
+ """
477
+ # protections
478
+ parameter = parameter.lower()
479
+ region = self._precheck_set_function(parameter, constant, region)
480
+ if parameter == "flatfield":
481
+ value = constant
482
+ else:
483
+ value = constant.value
484
+
485
+ if not isinstance(value, (int, float)) or isinstance(value, np.ndarray):
486
+ raise TypeError("The parameter 'quantity' must be a single number")
487
+
488
+ # set parameter
489
+ try:
490
+ if isinstance(constant, Quantity):
491
+ getattr(self, parameter)[region.python] = constant
492
+ else:
493
+ getattr(self, parameter)[region.python] = float(constant)
494
+ except AttributeError:
495
+ raise RuntimeError(f"Unexpected parameter '{parameter}' for SimulateCCDExposure")
496
+
497
+ def set_array2d(self, parameter, array2d, region=None):
498
+ """
499
+ Set the value of a particular parameter to a 2D array.
500
+
501
+ The parameter can be any of VALID_PARAMETERS. The 2D array
502
+ may correspond to the whole simulated array or to a specific
503
+ region.
504
+
505
+ Parameters
506
+ ----------
507
+ parameter : str
508
+ CCD parameter to set. It must be any of VALID_PARAMETERS.
509
+ array2d : Quantity
510
+ Array of values to be used to define 'parameter'.
511
+ region : SliceRegion2D
512
+ Region in which to define de parameter. When it is None, it
513
+ indicates that 'quantity' has the same shape as the simulated
514
+ image.
515
+ """
516
+ # protections
517
+ parameter = parameter.lower()
518
+ region = self._precheck_set_function(parameter, array2d, region)
519
+ if parameter == "flatfield":
520
+ value = array2d.astype(float)
521
+ else:
522
+ value = array2d.value
523
+
524
+ if not isinstance(value, np.ndarray):
525
+ raise TypeError("The parameter 'quantity' must be a numpy array of quantities")
526
+ naxis2_, naxis1_ = value.shape
527
+ if self.naxis1 != naxis1_ or self.naxis2 != naxis2_:
528
+ print(f"{value.shape=}")
529
+ raise ValueError(f"The parameter 'quantity' must have shape ({self.naxis1=}, {self.naxis2=})")
530
+
531
+ # set parameter
532
+ try:
533
+ getattr(self, parameter)[region.python] = array2d[region.python]
534
+ except AttributeError:
535
+ raise RuntimeError(f"Unexpected parameter '{parameter}' for SimulateCCDExposure")
536
+
537
+ def run(self, imgtype, method="Poisson", seed=None):
538
+ """
539
+ Execute the generation of the simulated CCD exposure.
540
+
541
+ This function generates an image of type 'imgtype', which must
542
+ be one of VALID_IMAGE_TYPES. The signal can be generated using
543
+ either method: Poisson or Gaussian. It is possible to set the
544
+ seed in order to initialize the random number generator.
545
+
546
+ Parameters
547
+ ----------
548
+ imgtype : str
549
+ Type of image to be generated. It must be one of
550
+ VALID_IMAGE_TYPES.
551
+ method : str
552
+ Method to generate the simulated data. It must be one of
553
+ VALID_METHODS.
554
+ seed : int, optional
555
+ Seed for the random number generator. The default is None.
556
+
557
+ Returns
558
+ -------
559
+ result : SimulatedCCDResult
560
+ Instance of SimulatedCCDResult to store the simulated image
561
+ and the associated parameters employed to define how to
562
+ generate the simulated CCD exposure.
563
+ """
564
+ # protections
565
+ imgtype = imgtype.lower()
566
+ method = method.lower()
567
+ if imgtype not in VALID_IMAGE_TYPES:
568
+ raise ValueError(f'Unexpected {imgtype=}.\nValid image types: {VALID_IMAGE_TYPES}')
569
+ if method not in VALID_METHODS:
570
+ raise ValueError(f'Unexpected {method=}.\nValid methods: {VALID_METHODS}')
571
+
572
+ if seed is not None:
573
+ self._rng = np.random.default_rng(seed)
574
+
575
+ # initialize result instance
576
+ result = SimulatedCCDResult(
577
+ data=None,
578
+ unit=Unit('adu'),
579
+ imgtype=imgtype,
580
+ method=method,
581
+ origin=self,
582
+ seed=seed
583
+ )
584
+
585
+ # BIAS and Readout Noise
586
+ if np.isnan(self.bias.value).any():
587
+ raise ValueError(f"The parameter 'bias' contains NaN")
588
+ if np.isnan(self.readout_noise.value).any():
589
+ raise ValueError(f"The parameter 'readout_noise' contains NaN")
590
+ image2d = self._rng.normal(
591
+ loc=self.bias.value,
592
+ scale=self.readout_noise.value
593
+ )
594
+ if imgtype == "bias":
595
+ result.data = image2d
596
+ return result
597
+
598
+ # DARK
599
+ if np.isnan(self.dark.value).any():
600
+ raise ValueError(f"The parameter 'dark' contains NaN")
601
+ image2d += self.dark.value
602
+ if imgtype == "dark":
603
+ result.data = image2d
604
+ return result
605
+
606
+ # OBJECT
607
+ if np.isnan(self.flatfield).any():
608
+ raise ValueError(f"The parameter 'flatfield' contains NaN")
609
+ if np.isnan(self.data_model.value).any():
610
+ raise ValueError(f"The parameter 'data_model' contains NaN")
611
+ if np.isnan(self.gain.value).any():
612
+ raise ValueError(f"The parameter 'gain' contains NaN")
613
+ if method.lower() == "poisson":
614
+ # transform data_model from ADU to electrons,
615
+ # generate Poisson distribution
616
+ # and transform back from electrons to ADU
617
+ image2d += self.flatfield * self._rng.poisson(
618
+ self.data_model.value * self.gain.value
619
+ ) / self.gain.value
620
+ elif method.lower() == "gaussian":
621
+ image2d += self.flatfield * self._rng.normal(
622
+ loc=self.data_model.value,
623
+ scale=np.sqrt(self.data_model.value/self.gain.value)
624
+ )
625
+ else:
626
+ raise RuntimeError(f"Unknown method: {method}")
627
+
628
+ # round to integer
629
+ image2d = np.round(image2d)
630
+ # saturated pixels
631
+ if self.bitpix in [8, 16, 32, 64]:
632
+ saturation = 2**self.bitpix - 1
633
+ image2d[self.data_model.value >= saturation] = saturation
634
+ image2d[image2d >= saturation] = saturation
635
+ else:
636
+ raise ValueError(f"The parameter 'bitpix' must be one of {VALID_BITPIX.keys()}")
637
+
638
+ result.data = image2d.astype(VALID_BITPIX[self.bitpix])
639
+ return result
teareduce/sliceregion.py CHANGED
@@ -1,5 +1,5 @@
1
1
  #
2
- # Copyright 2022-2024 Universidad Complutense de Madrid
2
+ # Copyright 2022-2025 Universidad Complutense de Madrid
3
3
  #
4
4
  # This file is part of teareduce
5
5
  #
@@ -7,18 +7,60 @@
7
7
  # License-Filename: LICENSE.txt
8
8
  #
9
9
 
10
+ import numpy as np
11
+ import re
10
12
 
11
13
  class SliceRegion1D:
12
14
  """Store indices for slicing of 1D regions.
13
15
 
14
16
  The attributes .python and .fits provide the indices following
15
17
  the Python and the FITS convention, respectively.
16
-
18
+
19
+ Attributes
20
+ ----------
21
+ fits : slice
22
+ 1D slice following the FITS convention.
23
+ python : slice
24
+ 1D slice following the Python convention.
25
+ mode : str
26
+ Convention mode employed to define the slice.
27
+ The two possible modes are 'fits' and 'python'.
28
+ fits_section : str
29
+ Resulting slice section in FITS convention: '[num1:num2]'.
30
+ This string is defined after successfully initializing
31
+ the SliceRegion1D instance.
32
+
33
+ Methods
34
+ -------
35
+ within(other)
36
+ Check if slice 'other' is within the parent slice.
17
37
  """
18
38
 
19
39
  def __init__(self, region, mode=None):
40
+ """Initialize SliceRegion1D.
41
+
42
+ Parameters
43
+ ----------
44
+ region : slice or str
45
+ Slice region. It can be provided as np.s_[num1:num2],
46
+ as slice(num1, num2) or as a string '[num1:num2]'
47
+ mode : str
48
+ Convention mode employed to define the slice.
49
+ The two possible modes are 'fits' and 'python'.
50
+ """
51
+ if isinstance(region, str):
52
+ pattern = r'^\s*\[\s*\d+\s*:\s*\d+\s*\]\s*$'
53
+ if not re.match(pattern, region):
54
+ raise ValueError(f"Invalid {region!r}. It must match '[num:num]'")
55
+ # extract numbers and generate np.s_[num:num]
56
+ numbers_str = re.findall(r'\d+', region)
57
+ numbers_int = list(map(int, numbers_str))
58
+ region = np.s_[numbers_int[0]:numbers_int[1]]
59
+
20
60
  if isinstance(region, slice):
21
- pass
61
+ for number in [slice.start, slice.stop]:
62
+ if number is None:
63
+ raise ValueError(f'Invalid {slice!r}: you must specify start:stop in slice by number')
22
64
  else:
23
65
  raise ValueError(f'Object {region} of type {type(region)} is not a slice')
24
66
 
@@ -44,6 +86,9 @@ class SliceRegion1D:
44
86
  else:
45
87
  raise ValueError(errmsg)
46
88
 
89
+ s = self.fits
90
+ self.fits_section = f'[{s.start}:{s.stop}]'
91
+
47
92
  def __eq__(self, other):
48
93
  return self.fits == other.fits and self.python == other.python
49
94
 
@@ -56,17 +101,34 @@ class SliceRegion1D:
56
101
  f'{self.python!r}, mode="python")')
57
102
 
58
103
  def within(self, other):
104
+ """Determine if slice 'other' is within the parent slice.
105
+
106
+ Parameters
107
+ ----------
108
+ other : SliceRegion1D
109
+ New instance for which we want to determine
110
+ if it is within the parent SliceRegion1D instance.
111
+
112
+ Returns
113
+ -------
114
+ result : bool
115
+ Return True if 'other' is within the parent slice.
116
+ False otherwise.
117
+ """
59
118
  if isinstance(other, self.__class__):
60
119
  pass
61
120
  else:
62
121
  raise ValueError(f'Object {other} of type {type(other)} is not a {self.__class__.__name__}')
63
- region = self.python
64
- region_other = other.python
65
- if region.start < region_other.start:
66
- return False
67
- if region.stop > region_other.stop:
68
- return False
69
- return True
122
+
123
+ s = self.python
124
+ s_other = other.python
125
+ result = False
126
+ if s.start < s_other.start:
127
+ return result
128
+ if s.stop > s_other.stop:
129
+ return result
130
+ result = True
131
+ return result
70
132
 
71
133
 
72
134
  class SliceRegion2D:
@@ -75,23 +137,60 @@ class SliceRegion2D:
75
137
  The attributes .python and .fits provide the indices following
76
138
  the Python and the FITS convention, respectively.
77
139
 
78
- """
140
+ Attributes
141
+ ----------
142
+ fits : slice
143
+ 1D slice following the FITS convention.
144
+ python : slice
145
+ 1D slice following the Python convention.
146
+ mode : str
147
+ Convention mode employed to define the slice.
148
+ The two possible modes are 'fits' and 'python'.
149
+ fits_section : str
150
+ Resulting slice section in FITS convention:
151
+ '[num1:num2,num3:num4]'. This string is defined after
152
+ successfully initializing the SliceRegion2D instance.
153
+
154
+ Methods
155
+ -------
156
+ within(other)
157
+ Check if slice 'other' is within the parent slice."""
79
158
 
80
159
  def __init__(self, region, mode=None):
81
- if len(region) != 2:
82
- raise ValueError(f'This class {self.__class__.__name__} '
83
- 'only handles 2D regions')
160
+ """Initialize SliceRegion1D.
84
161
 
85
- s1, s2 = region
86
- for item in [s1, s2]:
87
- if isinstance(item, slice):
88
- pass
89
- else:
90
- raise ValueError(f'Object {item} of type {type(item)} is not a slice')
162
+ Parameters
163
+ ----------
164
+ region : slice or str
165
+ Slice region. It can be provided as np.s_[num1:num2, num3:num4],
166
+ as a tuple (slice(num1, num2), slice(num3, num4)),
167
+ or as a string '[num1:num2, num3:num4]'
168
+ mode : str
169
+ Convention mode employed to define the slice.
170
+ The two possible modes are 'fits' and 'python'.
171
+ """
172
+ if isinstance(region, str):
173
+ pattern = r'^\s*\[\s*\d+\s*:\s*\d+\s*,\s*\d+\s*:\s*\d+\s*\]\s*$'
174
+ if not re.match(pattern, region):
175
+ raise ValueError(f"Invalid {region!r}. It must match '[num:num, num:num]'")
176
+ # extract numbers and generate np.s_[num:num, num:num]
177
+ numbers_str = re.findall(r'\d+', region)
178
+ numbers_int = list(map(int, numbers_str))
179
+ region = np.s_[numbers_int[0]:numbers_int[1], numbers_int[2]:numbers_int[3]]
91
180
 
92
- if s1.step not in [1, None] or s2.step not in [1, None]:
93
- raise ValueError(f'This class {self.__class__.__name__} '
94
- 'does not handle step != 1')
181
+ if isinstance(region, tuple) and len(region) == 2:
182
+ s1, s2 = region
183
+ for item in [s1, s2]:
184
+ if isinstance(item, slice):
185
+ for number in [s1.start, s1.stop, s2.start, s2.stop]:
186
+ if number is None:
187
+ raise ValueError(f'Invalid {item!r}: you must specify start:stop in slice by number')
188
+ else:
189
+ raise ValueError(f'Object {item} of type {type(item)} is not a slice')
190
+ if s1.step not in [1, None] or s2.step not in [1, None]:
191
+ raise ValueError(f'This class {self.__class__.__name__} does not handle step != 1')
192
+ else:
193
+ raise ValueError(f'This class {self.__class__.__name__} only handles 2D regions')
95
194
 
96
195
  errmsg = f'Invalid mode={mode}. Only "FITS" or "Python" (case insensitive) are valid'
97
196
  if mode is None:
@@ -116,7 +215,7 @@ class SliceRegion2D:
116
215
  raise ValueError(errmsg)
117
216
 
118
217
  s1, s2 = self.fits
119
- self.fits_section = f'[{s1.start}:{s1.stop}, {s2.start}:{s2.stop}]'
218
+ self.fits_section = f'[{s1.start}:{s1.stop},{s2.start}:{s2.stop}]'
120
219
 
121
220
  def __eq__(self, other):
122
221
  return self.fits == other.fits and self.python == other.python
@@ -130,6 +229,20 @@ class SliceRegion2D:
130
229
  f'{self.python!r}, mode="python")')
131
230
 
132
231
  def within(self, other):
232
+ """Determine if slice 'other' is within the parent slice.
233
+
234
+ Parameters
235
+ ----------
236
+ other : SliceRegion2D
237
+ New instance for which we want to determine
238
+ if it is within the parent SliceRegion2D instance.
239
+
240
+ Returns
241
+ -------
242
+ result : bool
243
+ Return True if 'other' is within the parent slice.
244
+ False otherwise.
245
+ """
133
246
  if isinstance(other, self.__class__):
134
247
  pass
135
248
  else:
@@ -137,12 +250,14 @@ class SliceRegion2D:
137
250
 
138
251
  s1, s2 = self.python
139
252
  s1_other, s2_other = other.python
253
+ result = False
140
254
  if s1.start < s1_other.start:
141
- return False
255
+ return result
142
256
  if s1.stop > s1_other.stop:
143
- return False
257
+ return result
144
258
  if s2.start < s2_other.start:
145
- return False
259
+ return result
146
260
  if s2.stop > s2_other.stop:
147
- return False
148
- return True
261
+ return result
262
+ result = True
263
+ return result
teareduce/version.py CHANGED
@@ -8,7 +8,7 @@
8
8
  # License-Filename: LICENSE.txt
9
9
  #
10
10
 
11
- version = '0.3.6'
11
+ version = '0.4.0'
12
12
 
13
13
 
14
14
  def main():
@@ -0,0 +1,28 @@
1
+ #
2
+ # Copyright 2025 Universidad Complutense de Madrid
3
+ #
4
+ # This file is part of teareduce
5
+ #
6
+ # SPDX-License-Identifier: GPL-3.0+
7
+ # License-Filename: LICENSE.txt
8
+ #
9
+
10
+ from astropy.io import fits
11
+
12
+
13
+ def write_array_to_fits(data, filename, overwrite):
14
+ """Write a single array to a FITS file.
15
+
16
+ Parameters
17
+ ----------
18
+ data : numpy.ndarray
19
+ Array to write.
20
+ filename : str
21
+ Path to the FITS file.
22
+ overwrite : bool
23
+ Whether to overwrite the existing FITS file.
24
+ """
25
+
26
+ hdu = fits.PrimaryHDU(data)
27
+ hdulist = fits.HDUList([hdu])
28
+ hdulist.writeto(filename, overwrite=overwrite)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: teareduce
3
- Version: 0.3.6
3
+ Version: 0.4.0
4
4
  Summary: Utilities for astronomical data reduction
5
5
  Author-email: Nicolás Cardiel <cardiel@ucm.es>
6
6
  License: GPL-3.0-or-later
@@ -21,14 +21,14 @@ Requires-Python: >=3.8
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE.txt
23
23
  Requires-Dist: astropy
24
- Requires-Dist: importlib-resources
24
+ Requires-Dist: importlib_resources
25
25
  Requires-Dist: lmfit
26
26
  Requires-Dist: matplotlib
27
- Requires-Dist: numpy >=1.20
27
+ Requires-Dist: numpy>=1.20
28
28
  Requires-Dist: scipy
29
29
  Requires-Dist: tqdm
30
30
  Provides-Extra: test
31
- Requires-Dist: pytest ; extra == 'test'
31
+ Requires-Dist: pytest; extra == "test"
32
32
 
33
33
  # teareduce
34
34
  Utilities for astronomical data reduction
@@ -1,23 +1,25 @@
1
- teareduce/__init__.py,sha256=QAwJ86DoxjOX8_jU-bctVc7VZfey6a4sYYovHzRnXkg,1034
1
+ teareduce/__init__.py,sha256=MLlKpaDRjG33kfn52OHonENfsHYZqlGBmQFhk7_CUR0,1214
2
2
  teareduce/avoid_astropy_warnings.py,sha256=2YgQ47pxsKYWDxUtzyEOfh3Is3aAHHmjJkuOa1JCDN4,648
3
3
  teareduce/correct_pincushion_distortion.py,sha256=Xpt03jtmJMyqik4ta95zMRE3Z6dVfzzHI2z5IDbtnMk,1685
4
4
  teareduce/cosmicrays.py,sha256=y8f5KLUsiOeOkArGnZVi_q11chsgnYlwtxbSD_a9EAQ,24209
5
5
  teareduce/ctext.py,sha256=8QP_KW7ueJ34IUyduVpy7nk-x0If5eilawf87icDMJA,2084
6
6
  teareduce/draw_rectangle.py,sha256=xlwcKIkl7e0U6sa9zWZr8t_WuWAte_UKIqCwZQ41x4Q,1922
7
- teareduce/elapsed_time.py,sha256=ajBPYuGGd7oatpwnHi0TkGsIIw-A8bNDPzUoGY2KsZ4,1045
8
- teareduce/imshow.py,sha256=1DyC-n8jX7n8yTnX1BCj844Fobx7jFJxUoTWcbkvs0E,3972
7
+ teareduce/elapsed_time.py,sha256=Nv77LjxvfuL3EECU7F0nMzEjD-qkdQq0C5IoRGzO-5k,1403
8
+ teareduce/imshow.py,sha256=hDefLOL2R_pXkyHvruzaXrj9T9_SL7y7qjgYDmUuV7w,4861
9
9
  teareduce/numsplines.py,sha256=1PpG-frdc9Qz3VRbC7XyZFWKmhus05ID4REtFnWDmUo,8049
10
10
  teareduce/peaks_spectrum.py,sha256=YPCJz8skJmIjWYqT7ZhBJGhnqPayFwy5xb7I9OHlUZI,9890
11
11
  teareduce/polfit.py,sha256=CGsrRsz_Du2aKxOcgXi36lpAZO04JyqCCUaxhC0C-Mk,14281
12
12
  teareduce/robust_std.py,sha256=dk1G3VgiplZzmSfL7qWniEZ-5c3novHnBpRPCM77u84,1084
13
13
  teareduce/sdistortion.py,sha256=5ZsZn4vD5Sw2aoqO8-NIOH7H89Zmh7ZDkow6YbAotHU,5916
14
- teareduce/sliceregion.py,sha256=ACMfl5jDL0Bw2fnhZBzhvyDRiI7RoD5ut-xZ8sgnz8Q,5104
14
+ teareduce/simulateccdexposure.py,sha256=3SNPBOP09vnwoGqdf97f9s21WmTIw6skafZ6qRkJUrw,24642
15
+ teareduce/sliceregion.py,sha256=XVyUuY9P1ZuTiE5PARTmA51KA4bfHVFWy_Wt_blqd80,9450
15
16
  teareduce/statsummary.py,sha256=mtaM21d5aHvtLjCt_SSDMvD_fjI5nK21ZqxuDtcvldI,5426
16
- teareduce/version.py,sha256=TciYYibvObs3w42_51hmaxWZMNPIi6pW49N7ne4lKaQ,303
17
+ teareduce/version.py,sha256=8qbLEf4-_Nt3i3QYFA-69LhL_9dbKjM11e4aC50LJ8U,303
17
18
  teareduce/wavecal.py,sha256=iiKG_RPW2CllwZxG5fTsyckE0Ec_IeZ6v7v2cQt6OeU,68706
19
+ teareduce/write_array_to_fits.py,sha256=kWDrEH9coJ1yIu56oQJpWtDqJL4c8HGmssE9jle4e94,617
18
20
  teareduce/zscale.py,sha256=HuPYagTW55D7RtjPGc7HcibQlCx5oqLYHKoM6WEHG2g,1161
19
- teareduce-0.3.6.dist-info/LICENSE.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
20
- teareduce-0.3.6.dist-info/METADATA,sha256=XM-JOUgIRjyeAnZw_UzJvwV6Uc90HPrfaMvgDVUldj4,2304
21
- teareduce-0.3.6.dist-info/WHEEL,sha256=mguMlWGMX-VHnMpKOjjQidIo1ssRlCFu4a4mBpz1s2M,91
22
- teareduce-0.3.6.dist-info/top_level.txt,sha256=7OkwtX9zNRkGJ7ACgjk4ESgC74qUYcS5O2qcO0v-Si4,10
23
- teareduce-0.3.6.dist-info/RECORD,,
21
+ teareduce-0.4.0.dist-info/LICENSE.txt,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
22
+ teareduce-0.4.0.dist-info/METADATA,sha256=iFb3sEiauuYWrXW7ecoTT3P7DCJSBNrrR513NZaWjno,2302
23
+ teareduce-0.4.0.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
24
+ teareduce-0.4.0.dist-info/top_level.txt,sha256=7OkwtX9zNRkGJ7ACgjk4ESgC74qUYcS5O2qcO0v-Si4,10
25
+ teareduce-0.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (70.1.1)
2
+ Generator: setuptools (75.8.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5