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

nikhil-sarin / redback / 15712503173

17 Jun 2025 04:10PM UTC coverage: 86.934% (-0.5%) from 87.445%
15712503173

Pull #280

github

web-flow
Merge 267929d49 into 1dc53db79
Pull Request #280: Extra spectrum models

7 of 88 new or added lines in 3 files covered. (7.95%)

11 existing lines in 2 files now uncovered.

13586 of 15628 relevant lines covered (86.93%)

0.87 hits per line

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

87.28
/redback/sed.py
1
from typing import Union
1✔
2

3
import numpy as np
1✔
4
from sncosmo import TimeSeriesSource
1✔
5

6
from redback.constants import *
1✔
7
from redback.utils import nu_to_lambda, bandpass_magnitude_to_flux
1✔
8

9

10
def _bandflux_single_redback(model, band, time_or_phase):
1✔
11
    """
12

13
    Synthetic photometry of a model through a single bandpass
14

15
    :param model: Source object
16
    :param band: Bandpass
17
    :param time_or_phase: Time or phase numpy array
18
    :return: bandflux through the bandpass
19
    """
20
    from sncosmo.utils import integration_grid
1✔
21
    HC_ERG_AA = 1.9864458571489284e-08 # planck * speed_of_light in AA/s
1✔
22
    MODEL_BANDFLUX_SPACING = 5.0  # Angstroms
1✔
23

24
    if (band.minwave() < model.minwave() or band.maxwave() > model.maxwave()):
1✔
25
        raise ValueError('bandpass {0!r:s} [{1:.6g}, .., {2:.6g}] '
×
26
                         'outside spectral range [{3:.6g}, .., {4:.6g}]'
27
                         .format(band.name, band.minwave(), band.maxwave(),
28
                                 model.minwave(), model.maxwave()))
29

30
        # Set up wavelength grid. Spacing (dwave) evenly divides the bandpass,
31
        # closest to 5 angstroms without going over.
32
    wave, dwave = integration_grid(band.minwave(), band.maxwave(),
1✔
33
                                   MODEL_BANDFLUX_SPACING)
34
    trans = band(wave)
1✔
35
    f = model._flux(time_or_phase, wave)
1✔
36
    f = np.abs(f)
1✔
37
    return np.sum(wave * trans * f, axis=1) * dwave / HC_ERG_AA
1✔
38

39

40
def _bandflux_redback(model, band, time_or_phase, zp, zpsys):
1✔
41
    """
42
    Support function for bandflux in Source and Model. Follows SNCOSMO
43

44
    This is necessary to have outside because ``phase`` is used in Source
45
    and ``time`` is used in Model, and we want the method signatures to
46
    have the right variable name.
47
    """
48
    from sncosmo.magsystems import get_magsystem
1✔
49
    from sncosmo.bandpasses import get_bandpass
1✔
50

51
    if zp is not None and zpsys is None:
1✔
52
        raise ValueError('zpsys must be given if zp is not None')
×
53

54
    # broadcast arrays
55
    if zp is None:
1✔
56
        time_or_phase, band = np.broadcast_arrays(time_or_phase, band)
1✔
57
    else:
58
        time_or_phase, band, zp, zpsys = \
×
59
            np.broadcast_arrays(time_or_phase, band, zp, zpsys)
60

61
    # Convert all to 1-d arrays.
62
    ndim = time_or_phase.ndim  # Save input ndim for return val.
1✔
63
    time_or_phase = np.atleast_1d(time_or_phase)
1✔
64
    band = np.atleast_1d(band)
1✔
65
    if zp is not None:
1✔
66
        zp = np.atleast_1d(zp)
×
67
        zpsys = np.atleast_1d(zpsys)
×
68

69
    # initialize output arrays
70
    bandflux = np.zeros(time_or_phase.shape, dtype=float)
1✔
71

72
    # Loop over unique bands.
73
    for b in set(band):
1✔
74
        mask = band == b
1✔
75
        b = get_bandpass(b)
1✔
76

77
        fsum = _bandflux_single_redback(model, b, time_or_phase[mask])
1✔
78

79
        if zp is not None:
1✔
80
            zpnorm = 10. ** (0.4 * zp[mask])
×
81
            bandzpsys = zpsys[mask]
×
82
            for ms in set(bandzpsys):
×
83
                mask2 = bandzpsys == ms
×
84
                ms = get_magsystem(ms)
×
85
                zpnorm[mask2] = zpnorm[mask2] / ms.zpbandflux(b)
×
86
            fsum *= zpnorm
×
87

88
        bandflux[mask] = fsum
1✔
89

90
    if ndim == 0:
1✔
91
        return bandflux[0]
×
92
    return bandflux
1✔
93

94
def _bandmag_redback(model, band, magsys, time_or_phase):
1✔
95
    """
96
    Support function for bandflux in Source and Model.
97
    This is necessary to have outside the models because ``phase`` is used in
98
    Source and ``time`` is used in Model.
99
    """
100
    from sncosmo.magsystems import get_magsystem
1✔
101

102
    bandflux = _bandflux_redback(model, band, time_or_phase, None, None)
1✔
103
    band, magsys, bandflux = np.broadcast_arrays(band, magsys, bandflux)
1✔
104
    return_scalar = (band.ndim == 0)
1✔
105
    band = band.ravel()
1✔
106
    magsys = magsys.ravel()
1✔
107
    bandflux = bandflux.ravel()
1✔
108

109
    result = np.empty(bandflux.shape, dtype=float)
1✔
110
    for i, (b, ms, f) in enumerate(zip(band, magsys, bandflux)):
1✔
111
        ms = get_magsystem(ms)
1✔
112
        zpf = ms.zpbandflux(b)
1✔
113
        result[i] = -2.5 * np.log10(f / zpf)
1✔
114

115
    if return_scalar:
1✔
116
        return result[0]
×
117
    return result
1✔
118

119
class RedbackTimeSeriesSource(TimeSeriesSource):
1✔
120
        def __init__(self, phase, wave, flux, **kwargs):
1✔
121
            """
122
            RedbackTimeSeriesSource is a subclass of sncosmo.TimeSeriesSource that adds the ability to return the
123
            flux density at a given time and wavelength, and changes
124
            the behaviour of the _flux method to better handle models with very low flux values.
125

126
            :param phase: phase/time array
127
            :param wave: wavelength array in Angstrom
128
            :param spectra: spectra in erg/cm^2/s/A evaluated at all times and frequencies; shape (len(times), len(frequency_array))
129
            :param kwargs: additional arguments
130
            """
131
            super(RedbackTimeSeriesSource, self).__init__(phase=phase, wave=wave, flux=flux, **kwargs)
1✔
132

133
        def get_flux_density(self, time, wavelength):
1✔
134
            """
135
            Get the flux density at a given time and wavelength.
136

137
            :param time: time in days
138
            :param wavelength: wavelength in Angstrom
139
            :return: flux density in erg/cm^2/s/A
140
            """
141
            return self._flux(time, wavelength)
×
142

143
        def bandflux(self, band, phase, zp=None, zpsys=None):
1✔
144
            """
145
            Flux through the given bandpass(es) at the given phase(s).
146

147
            Default return value is flux in photons / s / cm^2. If zp and zpsys
148
            are given, flux(es) are scaled to the requested zeropoints.
149

150
            Parameters
151
            ----------
152
            band : str or list_like
153
                Name(s) of bandpass(es) in registry.
154
            phase : float or list_like, optional
155
                Phase(s) in days. Default is `None`, which corresponds to the full
156
                native phase sampling of the model.
157
            zp : float or list_like, optional
158
                If given, zeropoint to scale flux to (must also supply ``zpsys``).
159
                If not given, flux is not scaled.
160
            zpsys : str or list_like, optional
161
                Name of a magnitude system in the registry, specifying the system
162
                that ``zp`` is in.
163

164
            Returns
165
            -------
166
            bandflux : float or `~numpy.ndarray`
167
                Flux in photons / s /cm^2, unless `zp` and `zpsys` are
168
                given, in which case flux is scaled so that it corresponds
169
                to the requested zeropoint. Return value is `float` if all
170
                input parameters are scalars, `~numpy.ndarray` otherwise.
171
            """
172
            return _bandflux_redback(self, band, phase, zp, zpsys)
×
173

174
        def bandmag(self, band, magsys, phase):
1✔
175
            """Magnitude at the given phase(s) through the given
176
            bandpass(es), and for the given magnitude system(s).
177

178
            Parameters
179
            ----------
180
            band : str or list_like
181
                Name(s) of bandpass in registry.
182
            magsys : str or list_like
183
                Name(s) of `~sncosmo.MagSystem` in registry.
184
            phase : float or list_like
185
                Phase(s) in days.
186

187
            Returns
188
            -------
189
            mag : float or `~numpy.ndarray`
190
                Magnitude for each item in band, magsys, phase.
191
                The return value is a float if all parameters are not iterables.
192
                The return value is an `~numpy.ndarray` if any are iterable.
193
            """
194
            return _bandmag_redback(self, band, magsys, phase)
1✔
195

196

197

198
def blackbody_to_flux_density(temperature, r_photosphere, dl, frequency):
1✔
199
    """
200
    A general blackbody_to_flux_density formula
201

202
    :param temperature: effective temperature in kelvin
203
    :param r_photosphere: photosphere radius in cm
204
    :param dl: luminosity_distance in cm
205
    :param frequency: frequency to calculate in Hz - Must be same length as time array or a single number.
206
                      In source frame
207
    :return: flux_density in erg/s/Hz/cm^2
208
    """
209
    # adding units back in to ensure dimensions are correct
210
    frequency = frequency * uu.Hz
1✔
211
    radius = r_photosphere * uu.cm
1✔
212
    dl = dl * uu.cm
1✔
213
    temperature = temperature * uu.K
1✔
214
    planck = cc.h.cgs
1✔
215
    speed_of_light = cc.c.cgs
1✔
216
    boltzmann_constant = cc.k_B.cgs
1✔
217
    num = 2 * np.pi * planck * frequency ** 3 * radius ** 2
1✔
218
    denom = dl ** 2 * speed_of_light ** 2
1✔
219
    frac = 1. / (np.expm1((planck * frequency) / (boltzmann_constant * temperature)))
1✔
220
    flux_density = num / denom * frac
1✔
221
    return flux_density
1✔
222

223

224
class _SED(object):
1✔
225

226
    # sed units are erg/s/Angstrom - need to turn them into flux density compatible units
227
    UNITS = uu.erg / uu.s / uu.Hz / uu.cm**2
1✔
228

229
    def __init__(self, frequency: Union[np.ndarray, float], luminosity_distance: float, length=1) -> None:
1✔
230
        self.sed = None
1✔
231
        if isinstance(frequency, (float, int)):
1✔
232
            self.frequency = np.array([frequency] * length)
1✔
233
        else:
234
            self.frequency = frequency
1✔
235
        self.luminosity_distance = luminosity_distance
1✔
236

237
    @property
1✔
238
    def flux_density(self):
1✔
239
        flux_density = self.sed.copy()
1✔
240
        # add distance units
241
        flux_density /= (4 * np.pi * self.luminosity_distance ** 2)
1✔
242
        # get rid of Angstrom and normalise to frequency
243
        flux_density *= nu_to_lambda(self.frequency)
1✔
244
        flux_density /= self.frequency
1✔
245

246
        # add units
247
        flux_density = flux_density << self.UNITS
1✔
248

249
        # convert to mJy
250
        return flux_density.to(uu.mJy)
1✔
251

252
def boosted_bolometric_luminosity(temperature, radius, lambda_cut):
1✔
253
    """
254
    Compute the boosted bolometric luminosity for a blackbody with temperature T (K)
255
    and radius R (cm) to account for missing blue flux.
256

257
    It uses:
258
        L_bb = 4π R² σ_SB T⁴,
259
    F_tot = σ_SB T⁴, and integrates the flux density redward of lambda_cut.
260

261
    Parameters
262
    ----------
263
    temperature : float
264
        Temperature (K)
265
    radius : float
266
        Photospheric radius (cm)
267
    lambda_cut : float
268
        Cutoff wavelength in centimeters (i.e. converted from angstroms by multiplying by 1e-8)
269

270
    Returns
271
    -------
272
    L_boosted : float
273
        The corrected bolometric luminosity (erg/s)
274
    """
275
    from scipy.integrate import quad
1✔
276
    sigma_SB = sigma_sb  # Stefan–Boltzmann constant in cgs
1✔
277

278
    # Compute pure-blackbody bolometric luminosity.
279
    L_bb = 4.0 * np.pi * radius ** 2 * sigma_SB * temperature ** 4
1✔
280
    # Total flux per unit area (erg/s/cm^2)
281
    F_tot = sigma_SB * temperature ** 4
1✔
282

283
    # Define the Planck function B_lambda (in erg/s/cm^2/cm/sr)
284
    def planck_lambda(lam, T):
1✔
285
        h = planck  # erg s
1✔
286
        c = speed_of_light  # cm/s
1✔
287
        k = boltzmann_constant  # erg/K
1✔
288
        return (2.0 * h * c ** 2) / (lam ** 5) / (np.exp(h * c / (lam * k * T)) - 1.0)
1✔
289

290
    # Integrand: π * B_lambda gives flux per unit wavelength (erg/s/cm²/cm)
291
    integrand = lambda lam, T: np.pi * planck_lambda(lam, T)
1✔
292
    # Integrate from lambda_cut to infinity to get the red flux.
293
    F_red, integration_error = quad(integrand, lambda_cut, np.inf, args=(temperature,))
1✔
294
    # Compute boost factor.
295
    Boost = F_tot / F_red
1✔
296
    # Corrected luminosity.
297
    return Boost * L_bb, L_bb
1✔
298

299

300
class CutoffBlackbody(_SED):
1✔
301

302
    X_CONST = planck * speed_of_light / boltzmann_constant
1✔
303
    FLUX_CONST = 4 * np.pi * 2 * np.pi * planck * speed_of_light ** 2 * angstrom_cgs
1✔
304

305
    reference = "https://ui.adsabs.harvard.edu/abs/2017ApJ...850...55N/abstract"
1✔
306

307
    def __init__(self, time: np.ndarray, temperature: np.ndarray, luminosity: np.ndarray, r_photosphere: np.ndarray,
1✔
308
                 frequency: Union[float, np.ndarray], luminosity_distance: float, cutoff_wavelength: float,
309
                 **kwargs: None) -> None:
310
        """
311
        Blackbody SED with a cutoff
312

313
        :param time: time in source frame
314
        :param temperature: temperature in kelvin
315
        :param luminosity: luminosity in cgs
316
        :param r_photosphere: photosphere radius in cm
317
        :param frequency: frequency in Hz - must be a single number or same length as time array
318
        :param luminosity_distance: dl in cm
319
        :param cutoff_wavelength: cutoff wavelength in Angstrom
320
        :param kwargs: None
321
        """
322
        super(CutoffBlackbody, self).__init__(
1✔
323
            frequency=frequency, luminosity_distance=luminosity_distance, length=len(time))
324
        self.time = time
1✔
325
        self.unique_times = np.unique(self.time)
1✔
326
        tsort = np.argsort(self.time)
1✔
327
        self.uniq_is = np.searchsorted(self.time, self.unique_times, sorter=tsort)
1✔
328

329
        self.luminosity = luminosity
1✔
330
        self.temperature = temperature
1✔
331
        self.r_photosphere = r_photosphere
1✔
332
        self.cutoff_wavelength = cutoff_wavelength * angstrom_cgs
1✔
333

334
        self.norms = None
1✔
335

336
        self.sed = np.zeros(len(self.time))
1✔
337
        self.calculate_flux_density()
1✔
338

339
    @property
1✔
340
    def wavelength(self):
1✔
341
        if len(self.frequency) == 1:
1✔
342
            self.frequency = np.ones(len(self.time)) * self.frequency              
×
343
        wavelength = nu_to_lambda(self.frequency) * angstrom_cgs
1✔
344
        if len(wavelength) != len(self.time):
1✔
345
            wavelength = np.tile(wavelength.T, (len(self.time), 1)).T
1✔
346
        return wavelength
1✔
347

348
    @property
1✔
349
    def mask(self):
1✔
350
        return self.wavelength < self.cutoff_wavelength
1✔
351

352
    @property
1✔
353
    def nxcs(self):
1✔
354
        return self.X_CONST * np.array(range(1, 11))
1✔
355

356
    def _set_sed(self):
1✔
357
        if np.size(self.wavelength) != np.size(self.time):
1✔
358
            self.sed = np.zeros((len(self.frequency), len(self.time)))
1✔
359
            self.r_photosphere = np.tile(self.r_photosphere, (len(self.frequency), 1))
1✔
360
            self.temperature = np.tile(self.temperature, (len(self.frequency), 1))
1✔
361
            self.sed[self.mask] = \
1✔
362
                self.FLUX_CONST * (self.r_photosphere[self.mask]**2 / self.cutoff_wavelength /
363
                                self.wavelength[self.mask] ** 4) \
364
                / np.expm1(self.X_CONST / self.wavelength[self.mask] / self.temperature[self.mask])
365
            self.sed[~self.mask] = \
1✔
366
                self.FLUX_CONST * (self.r_photosphere[~self.mask]**2 / self.wavelength[~self.mask]**5) \
367
                / np.expm1(self.X_CONST / self.wavelength[~self.mask] / self.temperature[~self.mask])
368
            self.sed *= self.norms[np.searchsorted(self.unique_times, self.time)]
1✔
369
        else:    
370
            self.sed[self.mask] = \
1✔
371
                self.FLUX_CONST * (self.r_photosphere[self.mask]**2 / self.cutoff_wavelength /
372
                            self.wavelength[self.mask] ** 4) \
373
                / np.expm1(self.X_CONST / self.wavelength[self.mask] / self.temperature[self.mask])
374
            self.sed[~self.mask] = \
1✔
375
                self.FLUX_CONST * (self.r_photosphere[~self.mask]**2 / self.wavelength[~self.mask]**5) \
376
                / np.expm1(self.X_CONST / self.wavelength[~self.mask] / self.temperature[~self.mask])
377
            # Apply renormalisation
378
            self.sed *= self.norms[np.searchsorted(self.unique_times, self.time)]
1✔
379

380
    def _set_norm(self):
1✔
381
        self.norms = self.luminosity[self.uniq_is] / \
1✔
382
                     (self.FLUX_CONST / angstrom_cgs * self.r_photosphere[self.uniq_is] ** 2 * self.temperature[
383
                         self.uniq_is])
384

385
        tp = self.temperature[self.uniq_is].reshape(len(self.unique_times), 1)
1✔
386
        tp2 = tp ** 2
1✔
387
        tp3 = tp ** 3
1✔
388

389
        c1 = np.exp(-self.nxcs / (self.cutoff_wavelength * tp))
1✔
390

391
        term_1 = \
1✔
392
             c1 * (self.nxcs ** 2 + 2 * (self.nxcs * self.cutoff_wavelength * tp + self.cutoff_wavelength ** 2 * tp2)) \
393
            / (self.nxcs ** 3 * self.cutoff_wavelength ** 3)
394
        term_2 = \
1✔
395
            (6 * tp3 - c1 *
396
             (self.nxcs ** 3 + 3 * self.nxcs ** 2 * self.cutoff_wavelength * tp + 6 *
397
              (self.nxcs * self.cutoff_wavelength ** 2 * tp2 + self.cutoff_wavelength ** 3 * tp3))
398
             / self.cutoff_wavelength ** 3) / self.nxcs ** 4
399
        f_blue_reds = np.sum(term_1 + term_2, 1)
1✔
400
        self.norms /= f_blue_reds
1✔
401

402
    def calculate_flux_density(self):
1✔
403
        self._set_norm()
1✔
404
        self._set_sed()
1✔
405
        return self.flux_density
1✔
406

407

408
class PowerlawPlusBlackbody:
1✔
409
    """SED class for power law + blackbody combination with time-evolving power law"""
410

411
    def __init__(self, temperature, r_photosphere, pl_amplitude, pl_slope, pl_evolution_index, time,
1✔
412
                 reference_wavelength, frequency, luminosity_distance):
NEW
413
        self.temperature = temperature
×
NEW
414
        self.r_photosphere = r_photosphere
×
NEW
415
        self.pl_amplitude = pl_amplitude
×
NEW
416
        self.pl_slope = pl_slope
×
NEW
417
        self.pl_evolution_index = pl_evolution_index
×
NEW
418
        self.time = time
×
NEW
419
        self.reference_wavelength = reference_wavelength
×
NEW
420
        self.frequency = frequency
×
NEW
421
        self.luminosity_distance = luminosity_distance
×
422

423
        # Calculate combined flux density
NEW
424
        self._calculate_flux_density()
×
425

426
    def _calculate_flux_density(self):
1✔
427
        """Calculate power law + blackbody flux density with time-evolving power law"""
428

429
        # Blackbody component using the provided function
NEW
430
        bb_flux_density = blackbody_to_flux_density(temperature=self.temperature,
×
431
                                                    r_photosphere=self.r_photosphere,
432
                                                    dl=self.luminosity_distance,
433
                                                    frequency=self.frequency)
434

435
        # Time-evolving power law component
436
        # Convert frequency to wavelength for power law calculation
NEW
437
        wavelength = (speed_of_light * 1e8) / self.frequency  # Angstroms
×
438

439
        # Calculate time-evolved power law amplitude
NEW
440
        pl_amplitude_evolved = self.pl_amplitude * (self.time / 1.0) ** (-self.pl_evolution_index)
×
441

442
        # Calculate power law in F_lambda
NEW
443
        pl_flux_lambda = pl_amplitude_evolved * (wavelength / self.reference_wavelength) ** self.pl_slope
×
444

445
        # Convert power law from F_lambda to F_nu
NEW
446
        pl_flux_density = pl_flux_lambda * wavelength ** 2 / (speed_of_light * 1e8)
×
NEW
447
        pl_flux_density = pl_flux_density * uu.erg / uu.s / uu.cm ** 2 / uu.Hz
×
448

449
        # Combine components
NEW
450
        total_flux_density = bb_flux_density + pl_flux_density
×
451

NEW
452
        self.flux_density = total_flux_density
×
453

454
class Blackbody(object):
1✔
455

456
    reference = "It is a blackbody - Do you really need a reference for this?"
1✔
457

458
    def __init__(self, temperature: np.ndarray, r_photosphere: np.ndarray, frequency: np.ndarray,
1✔
459
                 luminosity_distance: float, **kwargs: None) -> None:
460
        """
461
        Simple Blackbody SED
462

463
        :param temperature: effective temperature in kelvin
464
        :param r_photosphere: photosphere radius in cm
465
        :param frequency: frequency to calculate in Hz - Must be same length as time array or a single number.
466
                          In source frame
467
        :param luminosity_distance: luminosity_distance in cm
468
        :param kwargs: None
469
        """
470
        self.temperature = temperature
1✔
471
        self.r_photosphere = r_photosphere
1✔
472
        self.frequency = frequency
1✔
473
        self.luminosity_distance = luminosity_distance
1✔
474

475
        self.flux_density = self.calculate_flux_density()
1✔
476

477
    def calculate_flux_density(self):
1✔
478
        self.flux_density = blackbody_to_flux_density(
1✔
479
            temperature=self.temperature, r_photosphere=self.r_photosphere,
480
            frequency=self.frequency, dl=self.luminosity_distance)
481
        return self.flux_density
1✔
482

483

484
class Synchrotron(_SED):
1✔
485

486
    reference = "https://ui.adsabs.harvard.edu/abs/2004rvaa.conf...13H/abstract"
1✔
487

488
    def __init__(self, frequency: Union[np.ndarray, float], luminosity_distance: float, pp: float, nu_max: float,
1✔
489
                 source_radius: float = 1e13, f0: float = 1e-26, **kwargs: None) -> None:
490
        """
491
        Synchrotron SED
492

493
        :param frequency: frequency to calculate in Hz - Must be same length as time array or a single number.
494
                          In source frame.
495
        :param luminosity_distance: luminosity_distance in cm
496
        :param pp: synchrotron power law slope
497
        :param nu_max: max frequency
498
        :param source_radius: emitting source radius
499
        :param f0: frequency normalization
500
        :param kwargs: None
501
        """
502
        super(Synchrotron, self).__init__(frequency=frequency, luminosity_distance=luminosity_distance)
1✔
503
        self.pp = pp
1✔
504
        self.nu_max = nu_max
1✔
505
        self.source_radius = source_radius
1✔
506
        self.f0 = f0
1✔
507
        self.sed = None
1✔
508

509
        self.calculate_flux_density()
1✔
510

511
    @property
1✔
512
    def f_max(self):
1✔
513
        return self.f0 * self.source_radius**2 * self.nu_max ** 2.5  # for SSA
1✔
514

515
    @property
1✔
516
    def mask(self):
1✔
517
        return self.frequency < self.nu_max
1✔
518

519
    def _set_sed(self):
1✔
520
        self.sed = np.zeros(len(self.frequency))
1✔
521
        if self.frequency.ndim == 2:
1✔
522
            self.sed = np.zeros((len(self.frequency), 1))
1✔
523
        self.sed[self.mask] = \
1✔
524
            self.f0 * self.source_radius**2 * (self.frequency[self.mask]/self.nu_max) ** 2.5 \
525
            * angstrom_cgs / speed_of_light * self.frequency[self.mask] ** 2
526
        self.sed[~self.mask] = \
1✔
527
            self.f_max * (self.frequency[~self.mask]/self.nu_max)**(-(self.pp - 1.)/2.) \
528
            * angstrom_cgs / speed_of_light * self.frequency[~self.mask] ** 2
529

530
    def calculate_flux_density(self):
1✔
531
        self._set_sed()
1✔
532
        return self.flux_density
1✔
533

534

535
class Line(_SED):
1✔
536
    """
537
    A class that modifies an input SED by incorporating a time‐dependent absorption line feature.
538

539
    Reference:
540
      https://ui.adsabs.harvard.edu/abs/2018ApJS..236....6G/abstract
541
    """
542

543
    reference = "https://ui.adsabs.harvard.edu/abs/2018ApJS..236....6G/abstract"
1✔
544

545
    def __init__(self,
1✔
546
                 time: np.ndarray,
547
                 luminosity: np.ndarray,
548
                 frequency: Union[np.ndarray, float],
549
                 sed: Union[_SED, Blackbody],
550
                 luminosity_distance: float,
551
                 line_wavelength: float = 7.5e3,
552
                 line_width: float = 500,
553
                 line_time: float = 50,
554
                 line_duration: float = 25,
555
                 line_amplitude: float = 0.3,
556
                 **kwargs) -> None:
557
        """
558
        Modifies the input SED by imposing a time-dependent absorption line.
559

560
        Parameters
561
        ----------
562
        time : np.ndarray
563
            1D array of times (in source frame).
564
        luminosity : np.ndarray
565
            1D array of luminosities (in cgs units) corresponding to each time.
566
        frequency : Union[np.ndarray, float]
567
            Frequency (in Hz, source frame) at which to calculate the flux.
568
            If an array is provided, its shape must be broadcastable with time.
569
        sed : Union[_SED, Blackbody]
570
            An instantiated SED object (e.g. a CutoffBlackbody).
571
        luminosity_distance : float
572
            Luminosity distance in cm.
573
        line_wavelength : float, optional
574
            Central wavelength of the line in angstrom (default 7500 Å).
575
        line_width : float, optional
576
            Line width (sigma) in angstrom (default 500 Å).
577
        line_time : float, optional
578
            The time when the line is strongest (default 50).
579
        line_duration : float, optional
580
            The characteristic duration of the line (default 25).
581
        line_amplitude : float, optional
582
            The maximum amplitude of the line absorption (default 0.3).
583
        kwargs : dict, optional
584
            Other keyword arguments.
585
        """
586
        # Reshape time and luminosity to column vectors (n_time, 1).
587
        self.time = np.atleast_1d(time).reshape(-1, 1)
1✔
588
        self.luminosity = np.atleast_1d(luminosity).reshape(-1, 1)
1✔
589

590
        # Convert frequency to a row vector (1, n_freq)
591
        freq_arr = np.atleast_1d(frequency)
1✔
592
        if freq_arr.ndim == 1:
1✔
593
            freq_arr = freq_arr.reshape(1, -1)
1✔
594
        self.frequency = freq_arr  # shape (1, n_freq)
1✔
595

596
        # Call the parent initializer.
597
        super().__init__(frequency=self.frequency,
1✔
598
                         luminosity_distance=luminosity_distance,
599
                         length=self.time.shape[0])
600

601
        # Keep a reference to the SED to modify.
602
        self.SED = sed
1✔
603
        self.sed = None  # This will be computed in _set_sed().
1✔
604

605
        # Save the line parameters.
606
        self.line_wavelength = line_wavelength
1✔
607
        self.line_width = line_width
1✔
608
        self.line_time = line_time
1✔
609
        self.line_duration = line_duration
1✔
610
        self.line_amplitude = line_amplitude
1✔
611

612
        # Use an internal attribute to store the computed flux density.
613
        self._flux_density = None
1✔
614

615
        # Calculate the flux density.
616
        self.calculate_flux_density()
1✔
617

618
    @property
1✔
619
    def flux_density(self):
1✔
620
        """
621
        Returns the computed flux density; this property wraps an internal attribute.
622
        """
623
        return self._flux_density
1✔
624

625
    @property
1✔
626
    def wavelength(self):
1✔
627
        """
628
        Returns the wavelength corresponding to self.frequency (using nu_to_lambda),
629
        while preserving broadcasting.
630
        """
631
        return nu_to_lambda(self.frequency)
1✔
632

633
    def calculate_flux_density(self):
1✔
634
        """
635
        Compute the modified SED with the absorption line and store the result internally.
636
        """
637
        self._set_sed()
1✔
638
        return self._flux_density
1✔
639

640
    def _set_sed(self):
1✔
641
        """
642
        Compute and set the modified SED (flux density) with proper units.
643
        Assumes:
644
          - self.SED.sed has shape (n_freq, n_time) (e.g., (100,300))
645
          - self.time and self.luminosity are processed so that the time‐axis is the second axis.
646
        """
647
        # Transpose time and luminosity to have time along the second axis
648
        time_vals = self.time.T  # Shape (1, n_time)
1✔
649
        lum_vals = self.luminosity.T  # Shape (1, n_time)
1✔
650

651
        # Compute a time-dependent amplitude (expected shape (1, n_time))
652
        amplitude_time = self.line_amplitude * np.exp(
1✔
653
            -0.5 * ((time_vals - self.line_time) / self.line_duration) ** 2
654
        )
655

656
        # Get the baseline SED (shape (n_freq, n_time))
657
        sed_base = self.SED.sed
1✔
658

659
        # Modify the SED by attenuating with the time-dependent factor
660
        sed_modified = sed_base * (1 - amplitude_time)
1✔
661

662
        # Compute additional scaling factors as needed (example below)
663
        amplitude_scaled = amplitude_time * lum_vals / (self.line_width * np.sqrt(2 * np.pi))
1✔
664
        line_profile = np.exp(-0.5 * ((self.wavelength - self.line_wavelength) / self.line_width) ** 2)
1✔
665
        sed_modified += amplitude_scaled * line_profile
1✔
666

667
        # IMPORTANT: Convert sed_modified (a numpy array) to an astropy Quantity.
668
        # Here, we assume the flux density is in units of erg/s/cm^2/Hz.
669
        self._flux_density = sed_modified * uu.erg / uu.s / uu.cm ** 2 / uu.Hz
1✔
670

671
def get_correct_output_format_from_spectra(time, time_eval, spectra, lambda_array, **kwargs):
1✔
672
    """
673
    Use SNcosmo to get the bandpass flux or magnitude in AB from spectra at given times.
674

675
    :param time: times in observer frame in days to evaluate the model on
676
    :param time_eval: times in observer frame where spectra are evaluated. A densely sampled array for accuracy
677
    :param bands: band array - must be same length as time array or a single band
678
    :param spectra: spectra in erg/cm^2/s/A evaluated at all times and frequencies; shape (len(times), len(frequency_array))
679
    :param lambda_array: wavelenth array in Angstrom in observer frame
680
    :param kwargs: Additional parameters
681
    :param output_format: 'flux', 'magnitude', 'sncosmo_source', 'flux_density'
682
    :return: flux, magnitude or SNcosmo TimeSeries Source depending on output format kwarg
683
    """
684
    # clean up spectrum to remove nonsensical values before creating sncosmo source
685
    spectra = np.nan_to_num(spectra)
1✔
686
    spectra[spectra.value == np.nan_to_num(np.inf)] = 1e-30 * np.mean(spectra[5])
1✔
687
    spectra[spectra.value == 0.] = 1e-30 * np.mean(spectra[5])
1✔
688
    time_spline_degree = kwargs.get('time_spline_degree', 3)
1✔
689
    source = RedbackTimeSeriesSource(phase=time_eval, wave=lambda_array, flux=spectra,
1✔
690
                                     time_spline_degree=time_spline_degree)
691
    if kwargs['output_format'] == 'flux':
1✔
692
        bands = kwargs['bands']
1✔
693
        magnitude = source.bandmag(phase=time, band=bands, magsys='ab')
1✔
694
        return bandpass_magnitude_to_flux(magnitude=magnitude, bands=bands)
1✔
695
    elif kwargs['output_format'] == 'magnitude':
1✔
696
        bands = kwargs['bands']
1✔
697
        magnitude = source.bandmag(phase=time, band=bands, magsys='ab')
1✔
698
        return magnitude
1✔
699
    elif kwargs['output_format'] == 'sncosmo_source':
1✔
700
        return source
1✔
701
    else:
702
        raise ValueError("Output format must be 'flux', 'magnitude', 'sncosmo_source', or 'flux_density'")
×
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