• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LSSTDESC / Spectractor / 16369529926

18 Jul 2025 11:34AM UTC coverage: 83.485% (+9.2%) from 74.269%
16369529926

Pull #167

github

web-flow
Merge 9bf8767fe into 9db9e1feb
Pull Request #167: Debug

593 of 684 new or added lines in 20 files covered. (86.7%)

18 existing lines in 6 files now uncovered.

7901 of 9464 relevant lines covered (83.48%)

0.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.94
/spectractor/simulation/image_simulation.py
1
from spectractor import parameters
1✔
2
from spectractor.config import set_logger
1✔
3
from spectractor.tools import (rebin, pixel_rotation, set_wcs_file_name, set_sources_file_name,
1✔
4
                               set_gaia_catalog_file_name, load_wcs_from_file, ensure_dir,
5
                               plot_image_simple, iraf_source_detection)
6
from spectractor.extractor.images import Image, find_target
1✔
7
from spectractor.astrometry import get_gaia_coords_after_proper_motion
1✔
8
from spectractor.extractor.background import remove_image_background_sextractor
1✔
9
from spectractor.simulation.simulator import SpectrogramModel
1✔
10
from spectractor.simulation.atmosphere import Atmosphere
1✔
11
from spectractor.extractor.spectrum import Spectrum
1✔
12
from spectractor.extractor.psf import PSF
1✔
13

14
from astropy.io import fits, ascii
1✔
15
import astropy.units as units
1✔
16
from astropy.coordinates import SkyCoord
1✔
17
from astropy.table import Table
1✔
18
from scipy.signal import fftconvolve
1✔
19
from scipy.signal.windows import gaussian
1✔
20
import matplotlib.pyplot as plt
1✔
21
from matplotlib.ticker import MaxNLocator
1✔
22
import numpy as np
1✔
23
import copy
1✔
24
import os
1✔
25

26

27
class StarModel:
1✔
28
    """Class to model a star in the image simulation process.
29

30
    Attributes
31
    ----------
32
    x0: float
33
        X position of the star centroid in pixels.
34
    y0: float
35
        Y position of the star centroid in pixels.
36
    amplitude: amplitude
37
        The amplitude of the star in image units.
38
    """
39

40
    def __init__(self, centroid_coords, psf, amplitude):
1✔
41
        """Create a StarModel instance.
42

43
        The model is based on an Astropy Fittable2DModel. The centroid and amplitude
44
        parameters of the given model are updated by the dedicated arguments.
45

46
        Parameters
47
        ----------
48
        centroid_coords: array_like
49
            Tuple of (x,y) coordinates of the desired star centroid in pixels.
50
        psf: PSF
51
            PSF model
52
        amplitude: float
53
            The desired amplitude of the star in image units.
54

55
        Examples
56
        --------
57
        >>> from spectractor.extractor.psf import Moffat
58
        >>> p = (100, 50, 50, 5, 2, 200)
59
        >>> psf = Moffat(p)
60
        >>> s = StarModel((20, 10), psf, 200)
61
        >>> s.plot_model()
62
        >>> s.x0
63
        20
64
        >>> s.y0
65
        10
66
        >>> s.amplitude
67
        200
68
        """
69
        self.my_logger = set_logger(self.__class__.__name__)
1✔
70
        self.x0 = centroid_coords[0]
1✔
71
        self.y0 = centroid_coords[1]
1✔
72
        self.amplitude = amplitude
1✔
73
        self.psf = copy.deepcopy(psf)
1✔
74
        self.psf.params.values[1] = self.x0
1✔
75
        self.psf.params.values[2] = self.y0
1✔
76
        self.psf.params.values[0] = self.amplitude
1✔
77
        # to be realistic, usually fitted fwhm is too big, divide gamma by 2
78
        self.fwhm = self.psf.params.values[3]
1✔
79
        # self.sigma = self.model.stddev / 2
80

81
    def plot_model(self):
1✔
82
        """
83
        Plot the star model.
84
        """
85
        x = np.arange(self.x0 - 5 * self.fwhm, self.x0 + 5 * self.fwhm)
1✔
86
        y = np.arange(self.y0 - 5 * self.fwhm, self.y0 + 5 * self.fwhm)
1✔
87
        xx, yy = np.meshgrid(x, y)
1✔
88
        star = self.psf.evaluate(np.array([xx, yy]))
1✔
89
        fig, ax = plt.subplots(1, 1)
1✔
90
        plot_image_simple(ax, star, title=f'Star model: A={self.amplitude:.2f}, fwhm={self.fwhm:.2f}',
1✔
91
                          units='Arbitrary units')
92
        if parameters.DISPLAY:
1✔
93
            plt.show()
×
94
        if parameters.PdfPages:
1✔
95
            parameters.PdfPages.savefig()
×
96

97

98
class StarFieldModel:
1✔
99

100
    def __init__(self, base_image, flux_factor=1):
1✔
101
        """
102
        Examples
103
        --------
104

105
        >>> from spectractor.extractor.images import Image, find_target
106
        >>> im = Image('tests/data/reduc_20170530_134.fits', target_label="HD111980")
107
        >>> x0, y0 = find_target(im, guess=(740, 680))
108
        >>> s = StarFieldModel(im)
109
        >>> xx, yy = np.mgrid[0:1000:1, 0:1000:1]
110
        >>> s.field = s.model(xx, yy)
111
        >>> s.plot_model()
112

113
        """
114
        self.my_logger = set_logger(self.__class__.__name__)
1✔
115
        self.image = base_image
1✔
116
        self.target = base_image.target
1✔
117
        self.field = None
1✔
118
        self.stars = []
1✔
119
        self.pixcoords = []
1✔
120
        self.fwhm = base_image.target_star2D.params.values[3]
1✔
121
        self.flux_factor = flux_factor
1✔
122
        self.set_star_list()
1✔
123

124
    # noinspection PyUnresolvedReferences
125
    def set_star_list(self):
1✔
126
        x0, y0 = self.image.target_pixcoords
1✔
127
        sources_file_name = set_sources_file_name(self.image.file_name)
1✔
128
        wcs_file_name = set_wcs_file_name(self.image.file_name)
1✔
129
        gaia_catalog_file_name = set_gaia_catalog_file_name(self.image.file_name)
1✔
130
        if os.path.isfile(wcs_file_name) and os.path.isfile(gaia_catalog_file_name):
1✔
131
            # load gaia catalog
132
            gaia_catalog = ascii.read(gaia_catalog_file_name, format="ecsv")
1✔
133
            gaia_coord_after_motion = get_gaia_coords_after_proper_motion(gaia_catalog, self.image.date_obs)
1✔
134
            # load WCS
135
            wcs = load_wcs_from_file(wcs_file_name)
1✔
136
            # catalog matching to set star positions using Gaia
137
            target_coord = wcs.all_pix2world([x0 * parameters.CCD_REBIN], [y0 * parameters.CCD_REBIN], 0)
1✔
138
            target_coord = SkyCoord(ra=target_coord[0] * units.deg, dec=target_coord[1] * units.deg,
1✔
139
                                    frame="icrs", obstime=self.image.date_obs, equinox="J2000")
140
            gaia_target_index, dist_2d, dist_3d = target_coord.match_to_catalog_sky(gaia_coord_after_motion)
1✔
141
            dx, dy = 0, 0
1✔
142
            for gaia_i in range(len(gaia_catalog)):
1✔
143
                x, y = np.array(wcs.all_world2pix(gaia_coord_after_motion[gaia_i].ra,
1✔
144
                                                  gaia_coord_after_motion[gaia_i].dec, 0)) / parameters.CCD_REBIN
145
                if gaia_i == gaia_target_index[0]:
1✔
146
                    dx = x0 - x
1✔
147
                    dy = y0 - y
1✔
148
                A = 10 ** (-gaia_catalog['phot_g_mean_mag'][gaia_i] / 2.5)
1✔
149
                self.stars.append(StarModel([x, y], self.image.target_star2D, A))
1✔
150
                self.pixcoords.append([x, y])
1✔
151
            # rescale using target fitted amplitude
152
            amplitudes = np.array([star.amplitude for star in self.stars])
1✔
153
            target_flux = self.image.target_star2D.params.values[0]
1✔
154
            amplitudes *= target_flux / self.stars[gaia_target_index[0]].amplitude * self.flux_factor
1✔
155
            for k, star in enumerate(self.stars):
1✔
156
                star.amplitude = amplitudes[k]
1✔
157
                # shift x,y star positions according to target position
158
                star.x0 += dx
1✔
159
                star.y0 += dy
1✔
160
                star.psf.params.values[1] += dx
1✔
161
                star.psf.params.values[2] += dy
1✔
162
                star.psf.params.values[0] = amplitudes[k]
1✔
163
        elif os.path.isfile(sources_file_name):
×
164
            # load sources positions and flux
165
            sources = Table.read(sources_file_name)
×
166
            sources['X'].name = "xcentroid"
×
167
            sources['Y'].name = "ycentroid"
×
168
            sources['FLUX'].name = "flux"
×
169
            for k, source in enumerate(sources):
×
170
                x, y = np.array([sources['xcentroid'][k], sources['ycentroid'][k]]) / parameters.CCD_REBIN
×
171
                A = sources['flux'][k] * self.flux_factor
×
172
                self.stars.append(StarModel([x, y], self.image.target_star2D, A))
×
173
                self.pixcoords.append([x, y])
×
174
        else:
175
            # try extraction using iraf source detection
176
            # mask background, faint stars, and saturated pixels
177
            data = np.copy(self.image.data)
×
178
            # mask order0 and spectrum
179
            margin = 30
×
180
            mask = np.zeros(data.shape, dtype=bool)
×
181
            for y in range(int(y0) - 100, int(y0) + 100):
×
182
                for x in range(self.image.data.shape[1]):
×
183
                    u, v = pixel_rotation(x, y, self.image.disperser.theta([x0, y0]) * np.pi / 180., x0, y0)
×
184
                    if margin > v > -margin:
×
185
                        mask[y, x] = True
×
186
            # remove background and detect sources
187
            data_wo_bkg = remove_image_background_sextractor(data)
×
188
            sources = iraf_source_detection(data_wo_bkg, mask=mask)
×
189
            for k, source in enumerate(sources):
×
190
                x, y = sources['xcentroid'][k], sources['ycentroid'][k]
×
191
                A = sources['flux'][k] * self.flux_factor
×
192
                self.stars.append(StarModel([x, y], self.image.target_star2D, A))
×
193
                self.pixcoords.append([x, y])
×
194
        self.pixcoords = np.array(self.pixcoords).T
1✔
195

196
    def model(self, x, y):
1✔
197
        if self.field is None:
1✔
198
            window = int(20 * self.fwhm)
1✔
199
            self.field = self.stars[0].psf.evaluate(np.array([x, y]))
1✔
200
            for k in range(1, len(self.stars)):
1✔
201
                left = max(0, int(self.pixcoords[0][k]) - window)
1✔
202
                right = min(np.max(x), int(self.pixcoords[0][k]) + window)
1✔
203
                low = max(0, int(self.pixcoords[1][k]) - window)
1✔
204
                up = min(np.max(y), int(self.pixcoords[1][k]) + window)
1✔
205
                if up < low or left > right:
1✔
206
                    continue
1✔
207
                yy, xx = np.mgrid[low:up, left:right]
1✔
208
                self.field[low:up, left:right] += self.stars[k].psf.evaluate(np.array([xx, yy]))
1✔
209
        return self.field
1✔
210

211
    def plot_model(self):
1✔
212
        fig, ax = plt.subplots(1, 1)
1✔
213
        plot_image_simple(ax, self.field, scale="log10", target_pixcoords=self.pixcoords)
1✔
214
        if parameters.DISPLAY:
1✔
215
            plt.show()
×
216
        if parameters.PdfPages:
1✔
217
            parameters.PdfPages.savefig()
×
218

219

220
class BackgroundModel:
1✔
221
    """Class to model the background of the simulated image.
222

223
    Attributes
224
    ----------
225
    Nx: int
226
        Size of the background along X axis in pixels.
227
    Ny: int
228
        Size of the background along Y axis in pixels.
229
    level: float
230
        The mean level of the background in image units.
231
    frame: array_like
232
        (x, y, smooth) right and upper limits in pixels of a vignetting frame,
233
        and the smoothing gaussian width (default: None).
234
    """
235

236
    def __init__(self, Nx, Ny, level, frame=None):
1✔
237
        """Create a BackgroundModel instance.
238

239
        Parameters
240
        ----------
241
        Nx: int
242
            Size of the background along X axis in pixels.
243
        Ny: int
244
            Size of the background along Y axis in pixels.
245
        level: float
246
            The mean level of the background in image units.
247
        frame: array_like, None
248
            (x, y, smooth) right and upper limits in pixels of a vignetting frame,
249
            and the smoothing gaussian width (default: None).
250

251
        Examples
252
        --------
253
        >>> from spectractor import parameters
254
        >>> Nx, Ny = 200, 300
255
        >>> bgd = BackgroundModel(Nx, Ny, 10)
256
        >>> model = bgd.model()
257
        >>> np.all(model==10)
258
        True
259
        >>> assert model.shape == (Ny, Nx)
260
        >>> bgd = BackgroundModel(Nx, Ny, 10, frame=(160, 180, 3))
261
        >>> bgd.plot_model()
262
        """
263
        self.my_logger = set_logger(self.__class__.__name__)
1✔
264
        self.Nx = Nx
1✔
265
        self.Ny = Ny
1✔
266
        self.level = level
1✔
267
        if self.level <= 0:
1✔
268
            self.my_logger.warning('\n\tBackground level must be strictly positive.')
×
269
        else:
270
            self.my_logger.info(f'\n\tBackground set to {level:.3f} ADU/s.')
1✔
271
        self.frame = frame
1✔
272

273
    def model(self):
1✔
274
        """Compute the background model for the image simulation in image units.
275

276
        A shadowing vignetting frame is roughly simulated if self.frame is set.
277

278
        Returns
279
        -------
280
        bkgd: array_like
281
            The array of the background model.
282

283
        """
284
        xx, yy = np.mgrid[0:self.Ny:1, 0:self.Nx:1]
1✔
285
        bkgd = self.level * np.ones_like(xx)
1✔
286
        if self.frame is None:
1✔
287
            return bkgd
1✔
288
        else:
289
            xlim, ylim, width = self.frame
1✔
290
            bkgd[ylim:, :] = self.level / 100
1✔
291
            bkgd[:, xlim:] = self.level / 100
1✔
292
            kernel = np.outer(gaussian(self.Nx, width), gaussian(self.Ny, width))
1✔
293
            bkgd = fftconvolve(bkgd, kernel, mode='same')
1✔
294
            bkgd *= self.level / bkgd[self.Ny // 2, self.Nx // 2]
1✔
295
            return bkgd
1✔
296

297
    def plot_model(self):
1✔
298
        """Plot the background model.
299

300
        """
301
        bkgd = self.model()
1✔
302
        fig, ax = plt.subplots(1, 1)
1✔
303
        im = plt.imshow(bkgd, origin='lower', cmap='jet')
1✔
304
        ax.grid(color='white', ls='solid')
1✔
305
        ax.grid(True)
1✔
306
        ax.set_xlabel('X [pixels]')
1✔
307
        ax.set_ylabel('Y [pixels]')
1✔
308
        ax.set_title('Background model')
1✔
309
        cb = plt.colorbar(im, ax=ax)
1✔
310
        cb.formatter.set_powerlimits((0, 0))
1✔
311
        cb.locator = MaxNLocator(7, prune=None)
1✔
312
        cb.update_ticks()
1✔
313
        cb.set_label('Arbitrary units')  # ,fontsize=16)
1✔
314
        if parameters.DISPLAY:
1✔
315
            plt.show()
×
316
        if parameters.PdfPages:
1✔
317
            parameters.PdfPages.savefig()
×
318

319

320
class FlatModel:
1✔
321
    """Class to model the pixel flat of the simulated image. Flat is dimensionless and its average must be one.
322

323
    Attributes
324
    ----------
325
    Nx: int
326
        Size of the background along X axis in pixels.
327
    Ny: int
328
        Size of the background along Y axis in pixels.
329
    gains: array_like
330
        The list of gains to apply. The average must be one.
331
    randomness_level: float
332
        Level of random quantum efficiency to apply to pixels (default: 0.).
333
    """
334

335
    def __init__(self, Nx, Ny, gains, randomness_level=0.):
1✔
336
        """Create a FlatModel instance. Flat is dimensionless and its average must be one.
337

338
        Parameters
339
        ----------
340
        Nx: int
341
            Size of the background along X axis in pixels.
342
        Ny: int
343
            Size of the background along Y axis in pixels.
344
        gains: array_like
345
            The list of gains to apply. The average must be one.
346
        randomness_level: float
347
            Level of random quantum efficiency to apply to pixels (default: 0.).
348

349
        Examples
350
        --------
351
        >>> from spectractor import parameters
352
        >>> Nx, Ny = 200, 300
353
        >>> flat = FlatModel(Nx, Ny, gains=[[1, 2, 3, 4], [4, 3, 2, 1]])
354
        >>> model = flat.model()
355
        >>> print(f"{np.mean(model):.4f}")
356
        1.0000
357
        >>> assert model.shape == (Ny, Nx)
358
        >>> flat.plot_model()
359
        """
360
        self.my_logger = set_logger(self.__class__.__name__)
1✔
361
        self.Nx = Nx
1✔
362
        self.Ny = Ny
1✔
363
        self.gains = np.atleast_2d(gains).astype(float)
1✔
364
        if len(self.gains) <= 0:
1✔
365
            raise ValueError(f"Gains list is empty")
×
366
        if np.any(self.gains <= 0):
1✔
367
            raise ValueError(f"One the gain values is negative. Got {self.gains}.")
×
368
        if np.mean(self.gains) != 1.:
1✔
369
            self.my_logger.warning(f"\n\tGains list average is not one but {np.mean(self.gains)}. "
1✔
370
                                   "I scaled them to have an average of one.")
371
            self.gains /= np.mean(self.gains)
1✔
372
        self.my_logger.warning(f'\n\tRelative gains are set to {self.gains}.')
1✔
373
        self.randomness_level = randomness_level
1✔
374

375
    def model(self):
1✔
376
        """Compute the flat model for the image simulation (no units).
377

378
        Returns
379
        -------
380
        flat: array_like
381
            The array of the flat model.
382
        """
383
        yy, xx = np.mgrid[0:self.Nx:1, 0:self.Ny:1]
1✔
384
        flat = np.ones_like(xx, dtype=float)
1✔
385
        hflats = np.array_split(flat, self.gains.shape[0])
1✔
386
        for h in range(self.gains.shape[0]):
1✔
387
            vflats = np.array_split(hflats[h].T, self.gains.shape[1])
1✔
388
            for v in range(self.gains.shape[1]):
1✔
389
                vflats[v] *= self.gains[h,v]
1✔
390
            hflats[h] = np.concatenate(vflats).T
1✔
391
        flat = np.concatenate(hflats).T
1✔
392
        if self.randomness_level != 0:
1✔
393
            flat += np.random.uniform(-self.randomness_level, self.randomness_level, size=flat.shape)
1✔
394

395
        return flat
1✔
396

397
    def plot_model(self):
1✔
398
        """Plot the flat model.
399

400
        """
401
        flat = self.model()
1✔
402
        fig, ax = plt.subplots(1, 1)
1✔
403
        im = plt.imshow(flat, origin='lower', cmap='jet')
1✔
404
        ax.grid(color='white', ls='solid')
1✔
405
        ax.grid(True)
1✔
406
        ax.set_xlabel('X [pixels]')
1✔
407
        ax.set_ylabel('Y [pixels]')
1✔
408
        ax.set_title('Flat model')
1✔
409
        cb = plt.colorbar(im, ax=ax)
1✔
410
        cb.formatter.set_powerlimits((0, 0))
1✔
411
        cb.locator = MaxNLocator(7, prune=None)
1✔
412
        cb.update_ticks()
1✔
413
        cb.set_label('Dimensionless')  # ,fontsize=16)
1✔
414
        if parameters.DISPLAY:
1✔
415
            plt.show()
×
416
        if parameters.PdfPages:
1✔
417
            parameters.PdfPages.savefig()
×
418

419

420

421
class ImageModel(Image):
1✔
422

423
    def __init__(self, filename, target_label=None):
1✔
424
        self.my_logger = set_logger(self.__class__.__name__)
1✔
425
        Image.__init__(self, filename, target_label=target_label)
1✔
426
        self.true_lambdas = None
1✔
427
        self.true_spectrum = None
1✔
428

429
    def compute(self, star, background, spectrogram, starfield=None, flat=None):
1✔
430
        yy, xx = np.mgrid[0:parameters.CCD_IMSIZE:1, 0:parameters.CCD_IMSIZE:1]
1✔
431
        if starfield is not None:
1✔
432
            starfield_mod = starfield.model(xx, yy)
1✔
433
            self.data = starfield_mod
1✔
434
            self.starfield = np.copy(starfield_mod)
1✔
435
            if parameters.DEBUG:
1✔
UNCOV
436
                self.plot_image(scale='symlog', target_pixcoords=starfield.pixcoords)
×
UNCOV
437
                starfield.plot_model()
×
438
        else:
439
            self.data = star.psf.evaluate(np.array([xx, yy]))
×
440
        self.data += background.model()
1✔
441
        if spectrogram.full_image:
1✔
442
            self.data[spectrogram.spectrogram_ymin:spectrogram.spectrogram_ymax, :] += spectrogram.spectrogram_data
1✔
443
        else:
444
            self.data[spectrogram.spectrogram_ymin:spectrogram.spectrogram_ymax,
×
445
                      spectrogram.spectrogram_xmin:spectrogram.spectrogram_xmax] += spectrogram.spectrogram_data
446
        if flat is not None:
1✔
447
            flat_mod = flat.model()
1✔
448
            self.data *= flat_mod
1✔
449
            self.flat = flat_mod
1✔
450

451
    def add_poisson_and_read_out_noise(self):  # pragma: no cover
452
        if self.units != 'ADU':
453
            raise AttributeError('Poisson noise procedure has to be applied on map in ADU units')
454
        d = np.copy(self.data).astype(float)
455
        # convert to electron counts
456
        d *= self.gain
457
        # Poisson noise
458
        noisy = np.random.poisson(d).astype(float)
459
        # Add read-out noise is available
460
        if self.read_out_noise is not None:
461
            noisy += np.random.normal(scale=self.read_out_noise)
462
        # reconvert to ADU
463
        self.data = noisy / self.gain
464
        # removes zeros
465
        min_noz = np.min(self.data[self.data > 0])
466
        self.data[self.data <= 0] = min_noz
467

468
    def save_image(self, output_filename, overwrite=False):
1✔
469
        hdu0 = fits.PrimaryHDU()
1✔
470
        hdu0.data = self.data
1✔
471
        hdu0.header = self.header
1✔
472
        hdu1 = fits.ImageHDU()
1✔
473
        # hdu1.data = [self.true_lambdas, self.true_spectrum]
474
        hdulist = fits.HDUList([hdu0, hdu1])
1✔
475
        hdulist.writeto(output_filename, overwrite=overwrite)
1✔
476
        self.my_logger.info('\n\tImage saved in %s' % output_filename)
1✔
477

478
    def load_image(self, filename):
1✔
479
        super(ImageModel, self).load_image(filename)
1✔
480
        # hdu_list = fits.open(filename)
481
        # self.true_lambdas, self.true_spectrum = hdu_list[1].data
482

483

484
def ImageSim(image_filename, spectrum_filename, outputdir, pwv=5, ozone=300, aerosols=0.03, A1=1, A2=1, A3=1, angstrom_exponent=None,
1✔
485
             psf_poly_params=None, psf_type=None, diffraction_orders=None, with_rotation=True, with_starfield=True, with_adr=True, with_noise=True, with_flat=True):
486
    """ The basic use of the extractor consists first to define:
487
    - the path to the fits image from which to extract the image,
488
    - the path of the output directory to save the extracted spectrum (created automatically if does not exist yet),
489
    - the rough position of the object in the image,
490
    - the name of the target (to search for the extra-atmospheric spectrum if available).
491
    Then different parameters and systematics can be set:
492
    - pwv: the pressure water vapor (in mm)
493
    - ozone: the ozone quantity (in XX)
494
    - aerosols: the vertical aerosol optical depth
495
    - A1: a global grey absorption parameter for the spectrum
496
    - A2: the relative amplitude of second order compared with first order
497
    - with_rotation: rotate the spectrum according to the disperser characteristics (True by default)
498
    - with_stars: include stars in the image field (True by default)
499
    - with_adr: include ADR effect (True by default)
500
    - with_flat: include flat (True by default)
501
    """
502
    my_logger = set_logger(__name__)
1✔
503
    my_logger.info(f'\n\tStart IMAGE SIMULATOR')
1✔
504
    # Load reduced image
505
    spectrum = Spectrum(spectrum_filename)
1✔
506
    parameters.CALLING_CODE = ""
1✔
507
    if diffraction_orders is None:
1✔
508
        diffraction_orders = np.arange(spectrum.order, spectrum.order + 3 * np.sign(spectrum.order), np.sign(spectrum.order))
1✔
509
    image = ImageModel(image_filename, target_label=spectrum.target.label)
1✔
510
    guess = np.array([spectrum.header['TARGETX'], spectrum.header['TARGETY']])
1✔
511
    if parameters.CCD_REBIN != 1:
1✔
512
        # these lines allow to simulate images using rebinned spectrum files
513
        guess *= parameters.CCD_REBIN
×
514
        new_shape = np.asarray((parameters.CCD_IMSIZE, parameters.CCD_IMSIZE))
×
515
        old_edge = parameters.CCD_IMSIZE * parameters.CCD_REBIN
×
516
        image.gain = rebin(image.gain[:old_edge, :old_edge], new_shape, FLAG_MAKESUM=False)
×
517
        image.read_out_noise = rebin(image.read_out_noise[:old_edge, :old_edge], new_shape, FLAG_MAKESUM=False)
×
518

519
    if parameters.DEBUG:
1✔
UNCOV
520
        image.plot_image(scale='symlog', target_pixcoords=guess)
×
521
    # Fit the star 2D profile
522
    my_logger.info('\n\tSearch for the target in the image...')
1✔
523
    target_pixcoords = find_target(image, guess)
1✔
524
    # Background model
525
    my_logger.info('\n\tBackground model...')
1✔
526
    bgd_level = float(np.mean(spectrum.spectrogram_bgd))
1✔
527
    background = BackgroundModel(parameters.CCD_IMSIZE, parameters.CCD_IMSIZE, level=bgd_level, frame=None)
1✔
528
    if parameters.DEBUG:
1✔
UNCOV
529
        background.plot_model()
×
530

531
    # Target model
532
    my_logger.info('\n\tStar model...')
1✔
533
    # Spectrogram is simulated with spectrum.x0 target position: must be this position to simulate the target.
534
    star = StarModel(np.array(image.target_pixcoords) / parameters.CCD_REBIN, image.target_star2D, image.target_star2D.params.values[0])
1✔
535
    # reso = star.fwhm
536
    if parameters.DEBUG:
1✔
UNCOV
537
        star.plot_model()
×
538

539
    # Star field model
540
    starfield = None
1✔
541
    if with_starfield:
1✔
542
        my_logger.info('\n\tStar field model...')
1✔
543
        starfield = StarFieldModel(image, flux_factor=1)
1✔
544

545
    # Flat model
546
    flat = None
1✔
547
    if with_flat:
1✔
548
        my_logger.info('\n\tFlat model...')
1✔
549
        flat = FlatModel(parameters.CCD_IMSIZE, parameters.CCD_IMSIZE, gains=[[1, 2, 3, 4], [4, 3, 2, 1]], randomness_level=1e-2)
1✔
550
        if parameters.DEBUG:
1✔
UNCOV
551
            flat.plot_model()
×
552

553
    # Spectrum model
554
    my_logger.info('\n\tSpectrum model...')
1✔
555
    airmass = image.header['AIRMASS']
1✔
556
    pressure = image.header['OUTPRESS']
1✔
557
    temperature = image.header['OUTTEMP']
1✔
558

559
    # Rotation: useful only to fill the Dy_disp_axis column in PSF table
560
    if not with_rotation:
1✔
561
        rotation_angle = 0
×
562
    else:
563
        rotation_angle = spectrum.rotation_angle
1✔
564

565
    # Load PSF
566
    if psf_type is not None:
1✔
567
        from spectractor.extractor.psf import load_PSF
×
568
        parameters.PSF_TYPE = psf_type
×
569
        psf = load_PSF(psf_type=psf_type)
×
570
        spectrum.psf = psf
×
571
        spectrum.chromatic_psf.psf = psf
×
572
    if psf_poly_params is None:
1✔
573
        my_logger.info('\n\tUse PSF parameters from _table.csv file.')
×
574
        psf_poly_params = spectrum.chromatic_psf.from_table_to_poly_params()
×
575
    else:
576
        spectrum.chromatic_psf.deg = ((len(psf_poly_params) - 1) // (len(spectrum.chromatic_psf.psf.params.labels) - 2) - 1) // len(diffraction_orders)
1✔
577
        spectrum.chromatic_psf.set_polynomial_degrees(spectrum.chromatic_psf.deg)
1✔
578
        if spectrum.chromatic_psf.deg == 0:  # x_c must have deg >= 1
1✔
579
            psf_poly_params.insert(1, 0)
×
580
        my_logger.info(f'\n\tUse PSF parameters {psf_poly_params} as polynoms of '
1✔
581
                       f'degree {spectrum.chromatic_psf.degrees}')
582
    if psf_type is not None and psf_poly_params is not None:
1✔
583
        spectrum.chromatic_psf.init_from_table()
×
584

585
    # Simulate spectrogram
586
    atmosphere = Atmosphere(airmass, pressure, temperature)
1✔
587
    spectrogram = SpectrogramModel(spectrum, atmosphere=atmosphere, fast_sim=False, full_image=True,
1✔
588
                                   with_adr=with_adr, diffraction_orders=diffraction_orders)
589
    spectrogram.simulate(A1, A2, A3, aerosols, angstrom_exponent, ozone, pwv,
1✔
590
                         parameters.DISTANCE2CCD, 0, 0, rotation_angle, psf_poly_params)
591

592
    # Image model
593
    my_logger.info('\n\tImage model...')
1✔
594
    image.compute(star, background, spectrogram, starfield=starfield, flat=flat)
1✔
595

596
    # Recover true spectrum
597
    spectrogram.set_true_spectrum(spectrogram.lambdas, aerosols, ozone, pwv, shift_t=0)
1✔
598
    true_lambdas = np.copy(spectrogram.true_lambdas)
1✔
599
    true_spectrum = np.copy(spectrogram.true_spectrum)
1✔
600

601
    # Saturation effects
602
    saturated_pixels = np.where(spectrogram.spectrogram_data > image.saturation)[0]
1✔
603
    if len(saturated_pixels) > 0:
1✔
604
        my_logger.warning(f"\n\t{len(saturated_pixels)} saturated pixels detected above saturation "
×
605
                          f"level at {image.saturation} ADU/s in the spectrogram."
606
                          f"\n\tSpectrogram maximum is at {np.max(spectrogram.spectrogram_data)} ADU/s.")
607
    image.data[image.data > image.saturation] = image.saturation
1✔
608

609
    # Convert data from ADU/s in ADU
610
    image.convert_to_ADU_units()
1✔
611

612
    # Add Poisson and read-out noise
613
    if with_noise:
1✔
614
        image.add_poisson_and_read_out_noise()
×
615

616
    # Round float ADU into closest integers
617
    # image.data = np.around(image.data)
618
    if parameters.OBS_NAME == "AUXTEL":
1✔
619
        image.data = image.data.T[::-1, ::-1]
×
620

621
    # Plot
622
    if parameters.VERBOSE and parameters.DISPLAY:  # pragma: no cover
623
        image.convert_to_ADU_rate_units()
624
        image.plot_image(scale="symlog", title="Image simulation", target_pixcoords=target_pixcoords, units=image.units)
625
        image.convert_to_ADU_units()
626

627
    # Set output path
628
    ensure_dir(outputdir)
1✔
629
    output_filename = image_filename.split('/')[-1]
1✔
630
    output_filename = (output_filename.replace('reduc', 'sim')).replace('trim', 'sim').replace('exposure', 'sim')
1✔
631
    output_filename = os.path.join(outputdir, output_filename)
1✔
632

633
    # Save images and parameters
634
    image.header['A1_T'] = A1
1✔
635
    image.header['A2_T'] = A2
1✔
636
    image.header['A3_T'] = A3
1✔
637
    image.header['X0_T'] = spectrum.x0[0]
1✔
638
    image.header['Y0_T'] = spectrum.x0[1]
1✔
639
    image.header['D2CCD_T'] = float(parameters.DISTANCE2CCD)
1✔
640
    image.header['OZONE_T'] = ozone
1✔
641
    image.header['PWV_T'] = pwv
1✔
642
    image.header['VAOD_T'] = aerosols
1✔
643
    image.header['ROT_T'] = rotation_angle
1✔
644
    image.header['ROTATION'] = int(with_rotation)
1✔
645
    image.header['STARS'] = int(with_starfield)
1✔
646
    image.header['BKGD_LEV'] = background.level
1✔
647
    image.header['PSF_DEG'] = spectrogram.chromatic_psf.deg
1✔
648
    image.header['PSF_TYPE'] = parameters.PSF_TYPE
1✔
649
    psf_poly_params_truth = np.array(psf_poly_params)
1✔
650
    if psf_poly_params_truth.size > spectrum.spectrogram_Nx:
1✔
651
        psf_poly_params_truth = psf_poly_params_truth[spectrum.spectrogram_Nx:]
×
652
    image.header['LBDAS_T'] = str(np.round(true_lambdas, decimals=2).tolist())
1✔
653
    image.header['AMPLIS_T'] = str(true_spectrum.tolist())
1✔
654
    image.header['PSF_P_T'] = str(psf_poly_params_truth.tolist())
1✔
655
    image.save_image(output_filename, overwrite=True)
1✔
656
    return image
1✔
657

658

659
if __name__ == "__main__":
1✔
660
    import doctest
×
661

662
    doctest.testmod()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc