redback 1.0.31__py3-none-any.whl → 1.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. redback/__init__.py +3 -2
  2. redback/analysis.py +321 -4
  3. redback/filters.py +57 -23
  4. redback/get_data/directory.py +18 -0
  5. redback/likelihoods.py +260 -0
  6. redback/model_library.py +12 -2
  7. redback/plotting.py +335 -4
  8. redback/priors/blackbody_spectrum_with_absorption_and_emission_lines.prior +9 -0
  9. redback/priors/csm_shock_and_arnett_two_rphots.prior +11 -0
  10. redback/priors/exp_rise_powerlaw_decline.prior +6 -0
  11. redback/priors/powerlaw_spectrum_with_absorption_and_emission_lines.prior +8 -0
  12. redback/priors/salt2.prior +6 -0
  13. redback/priors/shock_cooling_and_arnett_bolometric.prior +11 -0
  14. redback/priors/shockcooling_morag.prior +6 -0
  15. redback/priors/shockcooling_morag_and_arnett.prior +10 -0
  16. redback/priors/shockcooling_morag_and_arnett_bolometric.prior +9 -0
  17. redback/priors/shockcooling_morag_bolometric.prior +5 -0
  18. redback/priors/shockcooling_sapirandwaxman.prior +6 -0
  19. redback/priors/shockcooling_sapirandwaxman_bolometric.prior +5 -0
  20. redback/priors/shockcooling_sapirwaxman_and_arnett.prior +10 -0
  21. redback/priors/shockcooling_sapirwaxman_and_arnett_bolometric.prior +9 -0
  22. redback/priors/shocked_cocoon_and_arnett.prior +13 -0
  23. redback/priors/synchrotron_ism.prior +6 -0
  24. redback/priors/synchrotron_massloss.prior +6 -0
  25. redback/priors/synchrotron_pldensity.prior +7 -0
  26. redback/priors/thermal_synchrotron_v2_fluxdensity.prior +8 -0
  27. redback/priors/thermal_synchrotron_v2_lnu.prior +7 -0
  28. redback/priors.py +10 -3
  29. redback/result.py +9 -1
  30. redback/sampler.py +46 -4
  31. redback/sed.py +48 -1
  32. redback/simulate_transients.py +5 -1
  33. redback/tables/filters.csv +265 -254
  34. redback/transient/__init__.py +2 -3
  35. redback/transient/transient.py +648 -10
  36. redback/transient_models/__init__.py +3 -2
  37. redback/transient_models/extinction_models.py +3 -2
  38. redback/transient_models/gaussianprocess_models.py +45 -0
  39. redback/transient_models/general_synchrotron_models.py +296 -6
  40. redback/transient_models/phenomenological_models.py +154 -7
  41. redback/transient_models/shock_powered_models.py +503 -40
  42. redback/transient_models/spectral_models.py +82 -0
  43. redback/transient_models/supernova_models.py +405 -31
  44. redback/transient_models/tde_models.py +57 -41
  45. redback/utils.py +302 -51
  46. {redback-1.0.31.dist-info → redback-1.12.0.dist-info}/METADATA +8 -6
  47. {redback-1.0.31.dist-info → redback-1.12.0.dist-info}/RECORD +50 -29
  48. {redback-1.0.31.dist-info → redback-1.12.0.dist-info}/WHEEL +1 -1
  49. {redback-1.0.31.dist-info → redback-1.12.0.dist-info/licenses}/LICENCE.md +0 -0
  50. {redback-1.0.31.dist-info → redback-1.12.0.dist-info}/top_level.txt +0 -0
@@ -8,8 +8,119 @@ import pandas as pd
8
8
 
9
9
  import redback
10
10
  from redback.plotting import \
11
- LuminosityPlotter, FluxDensityPlotter, IntegratedFluxPlotter, MagnitudePlotter, IntegratedFluxOpticalPlotter
11
+ LuminosityPlotter, FluxDensityPlotter, IntegratedFluxPlotter, MagnitudePlotter, \
12
+ IntegratedFluxOpticalPlotter, SpectrumPlotter, LuminosityOpticalPlotter
13
+ from redback.model_library import all_models_dict
14
+ from collections import namedtuple
15
+
16
+ class Spectrum(object):
17
+ def __init__(self, angstroms: np.ndarray, flux_density: np.ndarray, flux_density_err: np.ndarray,
18
+ time: str = None, name: str = '', **kwargs) -> None:
19
+ """
20
+ A class to store spectral data.
21
+
22
+ :param angstroms: Wavelength in angstroms.
23
+ :param flux_density: flux density in ergs/s/cm^2/angstrom.
24
+ :param flux_density_err: flux density error in ergs/s/cm^2/angstrom.
25
+ :param time: Time of the spectrum. Could be a phase or time since burst. Only used for plotting.
26
+ :param name: Name of the spectrum.
27
+ """
28
+
29
+ self.angstroms = angstroms
30
+ self.flux_density = flux_density
31
+ self.flux_density_err = flux_density_err
32
+ self.time = time
33
+ self.name = name
34
+ if self.time is None:
35
+ self.plot_with_time_label = False
36
+ else:
37
+ self.plot_with_time_label = True
38
+ self.directory_structure = redback.get_data.directory.spectrum_directory_structure(transient=name)
39
+ self.data_mode = 'spectrum'
40
+
41
+ @property
42
+ def xlabel(self) -> str:
43
+ """
44
+ :return: xlabel used in plotting functions
45
+ :rtype: str
46
+ """
47
+ return r'Wavelength [$\mathrm{\AA}$]'
48
+
49
+ @property
50
+ def ylabel(self) -> str:
51
+ """
52
+ :return: ylabel used in plotting functions
53
+ :rtype: str
54
+ """
55
+ return r'Flux ($10^{-17}$ erg s$^{-1}$ cm$^{-2}$ $\mathrm{\AA}$)'
56
+
57
+ def plot_data(self, axes: matplotlib.axes.Axes = None, filename: str = None, outdir: str = None, save: bool = True,
58
+ show: bool = True, color: str = 'k', **kwargs) -> matplotlib.axes.Axes:
59
+ """Plots the Transient data and returns Axes.
60
+
61
+ :param axes: Matplotlib axes to plot the lightcurve into. Useful for user specific modifications to the plot.
62
+ :param filename: Name of the file to be plotted in.
63
+ :param outdir: The directory in which to save the file in.
64
+ :param save: Whether to save the plot. (Default value = True)
65
+ :param show: Whether to show the plot. (Default value = True)
66
+ :param color: Color of the data.
67
+ :param kwargs: Additional keyword arguments to pass in the Plotter methods.
68
+ Available in the online documentation under at `redback.plotting.Plotter`.
69
+ `print(Transient.plot_data.__doc__)` to see all options!
70
+ :return: The axes with the plot.
71
+ """
12
72
 
73
+ plotter = SpectrumPlotter(spectrum=self, color=color, filename=filename, outdir=outdir, **kwargs)
74
+ return plotter.plot_data(axes=axes, save=save, show=show)
75
+
76
+ def plot_spectrum(
77
+ self, model: callable, filename: str = None, outdir: str = None, axes: matplotlib.axes.Axes = None,
78
+ save: bool = True, show: bool = True, random_models: int = 100, posterior: pd.DataFrame = None,
79
+ model_kwargs: dict = None, **kwargs: None) -> matplotlib.axes.Axes:
80
+ """
81
+ :param model: The model used to plot the lightcurve.
82
+ :param filename: The output filename. Otherwise, use default which starts with the name
83
+ attribute and ends with *lightcurve.png.
84
+ :param axes: Axes to plot in if given.
85
+ :param save:Whether to save the plot.
86
+ :param show: Whether to show the plot.
87
+ :param random_models: Number of random posterior samples plotted faintly. (Default value = 100)
88
+ :param posterior: Posterior distribution to which to draw samples from. Is optional but must be given.
89
+ :param outdir: Out directory in which to save the plot. Default is the current working directory.
90
+ :param model_kwargs: Additional keyword arguments to be passed into the model.
91
+ :param kwargs: Additional keyword arguments to pass in the Plotter methods.
92
+ Available in the online documentation under at `redback.plotting.Plotter`.
93
+ `print(Transient.plot_lightcurve.__doc__)` to see all options!
94
+ :return: The axes.
95
+ """
96
+ plotter = SpectrumPlotter(
97
+ spectrum=self, model=model, filename=filename, outdir=outdir,
98
+ posterior=posterior, model_kwargs=model_kwargs, random_models=random_models, **kwargs)
99
+ return plotter.plot_spectrum(axes=axes, save=save, show=show)
100
+
101
+ def plot_residual(self, model: callable, filename: str = None, outdir: str = None, axes: matplotlib.axes.Axes = None,
102
+ save: bool = True, show: bool = True, posterior: pd.DataFrame = None,
103
+ model_kwargs: dict = None, **kwargs: None) -> matplotlib.axes.Axes:
104
+ """
105
+ :param model: The model used to plot the lightcurve.
106
+ :param filename: The output filename. Otherwise, use default which starts with the name
107
+ attribute and ends with *lightcurve.png.
108
+ :param axes: Axes to plot in if given.
109
+ :param save:Whether to save the plot.
110
+ :param show: Whether to show the plot.
111
+ :param posterior: Posterior distribution to which to draw samples from. Is optional but must be given.
112
+ :param outdir: Out directory in which to save the plot. Default is the current working directory.
113
+ :param model_kwargs: Additional keyword arguments to be passed into the model.
114
+ :param kwargs: Additional keyword arguments to pass in the Plotter methods.
115
+ Available in the online documentation under at `redback.plotting.Plotter`.
116
+ `print(Transient.plot_residual.__doc__)` to see all options!
117
+ :return: The axes.
118
+ """
119
+ plotter = SpectrumPlotter(
120
+ spectrum=self, model=model, filename=filename, outdir=outdir,
121
+ posterior=posterior, model_kwargs=model_kwargs, **kwargs)
122
+ return plotter.plot_residuals(axes=axes, save=save, show=show)
123
+ LuminosityPlotter, FluxDensityPlotter, IntegratedFluxPlotter, MagnitudePlotter, IntegratedFluxOpticalPlotter
13
124
 
14
125
  class Transient(object):
15
126
  DATA_MODES = ['luminosity', 'flux', 'flux_density', 'magnitude', 'counts', 'ttes']
@@ -164,6 +275,7 @@ class Transient(object):
164
275
  :return: Six elements when querying magnitude or flux_density data, Eight for 'all'.
165
276
  :rtype: tuple
166
277
  """
278
+ DATA_MODES = ['luminosity', 'flux', 'flux_density', 'magnitude', 'counts', 'ttes', 'all']
167
279
  df = pd.read_csv(processed_file_path)
168
280
  time_days = np.array(df["time (days)"])
169
281
  time_mjd = np.array(df["time"])
@@ -172,6 +284,8 @@ class Transient(object):
172
284
  bands = np.array(df["band"])
173
285
  flux_density = np.array(df["flux_density(mjy)"])
174
286
  flux_density_err = np.array(df["flux_density_error"])
287
+ if data_mode not in DATA_MODES:
288
+ raise ValueError(f"Data mode {data_mode} not in {DATA_MODES}")
175
289
  if data_mode == "magnitude":
176
290
  return time_days, time_mjd, magnitude, magnitude_err, bands
177
291
  elif data_mode == "flux_density":
@@ -372,7 +486,7 @@ class Transient(object):
372
486
  if self.use_phase_model:
373
487
  return r"Time [MJD]"
374
488
  else:
375
- return r"Time since burst [days]"
489
+ return r"Time since explosion [days]"
376
490
 
377
491
  @property
378
492
  def ylabel(self) -> str:
@@ -576,7 +690,11 @@ class Transient(object):
576
690
  else:
577
691
  plotter = IntegratedFluxPlotter(transient=self, color=color, filename=filename, outdir=outdir, **kwargs)
578
692
  elif self.luminosity_data:
579
- plotter = LuminosityPlotter(transient=self, color=color, filename=filename, outdir=outdir, **kwargs)
693
+ if self.optical_data:
694
+ plotter = LuminosityOpticalPlotter(transient=self, color=color, filename=filename, outdir=outdir,
695
+ **kwargs)
696
+ else:
697
+ plotter = LuminosityPlotter(transient=self, color=color, filename=filename, outdir=outdir, **kwargs)
580
698
  elif self.flux_density_data:
581
699
  plotter = FluxDensityPlotter(transient=self, color=color, filename=filename, outdir=outdir,
582
700
  plot_others=plot_others, **kwargs)
@@ -655,9 +773,13 @@ class Transient(object):
655
773
  transient=self, model=model, filename=filename, outdir=outdir,
656
774
  posterior=posterior, model_kwargs=model_kwargs, random_models=random_models, **kwargs)
657
775
  elif self.luminosity_data:
658
- plotter = LuminosityPlotter(
659
- transient=self, model=model, filename=filename, outdir=outdir,
660
- posterior=posterior, model_kwargs=model_kwargs, random_models=random_models, **kwargs)
776
+ if self.optical_data:
777
+ plotter = LuminosityOpticalPlotter(transient=self, model=model, filename=filename, outdir=outdir,
778
+ posterior=posterior, model_kwargs=model_kwargs, random_models=random_models, **kwargs)
779
+ else:
780
+ plotter = LuminosityPlotter(
781
+ transient=self, model=model, filename=filename, outdir=outdir,
782
+ posterior=posterior, model_kwargs=model_kwargs, random_models=random_models, **kwargs)
661
783
  elif self.flux_density_data:
662
784
  plotter = FluxDensityPlotter(
663
785
  transient=self, model=model, filename=filename, outdir=outdir,
@@ -693,13 +815,145 @@ class Transient(object):
693
815
  transient=self, model=model, filename=filename, outdir=outdir,
694
816
  posterior=posterior, model_kwargs=model_kwargs, **kwargs)
695
817
  elif self.luminosity_data:
696
- plotter = LuminosityPlotter(
697
- transient=self, model=model, filename=filename, outdir=outdir,
698
- posterior=posterior, model_kwargs=model_kwargs, **kwargs)
818
+ if self.optical_data:
819
+ plotter = LuminosityOpticalPlotter(
820
+ transient=self, model=model, filename=filename, outdir=outdir,
821
+ posterior=posterior, model_kwargs=model_kwargs, **kwargs)
822
+ else:
823
+ plotter = LuminosityPlotter(
824
+ transient=self, model=model, filename=filename, outdir=outdir,
825
+ posterior=posterior, model_kwargs=model_kwargs, **kwargs)
699
826
  else:
700
827
  raise ValueError("Residual plotting not implemented for this data mode")
701
828
  return plotter.plot_residuals(axes=axes, save=save, show=show)
702
829
 
830
+
831
+ def fit_gp(self, mean_model, kernel, prior=None, use_frequency=True):
832
+ """
833
+ Fit a GP to the data using george and scipy minimization.
834
+
835
+ :param mean_model: Mean model to use in the GP fit. Can be a string to refer to a redback model, a callable, or None
836
+ :param kernel: George GP to use. User must ensure this is set up correctly.
837
+ :param prior: Prior to use when fitting with a mean model.
838
+ :param use_frequency: Whether to use the effective frequency in a 2D GP fit. Cannot be used with most mean models.
839
+ :return: Named tuple with George GP object and additional useful data.
840
+ """
841
+ try:
842
+ import george
843
+ import george.kernels as kernels
844
+ except ImportError:
845
+ redback.utils.logger.warning("George must be installed to use GP fitting.")
846
+ import scipy.optimize as op
847
+ from bilby.core.likelihood import function_to_george_mean_model
848
+
849
+ output = namedtuple("gp_out", ["gp", "scaled_y", "y_scaler", 'use_frequency', 'mean_model'])
850
+ output.use_frequency = use_frequency
851
+ output.mean_model = mean_model
852
+
853
+ if self.data_mode == 'luminosity':
854
+ x = self.time_rest_frame
855
+ y = self.y
856
+ try:
857
+ y_err = np.max(self.y_err, axis=0)
858
+ except IndexError:
859
+ y_err = self.y_err
860
+ else:
861
+ x, x_err, y, y_err = self.get_filtered_data()
862
+ redback.utils.logger.info("Rescaling data for GP fitting.")
863
+ gp_y_err = y_err / np.max(y)
864
+ gp_y = y / np.max(y)
865
+ output.scaled_y = gp_y
866
+ output.y_scaler = np.max(y)
867
+
868
+ def nll(p):
869
+ gp.set_parameter_vector(p)
870
+ ll = gp.log_likelihood(gp_y, quiet=True)
871
+ return -ll if np.isfinite(ll) else 1e25
872
+
873
+ def grad_nll(p):
874
+ gp.set_parameter_vector(p)
875
+ return -gp.grad_log_likelihood(gp_y, quiet=True)
876
+
877
+ if use_frequency:
878
+ redback.utils.logger.info("Using frequencies and time in the GP fit.")
879
+ redback.utils.logger.info("Kernel used: " + str(kernel))
880
+ redback.utils.logger.info("Ensure that the kernel is set up correctly for 2D GP.")
881
+ redback.utils.logger.info("You will be returned a single GP object with frequency as a parameter")
882
+ freqs = self.filtered_frequencies
883
+ X = np.column_stack((freqs, x))
884
+ else:
885
+ redback.utils.logger.info("Using time in GP fit.")
886
+ redback.utils.logger.info("Kernel used: " + str(kernel))
887
+ redback.utils.logger.info("Ensure that the kernel is set up correctly for 1D GP.")
888
+ redback.utils.logger.info("You will be returned a GP object unique to a band/frequency"
889
+ " in the data if working with multiband data")
890
+ X = x
891
+
892
+ if mean_model is None:
893
+ redback.utils.logger.info("Mean model not given, fitting GP with no mean model.")
894
+ gp = george.GP(kernel)
895
+ gp.compute(X, gp_y_err + 1e-8)
896
+ p0 = gp.get_parameter_vector()
897
+ results = op.minimize(nll, p0, jac=grad_nll)
898
+ gp.set_parameter_vector(results.x)
899
+ redback.utils.logger.info(f"GP final loglikelihood: {gp.log_likelihood(gp_y)}")
900
+ redback.utils.logger.info(f"GP final parameters: {gp.get_parameter_dict()}")
901
+ output.gp = gp
902
+ else:
903
+ if isinstance(mean_model, str):
904
+ mean_model_func = all_models_dict[mean_model]
905
+ redback.utils.logger.info("Using inbuilt redback function {} as a mean model.".format(mean_model))
906
+ if prior is None:
907
+ redback.utils.logger.warning("No prior given for mean model. Using default prior.")
908
+ prior = redback.priors.get_priors(mean_model)
909
+ else:
910
+ mean_model_func = mean_model
911
+ redback.utils.logger.info("Using user-defined python function as a mean model.")
912
+
913
+ if prior is None:
914
+ redback.utils.logger.warning("Prior must be specified for GP fit with a mean model")
915
+ raise ValueError("No prior specified")
916
+
917
+ if self.data_mode in ['flux_density', 'magnitude', 'flux']:
918
+ redback.utils.logger.info("Setting up GP version of mean model.")
919
+ gp_dict = {}
920
+ scaled_y_dict = {}
921
+ for ii in range(len(self.unique_bands)):
922
+ scaled_y_dict[self.unique_bands[ii]] = gp_y[self.list_of_band_indices[ii]]
923
+ redback.utils.logger.info("Fitting for band {}".format(self.unique_bands[ii]))
924
+ gp_x = X[self.list_of_band_indices[ii]]
925
+
926
+ def nll(p):
927
+ gp.set_parameter_vector(p)
928
+ ll = gp.log_likelihood(gp_y[self.list_of_band_indices[ii]], quiet=True)
929
+ return -ll if np.isfinite(ll) else 1e25
930
+
931
+ mean_model_class = function_to_george_mean_model(mean_model_func)
932
+ mm = mean_model_class(**prior.sample())
933
+ gp = george.GP(kernel, mean=mm, fit_mean=True)
934
+ gp.compute(gp_x, gp_y_err[self.list_of_band_indices[ii]] + 1e-8)
935
+ p0 = gp.get_parameter_vector()
936
+ results = op.minimize(nll, p0)
937
+ gp.set_parameter_vector(results.x)
938
+ redback.utils.logger.info(f"GP final loglikelihood: {gp.log_likelihood(gp_y[self.list_of_band_indices[ii]])}")
939
+ redback.utils.logger.info(f"GP final parameters: {gp.get_parameter_dict()}")
940
+ gp_dict[self.unique_bands[ii]] = gp
941
+ del gp
942
+ output.gp = gp_dict
943
+ output.scaled_y = scaled_y_dict
944
+ else:
945
+ mean_model_class = function_to_george_mean_model(mean_model_func)
946
+ mm = mean_model_class(**prior.sample())
947
+ gp = george.GP(kernel, mean=mm, fit_mean=True)
948
+ gp.compute(X, gp_y_err + 1e-8)
949
+ p0 = gp.get_parameter_vector()
950
+ results = op.minimize(nll, p0)
951
+ gp.set_parameter_vector(results.x)
952
+ redback.utils.logger.info(f"GP final loglikelihood: {gp.log_likelihood(gp_y)}")
953
+ redback.utils.logger.info(f"GP final parameters: {gp.get_parameter_dict()}")
954
+ output.gp = gp
955
+ return output
956
+
703
957
  def plot_multiband_lightcurve(
704
958
  self, model: callable, filename: str = None, outdir: str = None,
705
959
  figure: matplotlib.figure.Figure = None, axes: matplotlib.axes.Axes = None,
@@ -871,7 +1125,8 @@ class OpticalTransient(Transient):
871
1125
  use_phase_model=use_phase_model, optical_data=optical_data,
872
1126
  system=system, bands=bands, plotting_order=plotting_order,
873
1127
  active_bands=active_bands, **kwargs)
874
- self.directory_structure = None
1128
+ self.directory_structure = redback.get_data.directory.DirectoryStructure(
1129
+ directory_path=".", raw_file_path=".", processed_file_path=".")
875
1130
 
876
1131
  @classmethod
877
1132
  def from_open_access_catalogue(
@@ -944,3 +1199,386 @@ class OpticalTransient(Transient):
944
1199
  transient_dir, _, _ = redback.get_data.directory.open_access_directory_structure(
945
1200
  transient=self.name, transient_type=self.__class__.__name__.lower())
946
1201
  return transient_dir
1202
+
1203
+ def estimate_bb_params(self, distance: float = 1e27, bin_width: float = 1.0, min_filters: int = 3, **kwargs):
1204
+ """
1205
+ Estimate the blackbody temperature and photospheric radius as functions of time by fitting
1206
+ a blackbody SED to the multi‑band photometry.
1207
+
1208
+ The method groups the photometric data into time bins (epochs) of width bin_width (in the
1209
+ same units as self.x, typically days). For each epoch with at least min_filters measurements
1210
+ (from distinct filters), it fits a blackbody model to the data. When working with photometry
1211
+ provided in an effective flux density format (data_mode == "flux_density") the effective–wavelength
1212
+ approximation is used. When the data_mode is "flux" (or "magnitude") users have the option
1213
+ (via use_eff_wavelength=True) to instead use the effective wavelength approximation by converting AB
1214
+ magnitudes to flux density (using redback.utils.calc_flux_density_from_ABmag). If this flag is not
1215
+ provided (or is False) then the full bandpass integration is applied.
1216
+
1217
+ Parameters
1218
+ ----------
1219
+ distance : float, optional
1220
+ Distance to the transient in centimeters. Default is 1e27 cm.
1221
+ bin_width : float, optional
1222
+ Width of the time bins (in days) used to group the photometric data. Default is 1.0.
1223
+ min_filters : int, optional
1224
+ Minimum number of measurements (from distinct filters) required in a bin to perform the fit.
1225
+ Default is 3.
1226
+ kwargs : Additional keyword arguments
1227
+ maxfev : int, optional, default is 1000
1228
+ T_init : float, optional, default is 1e4, used as the initial guess for the fit.
1229
+ R_init : float, optional, default is 1e15, used as the initial guess for the fit.
1230
+ use_eff_wavelength : bool, optional, default is False.
1231
+ If True, then even for photometry provided as magnitudes (or bandpass fluxes),
1232
+ the effective wavelength approximation is used. In that case the AB magnitudes are
1233
+ converted to flux densities via redback.utils.calc_flux_density_from_ABmag.
1234
+ If False, full bandpass integration is used.
1235
+
1236
+ Returns
1237
+ -------
1238
+ df_bb : pandas.DataFrame or None
1239
+ A DataFrame containing columns:
1240
+ - epoch_times : binned epoch times,
1241
+ - temperature : best-fit blackbody temperatures (Kelvin),
1242
+ - radius : best-fit photospheric radii (cm),
1243
+ - temp_err : 1σ uncertainties on the temperatures,
1244
+ - radius_err : 1σ uncertainties on the radii.
1245
+ Returns None if insufficient data are available.
1246
+ """
1247
+ from scipy.optimize import curve_fit
1248
+ import astropy.units as uu
1249
+ import numpy as np
1250
+ import pandas as pd
1251
+
1252
+ # Get the filtered photometry.
1253
+ # Assumes self.get_filtered_data() returns (time, time_err, y, y_err)
1254
+ time_data, _, flux_data, flux_err_data = self.get_filtered_data()
1255
+
1256
+ redback.utils.logger.info("Estimating blackbody parameters for {}.".format(self.name))
1257
+ redback.utils.logger.info("Using data mode = {}".format(self.data_mode))
1258
+
1259
+ # Determine whether we are in bandpass mode.
1260
+ use_bandpass = False
1261
+ if hasattr(self, "data_mode") and self.data_mode in ['flux', 'magnitude']:
1262
+ use_bandpass = True
1263
+ # Assume self.filtered_sncosmo_bands contains the (string) band names.
1264
+ band_data = self.filtered_sncosmo_bands
1265
+ else:
1266
+ # Otherwise the flux data and frequencies are assumed to be given.
1267
+ redback.utils.logger.info("Using effective wavelength approximation for {}".format(self.data_mode))
1268
+ freq_data = self.filtered_frequencies
1269
+
1270
+ # Option: force effective wavelength approximation even if data_mode is bandpass.
1271
+ force_eff = kwargs.get('use_eff_wavelength', False)
1272
+ if use_bandpass and force_eff:
1273
+ redback.utils.logger.warning("Using effective wavelength approximation for {}".format(self.data_mode))
1274
+
1275
+ if self.data_mode == 'magnitude':
1276
+ # Convert the AB magnitudes to flux density using the redback function.
1277
+ from redback.utils import abmag_to_flux_density_and_error_inmjy
1278
+ flux_data, flux_err_data = abmag_to_flux_density_and_error_inmjy(flux_data, flux_err_data)
1279
+ freq_data = redback.utils.bands_to_frequency(band_data)
1280
+ else:
1281
+ # Convert the bandpass fluxes to flux density using the redback function.
1282
+ from redback.utils import bandpass_flux_to_flux_density, bands_to_effective_width
1283
+ redback.utils.logger.warning("Ensure filters.csv has the correct bandpass effective widths for your filter.")
1284
+ effective_widths = bands_to_effective_width(band_data)
1285
+ freq_data = redback.utils.bands_to_frequency(band_data)
1286
+ flux_data, flux_err_data = bandpass_flux_to_flux_density(flux_data, flux_err_data, effective_widths)
1287
+ # Use the effective frequency approach.
1288
+ use_bandpass = False
1289
+
1290
+ # Get initial guesses.
1291
+ T_init = kwargs.get('T_init', 1e4)
1292
+ R_init = kwargs.get('R_init', 1e15)
1293
+ maxfev = kwargs.get('maxfev', 1000)
1294
+
1295
+ # Sort photometric data by time.
1296
+ sort_idx = np.argsort(time_data)
1297
+ time_data = time_data[sort_idx]
1298
+ flux_data = flux_data[sort_idx]
1299
+ flux_err_data = flux_err_data[sort_idx]
1300
+ if use_bandpass:
1301
+ band_data = np.array(band_data)[sort_idx]
1302
+ else:
1303
+ freq_data = np.array(freq_data)[sort_idx]
1304
+
1305
+ # Retrieve redshift.
1306
+ redshift = np.nan_to_num(self.redshift)
1307
+ if redshift <= 0.:
1308
+ raise ValueError("Redshift must be provided to perform K-correction.")
1309
+
1310
+ # For effective frequency mode, K-correct frequencies.
1311
+ if not use_bandpass:
1312
+ freq_data, _ = redback.utils.calc_kcorrected_properties(frequency=freq_data,
1313
+ redshift=redshift, time=0.)
1314
+
1315
+ # Define the model functions.
1316
+ if not use_bandpass:
1317
+ # --- Effective-wavelength model ---
1318
+ def bb_model(freq, logT, logR):
1319
+ T = 10 ** logT
1320
+ R = 10 ** logR
1321
+ # Compute the model flux density in erg/s/cm^2/Hz.
1322
+ model_flux_cgs = redback.sed.blackbody_to_flux_density(T, R, distance, freq)
1323
+ # Convert to mJy. (1 Jy = 1e-23 erg/s/cm^2/Hz; 1 mJy = 1e-3 Jy = 1e-26 erg/s/cm^2/Hz)
1324
+ model_flux_mjy = (model_flux_cgs / (1e-26 * uu.erg / uu.s / uu.cm**2 / uu.Hz)).value
1325
+ return model_flux_mjy
1326
+
1327
+ model_func = bb_model
1328
+ else:
1329
+ # --- Full bandpass integration model ---
1330
+ # In this branch we do NOT want to pass strings to curve_fit.
1331
+ # Instead, we will dummy-encode the independent variable as indices.
1332
+ # We also capture the band names in a closure variable.
1333
+ def bb_model_bandpass_from_index(x, logT, logR):
1334
+ # Ensure x is a numpy array and convert indices to integers.
1335
+ i_idx = np.round(x).astype(int)
1336
+ # Retrieve all corresponding band names in one step.
1337
+ bands = np.array(epoch_bands)[i_idx]
1338
+ # Call bb_model_bandpass with the entire array of bands.
1339
+ return bb_model_bandpass(bands, logT, logR, redshift, distance, output_format=self.data_mode)
1340
+
1341
+ def bb_model_bandpass(band, logT, logR, redshift, distance, output_format='magnitude'):
1342
+ from redback.utils import calc_kcorrected_properties, lambda_to_nu, bandpass_magnitude_to_flux
1343
+ # Create a wavelength grid (in Å) from 100 to 80,000 Å.
1344
+ lambda_obs = np.geomspace(100, 80000, 300)
1345
+ # Convert to frequency (Hz) and apply K-correction.
1346
+ frequency, _ = calc_kcorrected_properties(frequency=lambda_to_nu(lambda_obs),
1347
+ redshift=redshift, time=0.)
1348
+ T = 10 ** logT
1349
+ R = 10 ** logR
1350
+ # Compute the model SED (flux density in erg/s/cm^2/Hz).
1351
+ model_flux = redback.sed.blackbody_to_flux_density(T, R, distance, frequency)
1352
+ # Convert the SED to per-Å units.
1353
+ _spectra = model_flux.to(uu.erg / uu.cm ** 2 / uu.s / uu.Angstrom,
1354
+ equivalencies=uu.spectral_density(wav=lambda_obs * uu.Angstrom))
1355
+ spectra = np.zeros((5, 300))
1356
+ spectra[:, :] = _spectra.value
1357
+ # Create a source object from the spectrum.
1358
+ source = redback.sed.RedbackTimeSeriesSource(phase=np.array([0, 1, 2, 3, 4]),
1359
+ wave=lambda_obs, flux=spectra)
1360
+ if output_format == 'flux':
1361
+ # Convert bandpass magnitude to flux.
1362
+ mag = source.bandmag(phase=0, band=band, magsys='ab')
1363
+ return bandpass_magnitude_to_flux(magnitude=mag, bands=band)
1364
+ elif output_format == 'magnitude':
1365
+ mag = source.bandmag(phase=0, band=band, magsys='ab')
1366
+ return mag
1367
+ else:
1368
+ raise ValueError("Unknown output_format in bb_model_bandpass.")
1369
+
1370
+ # Our wrapper for curve_fit uses dummy x-values.
1371
+ model_func = bb_model_bandpass_from_index
1372
+
1373
+ # Initialize lists to store fit results.
1374
+ epoch_times = []
1375
+ temperatures = []
1376
+ radii = []
1377
+ temp_errs = []
1378
+ radius_errs = []
1379
+
1380
+ t_min = np.min(time_data)
1381
+ t_max = np.max(time_data)
1382
+ bins = np.arange(t_min, t_max + bin_width, bin_width)
1383
+ redback.utils.logger.info("Number of bins: {}".format(len(bins)))
1384
+
1385
+ # Ensure at least one bin has enough points.
1386
+ bins_with_enough = [i for i in range(len(bins) - 1)
1387
+ if np.sum((time_data >= bins[i]) & (time_data < bins[i + 1])) >= min_filters]
1388
+ if len(bins_with_enough) == 0:
1389
+ redback.utils.logger.warning("No time bins have at least {} measurements. Fitting cannot proceed.".format(min_filters))
1390
+ redback.utils.logger.warning("Try generating more data through GPs, increasing bin widths, or using fewer filters.")
1391
+ return None
1392
+
1393
+ # Loop over bins (epochs): for each with enough data perform the fit.
1394
+ for i in range(len(bins) - 1):
1395
+ mask = (time_data >= bins[i]) & (time_data < bins[i + 1])
1396
+ if np.sum(mask) < min_filters:
1397
+ continue
1398
+ t_epoch = np.mean(time_data[mask])
1399
+ try:
1400
+ if not use_bandpass:
1401
+ # Use effective frequency array (numeric).
1402
+ xdata = freq_data[mask]
1403
+ else:
1404
+ # For full bandpass integration mode, we dummy encode xdata.
1405
+ # We ignore the value and simply use indices [0, 1, 2, ...].
1406
+ epoch_bands = list(band_data[mask]) # capture the list of bands for this epoch
1407
+ xdata = np.arange(len(epoch_bands))
1408
+ popt, pcov = curve_fit(
1409
+ model_func,
1410
+ xdata,
1411
+ flux_data[mask],
1412
+ sigma=flux_err_data[mask],
1413
+ p0=[np.log10(T_init), np.log10(R_init)],
1414
+ absolute_sigma=True,
1415
+ maxfev=maxfev
1416
+ )
1417
+ except Exception as e:
1418
+ redback.utils.logger.warning(f"Fit failed for epoch {i}: {e}")
1419
+ redback.utils.logger.warning(f"Skipping epoch {i} with time {t_epoch:.2f} days.")
1420
+ continue
1421
+
1422
+ logT_fit, logR_fit = popt
1423
+ T_fit = 10 ** logT_fit
1424
+ R_fit = 10 ** logR_fit
1425
+ perr = np.sqrt(np.diag(pcov))
1426
+ T_err = np.log(10) * T_fit * perr[0]
1427
+ R_err = np.log(10) * R_fit * perr[1]
1428
+
1429
+ epoch_times.append(t_epoch)
1430
+ temperatures.append(T_fit)
1431
+ radii.append(R_fit)
1432
+ temp_errs.append(T_err)
1433
+ radius_errs.append(R_err)
1434
+
1435
+ if len(epoch_times) == 0:
1436
+ redback.utils.logger.warning("No epochs with sufficient data yielded a successful fit.")
1437
+ return None
1438
+
1439
+ df_bb = pd.DataFrame({
1440
+ 'epoch_times': epoch_times,
1441
+ 'temperature': temperatures,
1442
+ 'radius': radii,
1443
+ 'temp_err': temp_errs,
1444
+ 'radius_err': radius_errs
1445
+ })
1446
+
1447
+ redback.utils.logger.info('Masking epochs with likely wrong extractions')
1448
+ df_bb = df_bb[df_bb['temp_err'] / df_bb['temperature'] < 1]
1449
+ df_bb = df_bb[df_bb['radius_err'] / df_bb['radius'] < 1]
1450
+ return df_bb
1451
+
1452
+
1453
+ def estimate_bolometric_luminosity(self, distance: float = 1e27, bin_width: float = 1.0,
1454
+ min_filters: int = 3, **kwargs):
1455
+ """
1456
+ Estimate the bolometric luminosity as a function of time by fitting the blackbody SED
1457
+ to the multi‑band photometry and then integrating that spectrum. For each epoch the bolometric
1458
+ luminosity is computed using the Stefan–Boltzmann law evaluated at the source:
1459
+
1460
+ L_bol = 4 π R² σ_SB T⁴
1461
+
1462
+ Uncertainties in T and R are propagated assuming
1463
+
1464
+ (ΔL_bol / L_bol)² = (2 ΔR / R)² + (4 ΔT / T)².
1465
+
1466
+ Optionally, two corrections can be applied:
1467
+
1468
+ 1. A boost–factor to “restore” missing blue flux. If a cutoff wavelength is provided via
1469
+ the keyword 'lambda_cut' (in angstroms), it is converted to centimeters and a boost factor is
1470
+ calculated as:
1471
+
1472
+ Boost = (F_tot / F_red)
1473
+
1474
+ where F_tot = σ_SB T⁴ and F_red is computed by numerically integrating π * B_λ(T)
1475
+ from the cutoff wavelength (in cm) to infinity. The final (boosted) luminosity becomes:
1476
+
1477
+ L_boosted = Boost × (4π R² σ_SB T⁴).
1478
+
1479
+ 2. An extinction correction. If the bolometric extinction (A_ext, in magnitudes) is supplied via
1480
+ the keyword 'A_ext', the luminosity will be reduced by a factor of 10^(–0.4·A_ext) to account
1481
+ for dust extinction. (A_ext defaults to 0.)
1482
+
1483
+ Parameters
1484
+ ----------
1485
+ distance : float, optional
1486
+ Distance to the transient in centimeters. (Default is 1e27 cm.)
1487
+ bin_width : float, optional
1488
+ Width of the time bins (in days) used for grouping photometry. (Default is 1.0.)
1489
+ min_filters : int, optional
1490
+ Minimum number of independent filters required in a bin to perform a fit. (Default is 3.)
1491
+ kwargs : dict, optional
1492
+ Additional keyword arguments to pass to `estimate_bb_params` (e.g., maxfev, T_init, R_init,
1493
+ use_eff_wavelength, etc.). Additionally:
1494
+ - 'lambda_cut': If provided (in angstroms), the bolometric luminosity will be “boosted”
1495
+ to account for missing blue flux.
1496
+ - 'A_ext': Bolometric extinction in magnitudes. The observed luminosity is increased by a factor
1497
+ 10^(+0.4·A_ext). (Default is 0.)
1498
+
1499
+ Returns
1500
+ -------
1501
+ df_bol : pandas.DataFrame or None
1502
+ A DataFrame containing columns:
1503
+ - epoch_times: Mean time of the bin (days).
1504
+ - temperature: Fitted blackbody temperature (K).
1505
+ - radius: Fitted photospheric radius (cm).
1506
+ - lum_bol: Derived bolometric luminosity (1e50 erg/s) computed as 4π R² σ_SB T⁴
1507
+ (boosted and extinction-corrected if requested).
1508
+ - lum_bol_bb: Derived bolometric blackbody luminosity (1e50 erg/s) computed as 4π R² σ_SB T⁴,
1509
+ before applying either the boost or extinction correction.
1510
+ - lum_bol_err: 1σ uncertainty on L_bol (1e50 erg/s) from error propagation.
1511
+ - time_rest_frame: Epoch time divided by (1+redshift), i.e., the rest-frame time in days.
1512
+ Returns None if no valid blackbody fits were obtained.
1513
+ """
1514
+ from redback.sed import boosted_bolometric_luminosity
1515
+
1516
+ # Retrieve optional lambda_cut (in angstroms) for the boost correction.
1517
+ lambda_cut_angstrom = kwargs.pop('lambda_cut', None)
1518
+ if lambda_cut_angstrom is not None:
1519
+ redback.utils.logger.info("Including effects of missing flux due to line blanketing.")
1520
+ redback.utils.logger.info(
1521
+ "Using lambda_cut = {} Å for bolometric luminosity boost.".format(lambda_cut_angstrom))
1522
+ # Convert lambda_cut from angstroms to centimeters (1 Å = 1e-8 cm)
1523
+ lambda_cut = lambda_cut_angstrom * 1e-8
1524
+ else:
1525
+ redback.utils.logger.info("No lambda_cut provided; no correction applied. Assuming a pure blackbody SED.")
1526
+ lambda_cut = None
1527
+
1528
+ # Retrieve optional extinction in magnitudes.
1529
+ A_ext = kwargs.pop('A_ext', 0.0)
1530
+ if A_ext != 0.0:
1531
+ redback.utils.logger.info("Applying extinction correction with A_ext = {} mag.".format(A_ext))
1532
+ extinction_factor = 10 ** (0.4 * A_ext)
1533
+
1534
+ # Retrieve blackbody parameters via your existing method.
1535
+ df_bb = self.estimate_bb_params(distance=distance, bin_width=bin_width, min_filters=min_filters, **kwargs)
1536
+ if df_bb is None or len(df_bb) == 0:
1537
+ redback.utils.logger.warning("No valid blackbody fits were obtained; cannot estimate bolometric luminosity.")
1538
+ return None
1539
+
1540
+ # Compute L_bol (or L_boosted) for each epoch and propagate uncertainties.
1541
+ L_bol = []
1542
+ L_bol_err = []
1543
+ L_bol_bb = []
1544
+ L_bol_bb_err = []
1545
+ for index, row in df_bb.iterrows():
1546
+ temp = row['temperature']
1547
+ radius = row['radius']
1548
+ T_err = row['temp_err']
1549
+ R_err = row['radius_err']
1550
+
1551
+ # Use boosted luminosity if lambda_cut is provided.
1552
+ if lambda_cut is not None:
1553
+ lum, lum_bb = boosted_bolometric_luminosity(temp, radius, lambda_cut)
1554
+ else:
1555
+ lum = 4 * np.pi * (radius ** 2) * redback.constants.sigma_sb * (temp ** 4)
1556
+ lum_bb = lum
1557
+
1558
+ # Apply extinction correction to both luminosities.
1559
+ lum *= extinction_factor
1560
+ lum_bb *= extinction_factor
1561
+
1562
+ # Propagate uncertainties using:
1563
+ # (ΔL/L)² = (2 ΔR / R)² + (4 ΔT / T)².
1564
+ rel_err = np.sqrt((2 * R_err / radius) ** 2 + (4 * T_err / temp) ** 2)
1565
+ L_err = lum * rel_err
1566
+ L_err_bb = lum_bb * rel_err
1567
+
1568
+ L_bol.append(lum)
1569
+ L_bol_bb.append(lum_bb)
1570
+ L_bol_err.append(L_err)
1571
+ L_bol_bb_err.append(L_err_bb)
1572
+
1573
+ df_bol = df_bb.copy()
1574
+ df_bol['lum_bol'] = np.array(L_bol) / 1e50
1575
+ df_bol['lum_bol_err'] = np.array(L_bol_err) / 1e50
1576
+ df_bol['lum_bol_bb'] = np.array(L_bol_bb) / 1e50
1577
+ df_bol['lum_bol_bb_err'] = np.array(L_bol_bb_err) / 1e50
1578
+ df_bol['time_rest_frame'] = df_bol['epoch_times'] / (1 + self.redshift)
1579
+
1580
+ redback.utils.logger.info('Masking bolometric estimates with likely wrong extractions')
1581
+ df_bol = df_bol[df_bol['lum_bol_err'] / df_bol['lum_bol'] < 1]
1582
+ redback.utils.logger.info(
1583
+ "Estimated bolometric luminosity using blackbody integration (with boost and extinction corrections if specified).")
1584
+ return df_bol