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

LSSTDESC / Spectractor / 4890731301

pending completion
4890731301

Pull #125

github

GitHub
Merge 79e1b14f6 into 3549ae5c3
Pull Request #125: Fitparams

892 of 892 new or added lines in 16 files covered. (100.0%)

7127 of 7963 relevant lines covered (89.5%)

0.9 hits per line

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

90.47
/spectractor/extractor/chromaticpsf.py
1
import numpy as np
1✔
2
import os
1✔
3
import matplotlib.pyplot as plt
1✔
4
import matplotlib as mpl
1✔
5
from tqdm import tqdm
1✔
6
import copy
1✔
7

8
from scipy.interpolate import interp1d
1✔
9
from scipy.signal import convolve2d
1✔
10
from scipy import sparse
1✔
11

12
from astropy.table import Table
1✔
13

14
from spectractor.tools import (fit_poly1d, plot_image_simple, compute_fwhm)
1✔
15
from spectractor.extractor.background import extract_spectrogram_background_sextractor
1✔
16
from spectractor.extractor.psf import PSF, PSFFitWorkspace, MoffatGauss, Moffat
1✔
17
from spectractor import parameters
1✔
18
from spectractor.config import set_logger
1✔
19
from spectractor.fit.fitter import (FitParameters, FitWorkspace, run_minimisation, run_minimisation_sigma_clipping,
1✔
20
                                    RegFitWorkspace)
21

22

23
class ChromaticPSF:
1✔
24
    """Class to store a PSF evolving with wavelength.
25

26
    The wavelength evolution is stored in an Astropy table instance. Whatever the PSF model, the common keywords are:
27

28
    * lambdas: the wavelength [nm]
29
    * Dx: the distance along X axis to order 0 position of the PSF model centroid  [pixels]
30
    * Dy: the distance along Y axis to order 0 position of the PSF model centroid [pixels]
31
    * Dy_disp_axis: the distance along Y axis to order 0 position of the mean dispersion axis [pixels]
32
    * flux_sum: the transverse sum of the data flux [spectrogram units]
33
    * flux_integral: the integral of the best fitting PSF model to the data (should be equal to the amplitude parameter
34
    of the PSF model if the model is correclty normalized to one) [spectrogram units]
35
    * flux_err: the uncertainty on flux_sum [spectrogram units]
36
    * fwhm: the FWHM of the best fitting PSF model [pixels]
37
    * Dy_fwhm_sup: the distance along Y axis to order 0 position of the upper FWHM edge [pixels]
38
    * Dy_fwhm_inf: the distance along Y axis to order 0 position of the lower FWHM edge [pixels]
39

40
    Then all the specific parameter of the PSF model are stored in other columns with their wavelength evolution
41
    (read from PSF.param_names attribute).
42

43
    A saturation level should be specified in data units.
44

45
    """
46

47
    def __init__(self, psf, Nx, Ny, x0=0, y0=None, deg=4, saturation=None, file_name=''):
1✔
48
        """Initialize a ChromaticPSF instance.
49

50

51
        Parameters
52
        ----------
53
        psf: PSF
54
            The PSF class model to build the PSF.
55
        Nx: int
56
            The number of pixels along the dispersion axis.
57
        Ny: int
58
            The number of pixels transverse to the dispersion axis.
59
        x0: float
60
            Relative position to pixel (0, 0) of the order 0 position along x (default: 0).
61
        y0: float
62
            Relative position to pixel (0, 0) of the order 0 position along y.
63
            If None, y0 is set at Ny/2 (default: None).
64
        deg: int, optional
65
            The degree of the polynomial functions to model the chromatic evolution
66
            of the PSF shape parameters (default: 4).
67
        saturation: float, optional
68
            The image saturation level (default: None).
69
        file_name: str, optional
70
            A path to a CSV file containing a ChromaticPSF table and load it (default: "").
71
        """
72
        self.my_logger = set_logger(self.__class__.__name__)
1✔
73
        self.psf = psf
1✔
74
        self.deg = -1
1✔
75
        self.degrees = {}
1✔
76
        self.set_polynomial_degrees(deg)
1✔
77
        self.Nx = Nx
1✔
78
        self.Ny = Ny
1✔
79
        self.x0 = x0
1✔
80
        if y0 is None:
1✔
81
            y0 = Ny / 2
1✔
82
        self.y0 = y0
1✔
83
        self.profile_params = np.zeros((Nx, len(self.psf.param_names)))
1✔
84
        self.pixels = np.mgrid[:Nx, :Ny]
1✔
85
        self.table = Table()
1✔
86
        self.saturation = 1e20
1✔
87
        self.poly_params_labels = []
1✔
88
        self.poly_params_names = []
1✔
89
        self.psf_param_start_index = 0
1✔
90
        self.n_poly_params = 0
1✔
91
        self.fitted_pixels = 0
1✔
92
        self.load_table(file_name=file_name, saturation=saturation)
1✔
93
        self.opt_reg = parameters.PSF_FIT_REG_PARAM
1✔
94
        self.cov_matrix = np.zeros((Nx, Nx))
1✔
95
        if file_name != "":
1✔
96
            self.poly_params = self.from_table_to_poly_params()
×
97

98
    def init_table(self, table=None, saturation=None):
1✔
99
        if table is None:
1✔
100
            arr = np.zeros((self.Nx, len(self.psf.param_names) + 10))
×
101
            table = Table(arr, names=['lambdas', 'Dx', 'Dy', 'Dy_disp_axis', 'flux_sum', 'flux_integral',
×
102
                                      'flux_err', 'fwhm', 'Dy_fwhm_sup', 'Dy_fwhm_inf'] + list(self.psf.param_names))
103
        self.table = table
1✔
104
        self.psf_param_start_index = 10
1✔
105
        self.n_poly_params = len(self.table)
1✔
106
        self.fitted_pixels = np.arange(len(self.table)).astype(int)
1✔
107
        self.saturation = saturation
1✔
108
        if saturation is None:
1✔
109
            self.saturation = 1e20
×
110
            self.my_logger.warning(f"\n\tSaturation level should be given to instanciate the ChromaticPSF "
×
111
                                   f"object. self.saturation is set arbitrarily to 1e20. Good luck.")
112
        for name in self.psf.param_names:
1✔
113
            self.n_poly_params += self.degrees[name] + 1
1✔
114
        self.poly_params = np.zeros(self.n_poly_params)
1✔
115
        self.poly_params_labels = []  # [f"a{k}" for k in range(self.poly_params.size)]
1✔
116
        self.poly_params_names = []  # "$a_{" + str(k) + "}$" for k in range(self.poly_params.size)]
1✔
117
        for ip, p in enumerate(self.psf.param_names):
1✔
118
            if ip == 0:
1✔
119
                self.poly_params_labels += [f"{p}^{k}" for k in range(len(self.table))]
1✔
120
                self.poly_params_names += \
1✔
121
                    ["$" + self.psf.axis_names[ip].replace("$", "") + "{(" + str(k) + ")}$"
122
                     for k in range(len(self.table))]
123
            else:
124
                for k in range(self.degrees[p] + 1):
1✔
125
                    self.poly_params_labels.append(f"{p}_{k}")
1✔
126
                    self.poly_params_names.append("$" + self.psf.axis_names[ip].replace("$", "")
1✔
127
                                                  + "^{(" + str(k) + ")}$")
128

129
    def load_table(self, file_name='', saturation=None):
1✔
130
        if file_name == '':
1✔
131
            arr = np.zeros((self.Nx, len(self.psf.param_names) + 10))
1✔
132
            table = Table(arr, names=['lambdas', 'Dx', 'Dy', 'Dy_disp_axis', 'flux_sum', 'flux_integral',
1✔
133
                                      'flux_err', 'fwhm', 'Dy_fwhm_sup', 'Dy_fwhm_inf'] + list(self.psf.param_names))
134
        else:
135
            table = Table.read(file_name)
×
136
        self.init_table(table, saturation=saturation)
1✔
137

138
    def resize_table(self, new_Nx):
1✔
139
        """Resize the table and interpolate existing values to a new length size.
140

141
        Parameters
142
        ----------
143
        new_Nx: int
144
            New length of the ChromaticPSF on X axis.
145

146
        Examples
147
        --------
148

149
        >>> psf = Moffat()
150
        >>> s = ChromaticPSF(psf, Nx=20, Ny=5, deg=1, saturation=20000)
151
        >>> params = s.generate_test_poly_params()
152
        >>> s.fill_table_with_profile_params(s.from_poly_params_to_profile_params(params))
153
        >>> print(np.sum(s.table["gamma"]))
154
        100.0
155
        >>> print(s.table["gamma"].size)
156
        20
157
        >>> s.resize_table(10)
158
        >>> print(np.sum(s.table["gamma"]))
159
        50.0
160
        >>> print(s.table["gamma"].size)
161
        10
162

163
        """
164
        new_chromatic_psf = ChromaticPSF(psf=self.psf, Nx=new_Nx, Ny=self.Ny, x0=self.x0, y0=self.y0,
1✔
165
                                         deg=self.deg, saturation=self.saturation)
166
        old_x = np.linspace(0, 1, self.Nx)
1✔
167
        new_x = np.linspace(0, 1, new_Nx)
1✔
168
        for colname in self.table.colnames:
1✔
169
            new_chromatic_psf.table[colname] = np.interp(new_x, old_x, np.array(self.table[colname]))
1✔
170
        self.init_table(table=new_chromatic_psf.table, saturation=self.saturation)
1✔
171
        self.__dict__.update(new_chromatic_psf.__dict__)
1✔
172

173
    def crop_table(self, new_Nx):
1✔
174
        """Crop the table to a new length size.
175

176
        Parameters
177
        ----------
178
        new_Nx: int
179
            New length of the ChromaticPSF on X axis.
180

181
        Examples
182
        --------
183

184
        >>> psf = Moffat()
185
        >>> s = ChromaticPSF(psf, Nx=20, Ny=5, deg=1, saturation=20000)
186
        >>> params = s.generate_test_poly_params()
187
        >>> s.fill_table_with_profile_params(s.from_poly_params_to_profile_params(params))
188
        >>> print(np.sum(s.table["gamma"]))
189
        100.0
190
        >>> print(s.table["gamma"].size)
191
        20
192
        >>> s.crop_table(10)
193
        >>> print(np.sum(s.table["gamma"]))
194
        50.0
195
        >>> print(s.table["gamma"].size)
196
        10
197

198
        """
199
        new_chromatic_psf = ChromaticPSF(psf=self.psf, Nx=new_Nx, Ny=self.Ny, x0=self.x0, y0=self.y0,
1✔
200
                                         deg=self.deg, saturation=self.saturation)
201
        for colname in self.table.colnames:
1✔
202
            new_chromatic_psf.table[colname] = self.table[colname][:new_Nx]
1✔
203
        self.init_table(table=new_chromatic_psf.table, saturation=self.saturation)
1✔
204
        self.__dict__.update(new_chromatic_psf.__dict__)
1✔
205

206
    def set_polynomial_degrees(self, deg):
1✔
207
        self.deg = deg
1✔
208
        self.degrees = {key: deg for key in self.psf.param_names}
1✔
209
        self.degrees["x_c"] = max(self.degrees["x_c"], 1)
1✔
210
        self.degrees['saturation'] = 0
1✔
211

212
    def generate_test_poly_params(self):
1✔
213
        """
214
        A set of parameters to define a test spectrogram. PSF function must be MoffatGauss
215
        for this test example.
216

217
        Returns
218
        -------
219
        profile_params: array
220
            The list of the test parameters
221

222
        Examples
223
        --------
224

225
        >>> psf = MoffatGauss()
226
        >>> s = ChromaticPSF(psf, Nx=5, Ny=4, deg=1, saturation=20000)
227
        >>> params = s.generate_test_poly_params()
228

229
        ..  doctest::
230
            :hide:
231
            >>> assert(np.all(np.isclose(params,[10, 50, 100, 150, 200, 0, 0, 0, 0, 5, 0, 2, 0, -0.4, -0.4,1,0,20000])))
232

233
        """
234
        if not isinstance(self.psf, MoffatGauss) and not isinstance(self.psf, Moffat):
1✔
235
            raise TypeError(f"In this test function, PSF model must be MoffatGauss or Moffat. Gave {type(self.psf)}.")
×
236
        params = [50 * i for i in range(self.Nx)]
1✔
237
        params[0] = 10
1✔
238
        # add absorption lines
239
        if self.Nx > 80:
1✔
240
            params = list(np.array(params)
1✔
241
                          - 3000 * np.exp(-((np.arange(self.Nx) - 70) / 2) ** 2)
242
                          - 2000 * np.exp(-((np.arange(self.Nx) - 50) / 2) ** 2))
243
        params += [0.] * (self.degrees['x_c'] - 1) + [0, 0]  # x mean
1✔
244
        params += [0.] * (self.degrees['y_c'] - 1) + [0, 0]  # y mean
1✔
245
        params += [0.] * (self.degrees['gamma'] - 1) + [0, 5]  # gamma
1✔
246
        params += [0.] * (self.degrees['alpha'] - 1) + [0, 2]  # alpha
1✔
247
        if isinstance(self.psf, MoffatGauss):
1✔
248
            params += [0.] * (self.degrees['eta_gauss'] - 1) + [-0.4, -0.4]  # eta_gauss
1✔
249
            params += [0.] * (self.degrees['stddev'] - 1) + [0, 1]  # stddev
1✔
250
        params += [self.saturation]  # saturation
1✔
251
        poly_params = np.zeros_like(params)
1✔
252
        poly_params[:self.Nx] = params[:self.Nx]
1✔
253
        index = self.Nx - 1
1✔
254
        for name in self.psf.param_names:
1✔
255
            if name == 'amplitude':
1✔
256
                continue
1✔
257
            else:
258
                shift = self.degrees[name] + 1
1✔
259
                c = np.polynomial.legendre.poly2leg(params[index + shift:index:-1])
1✔
260
                coeffs = np.zeros(shift)
1✔
261
                coeffs[:c.size] = c
1✔
262
                poly_params[index + 1:index + shift + 1] = coeffs
1✔
263
                index = index + shift
1✔
264
        return poly_params
1✔
265

266
    def build_spectrogram_image(self, poly_params, mode="1D"):
1✔
267
        """Simulate a 2D spectrogram of size Nx times Ny.
268

269
        Given a set of polynomial parameters defining the chromatic PSF model, a 2D spectrogram
270
        is produced either summing transverse 1D PSF profiles along the dispersion axis, or
271
        full 2D PSF profiles.
272

273
        Parameters
274
        ----------
275
        poly_params: array_like
276
            Parameter array of the model, in the form:
277
            - Nx first parameters are amplitudes for the Moffat transverse profiles
278
            - next parameters are polynomial coefficients for all the PSF parameters in the same order
279
            as in PSF definition, except amplitude.
280
        mode: str, optional
281
            Set the evaluation mode: either transverse 1D PSF profile (mode="1D") or full 2D PSF profile (mode="2D").
282

283
        Returns
284
        -------
285
        output: array
286
            A 2D array with the model.
287

288
        Examples
289
        --------
290

291
        >>> psf = MoffatGauss()
292
        >>> s = ChromaticPSF(psf, Nx=100, Ny=20, deg=4, saturation=20000)
293
        >>> poly_params = s.generate_test_poly_params()
294

295
        1D evaluation:
296

297
        .. doctest::
298

299
            >>> output = s.build_spectrogram_image(poly_params, mode="1D")
300
            >>> im = plt.imshow(output, origin='lower')  # doctest: +ELLIPSIS
301
            >>> plt.colorbar(im)  # doctest: +ELLIPSIS
302
            <matplotlib.colorbar.Colorbar object at 0x...>
303
            >>> plt.show()
304

305
        .. plot ::
306

307
            from spectractor.extractor.chromaticpsf import ChromaticPSF
308
            from spectractor.extractor.psf import MoffatGauss
309
            psf = MoffatGauss()
310
            s = ChromaticPSF(psf, Nx=100, Ny=20, deg=4, saturation=20000)
311
            poly_params = s.generate_test_poly_params()
312
            output = s.build_spectrogram_image(poly_params, mode="1D")
313
            im = plt.imshow(output, origin='lower')
314
            plt.colorbar(im)
315
            plt.show()
316

317
        2D evaluation:
318

319
        .. doctest::
320

321
            >>> output = s.build_spectrogram_image(poly_params, mode="2D")
322
            >>> im = plt.imshow(output, origin='lower')  # doctest: +ELLIPSIS
323
            >>> plt.colorbar(im)  # doctest: +ELLIPSIS
324
            <matplotlib.colorbar.Colorbar object at 0x...>
325
            >>> plt.show()
326

327
        .. plot ::
328

329
            from spectractor.extractor.chromaticpsf import ChromaticPSF
330
            from spectractor.extractor.psf import MoffatGauss
331
            psf = MoffatGauss()
332
            s = ChromaticPSF(psf, Nx=100, Ny=20, deg=4, saturation=20000)
333
            poly_params = s.generate_test_poly_params()
334
            output = s.build_spectrogram_image(poly_params, mode="2D")
335
            im = plt.imshow(output, origin='lower')
336
            plt.colorbar(im)
337
            plt.show()
338

339
        """
340
        if mode == "2D":
1✔
341
            yy, xx = np.mgrid[:self.Ny, :self.Nx]
1✔
342
            pixels = np.asarray([xx, yy])
1✔
343
        elif mode == "1D":
1✔
344
            pixels = np.arange(self.Ny)
1✔
345
        else:
346
            raise ValueError(f"Unknown evaluation mode={mode}. Must be '1D' or '2D'.")
×
347
        self.psf.apply_max_width_to_bounds(max_half_width=self.Ny)
1✔
348
        profile_params = self.from_poly_params_to_profile_params(poly_params, apply_bounds=True)
1✔
349
        profile_params[:, 1] = np.arange(self.Nx)  # replace x_c
1✔
350
        output = np.zeros((self.Ny, self.Nx))
1✔
351
        if mode == "1D":
1✔
352
            for x in range(self.Nx):
1✔
353
                output[:, x] = self.psf.evaluate(pixels, p=profile_params[x, :])
1✔
354
        elif mode == "2D":
1✔
355
            for x in range(self.Nx):
1✔
356
                output += self.psf.evaluate(pixels, p=profile_params[x, :])
1✔
357
        return np.clip(output, 0, self.saturation)
1✔
358

359
    def build_psf_cube(self, pixels, profile_params, fwhmx_clip=parameters.PSF_FWHM_CLIP,
1✔
360
                       fwhmy_clip=parameters.PSF_FWHM_CLIP, dtype="float64", mask=None):
361
        Ny_pix, Nx_pix = pixels[0].shape
1✔
362
        psf_cube = np.zeros((len(profile_params), Ny_pix, Nx_pix), dtype=dtype)
1✔
363
        fwhms = self.table["fwhm"]
1✔
364
        for x in range(len(profile_params)):
1✔
365
            xc, yc = profile_params[x, 1:3]
1✔
366
            if xc < - fwhmx_clip * fwhms[x]:
1✔
367
                continue
×
368
            if xc > Nx_pix + fwhmx_clip * fwhms[x]:
1✔
369
                break
1✔
370
            if mask is None:
1✔
371
                xmin = max(0, int(xc - max(1*parameters.PIXWIDTH_SIGNAL, fwhmx_clip * fwhms[x])))
1✔
372
                xmax = min(Nx_pix, int(xc + max(1*parameters.PIXWIDTH_SIGNAL, fwhmx_clip * fwhms[x])))
1✔
373
                ymin = max(0, int(yc - max(1*parameters.PIXWIDTH_SIGNAL, fwhmy_clip * fwhms[x])))
1✔
374
                ymax = min(Ny_pix, int(yc + max(1*parameters.PIXWIDTH_SIGNAL, fwhmy_clip * fwhms[x])))
1✔
375
                # print(x, xc, yc, xmin, xmax, ymin, ymax, fwhms[x])
376
                psf_cube[x, ymin:ymax, xmin:xmax] = self.psf.evaluate(pixels[:, ymin:ymax, xmin:xmax],
1✔
377
                                                                      p=profile_params[x, :])
378
            else:
379
                maskx = np.any(mask[x], axis=0)
1✔
380
                masky = np.any(mask[x], axis=1)
1✔
381
                xmin = np.argmax(maskx)
1✔
382
                ymin = np.argmax(masky)
1✔
383
                xmax = len(maskx) - np.argmax(maskx[::-1])
1✔
384
                ymax = len(masky) - np.argmax(masky[::-1])
1✔
385
                psf_cube[x, ymin:ymax, xmin:xmax] = self.psf.evaluate(pixels[:, ymin:ymax, xmin:xmax], p=profile_params[x, :])
1✔
386
        return psf_cube
1✔
387

388
    def fill_table_with_profile_params(self, profile_params):
1✔
389
        """
390
        Fill the table with the profile parameters.
391

392
        Parameters
393
        ----------
394
        profile_params: array
395
           a Nx * len(self.psf.param_names) numpy array containing the PSF parameters as a function of pixels.
396

397
        Examples
398
        --------
399

400
        >>> psf = MoffatGauss()
401
        >>> s = ChromaticPSF(psf, Nx=100, Ny=100, deg=4, saturation=8000)
402
        >>> poly_params_test = s.generate_test_poly_params()
403
        >>> profile_params = s.from_poly_params_to_profile_params(poly_params_test)
404
        >>> s.fill_table_with_profile_params(profile_params)
405

406
        ..  doctest::
407
            :hide:
408

409
            >>> assert(np.all(np.isclose(s.table['stddev'], 1*np.ones(100))))
410

411
        """
412
        for k, name in enumerate(self.psf.param_names):
1✔
413
            self.table[name] = profile_params[:, k]
1✔
414

415
    def rotate_table(self, angle_degree):
1✔
416
        """
417
        In self.table, rotate the columns Dx, Dy, Dy_fwhm_inf and Dy_fwhm_sup by an angle
418
        given in degree. The results overwrite the previous columns in self.table.
419

420
        Parameters
421
        ----------
422
        angle_degree: float
423
            Rotation angle in degree
424

425
        Examples
426
        --------
427

428
        >>> psf = MoffatGauss()
429
        >>> s = ChromaticPSF(psf, Nx=100, Ny=100, deg=4, saturation=8000)
430
        >>> s.table['Dx'] = np.arange(100)
431
        >>> s.rotate_table(45)
432

433
        ..  doctest::
434
            :hide:
435

436
            >>> assert(np.all(np.isclose(s.table['Dy'], -np.arange(100)/np.sqrt(2))))
437
            >>> assert(np.all(np.isclose(s.table['Dx'], np.arange(100)/np.sqrt(2))))
438
            >>> assert(np.all(np.isclose(s.table['Dy_fwhm_inf'], -np.arange(100)/np.sqrt(2))))
439
            >>> assert(np.all(np.isclose(s.table['Dy_fwhm_sup'], -np.arange(100)/np.sqrt(2))))
440
        """
441
        angle = angle_degree * np.pi / 180.
1✔
442
        rotmat = np.array([[np.cos(angle), np.sin(angle)], [-np.sin(angle), np.cos(angle)]])
1✔
443
        # finish with Dy_c to get correct Dx
444
        for name in ['Dy', 'Dy_fwhm_inf', 'Dy_fwhm_sup', 'Dy_disp_axis']:
1✔
445
            vec = list(np.array([self.table['Dx'], self.table[name]]).T)
1✔
446
            rot_vec = np.array([np.dot(rotmat, v) for v in vec])
1✔
447
            self.table[name] = rot_vec.T[1]
1✔
448
        self.table['Dx'] = rot_vec.T[0]
1✔
449

450
    def from_profile_params_to_poly_params(self, profile_params, indices=None):
1✔
451
        """
452
        Transform the profile_params array into a set of parameters for the chromatic PSF parameterisation.
453
        Fit Legendre polynomial functions across the pixels for each PSF parameters.
454
        The order of the polynomial functions is given by the self.degrees array.
455

456
        Parameters
457
        ----------
458
        profile_params: array
459
            a Nx * len(self.psf.param_names) numpy array containing the PSF parameters as a function of pixels.
460
        indices: array_like, optional
461
            Array of integer indices or boolean values that selects values in profile_params for the polynomial fits.
462
            If None every profile_params rows are used (default: None)
463

464
        Returns
465
        -------
466
        profile_params: array_like
467
            A set of parameters that can be evaluated by the chromatic PSF class evaluate function.
468

469
        Examples
470
        --------
471

472
        Build a mock spectrogram with random Poisson noise:
473

474
        >>> psf = MoffatGauss()
475
        >>> s = ChromaticPSF(psf, Nx=100, Ny=100, deg=4, saturation=8000)
476
        >>> poly_params_test = s.generate_test_poly_params()
477
        >>> data = s.build_spectrogram_image(poly_params_test, mode="1D")
478
        >>> data = np.random.poisson(data)
479
        >>> data_errors = np.sqrt(data+1)
480

481
        From the polynomial parameters to the profile parameters:
482

483
        >>> profile_params = s.from_poly_params_to_profile_params(poly_params_test)
484

485
        ..  doctest::
486
            :hide:
487

488
            >>> assert(np.all(np.isclose(profile_params[0], [10, 0, 50, 5, 2, 0, 1, 8e3])))
489

490
        From the profile parameters to the polynomial parameters:
491

492
        >>> profile_params = s.from_profile_params_to_poly_params(profile_params)
493

494
        ..  doctest::
495
            :hide:
496

497
            >>> assert(np.all(np.isclose(profile_params, poly_params_test)))
498
        """
499
        if indices is None :
1✔
500
            indices = np.full(len(self.table), True)
1✔
501
        poly_params = np.array([])
1✔
502
        amplitude = None
1✔
503
        for k, name in enumerate(self.psf.param_names):
1✔
504
            if name == 'amplitude':
1✔
505
                amplitude = profile_params[:, k]
1✔
506
                poly_params = np.concatenate([poly_params, amplitude])
1✔
507
        if amplitude is None:
1✔
508
            self.my_logger.warning('\n\tAmplitude array not initialized. '
×
509
                                   'Polynomial fit for shape parameters will be unweighted.')
510
            
511
        pixels = np.linspace(-1, 1, len(self.table))[indices]
1✔
512
        for k, name in enumerate(self.psf.param_names):
1✔
513
            delta = 0
1✔
514
            if name != 'amplitude':
1✔
515
                weights = np.copy(amplitude)[indices]
1✔
516
                if name == 'x_c':
1✔
517
                    delta = self.x0
1✔
518
                if name == 'y_c':
1✔
519
                    delta = self.y0
1✔
520
                fit = np.polynomial.legendre.legfit(pixels, profile_params[indices, k] - delta,
1✔
521
                                                    deg=self.degrees[name], w=weights)
522
                poly_params = np.concatenate([poly_params, fit])
1✔
523
        return poly_params
1✔
524

525
    def from_table_to_profile_params(self):  # pragma: nocover
526
        """
527
        Extract the profile parameters from self.table and fill an array of profile parameters.
528

529
        Parameters
530
        ----------
531

532
        Returns
533
        -------
534
        profile_params: array
535
            Nx * len(self.psf.param_names) numpy array containing the PSF parameters as a function of pixels.
536

537
        Examples
538
        --------
539

540
        >>> from spectractor.extractor.spectrum import Spectrum
541
        >>> s = Spectrum('./tests/data/reduc_20170530_134_spectrum.fits')
542
        >>> profile_params = s.chromatic_psf.from_table_to_profile_params()
543

544
        ..  doctest::
545
            :hide:
546

547
            >>> assert(profile_params.shape == (s.chromatic_psf.Nx, len(s.chromatic_psf.psf.param_names)))
548
            >>> assert not np.all(np.isclose(profile_params, np.zeros_like(profile_params)))
549
        """
550
        profile_params = np.zeros((len(self.table), len(self.psf.param_names)))
551
        for k, name in enumerate(self.psf.param_names):
552
            profile_params[:, k] = self.table[name]
553
        return profile_params
554

555
    def from_table_to_poly_params(self):  # pragma: nocover
556
        """
557
        Extract the polynomial parameters from self.table and fill an array with polynomial parameters.
558

559
        Parameters
560
        ----------
561

562
        Returns
563
        -------
564
        poly_params: array_like
565
            A set of polynomial parameters that can be evaluated by the chromatic PSF class evaluate function.
566

567
        Examples
568
        --------
569

570
        >>> from spectractor.extractor.spectrum import Spectrum
571
        >>> s = Spectrum('./tests/data/reduc_20170530_134_spectrum.fits')
572
        >>> poly_params = s.chromatic_psf.from_table_to_poly_params()
573

574
        ..  doctest::
575
            :hide:
576

577
            >>> assert(poly_params.size > s.chromatic_psf.Nx)
578
            >>> assert(len(poly_params.shape)==1)
579
            >>> assert not np.all(np.isclose(poly_params, np.zeros_like(poly_params)))
580
        """
581
        profile_params = self.from_table_to_profile_params()
582
        poly_params = self.from_profile_params_to_poly_params(profile_params)
583
        return poly_params
584

585
    def from_poly_params_to_profile_params(self, poly_params, apply_bounds=False):
1✔
586
        """
587
        Evaluate the PSF profile parameters from the polynomial coefficients. If poly_params length is smaller
588
        than self.Nx, it is assumed that the amplitude  parameters are not included and set to arbitrarily to 1.
589

590
        Parameters
591
        ----------
592
        poly_params: array_like
593
            Parameter array of the model, in the form:
594
                - Nx first parameters are amplitudes for the Moffat transverse profiles
595
                - next parameters are polynomial coefficients for all the PSF parameters in the same order
596
                as in PSF definition, except amplitude
597

598
        apply_bounds: bool, optional
599
            Force profile parameters to respect their boundary conditions if they lie outside (default: False)
600

601
        Returns
602
        -------
603
        profile_params: array
604
            Nx * len(self.psf.param_names) numpy array containing the PSF parameters as a function of pixels.
605

606
        Examples
607
        --------
608

609
        Build a mock spectrogram with random Poisson noise:
610

611
        >>> psf = MoffatGauss()
612
        >>> s = ChromaticPSF(psf, Nx=100, Ny=100, deg=1, saturation=8000)
613
        >>> poly_params_test = s.generate_test_poly_params()
614
        >>> data = s.build_spectrogram_image(poly_params_test, mode="1D")
615
        >>> data = np.random.poisson(data)
616
        >>> data_errors = np.sqrt(data+1)
617

618
        From the polynomial parameters to the profile parameters:
619

620
        >>> profile_params = s.from_poly_params_to_profile_params(poly_params_test, apply_bounds=True)
621

622
        ..  doctest::
623
            :hide:
624

625
            >>> assert(np.all(np.isclose(profile_params[0], [10, 0, 50, 5, 2, 0, 1, 8e3])))
626

627
        From the profile parameters to the polynomial parameters:
628

629
        >>> profile_params = s.from_profile_params_to_poly_params(profile_params)
630

631
        ..  doctest::
632
            :hide:
633

634
            >>> assert(np.all(np.isclose(profile_params, poly_params_test)))
635

636
        From the polynomial parameters to the profile parameters without Moffat amplitudes:
637

638
        >>> profile_params = s.from_poly_params_to_profile_params(poly_params_test[100:])
639

640
        ..  doctest::
641
            :hide:
642

643
            >>> assert(np.all(np.isclose(profile_params[0], [1, 0, 50, 5, 2, 0, 1, 8e3])))
644

645
        """
646
        length = len(self.table)
1✔
647
        pixels = np.linspace(-1, 1, length)
1✔
648
        profile_params = np.zeros((length, len(self.psf.param_names)))
1✔
649
        shift = 0
1✔
650
        for k, name in enumerate(self.psf.param_names):
1✔
651
            if name == 'amplitude':
1✔
652
                if len(poly_params) > length:
1✔
653
                    profile_params[:, k] = poly_params[:length]
1✔
654
                else:
655
                    profile_params[:, k] = np.ones(length)
1✔
656
            else:
657
                if len(poly_params) > length:
1✔
658
                    profile_params[:, k] = \
1✔
659
                        np.polynomial.legendre.legval(pixels,
660
                                                      poly_params[
661
                                                      length + shift:length + shift + self.degrees[name] + 1])
662
                else:
663
                    p = poly_params[shift:shift + self.degrees[name] + 1]
1✔
664
                    if len(p) > 0:  # to avoid saturation parameters in case not set
1✔
665
                        profile_params[:, k] = np.polynomial.legendre.legval(pixels, p)
1✔
666
                shift += self.degrees[name] + 1
1✔
667
                if name == 'x_c':
1✔
668
                    profile_params[:, k] += self.x0
1✔
669
                if name == 'y_c':
1✔
670
                    profile_params[:, k] += self.y0
1✔
671
        if apply_bounds:
1✔
672
            for k, name in enumerate(self.psf.param_names):
1✔
673
                profile_params[profile_params[:, k] <= self.psf.bounds[k][0], k] = self.psf.bounds[k][0]
1✔
674
                profile_params[profile_params[:, k] > self.psf.bounds[k][1], k] = self.psf.bounds[k][1]
1✔
675
        return profile_params
1✔
676

677
    def from_profile_params_to_shape_params(self, profile_params):
1✔
678
        """
679
        Compute the PSF integrals and FWHMS given the profile_params array and fill the table.
680

681
        Parameters
682
        ----------
683
        profile_params: array
684
         a Nx * len(self.psf.param_names) numpy array containing the PSF parameters as a function of pixels.
685

686
        Examples
687
        --------
688

689
        >>> psf = MoffatGauss()
690
        >>> s = ChromaticPSF(psf, Nx=100, Ny=100, deg=4, saturation=8000)
691
        >>> poly_params_test = s.generate_test_poly_params()
692
        >>> profile_params = s.from_poly_params_to_profile_params(poly_params_test)
693
        >>> s.from_profile_params_to_shape_params(profile_params)
694

695
        ..  doctest::
696
            :hide:
697

698
            >>> assert s.table['fwhm'][-1] > 0
699

700
        """
701
        self.fill_table_with_profile_params(profile_params)
1✔
702
        pixel_x = np.arange(self.Nx).astype(int)
1✔
703
        # oversampling for precise computation of the PSF
704
        # pixels.shape = (2, Nx, Ny): self.pixels[1<-y, 0<-first pixel value column, :]
705
        # TODO: account for rotation ad projection effects is PSF is not round
706
        pixel_eval = np.arange(self.pixels[1, 0, 0], self.pixels[1, 0, -1], 0.1)
1✔
707
        for x in pixel_x:
1✔
708
            p = profile_params[x, :]
1✔
709
            # compute FWHM transverse to dispersion axis (assuming revolution symmetry of the PSF)
710
            out = self.psf.evaluate(pixel_eval, p=p)
1✔
711
            fwhm = compute_fwhm(pixel_eval, out, center=p[2], minimum=0)
1✔
712
            self.table['flux_integral'][x] = p[0]  # if MoffatGauss1D normalized
1✔
713
            self.table['fwhm'][x] = fwhm
1✔
714
        # clean fwhm bad points
715
        fwhms = self.table['fwhm']
1✔
716
        mask = np.logical_and(fwhms > 1, fwhms < self.Ny // 2)  # more than 1 pixel or less than window
1✔
717
        self.table['fwhm'] = interp1d(pixel_x[mask], fwhms[mask], kind="linear",
1✔
718
                                      bounds_error=False, fill_value="extrapolate")(pixel_x)
719

720
    def set_bounds(self):
1✔
721
        """
722
        This function returns an array of bounds for iminuit. It is very touchy, change the values with caution !
723

724
        Returns
725
        -------
726
        bounds: array_like
727
            2D array containing the pair of bounds for each polynomial parameters.
728

729
        """
730
        bounds = [[], []]
1✔
731
        for k, name in enumerate(self.psf.param_names):
1✔
732
            tmp_bounds = [[-np.inf] * (1 + self.degrees[name]), [np.inf] * (1 + self.degrees[name])]
1✔
733
            if name == "saturation":
1✔
734
                tmp_bounds = [[0], [2 * self.saturation]]
1✔
735
            elif name == "amplitude":
1✔
736
                continue
1✔
737
            bounds[0] += tmp_bounds[0]
1✔
738
            bounds[1] += tmp_bounds[1]
1✔
739
        return np.array(bounds).T
1✔
740

741
    def set_bounds_for_minuit(self, data=None):  # pragma: nocover
742
        """
743
        This function returns an array of bounds for iminuit. It is very touchy, change the values with caution !
744

745
        Parameters
746
        ----------
747
        data: array_like, optional
748
            The data array, to set the bounds for the amplitude using its maximum.
749
            If None is provided, no bounds are provided for the amplitude parameters.
750

751
        Returns
752
        -------
753
        bounds: array_like
754
            2D array containing the pair of bounds for each polynomial parameters.
755

756
        """
757
        if self.saturation is None:
758
            self.saturation = 2 * np.max(data)
759
        if data is not None:
760
            Ny, Nx = data.shape
761
            bounds = [[0.1 * np.max(data[:, x]) for x in range(Nx)], [100.0 * np.max(data[:, x]) for x in range(Nx)]]
762
        else:
763
            bounds = [[], []]
764
        for k, name in enumerate(self.psf.param_names):
765
            tmp_bounds = [[-np.inf] * (1 + self.degrees[name]), [np.inf] * (1 + self.degrees[name])]
766
            if name == "saturation":
767
                if data is not None:
768
                    tmp_bounds = [[0.1 * np.max(data)], [2 * self.saturation]]
769
                else:
770
                    tmp_bounds = [[0], [2 * self.saturation]]
771
            elif name == "amplitude":
772
                continue
773
            bounds[0] += tmp_bounds[0]
774
            bounds[1] += tmp_bounds[1]
775
        return np.array(bounds).T
776

777
    def check_bounds(self, poly_params, noise_level=0):  # pragma: nocover
778
        """
779
        Evaluate the PSF profile parameters from the polynomial coefficients and check if they are within priors.
780

781
        Parameters
782
        ----------
783
        poly_params: array_like
784
            Parameter array of the model, in the form:
785
            - Nx first parameters are amplitudes for the Moffat transverse profiles
786
            - next parameters are polynomial coefficients for all the PSF parameters
787
            in the same order as in PSF definition, except amplitude
788
        noise_level: float, optional
789
            Noise level to set minimal boundary for amplitudes (negatively).
790

791
        Returns
792
        -------
793
        in_bounds: bool
794
            True if all parameters respect the model parameter priors.
795
        penalty: float
796
            Float value to add to chi square evaluating the degree of departure of a parameter from its boundaries.
797
        outbound_parameter_name: str
798
            Names of the parameters that are out of their boundaries.
799

800
        Examples
801
        --------
802

803
        Build a mock spectrogram with random Poisson noise:
804

805
        >>> psf = MoffatGauss()
806
        >>> s = ChromaticPSF(psf, Nx=100, Ny=100, deg=1, saturation=8000)
807
        >>> poly_params_test = s.generate_test_poly_params()
808

809
        Check bounds:
810

811
        >>> in_bounds, penalty, outbound_parameter_name = s.check_bounds(poly_params_test)
812

813
        .. doctest::
814
            :hide:
815

816
            >>> assert in_bounds is True
817
            >>> assert np.isclose(penalty, 0, atol=1e-16)
818
            >>> assert outbound_parameter_name is not None
819

820
        """
821
        in_bounds = True
822
        penalty = 0
823
        outbound_parameter_name = ""
824
        profile_params = self.from_poly_params_to_profile_params(poly_params)
825
        for k, name in enumerate(self.psf.param_names):
826
            p = profile_params[:, k]
827
            if name == 'amplitude':
828
                if np.any(p < -noise_level):
829
                    in_bounds = False
830
                    penalty += np.abs(np.sum(profile_params[p < -noise_level, k]))  # / np.mean(np.abs(p))
831
                    outbound_parameter_name += name + ' '
832
            elif name == "saturation":
833
                continue
834
            else:
835
                if np.any(p > self.psf.bounds[k][1]):
836
                    penalty += np.sum(profile_params[p > self.psf.bounds[k][1], k] - self.psf.bounds[k][1])
837
                    if not np.isclose(np.mean(p), 0):
838
                        penalty /= np.abs(np.mean(p))
839
                    in_bounds = False
840
                    outbound_parameter_name += name + ' '
841
                if np.any(p < self.psf.bounds[k][0]):
842
                    penalty += np.sum(self.psf.bounds[k][0] - profile_params[p < self.psf.bounds[k][0], k])
843
                    if not np.isclose(np.mean(p), 0):
844
                        penalty /= np.abs(np.mean(p))
845
                    in_bounds = False
846
                    outbound_parameter_name += name + ' '
847
            # elif name is "stddev":
848
            #     if np.any(p < 0) or np.any(p > self.Ny):
849
            #         in_bounds = False
850
            #         penalty = 1
851
            #         break
852
            # else:
853
            #    raise ValueError(f'Unknown parameter name {name} in set_bounds_for_minuit.')
854
        penalty *= self.Nx * self.Ny
855
        return in_bounds, penalty, outbound_parameter_name
856

857
    def get_algebraic_distance_along_dispersion_axis(self, shift_x=0, shift_y=0):
1✔
858
        return np.asarray(np.sign(self.table['Dx']) *
1✔
859
                          np.sqrt((self.table['Dx'] - shift_x) ** 2 + (self.table['Dy_disp_axis'] - shift_y) ** 2))
860

861
    def update(self, psf_poly_params, x0, y0, angle, plot=False):
1✔
862
        profile_params = self.from_poly_params_to_profile_params(psf_poly_params, apply_bounds=True)
1✔
863
        self.fill_table_with_profile_params(profile_params)
1✔
864
        Dx = np.arange(self.Nx) - x0  # distance in (x,y) spectrogram frame for column x
1✔
865
        self.table["Dx"] = Dx
1✔
866
        self.table['Dy_disp_axis'] = np.tan(angle * np.pi / 180) * self.table['Dx']
1✔
867
        self.table['Dy'] = np.copy(self.table['y_c']) - y0
1✔
868
        if plot:
1✔
869
            self.plot_summary()
×
870
        return profile_params
1✔
871

872
    def plot_summary(self, truth=None):
1✔
873
        fig, ax = plt.subplots(1, 1, sharex='all', figsize=(7, 4))
1✔
874
        PSF_models = []
1✔
875
        PSF_truth = []
1✔
876
        if truth is not None:
1✔
877
            PSF_truth = truth.from_poly_params_to_profile_params(truth.poly_params, apply_bounds=True)
1✔
878
        all_pixels = np.arange(self.profile_params.shape[0])
1✔
879
        for i, name in enumerate(self.psf.param_names):
1✔
880
            fit, cov, model = fit_poly1d(all_pixels[self.fitted_pixels], self.profile_params[self.fitted_pixels, i], order=self.degrees[name])
1✔
881
            PSF_models.append(np.polyval(fit, all_pixels))
1✔
882
        for i, name in enumerate(self.psf.param_names):
1✔
883
            p = ax.plot(all_pixels, self.profile_params[:, i], marker='+', linestyle='none')
1✔
884
            ax.plot(all_pixels[self.fitted_pixels], self.profile_params[self.fitted_pixels, i], label=name,
1✔
885
                       marker='o', linestyle='none', color=p[0].get_color())
886
            if i > 0:
1✔
887
                ax.plot(all_pixels, PSF_models[i], color=p[0].get_color())
1✔
888
            if truth is not None:
1✔
889
                ax.plot(all_pixels, PSF_truth[:, i], color=p[0].get_color(), linestyle='--')
1✔
890
        ax.set_xlabel('X pixels')
1✔
891
        ax.set_ylabel('PSF parameters')
1✔
892
        ax.grid()
1✔
893
        ax.set_yscale('symlog', linthresh=10)
1✔
894
        ax.legend()
1✔
895
        fig.tight_layout()
1✔
896
        # fig.subplots_adjust(hspace=0)
897
        if parameters.DISPLAY:  # pragma: no cover
898
            plt.show()
899

900
    def fit_transverse_PSF1D_profile(self, data, err, w, ws, pixel_step=1, bgd_model_func=None, saturation=None,
1✔
901
                                     live_fit=False, sigma_clip=5):
902
        """
903
        Fit the transverse profile of a 2D data image with a PSF profile.
904
        Loop is done on the x-axis direction.
905
        An order 1 polynomial function is fitted to subtract the background for each pixel
906
        with a 3*sigma outlier removal procedure to remove background stars.
907

908
        Parameters
909
        ----------
910
        data: array
911
            The 2D array image. The transverse profile is fitted on the y direction
912
            for all pixels along the x direction.
913
        err: array
914
            The uncertainties related to the data array.
915
        w: int
916
            Half width of central region where the spectrum is extracted and summed (default: 10)
917
        ws: list
918
            up/down region extension where the sky background is estimated with format [int, int] (default: [20,30])
919
        pixel_step: int, optional
920
            The step in pixels between the slices to be fitted (default: 1).
921
            The values for the skipped pixels are interpolated with splines from the fitted parameters.
922
        bgd_model_func: callable, optional
923
            A 2D function to model the extracted background (default: None -> null background)
924
        saturation: float, optional
925
            The saturation level of the image. Default is set to twice the maximum of the data array and has no effect.
926
        live_fit: bool, optional
927
            If True, the transverse profile fit is plotted in live accross the loop (default: False).
928
        sigma_clip: int
929
            Sigma for outlier rejection (default: 5).
930

931
        Examples
932
        --------
933

934
        Build a mock spectrogram with random Poisson noise:
935

936
        >>> psf = MoffatGauss()
937
        >>> s0 = ChromaticPSF(psf, Nx=100, Ny=100, saturation=1000)
938
        >>> params = s0.generate_test_poly_params()
939
        >>> saturation = params[-1]
940
        >>> data = s0.build_spectrogram_image(params, mode="1D")
941
        >>> bgd = 10*np.ones_like(data)
942
        >>> xx, yy = np.meshgrid(np.arange(s0.Nx), np.arange(s0.Ny))
943
        >>> bgd += 1000*np.exp(-((xx-20)**2+(yy-10)**2)/(2*2))
944
        >>> data += bgd
945
        >>> data = np.random.poisson(data)
946
        >>> data_errors = np.sqrt(data+1)
947

948
        Extract the background:
949

950
        >>> bgd_model_func, _, _ = extract_spectrogram_background_sextractor(data, data_errors, ws=[30,50])
951

952
        Fit the transverse profile:
953

954
        >>> s = ChromaticPSF(psf, Nx=100, Ny=100, deg=4, saturation=saturation)
955
        >>> s.fit_transverse_PSF1D_profile(data, data_errors, w=20, ws=[30,50], pixel_step=10,
956
        ... bgd_model_func=bgd_model_func, saturation=saturation, live_fit=False, sigma_clip=5)
957
        >>> s.plot_summary(truth=s0)
958

959
        ..  doctest::
960
            :hide:
961

962
            >>> assert(not np.any(np.isclose(s.table['flux_sum'][3:6], np.zeros(s.Nx)[3:6], rtol=1e-3)))
963
            >>> assert(np.all(np.isclose(s.table['Dy'][-10:-1], np.zeros(s.Nx)[-10:-1], rtol=1e-2)))
964

965
        """
966
        if saturation is None:
1✔
967
            saturation = 2 * np.max(data)
×
968
        Ny, Nx = data.shape
1✔
969
        middle = Ny // 2
1✔
970
        index = np.arange(Ny)
1✔
971
        # Prepare the fit: start with the maximum of the spectrum
972
        xmax_index = int(np.unravel_index(np.argmax(data[middle - w:middle + w, :]), data.shape)[1])
1✔
973
        bgd_index = np.concatenate((np.arange(0, middle - ws[0]), np.arange(middle + ws[0], Ny))).astype(int)
1✔
974
        y = data[:, xmax_index]
1✔
975
        # first fit with moffat only to initialize the guess
976
        # hypothesis that max of spectrum if well describe by a focused PSF
977
        if bgd_model_func is not None:
1✔
978
            signal = y - bgd_model_func(xmax_index, index)[:, 0]
1✔
979
        else:
980
            signal = y
×
981
        # fwhm = compute_fwhm(index, signal, minimum=0)
982
        # Initialize PSF
983
        psf = self.psf
1✔
984
        guess = np.copy(psf.p_default).astype(float)
1✔
985
        # guess = [2 * np.nanmax(signal), middle, 0.5 * fwhm, 2, 0, 0.1 * fwhm, saturation]
986
        signal_sum = np.nanmax(signal)
1✔
987
        guess[0] = signal_sum
1✔
988
        guess[1] = xmax_index
1✔
989
        guess[2] = middle
1✔
990
        guess[-1] = saturation
1✔
991
        # bounds = [(0.1 * maxi, 10 * maxi), (middle - w, middle + w), (0.1, min(fwhm, Ny // 2)), (0.1, self.alpha_max),
992
        #           (-1, 0),
993
        #           (0.1, min(Ny // 2, fwhm)),
994
        #           (0, 2 * saturation)]
995
        psf.apply_max_width_to_bounds(max_half_width=Ny)
1✔
996
        bounds = np.copy(psf.bounds)
1✔
997
        bounds[0] = (0.1 * signal_sum, 2 * signal_sum)
1✔
998
        bounds[2] = (middle - w, middle + w)
1✔
999
        bounds[-1] = (0, 2 * saturation)
1✔
1000
        # moffat_guess = [2 * np.nanmax(signal), middle, 0.5 * fwhm, 2]
1001
        # moffat_bounds = [(0.1 * maxi, 10 * maxi), (middle - w, middle + w), (0.1, min(fwhm, Ny // 2)), (0.1, 10)]
1002
        # fit = fit_moffat1d_outlier_removal(index, signal, sigma=sigma, niter=2,
1003
        #                                    guess=moffat_guess, bounds=np.array(moffat_bounds).T)
1004
        # moffat_guess = [getattr(fit, p).value for p in fit.param_names]
1005
        # signal_width_guess = moffat_guess[2]
1006
        # bounds[2] = (0.1, min(Ny // 2, 5 * signal_width_guess))
1007
        # bounds[5] = (0.1, min(Ny // 2, 5 * signal_width_guess))
1008
        # guess[:4] = moffat_guess
1009
        init_guess = np.copy(guess)
1✔
1010
        # Go from max to right, then from max to left
1011
        # includes the boundaries to avoid Runge phenomenum in chromatic_fit
1012
        pixel_range = list(np.arange(xmax_index, Nx, pixel_step).astype(int))
1✔
1013
        if Nx - 1 not in pixel_range:
1✔
1014
            pixel_range.append(Nx - 1)
1✔
1015
        pixel_range += list(np.arange(xmax_index, -1, -pixel_step).astype(int))
1✔
1016
        if 0 not in pixel_range:
1✔
1017
            pixel_range.append(0)
1✔
1018
        pixel_range = np.array(pixel_range)
1✔
1019
        self.fitted_pixels = np.full(Nx, False)
1✔
1020
        for x in tqdm(pixel_range, disable=not parameters.VERBOSE):
1✔
1021
            guess = np.copy(guess)
1✔
1022
            if x == xmax_index:
1✔
1023
                guess = np.copy(init_guess)
1✔
1024
            # fit the background with a polynomial function
1025
            y = data[:, x]
1✔
1026
            if bgd_model_func is not None:
1✔
1027
                # x_array = [x] * index.size
1028
                signal = y - bgd_model_func(x, index)[:, 0]
1✔
1029
            else:
1030
                signal = y
×
1031
            if np.mean(signal[bgd_index]) < 0:
1✔
1032
                signal -= np.mean(signal[bgd_index])
1✔
1033
            # in case guess amplitude is too low
1034
            # pdf = np.abs(signal)
1035
            signal_sum = np.nansum(signal[middle - ws[0]:middle + ws[0]])
1✔
1036
            if signal_sum < 3 * np.nanstd(signal[bgd_index]):
1✔
1037
                continue
1✔
1038
            # if signal_sum > 0:
1039
            #     pdf /= signal_sum
1040
            # mean = np.nansum(pdf * index)
1041
            # bounds[0] = (0.1 * np.nanstd(bgd), 2 * np.nanmax(y[middle - ws[0]:middle + ws[0]]))
1042
            bounds[0] = (0.1 * signal_sum, 1.5 * signal_sum)
1✔
1043
            guess[0] = signal_sum
1✔
1044
            guess[1] = x
1✔
1045
            # if guess[4] > -1:
1046
            #    guess[0] = np.max(signal) / (1 + guess[4])
1047
            # std = np.sqrt(np.nansum(pdf * (index - mean) ** 2))
1048
            # if guess[0] < 3 * np.nanstd(bgd):
1049
            #     guess[0] = float(0.9 * np.abs(np.nanmax(signal)))
1050
            # if guess[0] * (1 + 0*guess[4]) > 1.2 * maxi:
1051
            #     guess[0] = 0.9 * maxi
1052
            psf.p = guess
1✔
1053
            psf.bounds = bounds
1✔
1054
            w = PSFFitWorkspace(psf, signal, data_errors=err[:, x], bgd_model_func=None,
1✔
1055
                                live_fit=False, verbose=False)
1056
            try:
1✔
1057
                run_minimisation_sigma_clipping(w, method="newton", sigma_clip=sigma_clip, niter_clip=1, verbose=False)
1✔
1058
            except:
×
1059
                pass
×
1060
            best_fit = w.params.p
1✔
1061
            # It is better not to propagate the guess to further pixel columns
1062
            # otherwise fit_chromatic_psf1D is more likely to get trapped in a local minimum
1063
            # Randomness of the slice fit is better :
1064
            # guess = best_fit
1065
            self.profile_params[x, :] = best_fit
1✔
1066
            # TODO: propagate amplitude uncertainties from Newton fit
1067
            self.table['flux_err'][x] = np.sqrt(np.sum(err[:, x] ** 2))
1✔
1068
            self.table['flux_sum'][x] = np.sum(signal)
1✔
1069
            if live_fit and parameters.DISPLAY:  # pragma: no cover
1070
                if not np.any(np.isnan(best_fit[0])):
1071
                    w.live_fit = True
1072
                    w.plot_fit()
1073
            self.fitted_pixels[x] = True
1✔
1074
        # interpolate the skipped pixels with splines
1075
        all_pixels = np.arange(Nx)
1✔
1076
        #xp = np.array(sorted(set(list(pixel_range))))
1077
        xp = all_pixels[self.fitted_pixels]
1✔
1078
        #self.fitted_pixels = xp
1079
        for i in range(len(self.psf.param_names)):
1✔
1080
            yp = self.profile_params[xp, i]
1✔
1081
            self.profile_params[:, i] = interp1d(xp, yp, kind='cubic', fill_value='extrapolate', bounds_error=False)(all_pixels)
1✔
1082
        for x in all_pixels:
1✔
1083
            y = data[:, x]
1✔
1084
            if bgd_model_func is not None:
1✔
1085
                signal = y - bgd_model_func(x, index)[:, 0]
1✔
1086
            else:
1087
                signal = y
×
1088
            if np.mean(signal[bgd_index]) < 0:
1✔
1089
                signal -= np.mean(signal[bgd_index])
1✔
1090
            self.table['flux_err'][x] = np.sqrt(np.sum(err[:, x] ** 2))
1✔
1091
            self.table['flux_sum'][x] = np.sum(signal)
1✔
1092
        self.poly_params = self.from_profile_params_to_poly_params(self.profile_params, indices=self.fitted_pixels)
1✔
1093
        self.from_profile_params_to_shape_params(self.profile_params)
1✔
1094
        self.cov_matrix = np.diag(1 / np.array(self.table['flux_err']) ** 2)
1✔
1095

1096
    def fit_chromatic_psf(self, data, bgd_model_func=None, data_errors=None, mode="1D",
1✔
1097
                          amplitude_priors_method="noprior", verbose=False, live_fit=False):
1098
        """
1099
        Fit a chromatic PSF model on 2D data.
1100

1101
        Parameters
1102
        ----------
1103
        data: array_like
1104
            2D array containing the image data.
1105
        bgd_model_func: callable, optional
1106
            A 2D function to model the extracted background (default: None -> null background)
1107
        data_errors: np.array
1108
            the 2D array uncertainties.
1109
        mode: str, optional
1110
            Set the fitting mode: either transverse 1D PSF profile (mode="1D") or full 2D PSF profile (mode="2D").
1111
        amplitude_priors_method: str, optional
1112
            Prior method to use to constrain the amplitude parameters of the PSF (default: "noprior").
1113
        verbose: bool, optional
1114
            Set the verbosity of the fitting process (default: False).
1115

1116
        Returns
1117
        -------
1118
        w: ChromaticPSFFitWorkspace
1119
            The ChromaticPSFFitWorkspace containing info abut the fitting process.
1120

1121
        Examples
1122
        --------
1123

1124
        Set the parameters:
1125

1126
        >>> parameters.PIXDIST_BACKGROUND = 40
1127
        >>> parameters.PIXWIDTH_BACKGROUND = 10
1128
        >>> parameters.PIXWIDTH_SIGNAL = 30
1129
        >>> parameters.DEBUG = True
1130

1131
        Build a mock spectrogram with random Poisson noise using the full 2D PSF model:
1132

1133
        >>> psf = Moffat(clip=False)
1134
        >>> s0 = ChromaticPSF(psf, Nx=120, Ny=100, deg=2, saturation=10000000)
1135
        >>> params = s0.generate_test_poly_params()
1136
        >>> params[:s0.Nx] *= 1
1137
        >>> s0.poly_params = params
1138
        >>> saturation = params[-1]
1139
        >>> data = s0.build_spectrogram_image(params, mode="2D")
1140
        >>> bgd = 10*np.ones_like(data)
1141
        >>> data += bgd
1142
        >>> data = np.random.poisson(data)
1143
        >>> data_errors = np.sqrt(np.abs(data))
1144

1145
        Extract the background:
1146

1147
        >>> bgd_model_func, _, _ = extract_spectrogram_background_sextractor(data, data_errors, ws=[30,50])
1148

1149
        Propagate background uncertainties:
1150

1151
        >>> data_errors = np.sqrt(data_errors**2 + bgd_model_func(np.arange(s0.Nx), np.arange(s0.Ny)))
1152

1153
        Estimate the first guess values:
1154

1155
        >>> s = ChromaticPSF(psf, Nx=120, Ny=100, deg=2, saturation=saturation)
1156
        >>> s.fit_transverse_PSF1D_profile(data, data_errors, w=20, ws=[30,50],
1157
        ... pixel_step=1, bgd_model_func=bgd_model_func, saturation=saturation, live_fit=False)
1158
        >>> s.plot_summary(truth=s0)
1159
        >>> amplitude_residuals = [ [s0.poly_params[:s0.Nx], np.array(s.table["amplitude"])-s0.poly_params[:s0.Nx],
1160
        ... np.array(s.table['amplitude'] * s.table['flux_err'] / s.table['flux_sum'])] ]
1161

1162
        Fit the data using the transverse 1D PSF model only:
1163

1164
        >>> w = s.fit_chromatic_psf(data, mode="1D", data_errors=data_errors, bgd_model_func=bgd_model_func,
1165
        ... amplitude_priors_method="noprior", verbose=True)
1166
        >>> s.plot_summary(truth=s0)
1167
        >>> amplitude_residuals.append([s0.poly_params[:s0.Nx], w.amplitude_params-s0.poly_params[:s0.Nx],
1168
        ... w.amplitude_params_err])
1169

1170
        ..  doctest::
1171
            :hide:
1172

1173
            >>> residuals = [(w.data[x]-w.model[x])/w.err[x] for x in range(w.Nx)]
1174
            >>> assert w.costs[-1] /(w.Nx*w.Ny) < 1.5
1175
            >>> assert np.abs(np.mean(residuals)) < 0.15
1176
            >>> assert np.std(residuals) < 1.2
1177

1178
        Fit the data using the full 2D PSF model
1179

1180
        >>> parameters.PSF_FIT_REG_PARAM = 0.002
1181
        >>> w = s.fit_chromatic_psf(data, mode="2D", data_errors=data_errors, bgd_model_func=bgd_model_func,
1182
        ... amplitude_priors_method="psf1d", verbose=True)
1183
        >>> s.plot_summary(truth=s0)
1184
        >>> amplitude_residuals.append([s0.poly_params[:s0.Nx], w.amplitude_params-s0.poly_params[:s0.Nx],
1185
        ... w.amplitude_params_err])
1186
        >>> for k, label in enumerate(["Transverse", "PSF1D", "PSF2D"]):
1187
        ...     plt.errorbar(np.arange(s0.Nx), amplitude_residuals[k][1]/amplitude_residuals[k][2],
1188
        ...                  yerr=amplitude_residuals[k][2]/amplitude_residuals[k][2],
1189
        ...                  fmt="+", label=label)  # doctest: +ELLIPSIS
1190
        <ErrorbarContainer ...>
1191
        >>> plt.grid()
1192
        >>> plt.legend()  # doctest: +ELLIPSIS
1193
        <matplotlib.legend.Legend object at ...>
1194
        >>> plt.show()
1195

1196
        ..  doctest::
1197
            :hide:
1198

1199
            >>> residuals = (w.data.flatten()-w.model.flatten())/w.err.flatten()
1200
            >>> assert w.costs[-1] /(w.Nx*w.Ny) < 1.2
1201
            >>> assert np.abs(np.mean(residuals)) < 0.15
1202
            >>> assert np.std(residuals) < 1.2
1203
            >>> assert np.abs(np.mean((w.amplitude_params - s0.poly_params[:s0.Nx])/w.amplitude_params_err)) < 0.5
1204

1205
        """
1206
        if mode == "1D":
1✔
1207
            w = ChromaticPSF1DFitWorkspace(self, data, data_errors=data_errors, bgd_model_func=bgd_model_func,
1✔
1208
                                           amplitude_priors_method=amplitude_priors_method, verbose=verbose,
1209
                                           live_fit=live_fit)
1210
            run_minimisation(w, method="newton", ftol=1 / (w.Nx * w.Ny), xtol=1e-6, niter=50)
1✔
1211
        elif mode == "2D":
1✔
1212
            # first shot to set the mask
1213
            w = ChromaticPSF2DFitWorkspace(self, data, data_errors=data_errors, bgd_model_func=bgd_model_func,
1✔
1214
                                           amplitude_priors_method=amplitude_priors_method, verbose=verbose,
1215
                                           live_fit=live_fit)
1216
            run_minimisation(w, method="newton", ftol=10 / (w.Nx * w.Ny), xtol=1e-4, niter=50, verbose=verbose)
1✔
1217
            if amplitude_priors_method == "psf1d":
1✔
1218
                w_reg = RegFitWorkspace(w, opt_reg=parameters.PSF_FIT_REG_PARAM, verbose=verbose)
1✔
1219
                w_reg.run_regularisation()
1✔
1220
                w.reg = np.copy(w_reg.opt_reg)
1✔
1221
                w.trace_r = np.trace(w_reg.resolution)
1✔
1222
                self.opt_reg = w_reg.opt_reg
1✔
1223
                w.simulate(*w.params.p)
1✔
1224
                if np.trace(w.amplitude_cov_matrix) < np.trace(w.amplitude_priors_cov_matrix):
1✔
1225
                    self.my_logger.warning(
×
1226
                        f"\n\tTrace of final covariance matrix ({np.trace(w.amplitude_cov_matrix)}) is "
1227
                        f"below the trace of the prior covariance matrix "
1228
                        f"({np.trace(w.amplitude_priors_cov_matrix)}). This is probably due to a very "
1229
                        f"high regularisation parameter in case of a bad fit. Therefore the final "
1230
                        f"covariance matrix is mulitiplied by the ratio of the traces and "
1231
                        f"the amplitude parameters are very close the amplitude priors.")
1232
                    r = np.trace(w.amplitude_priors_cov_matrix) / np.trace(w.amplitude_cov_matrix)
×
1233
                    w.amplitude_cov_matrix *= r
×
1234
                    w.amplitude_params_err = np.array([np.sqrt(w.amplitude_cov_matrix[x, x]) for x in range(self.Nx)])
×
1235

1236
            w.set_mask(poly_params=w.poly_params)
1✔
1237
            # precise fit with sigma clipping
1238
            run_minimisation_sigma_clipping(w, method="newton", ftol=1 / (w.Nx * w.Ny), xtol=1e-6, niter=50,
1✔
1239
                                            sigma_clip=parameters.SPECTRACTOR_DECONVOLUTION_SIGMA_CLIP,
1240
                                            niter_clip=3, verbose=verbose)
1241
        else:
1242
            raise ValueError(f"Unknown fitting mode={mode}. Must be '1D' or '2D'.")
×
1243

1244
        # recompute and save params in class attributes
1245
        w.simulate(*w.params.p)
1✔
1246
        self.poly_params = w.poly_params
1✔
1247
        self.cov_matrix = np.copy(w.amplitude_cov_matrix)
1✔
1248

1249
        # add background crop to y_c
1250
        self.poly_params[w.Nx + w.y_c_0_index] += w.bgd_width
1✔
1251

1252
        # fill results
1253
        self.psf.apply_max_width_to_bounds(max_half_width=w.Ny + 2 * w.bgd_width)
1✔
1254
        self.set_bounds()
1✔
1255
        self.profile_params = self.from_poly_params_to_profile_params(self.poly_params, apply_bounds=True)
1✔
1256
        self.fill_table_with_profile_params(self.profile_params)
1✔
1257
        self.profile_params[:self.Nx, 0] = w.amplitude_params
1✔
1258
        self.profile_params[:self.Nx, 1] = np.arange(self.Nx)
1✔
1259
        self.from_profile_params_to_shape_params(self.profile_params)
1✔
1260

1261
        # save plots
1262
        if parameters.LSST_SAVEFIGPATH:  # pragma: no cover
1263
            w.plot_fit()
1264
        return w
1✔
1265

1266

1267
class ChromaticPSFFitWorkspace(FitWorkspace):
1✔
1268

1269
    def __init__(self, chromatic_psf, data, data_errors, bgd_model_func=None, file_name="",
1✔
1270
                 amplitude_priors_method="noprior", verbose=False, plot=False, live_fit=False, truth=None):
1271
        length = len(chromatic_psf.table)
1✔
1272
        params = FitParameters(np.copy(chromatic_psf.poly_params[length:]),
1✔
1273
                               input_labels=list(np.copy(chromatic_psf.poly_params_labels[length:])),
1274
                               axis_names=list(np.copy(chromatic_psf.poly_params_names[length:])), fixed=None,
1275
                               truth=truth, filename=file_name)
1276
        for k, par in enumerate(params.input_labels):
1✔
1277
            if "x_c" in par or "saturation" in par:
1✔
1278
                params.fixed[k] = True
1✔
1279
        FitWorkspace.__init__(self, params, file_name=file_name, verbose=verbose, plot=plot, live_fit=live_fit)
1✔
1280
        self.my_logger = set_logger(self.__class__.__name__)
1✔
1281
        self.chromatic_psf = chromatic_psf
1✔
1282
        self.data = data
1✔
1283
        self.err = data_errors
1✔
1284
        self.bgd_model_func = bgd_model_func
1✔
1285
        self.poly_params = np.copy(self.chromatic_psf.poly_params)
1✔
1286
        self.y_c_0_index = -1
1✔
1287
        for k, par in enumerate(params.input_labels):
1✔
1288
            if par == "y_c_0":
1✔
1289
                self.y_c_0_index = k
1✔
1290
                break
1✔
1291

1292
        # prepare the fit
1293
        self.Ny, self.Nx = self.data.shape
1✔
1294
        if self.Ny != self.chromatic_psf.Ny:
1✔
1295
            raise AttributeError(f"Data y shape {self.Ny} different from "
×
1296
                                 f"ChromaticPSF input Ny {self.chromatic_psf.Ny}.")
1297
        if self.Nx != self.chromatic_psf.Nx:
1✔
1298
            raise AttributeError(f"Data x shape {self.Nx} different from "
×
1299
                                 f"ChromaticPSF input Nx {self.chromatic_psf.Nx}.")
1300
        self.pixels = np.arange(self.Ny)
1✔
1301

1302
        # prepare the background, data and errors
1303
        self.bgd = np.zeros_like(self.data)
1✔
1304
        if self.bgd_model_func is not None:
1✔
1305
            self.bgd = self.bgd_model_func(np.arange(self.Nx), self.pixels)
1✔
1306
        self.data = self.data - self.bgd
1✔
1307
        self.bgd_std = float(np.std(np.random.poisson(np.abs(self.bgd))))
1✔
1308

1309
        # crop spectrogram to fit faster
1310
        self.bgd_width = parameters.PIXWIDTH_BACKGROUND + parameters.PIXDIST_BACKGROUND - parameters.PIXWIDTH_SIGNAL
1✔
1311
        self.data = self.data[self.bgd_width:-self.bgd_width, :]
1✔
1312
        self.pixels = np.arange(self.data.shape[0])
1✔
1313
        self.err = np.copy(self.err[self.bgd_width:-self.bgd_width, :])
1✔
1314
        self.Ny, self.Nx = self.data.shape
1✔
1315
        self.poly_params[self.Nx + self.y_c_0_index] -= self.bgd_width
1✔
1316
        self.data_before_mask = np.copy(self.data)
1✔
1317

1318
        # update the bounds
1319
        self.chromatic_psf.psf.apply_max_width_to_bounds(max_half_width=self.Ny)
1✔
1320
        self.bounds = self.chromatic_psf.set_bounds()
1✔
1321

1322
        # error matrix
1323
        # here image uncertainties are assumed to be uncorrelated
1324
        # (which is not exactly true in rotated images)
1325
        self.data_cov = (self.err * self.err).flatten()
1✔
1326
        self.W = 1. / (self.err * self.err)
1✔
1327
        self.W = self.W.flatten()
1✔
1328

1329
        # design matrix
1330
        self.M = np.zeros((self.Nx, self.data.size))
1✔
1331
        self.M_dot_W_dot_M = np.zeros((self.Nx, self.Nx))
1✔
1332

1333
        # prepare results
1334
        self.amplitude_params = np.zeros(self.Nx)
1✔
1335
        self.amplitude_params_err = np.zeros(self.Nx)
1✔
1336
        self.amplitude_cov_matrix = np.zeros((self.Nx, self.Nx))
1✔
1337

1338
        # priors on amplitude parameters
1339
        self.amplitude_priors_list = ['noprior', 'positive', 'smooth', 'psf1d', 'fixed']
1✔
1340
        self.amplitude_priors_method = amplitude_priors_method
1✔
1341
        self.fwhm_priors = np.copy(self.chromatic_psf.table['fwhm'])
1✔
1342
        self.reg = parameters.PSF_FIT_REG_PARAM
1✔
1343
        self.trace_r = -1
1✔
1344
        self.Q = np.zeros((self.Nx, self.Nx))
1✔
1345
        self.Q_dot_A0 = np.zeros(self.Nx)
1✔
1346
        if amplitude_priors_method not in self.amplitude_priors_list:
1✔
1347
            raise ValueError(f"Unknown prior method for the amplitude fitting: {self.amplitude_priors_method}. "
×
1348
                             f"Must be either {self.amplitude_priors_list}.")
1349
        if self.amplitude_priors_method == "psf1d":
1✔
1350
            self.amplitude_priors = np.copy(self.chromatic_psf.poly_params[:self.Nx])
1✔
1351
            self.amplitude_priors_cov_matrix = np.copy(self.chromatic_psf.cov_matrix)
1✔
1352
            # self.amplitude_priors_err = np.copy(self.chromatic_psf.table["flux_err"])
1353
            self.Q = np.diag([1 / np.sum(self.err[:, x] ** 2) for x in range(self.Nx)])
1✔
1354
            self.Q_dot_A0 = self.Q @ self.amplitude_priors
1✔
1355
        if self.amplitude_priors_method == "fixed":
1✔
1356
            self.amplitude_priors = np.copy(self.chromatic_psf.poly_params[:self.Nx])
1✔
1357

1358
    def plot_fit(self):
1✔
1359
        cmap_bwr = copy.copy(mpl.colormaps["bwr"])
1✔
1360
        cmap_bwr.set_bad(color='lightgrey')
1✔
1361
        cmap_viridis = copy.copy(mpl.colormaps["viridis"])
1✔
1362
        cmap_viridis.set_bad(color='lightgrey')
1✔
1363

1364
        data = np.copy(self.data_before_mask)
1✔
1365
        model = np.copy(self.model)
1✔
1366
        err = np.copy(self.err)
1✔
1367
        if isinstance(self, ChromaticPSF1DFitWorkspace):
1✔
1368
            data = np.array([data[x] for x in range(self.Nx)], dtype=float).T
1✔
1369
            model = np.array([model[x] for x in range(self.Nx)], dtype=float).T
1✔
1370
            err = np.array([err[x] for x in range(self.Nx)], dtype=float).T
1✔
1371
        if isinstance(self, ChromaticPSF2DFitWorkspace):
1✔
1372
            if len(self.outliers) > 0 or len(self.mask) > 0:
1✔
1373
                bad_indices = np.array(list(self.get_bad_indices()) + list(self.mask)).astype(int)
1✔
1374
                data[bad_indices] = np.nan
1✔
1375
            data = data.reshape((self.Ny, self.Nx))
1✔
1376
            model = model.reshape((self.Ny, self.Nx))
1✔
1377
            err = err.reshape((self.Ny, self.Nx))
1✔
1378
        gs_kw = dict(width_ratios=[3, 0.15], height_ratios=[1, 1, 1, 1])
1✔
1379

1380
        fig, ax = plt.subplots(nrows=4, ncols=2, figsize=(7, 7), gridspec_kw=gs_kw)
1✔
1381

1382
        norm = np.nanmax(data)
1✔
1383
        plot_image_simple(ax[1, 0], data=model / norm, aspect='auto', cax=ax[1, 1], vmin=0, vmax=1,
1✔
1384
                          units='1/max(data)', cmap=cmap_viridis)
1385
        ax[1, 0].set_title("Model", fontsize=10, loc='center', color='white', y=0.8)
1✔
1386
        plot_image_simple(ax[0, 0], data=data / norm, title='Data', aspect='auto',
1✔
1387
                          cax=ax[0, 1], vmin=0, vmax=1, units='1/max(data)', cmap=cmap_viridis)
1388
        ax[0, 0].set_title('Data', fontsize=10, loc='center', color='white', y=0.8)
1✔
1389
        residuals = (data - model)
1✔
1390
        # residuals_err = self.spectrum.spectrogram_err / self.model
1391
        norm = err
1✔
1392
        residuals /= norm
1✔
1393
        std = float(np.nanstd(residuals))
1✔
1394
        plot_image_simple(ax[2, 0], data=residuals, vmin=-5 * std, vmax=5 * std, title='(Data-Model)/Err',
1✔
1395
                          aspect='auto', cax=ax[2, 1], units='', cmap=cmap_bwr)
1396

1397
        ax[2, 0].set_title('(Data-Model)/Err', fontsize=10, loc='center', color='black', y=0.8)
1✔
1398
        ax[2, 0].text(0.05, 0.05, f'mean={np.nanmean(residuals):.3f}\nstd={np.nanstd(residuals):.3f}',
1✔
1399
                      horizontalalignment='left', verticalalignment='bottom',
1400
                      color='black', transform=ax[2, 0].transAxes)
1401
        ax[0, 0].set_xticks(ax[2, 0].get_xticks()[1:-1])
1✔
1402
        ax[0, 1].get_yaxis().set_label_coords(3.5, 0.5)
1✔
1403
        ax[1, 1].get_yaxis().set_label_coords(3.5, 0.5)
1✔
1404
        ax[2, 1].get_yaxis().set_label_coords(3.5, 0.5)
1✔
1405
        ax[3, 1].remove()
1✔
1406
        ax[3, 0].errorbar(np.arange(self.Nx), np.nansum(data, axis=0), yerr=np.sqrt(np.nansum(err ** 2, axis=0)),
1✔
1407
                          label='Data', fmt='k.', markersize=0.1)
1408
        model[np.isnan(data)] = np.nan  # mask background values outside fitted region
1✔
1409
        ax[3, 0].plot(np.arange(self.Nx), np.nansum(model, axis=0), label='Model')
1✔
1410
        ax[3, 0].set_ylabel('Transverse sum')
1✔
1411
        ax[3, 0].set_xlabel(parameters.PLOT_XLABEL)
1✔
1412
        ax[3, 0].legend(fontsize=7)
1✔
1413
        ax[3, 0].set_xlim((0, data.shape[1]))
1✔
1414
        ax[3, 0].grid(True)
1✔
1415
        fig.tight_layout()
1✔
1416
        if self.live_fit:  # pragma: no cover
1417
            plt.draw()
1418
            plt.pause(1e-8)
1419
            plt.close()
1420
        else:  # pragma: no cover
1421
            if parameters.DISPLAY and self.verbose:
1422
                plt.show()
1423
        if parameters.LSST_SAVEFIGPATH:  # pragma: no cover
1424
            fig.savefig(os.path.join(parameters.LSST_SAVEFIGPATH,
1425
                                     f'fit_chromatic_psf_best_fit_{self.amplitude_priors_method}.pdf'),
1426
                        dpi=100, bbox_inches='tight')
1427

1428

1429
class ChromaticPSF1DFitWorkspace(ChromaticPSFFitWorkspace):
1✔
1430

1431
    def __init__(self, chromatic_psf, data, data_errors, bgd_model_func=None, file_name="",
1✔
1432
                 amplitude_priors_method="noprior", verbose=False, plot=False, live_fit=False, truth=None):
1433
        ChromaticPSFFitWorkspace.__init__(self, chromatic_psf, data, data_errors, bgd_model_func,
1✔
1434
                                          file_name, amplitude_priors_method, verbose, plot, live_fit, truth=truth)
1435
        self.my_logger = set_logger(self.__class__.__name__)
1✔
1436
        self.pixels = np.arange(self.Ny)
1✔
1437

1438
        # error matrix
1439
        # here image uncertainties are assumed to be uncorrelated
1440
        # (which is not exactly true in rotated images)
1441
        self.data_cov = self.err * self.err
1✔
1442
        W = 1. / (self.err * self.err)
1✔
1443
        # these lines make the code thinks that W is block diagonal
1444
        self.W = np.empty(self.Nx, dtype=object)
1✔
1445
        for x in range(self.Nx):
1✔
1446
            self.W[x] = W[:, x]
1✔
1447

1448
        # data: ordered by pixel columns
1449
        data = np.empty(self.Nx, dtype=object)
1✔
1450
        err = np.empty(self.Nx, dtype=object)
1✔
1451
        for x in range(self.Nx):
1✔
1452
            data[x] = self.data[:, x]
1✔
1453
            err[x] = self.err[:, x]
1✔
1454
        self.data = data
1✔
1455
        self.data_before_mask = np.copy(data)
1✔
1456
        self.err = err
1✔
1457
        self.pixels = self.pixels.T
1✔
1458

1459
    def simulate(self, *shape_params):
1✔
1460
        """
1461
        Compute a ChromaticPSF model given PSF shape parameters and minimizing
1462
        amplitude parameters given a spectrogram data array.
1463

1464
        Parameters
1465
        ----------
1466
        shape_params: array_like
1467
            PSF shape polynomial parameter array.
1468

1469
        Examples
1470
        --------
1471

1472
        Set the parameters:
1473

1474
        >>> parameters.PIXDIST_BACKGROUND = 40
1475
        >>> parameters.PIXWIDTH_BACKGROUND = 10
1476
        >>> parameters.PIXWIDTH_SIGNAL = 30
1477

1478
        Build a mock spectrogram with random Poisson noise:
1479

1480
        >>> psf = MoffatGauss()
1481
        >>> s0 = ChromaticPSF(psf, Nx=100, Ny=100, deg=4, saturation=1000)
1482
        >>> params = s0.generate_test_poly_params()
1483
        >>> s0.poly_params = params
1484
        >>> saturation = params[-1]
1485
        >>> data = s0.build_spectrogram_image(params, mode="1D")
1486
        >>> bgd = 10*np.ones_like(data)
1487
        >>> data += bgd
1488
        >>> data = np.random.poisson(data)
1489
        >>> data_errors = np.sqrt(data+1)
1490

1491
        Extract the background:
1492

1493
        >>> bgd_model_func, _, _ = extract_spectrogram_background_sextractor(data, data_errors, ws=[30,50])
1494

1495
        Estimate the first guess values:
1496

1497
        >>> s = ChromaticPSF(psf, Nx=100, Ny=100, deg=4, saturation=saturation)
1498
        >>> s.fit_transverse_PSF1D_profile(data, data_errors, w=20, ws=[30,50],
1499
        ... pixel_step=1, bgd_model_func=bgd_model_func, saturation=saturation, live_fit=False)
1500
        >>> guess = np.copy(s.table["amplitude"])
1501

1502
        Fit the amplitude of data without any prior:
1503

1504
        >>> w = ChromaticPSF1DFitWorkspace(s, data, data_errors, bgd_model_func=bgd_model_func, verbose=True,
1505
        ... amplitude_priors_method="noprior")
1506
        >>> y, mod, mod_err = w.simulate(*s.poly_params[s.Nx:])
1507
        >>> w.plot_fit()
1508

1509
        ..  doctest::
1510
            :hide:
1511

1512
            >>> assert mod is not None
1513
            >>> assert np.mean(np.abs(mod.flatten()-np.concatenate(w.data).ravel())/np.concatenate(w.err).ravel()) < 1
1514

1515
        Fit the amplitude of data smoothing the result with a window of size 10 pixels:
1516

1517
        >>> w = ChromaticPSF1DFitWorkspace(s, data, data_errors, bgd_model_func=bgd_model_func, verbose=True,
1518
        ... amplitude_priors_method="smooth")
1519
        >>> y, mod, mod_err = w.simulate(*s.poly_params[s.Nx:])
1520
        >>> w.plot_fit()
1521

1522
        ..  doctest::
1523
            :hide:
1524

1525
            >>> assert mod is not None
1526
            >>> assert np.mean(np.abs(mod.flatten()-np.concatenate(w.data).ravel())/np.concatenate(w.err).ravel()) < 1
1527

1528
        Fit the amplitude of data using the transverse PSF1D fit as a prior and with a
1529
        Tikhonov regularisation parameter set by parameters.PSF_FIT_REG_PARAM:
1530

1531
        >>> w = ChromaticPSF1DFitWorkspace(s, data, data_errors, bgd_model_func=bgd_model_func, verbose=True,
1532
        ... amplitude_priors_method="psf1d")
1533
        >>> y, mod, mod_err = w.simulate(*s.poly_params[s.Nx:])
1534
        >>> w.plot_fit()
1535

1536
        ..  doctest::
1537
            :hide:
1538

1539
            >>> assert mod is not None
1540
            >>> assert np.mean(np.abs(mod.flatten()-np.concatenate(w.data).ravel())/np.concatenate(w.err).ravel()) < 1
1541

1542
        """
1543
        # linear regression for the amplitude parameters
1544
        poly_params = np.copy(self.poly_params)
1✔
1545
        poly_params[self.Nx:] = np.copy(shape_params)
1✔
1546
        poly_params[self.Nx + self.y_c_0_index] -= self.bgd_width
1✔
1547
        profile_params = self.chromatic_psf.from_poly_params_to_profile_params(poly_params, apply_bounds=True)
1✔
1548
        profile_params[:self.Nx, 0] = 1
1✔
1549
        profile_params[:self.Nx, 1] = np.arange(self.Nx)
1✔
1550
        # profile_params[:self.Nx, 2] -= self.bgd_width
1551
        if self.amplitude_priors_method != "fixed":
1✔
1552
            # Matrix filling
1553
            M = np.array([self.chromatic_psf.psf.evaluate(self.pixels, p=profile_params[x, :]) for x in range(self.Nx)])
1✔
1554
            M_dot_W_dot_M = np.array([M[x].T @ (self.W[x] * M[x]) for x in range(self.Nx)])
1✔
1555
            if self.amplitude_priors_method != "psf1d":
1✔
1556
                cov_matrix = np.diag([1 / M_dot_W_dot_M[x] if M_dot_W_dot_M[x] > 0 else 0.1 * self.bgd_std
1✔
1557
                                      for x in range(self.Nx)])
1558
                amplitude_params = np.array([
1✔
1559
                    M[x].T @ (self.W[x] * self.data[x]) / (M_dot_W_dot_M[x]) if M_dot_W_dot_M[
1560
                                                                                    x] > 0 else 0.1 * self.bgd_std
1561
                    for x in range(self.Nx)])
1562
                if self.amplitude_priors_method == "positive":
1✔
1563
                    amplitude_params[amplitude_params < 0] = 0
×
1564
                elif self.amplitude_priors_method == "smooth":
1✔
1565
                    null_indices = np.where(amplitude_params < 0)[0]
1✔
1566
                    for index in null_indices:
1✔
1567
                        right = amplitude_params[index]
×
1568
                        for i in range(index, min(index + 10, self.Nx)):
×
1569
                            right = amplitude_params[i]
×
1570
                            if i not in null_indices:
×
1571
                                break
×
1572
                        left = amplitude_params[index]
×
1573
                        for i in range(index, max(0, index - 10), -1):
×
1574
                            left = amplitude_params[i]
×
1575
                            if i not in null_indices:
×
1576
                                break
×
1577
                        amplitude_params[index] = 0.5 * (right + left)
×
1578
                elif self.amplitude_priors_method == "noprior":
1✔
1579
                    pass
1✔
1580
            else:
1581
                M_dot_W_dot_M_plus_Q = [M_dot_W_dot_M[x] + self.reg * self.Q[x, x] for x in range(self.Nx)]
1✔
1582
                cov_matrix = np.diag([1 / M_dot_W_dot_M_plus_Q[x] if M_dot_W_dot_M_plus_Q[x] > 0 else 0.1 * self.bgd_std
1✔
1583
                                      for x in range(self.Nx)])
1584
                amplitude_params = [
1✔
1585
                    cov_matrix[x, x] * (M[x].T @ (self.W[x] * self.data[x]) + self.reg * self.Q_dot_A0[x])
1586
                    for x in range(self.Nx)]
1587
            self.M = M
1✔
1588
            self.M_dot_W_dot_M = M_dot_W_dot_M
1✔
1589
            self.model = np.zeros((self.Nx, self.Ny), dtype=float)
1✔
1590
            for x in range(self.Nx):
1✔
1591
                self.model[x] = M[x] * amplitude_params[x]
1✔
1592

1593
        else:
1594
            amplitude_params = np.copy(self.amplitude_priors)
×
1595
            err2 = np.copy(amplitude_params)
×
1596
            err2[err2 <= 0] = np.min(np.abs(err2[err2 > 0]))
×
1597
            cov_matrix = np.diag(err2)
×
1598
        self.amplitude_params = np.copy(amplitude_params)
1✔
1599
        self.amplitude_params_err = np.array([np.sqrt(cov_matrix[x, x])
1✔
1600
                                              if cov_matrix[x, x] > 0 else 0 for x in range(self.Nx)])
1601
        self.amplitude_cov_matrix = np.copy(cov_matrix)
1✔
1602
        poly_params[:self.Nx] = np.copy(amplitude_params)
1✔
1603
        self.poly_params = np.copy(poly_params)
1✔
1604
        poly_params[self.Nx + self.y_c_0_index] += self.bgd_width
1✔
1605

1606
        if self.amplitude_priors_method == "fixed":
1✔
1607
            self.model = self.chromatic_psf.build_spectrogram_image(poly_params, mode="1D")[
×
1608
                         self.bgd_width:-self.bgd_width, :].T
1609
            fig = plt.figure()
×
1610
            plt.imshow(self.model, origin="lower")
×
1611
            plt.show()
×
1612
        self.model_err = np.zeros_like(self.model)
1✔
1613
        return self.pixels, self.model, self.model_err
1✔
1614

1615

1616
class ChromaticPSF2DFitWorkspace(ChromaticPSFFitWorkspace):
1✔
1617

1618
    def __init__(self, chromatic_psf, data, data_errors, bgd_model_func=None, amplitude_priors_method="noprior",
1✔
1619
                 file_name="", verbose=False, plot=False, live_fit=False, truth=None):
1620
        ChromaticPSFFitWorkspace.__init__(self, chromatic_psf, data, data_errors, bgd_model_func,
1✔
1621
                                          file_name, amplitude_priors_method, verbose, plot, live_fit, truth=truth)
1622
        self.my_logger = set_logger(self.__class__.__name__)
1✔
1623
        yy, xx = np.mgrid[:self.Ny, :self.Nx]
1✔
1624
        self.pixels = np.asarray([xx, yy])
1✔
1625

1626
        # error matrix
1627
        # here image uncertainties are assumed to be uncorrelated
1628
        # (which is not exactly true in rotated images)
1629
        self.W = 1. / (self.err * self.err)
1✔
1630
        self.W = self.W.flatten()
1✔
1631
        self.data = self.data.flatten()
1✔
1632
        self.err = self.err.flatten()
1✔
1633
        self.sparse_indices = None
1✔
1634

1635
        # create a mask
1636
        self.data_before_mask = np.copy(self.data)
1✔
1637
        self.W_before_mask = np.copy(self.W)
1✔
1638
        self.sqrtW = np.sqrt(sparse.diags(self.W))
1✔
1639
        self.psf_cube_masked = None
1✔
1640
        self.set_mask()
1✔
1641

1642
        # regularisation matrices
1643
        self.reg = parameters.PSF_FIT_REG_PARAM
1✔
1644
        if amplitude_priors_method == "psf1d":
1✔
1645
            # U = np.diag([1 / np.sqrt(np.sum(self.err[:, x]**2)) for x in range(self.Nx)])
1646
            self.U = np.diag([1 / np.sqrt(self.amplitude_priors_cov_matrix[x, x]) for x in range(self.Nx)])
1✔
1647
            L = np.diag(-2 * np.ones(self.Nx)) + np.diag(np.ones(self.Nx), -1)[:-1, :-1] \
1✔
1648
                + np.diag(np.ones(self.Nx), 1)[:-1, :-1]
1649
            L.astype(float)
1✔
1650
            L[0, 0] = -1
1✔
1651
            L[-1, -1] = -1
1✔
1652
            self.L = L
1✔
1653
            self.Q = L.T @ self.U.T @ self.U @ L
1✔
1654
            self.Q_dot_A0 = self.Q @ self.amplitude_priors
1✔
1655

1656
    def set_mask(self, poly_params=None):
1✔
1657
        if poly_params is None:
1✔
1658
            poly_params = self.poly_params
1✔
1659
        profile_params = self.chromatic_psf.from_poly_params_to_profile_params(poly_params, apply_bounds=True)
1✔
1660
        #if np.sum(self.chromatic_psf.table["fwhm"]) == 0:
1661
        self.chromatic_psf.from_profile_params_to_shape_params(profile_params)
1✔
1662
        psf_cube = self.chromatic_psf.build_psf_cube(self.pixels, profile_params,
1✔
1663
                                                     fwhmx_clip=3 * parameters.PSF_FWHM_CLIP,
1664
                                                     fwhmy_clip=parameters.PSF_FWHM_CLIP, dtype="float32")
1665
        self.psf_cube_masked = psf_cube > 0
1✔
1666
        flat_spectrogram = np.sum(self.psf_cube_masked.reshape(len(profile_params), self.pixels[0].size), axis=0)
1✔
1667
        mask = flat_spectrogram == 0  # < 1e-2 * np.max(flat_spectrogram)
1✔
1668
        mask = mask.reshape(self.pixels[0].shape)
1✔
1669
        kernel = np.ones((3, self.Nx//10))  # enlarge a bit more the edges of the mask
1✔
1670
        mask = convolve2d(mask, kernel, 'same').astype(bool)
1✔
1671
        for k in range(self.psf_cube_masked.shape[0]):
1✔
1672
            self.psf_cube_masked[k] *= ~mask
1✔
1673
        mask = mask.reshape((self.pixels[0].size,))
1✔
1674
        self.W = np.copy(self.W_before_mask)
1✔
1675
        self.W[mask] = 0
1✔
1676
        self.sqrtW = np.sqrt(sparse.diags(self.W))
1✔
1677
        self.sparse_indices = None
1✔
1678
        self.mask = list(np.where(mask)[0])
1✔
1679

1680
    def simulate(self, *shape_params):
1✔
1681
        r"""
1682
        Compute a ChromaticPSF2D model given PSF shape parameters and minimizing
1683
        amplitude parameters using a spectrogram data array.
1684

1685
        The ChromaticPSF2D model :math:`\vec{m}(\vec{x},\vec{p})` can be written as
1686

1687
        .. math ::
1688
            :label: chromaticpsf2d
1689

1690
            \vec{m}(\vec{x},\vec{p}) = \sum_{i=0}^{N_x} A_i \phi\left(\vec{x},\vec{p}_i\right)
1691

1692
        with :math:`\vec{x}` the 2D array  of the pixel coordinates, :math:`\vec{A}` the amplitude parameter array
1693
        along the x axis of the spectrogram, :math:`\phi\left(\vec{x},\vec{p}_i\right)` the 2D PSF kernel whose integral
1694
        is normalised to one parametrized with the :math:`\vec{p}_i` non-linear parameter array. If the :math:`\vec{x}`
1695
        2D array is flatten in 1D, equation :eq:`chromaticpsf2d` is
1696

1697
        .. math ::
1698
            :label: chromaticpsf2d_matrix
1699
            :nowrap:
1700

1701
            \begin{align}
1702
            \vec{m}(\vec{x},\vec{p}) & = \mathbf{M}\left(\vec{x},\vec{p}\right) \mathbf{A} \\
1703

1704
            \mathbf{M}\left(\vec{x},\vec{p}\right) & = \left(\begin{array}{cccc}
1705
             \phi\left(\vec{x}_1,\vec{p}_1\right) & \phi\left(\vec{x}_2,\vec{p}_1\right) & ...
1706
             & \phi\left(\vec{x}_{N_x},\vec{p}_1\right) \\
1707
             ... & ... & ... & ...\\
1708
             \phi\left(\vec{x}_1,\vec{p}_{N_x}\right) & \phi\left(\vec{x}_2,\vec{p}_{N_x}\right) & ...
1709
             & \phi\left(\vec{x}_{N_x},\vec{p}_{N_x}\right) \\
1710
            \end{array}\right)
1711
            \end{align}
1712

1713

1714
        with :math:`\mathbf{M}` the design matrix.
1715

1716
        The goal of this function is to perform a minimisation of the amplitude vector :math:`\mathbf{A}` given
1717
        a set of non-linear parameters :math:`\mathbf{p}` and a spectrogram data array :math:`mathbf{y}` modelise as
1718

1719
        .. math:: \mathbf{y} = \mathbf{m}(\vec{x},\vec{p}) + \vec{\epsilon}
1720

1721
        with :math:`\vec{\epsilon}` a random noise vector. The :math:`\chi^2` function to minimise is
1722

1723
        .. math::
1724
            :label: chromaticspsf2d_chi2
1725

1726
            \chi^2(\mathbf{A})= \left(\mathbf{y} - \mathbf{M}\left(\vec{x},\vec{p}\right) \mathbf{A}\right)^T \mathbf{W}
1727
            \left(\mathbf{y} - \mathbf{M}\left(\vec{x},\vec{p}\right) \mathbf{A} \right)
1728

1729

1730
        with :math:`\mathbf{W}` the weight matrix, inverse of the covariance matrix. In our case this matrix is diagonal
1731
        as the pixels are considered all independent. The minimum of equation :eq:`chromaticspsf2d_chi2` is reached for
1732
        a the set of amplitude parameters :math:`\hat{\mathbf{A}}` given by
1733

1734
        .. math::
1735

1736
            \hat{\mathbf{A}} =  (\mathbf{M}^T \mathbf{W} \mathbf{M})^{-1} \mathbf{M}^T \mathbf{W} \mathbf{y}
1737

1738
        The error matrix on the :math:`\hat{\mathbf{A}}` coefficient is simply
1739
        :math:`(\mathbf{M}^T \mathbf{W} \mathbf{M})^{-1}`.
1740

1741
        See Also
1742
        --------
1743
        ChromaticPSF1DFitWorkspace.simulate
1744

1745
        Parameters
1746
        ----------
1747
        shape_params: array_like
1748
            PSF shape polynomial parameter array.
1749

1750
        Examples
1751
        --------
1752

1753
        Set the parameters:
1754

1755
        .. doctest::
1756

1757
            >>> parameters.PIXDIST_BACKGROUND = 40
1758
            >>> parameters.PIXWIDTH_BACKGROUND = 10
1759
            >>> parameters.PIXWIDTH_SIGNAL = 30
1760

1761
        Build a mock spectrogram with random Poisson noise:
1762

1763
        .. doctest::
1764

1765
            >>> psf = Moffat(clip=False)
1766
            >>> s0 = ChromaticPSF(psf, Nx=120, Ny=100, deg=2, saturation=100000)
1767
            >>> params = s0.generate_test_poly_params()
1768
            >>> params[:s0.Nx] *= 10
1769
            >>> s0.poly_params = params
1770
            >>> saturation = params[-1]
1771
            >>> data = s0.build_spectrogram_image(params, mode="2D")
1772
            >>> bgd = 10*np.ones_like(data)
1773
            >>> data += bgd
1774
            >>> data = np.random.poisson(data)
1775
            >>> data_errors = np.sqrt(data+1)
1776

1777
        Extract the background:
1778

1779
        .. doctest::
1780

1781
            >>> bgd_model_func, _, _ = extract_spectrogram_background_sextractor(data, data_errors, ws=[30,50])
1782

1783
        Estimate the first guess values:
1784

1785
        .. doctest::
1786

1787
            >>> s = ChromaticPSF(psf, Nx=120, Ny=100, deg=2, saturation=saturation)
1788
            >>> s.fit_transverse_PSF1D_profile(data, data_errors, w=20, ws=[30,50],
1789
            ... pixel_step=1, bgd_model_func=bgd_model_func, saturation=saturation, live_fit=False)
1790
            >>> s.plot_summary(truth=s0)
1791

1792
        Simulate the data with fixed amplitude priors:
1793

1794
        .. doctest::
1795

1796
            >>> w = ChromaticPSF2DFitWorkspace(s, data, data_errors, bgd_model_func=bgd_model_func,
1797
            ... amplitude_priors_method="fixed", verbose=True)
1798
            >>> y, mod, mod_err = w.simulate(s.poly_params[s.Nx:])
1799
            >>> w.plot_fit()
1800

1801
        .. doctest::
1802
            :hide:
1803

1804
            >>> assert mod is not None
1805

1806
        Simulate the data with a Tikhonov prior on amplitude parameters:
1807

1808
        .. doctest::
1809

1810
            >>> parameters.PSF_FIT_REG_PARAM = 0.002
1811
            >>> s.poly_params = s.from_table_to_poly_params()
1812
            >>> w = ChromaticPSF2DFitWorkspace(s, data, data_errors, bgd_model_func=bgd_model_func,
1813
            ... amplitude_priors_method="psf1d", verbose=True)
1814
            >>> y, mod, mod_err = w.simulate(s.poly_params[s.Nx:])
1815
            >>> w.plot_fit()
1816

1817
        .. doctest::
1818
            :hide:
1819

1820
            >>> assert mod is not None
1821

1822
        """
1823
        # linear regression for the amplitude parameters
1824
        # prepare the vectors
1825
        poly_params = np.copy(self.poly_params)
1✔
1826
        poly_params[self.Nx:] = np.copy(shape_params)
1✔
1827
        poly_params[self.Nx + self.y_c_0_index] -= self.bgd_width
1✔
1828
        profile_params = self.chromatic_psf.from_poly_params_to_profile_params(poly_params, apply_bounds=True)
1✔
1829
        profile_params[:self.Nx, 0] = 1
1✔
1830
        profile_params[:self.Nx, 1] = np.arange(self.Nx)
1✔
1831
        # profile_params[:self.Nx, 2] -= self.bgd_width
1832
        if self.amplitude_priors_method != "fixed":
1✔
1833
            # Matrix filling
1834
            psf_cube = self.chromatic_psf.build_psf_cube(self.pixels, profile_params,
1✔
1835
                                                         fwhmx_clip=3 * parameters.PSF_FWHM_CLIP,
1836
                                                         fwhmy_clip=parameters.PSF_FWHM_CLIP, dtype="float32", mask=self.psf_cube_masked)
1837
            M = psf_cube.reshape(len(profile_params), self.pixels[0].size).T  # flattening
1✔
1838
            self.sparse_indices = np.where(M > 0)
1✔
1839
            M = sparse.csr_matrix((M[self.sparse_indices].ravel(), self.sparse_indices), shape=M.shape, dtype="float32")
1✔
1840
            M_dot_W = M.T * self.sqrtW
1✔
1841
            # Compute the minimizing amplitudes
1842
            M_dot_W_dot_M = M_dot_W @ M_dot_W.T
1✔
1843
            if self.amplitude_priors_method != "psf1d":
1✔
1844
                # try:
1845
                #     L = np.linalg.inv(np.linalg.cholesky(M_dot_W_dot_M))
1846
                #     cov_matrix = L.T @ L
1847
                # except np.linalg.LinAlgError:
1848
                cov_matrix = np.linalg.inv(M_dot_W_dot_M)
×
1849
                amplitude_params = cov_matrix @ (M.T @ (self.W * self.data))
×
1850
                if self.amplitude_priors_method == "positive":
×
1851
                    amplitude_params[amplitude_params < 0] = 0
×
1852
                elif self.amplitude_priors_method == "smooth":
×
1853
                    null_indices = np.where(amplitude_params < 0)[0]
×
1854
                    for index in null_indices:
×
1855
                        right = amplitude_params[index]
×
1856
                        for i in range(index, min(index + 10, self.Nx)):
×
1857
                            right = amplitude_params[i]
×
1858
                            if i not in null_indices:
×
1859
                                break
×
1860
                        left = amplitude_params[index]
×
1861
                        for i in range(index, max(0, index - 10), -1):
×
1862
                            left = amplitude_params[i]
×
1863
                            if i not in null_indices:
×
1864
                                break
×
1865
                        amplitude_params[index] = 0.5 * (right + left)
×
1866
                elif self.amplitude_priors_method == "noprior":
×
1867
                    pass
×
1868
            else:
1869
                M_dot_W_dot_M_plus_Q = M_dot_W_dot_M + self.reg * self.Q
1✔
1870
                # try:
1871
                #     L = np.linalg.inv(np.linalg.cholesky(M_dot_W_dot_M_plus_Q))
1872
                #     cov_matrix = L.T @ L
1873
                # except np.linalg.LinAlgError:
1874
                cov_matrix = np.linalg.inv(M_dot_W_dot_M_plus_Q)
1✔
1875
                amplitude_params = cov_matrix @ (M.T @ (self.W * self.data) + self.reg * self.Q_dot_A0)
1✔
1876
            amplitude_params = np.asarray(amplitude_params).reshape(-1)
1✔
1877
            self.M = M
1✔
1878
            self.M_dot_W_dot_M = M_dot_W_dot_M
1✔
1879
            self.model = M @ amplitude_params
1✔
1880
        else:
1881
            amplitude_params = np.copy(self.amplitude_priors)
1✔
1882
            err2 = np.copy(amplitude_params)
1✔
1883
            err2[err2 <= 0] = np.min(np.abs(err2[err2 > 0]))
1✔
1884
            cov_matrix = np.diag(err2)
1✔
1885
        poly_params[:self.Nx] = amplitude_params
1✔
1886
        self.poly_params = np.copy(poly_params)
1✔
1887
        self.amplitude_params = np.copy(amplitude_params)
1✔
1888
        # TODO: propagate and marginalize over the shape parameter uncertainties ?
1889
        self.amplitude_params_err = np.array([np.sqrt(cov_matrix[x, x]) for x in range(self.Nx)])
1✔
1890
        self.amplitude_cov_matrix = np.copy(cov_matrix)
1✔
1891
        # in_bounds, penalty, name = self.chromatic_psf.check_bounds(poly_params, noise_level=self.bgd_std)
1892
        if self.amplitude_priors_method == "fixed":
1✔
1893
            poly_params[self.Nx + self.y_c_0_index] += self.bgd_width
1✔
1894
            self.model = self.chromatic_psf.build_spectrogram_image(poly_params, mode="2D")[
1✔
1895
                         self.bgd_width:-self.bgd_width, :]
1896
        self.model_err = np.zeros_like(self.model)
1✔
1897
        return self.pixels, self.model, self.model_err
1✔
1898

1899

1900
if __name__ == "__main__":
1✔
1901
    import doctest
1✔
1902

1903
    doctest.testmod()
1✔
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

© 2025 Coveralls, Inc