Skip to content

Commit d8348f5

Browse files
author
David Turner
authored
Merge pull request #1336 from DavidT3/feat/addNTempApecModel
Feat/add n temp apec model
2 parents 7236f9c + 1081f46 commit d8348f5

3 files changed

Lines changed: 91 additions & 57 deletions

File tree

xga/xspec/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS).
2-
# Last modified by David J Turner (turne540@msu.edu) 27/04/2023, 12:16. Copyright (c) The Contributors
2+
# Last modified by David J Turner (turne540@msu.edu) 28/03/2025, 11:34. Copyright (c) The Contributors
33

44
from .fit import single_temp_apec, power_law, single_temp_apec_profile, blackbody, multi_temp_dem_apec, \
5-
single_temp_mekal
5+
single_temp_mekal, double_temp_apec
66
from .run import execute_cmd, xspec_call

xga/xspec/fit/general.py

Lines changed: 79 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS).
2-
# Last modified by David J Turner (turne540@msu.edu) 27/03/2025, 11:43. Copyright (c) The Contributors
2+
# Last modified by David J Turner (turne540@msu.edu) 29/03/2025, 05:01. Copyright (c) The Contributors
33

44
import warnings
55
from inspect import signature, Parameter
@@ -926,34 +926,26 @@ def blackbody(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, Q
926926
return script_paths, outfile_paths, num_cores, run_type, src_inds, None, timeout, model, fit_confs, inv_ents
927927

928928

929-
# If the spectrum checking step of the XSPEC fit is enabled (using the boolean flag spectrum_checking), then
930-
# each individual spectrum available for a given source will be fitted, and if the measured temperature is less
931-
# than or equal to 0.01keV, or greater than 20keV, or the temperature uncertainty is greater than 15keV, then
932-
# that spectrum will be rejected and not included in the final fit. Spectrum checking also involves rejecting any
933-
# spectra with fewer than 10 noticed channels.
934-
#
935-
# Freezing the temperature value of the fit is also possible, in cases where the data may not be sufficient to
936-
# constrain it, and an external temperature constrain is used (by passing to the 'start_temp' argument).
937-
938-
939929
@xspec_call
940-
def n_temp_apec(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, Quantity],
941-
num_temp_comp: Union[int, List[int]], inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'),
942-
start_temp: Quantity = Quantity(3.0, "keV"), start_met: float = 0.3,
930+
def double_temp_apec(sources: Union[BaseSource, BaseSample], outer_radius: Union[str, Quantity],
931+
inner_radius: Union[str, Quantity] = Quantity(0, 'arcsec'),
932+
start_temp_one: Quantity = Quantity(3.0, "keV"), start_temp_two: Quantity = Quantity(5.0, "keV"),
933+
start_met_one: float = 0.3, start_met_two: float = 0.3,
943934
lum_en: Quantity = Quantity([[0.5, 2.0], [0.01, 100.0]], "keV"), freeze_nh: bool = True,
944-
freeze_met: bool = True, freeze_temp: bool = False, lo_en: Quantity = Quantity(0.3, "keV"),
935+
freeze_met_one: bool = True, freeze_met_two: bool = True, freeze_temp_one: bool = False,
936+
freeze_temp_two: bool = False, lo_en: Quantity = Quantity(0.3, "keV"),
945937
hi_en: Quantity = Quantity(7.9, "keV"), par_fit_stat: float = 1., lum_conf: float = 68.,
946938
abund_table: str = "angr", fit_method: str = "leven", group_spec: bool = True, min_counts: int = 5,
947939
min_sn: float = None, over_sample: float = None, one_rmf: bool = True, num_cores: int = NUM_CORES,
948-
spectrum_checking: bool = True, timeout: Quantity = Quantity(1, 'hr')):
940+
spectrum_checking: bool = False, timeout: Quantity = Quantity(1, 'hr')):
949941
"""
950-
This is a convenience function for fitting an absorbed multi-temperature apec model(constant*tbabs*apec) to an
951-
object's spectrum - the number of temperature components (i.e. APEC models) is set using the 'num_temp_comp'
952-
argument. If there are no existing spectra with the passed settings, then they will be generated automatically.
942+
This is a convenience function for fitting an absorbed double-temperature apec model (constant*tbabs*(apec+apec))
943+
to an object's spectrum. If there are no existing spectra with the passed settings, then they will be
944+
generated automatically.
953945
954946
BE AWARE that higher quality data (i.e. higher signal-to-noise spectra, with a greater number of X-ray counts) are
955-
required to fit models with more free parameters, so the more temperature components you include, the less likely
956-
the model fit is to be successful.
947+
required to fit models with more free parameters, so this model fit is less likely to converge than single
948+
temperature apec fits for lower quality data.
957949
958950
:param List[BaseSource] sources: A single source object, or a sample of sources.
959951
:param str/Quantity outer_radius: The name or value of the outer radius of the region that the
@@ -965,14 +957,24 @@ def n_temp_apec(sources: Union[BaseSource, BaseSample], outer_radius: Union[str,
965957
desired spectrum covers (for instance 'r200' would be acceptable for a GalaxyCluster,
966958
or Quantity(1000, 'kpc')). By default this is zero arcseconds, resulting in a circular spectrum. If
967959
you are fitting for multiple sources then you can also pass a Quantity with one entry per source.
968-
:param Quantity start_temp: The initial temperature for the fit, the default is 3 keV. This value can also be
969-
a non-scalar Quantity, with an entry for every source in a sample (this is most useful when used with the
970-
'freeze_temp' argument, to provide some external constraint on temperature for objects with poor data).
971-
:param start_met: The initial metallicity for the fit (in ZSun).
960+
:param Quantity start_temp_one: The initial temperature of the first APEC model, the default is 3 keV. This value
961+
can also be a non-scalar Quantity, with an entry for every source in a sample (this is most useful when
962+
used with the 'freeze_temp' argument, to provide some external constraint on temperature for objects with
963+
poor data).
964+
:param Quantity start_temp_two: The initial temperature of the second APEC model, the default is 3 keV. This value
965+
can also be a non-scalar Quantity, with an entry for every source in a sample (this is most useful when
966+
used with the 'freeze_temp' argument, to provide some external constraint on temperature for objects with
967+
poor data).
968+
:param start_met_one: The initial metallicity of the first APEC model (in ZSun).
969+
:param start_met_two: The initial metallicity of the second APEC model (in ZSun).
972970
:param Quantity lum_en: Energy bands in which to measure luminosity.
973971
:param bool freeze_nh: Whether the hydrogen column density should be frozen. Default is True.
974-
:param bool freeze_met: Whether the metallicity parameter in the fit should be frozen. Default is True.
975-
:param bool freeze_temp: Whether the temperature parameter in the fit should be frozen. Default is False
972+
:param bool freeze_met_one: Whether the metallicity of the first APEC model should be frozen. Default is True.
973+
:param bool freeze_met_two: Whether the metallicity of the second APEC model should be frozen. Default is True.
974+
:param bool freeze_temp_one: Whether the temperature parameter of the first APEC model should be
975+
frozen. Default is False
976+
:param bool freeze_temp_two: Whether the temperature parameter of the second APEC model should be
977+
frozen. Default is False
976978
:param Quantity lo_en: The lower energy limit for the data to be fitted.
977979
:param Quantity hi_en: The upper energy limit for the data to be fitted.
978980
:param float par_fit_stat: The delta fit statistic for the XSPEC 'error' command, default is 1.0 which should be
@@ -985,7 +987,7 @@ def n_temp_apec(sources: Union[BaseSource, BaseSample], outer_radius: Union[str,
985987
:param float min_counts: If generating a grouped spectrum, this is the minimum number of counts per channel.
986988
To disable minimum counts set this parameter to None.
987989
:param float min_sn: If generating a grouped spectrum, this is the minimum signal to noise in each channel.
988-
To disable minimum signal to noise set this parameter to None.
990+
To disable minimum signal-to-noise set this parameter to None.
989991
:param float over_sample: The minimum energy resolution for each group, set to None to disable. e.g. if
990992
over_sample=3 then the minimum width of a group is 1/3 of the resolution FWHM at that energy.
991993
:param bool one_rmf: This flag tells the method whether it should only generate one RMF for a particular
@@ -998,37 +1000,56 @@ def n_temp_apec(sources: Union[BaseSource, BaseSample], outer_radius: Union[str,
9981000
Please note that this is not a timeout for the entire fitting process, but a timeout to individual source
9991001
fits.
10001002
"""
1001-
raise NotImplementedError("Barely begun")
1003+
1004+
# This is a little cheesy as we haven't decided what exactly to do yet, but the spectrum checking behaviour
1005+
# is super inconsistent with double_temp_apec - presumably just because the greater number of parameters
1006+
# makes estimating errors much less stable for individual spectral fits
1007+
if spectrum_checking:
1008+
raise NotImplementedError("The double_temp_apec function does not currently support "
1009+
"'spectrum_checking=True', as it exhibits very unstable behaviour.")
1010+
10021011
sources, inn_rad_vals, out_rad_vals = _pregen_spectra(sources, outer_radius, inner_radius, group_spec, min_counts,
10031012
min_sn, over_sample, one_rmf, num_cores)
10041013
sources = _check_inputs(sources, lum_en, lo_en, hi_en, fit_method, abund_table, timeout)
10051014

1006-
# Have to check that every source has a start temperature entry, if the user decided to pass a set of them
1007-
if not start_temp.isscalar and len(start_temp) != len(sources):
1008-
raise ValueError("If a non-scalar Quantity is passed for 'start_temp', it must have one entry for each "
1009-
"source. It currently has {n} for {s} sources.".format(n=len(start_temp), s=len(sources)))
1015+
# Have to check that every source has start temperature entries, if the user decided to pass a set of them - this
1016+
# is identical to the checks we perform at the beginning of 'single_temp_apec'
1017+
if ((not start_temp_one.isscalar and len(start_temp_one) != len(sources)) or
1018+
(not start_temp_two.isscalar and len(start_temp_two) != len(sources))):
1019+
raise ValueError("If a non-scalar Quantity is passed for 'start_temp_one' or 'start_temp_two', there must be "
1020+
"one entry for each source. They currently have {n1} and {n2} respectively, for {s} "
1021+
"sources.".format(n1=len(start_temp_one), n2=len(start_temp_two), s=len(sources)))
1022+
10101023
# Want to make sure that the start_temp variable is always a non-scalar Quantity with an entry for every source
10111024
# after this point, it means we normalise how we deal with it.
1012-
elif start_temp.isscalar:
1025+
if start_temp_one.isscalar:
10131026
# Doing it like this, defining a new variable and then redeclaring the start_temp in each bit of the loop
10141027
# below is important, as the fit_conf generation code accesses the current value of start_temp specifically
1015-
all_start_temps = Quantity([start_temp.value.copy()]*len(sources), start_temp.unit)
1028+
all_start_temp_ones = Quantity([start_temp_one.value.copy()]*len(sources), start_temp_one.unit)
10161029
else:
1017-
all_start_temps = start_temp.copy()
1030+
all_start_temp_ones = start_temp_one.copy()
10181031

1019-
# This function is for a set model, absorbed apec, so I can hard code all of this stuff.
1020-
# These will be inserted into the general XSPEC script template, so lists of parameters need to be in the form
1021-
# of TCL lists.
1022-
model = "constant*tbabs*apec"
1023-
par_names = "{factor nH kT Abundanc Redshift norm}"
1032+
# And the same thing for start temperature the second
1033+
if start_temp_two.isscalar:
1034+
# Doing it like this, defining a new variable and then redeclaring the start_temp in each bit of the loop
1035+
# below is important, as the fit_conf generation code accesses the current value of start_temp specifically
1036+
all_start_temp_twos = Quantity([start_temp_two.value.copy()]*len(sources), start_temp_two.unit)
1037+
else:
1038+
all_start_temp_twos = start_temp_two.copy()
1039+
1040+
# This function is for a set model, absorbed double apec, so I can hard code all of this stuff.
1041+
model = "constant*tbabs*(apec+apec)"
1042+
# These will be inserted into the general XSPEC script template, so lists of parameters need to be
1043+
# in the form of TCL lists.
1044+
par_names = "{factor nH kT Abundanc Redshift norm kT Abundanc Redshift norm}"
10241045
lum_low_lims = "{" + " ".join(lum_en[:, 0].to("keV").value.astype(str)) + "}"
10251046
lum_upp_lims = "{" + " ".join(lum_en[:, 1].to("keV").value.astype(str)) + "}"
10261047

10271048
# Here we generate the fit configuration storage key from those arguments to this function that control the fit
10281049
# and how it behaves
1029-
rel_args = FIT_FUNC_ARGS['single_temp_apec']
1050+
rel_args = FIT_FUNC_ARGS['double_temp_apec']
10301051

1031-
sig = signature(single_temp_apec)
1052+
sig = signature(double_temp_apec)
10321053
cur_args = {k: v.default for k, v in sig.parameters.items() if v.default is not Parameter.empty}
10331054

10341055
# This is purely for developers, as a check to make sure that the FIT_FUNC_ARGS dictionary is updated if the
@@ -1063,22 +1084,27 @@ def n_temp_apec(sources: Union[BaseSource, BaseSample], outer_radius: Union[str,
10631084
if source.redshift is None:
10641085
raise ValueError("You cannot supply a source without a redshift to this model.")
10651086

1066-
# Whatever start temperature is passed gets converted to keV, this will be put in the template
1067-
start_temp = all_start_temps[src_ind].to("keV", equivalencies=u.temperature_energy())
1087+
# Whatever start temperatures are passed get converted to keV - then will be put in the template
1088+
start_temp_one = all_start_temp_ones[src_ind].to("keV", equivalencies=u.temperature_energy())
1089+
start_temp_two = all_start_temp_twos[src_ind].to("keV", equivalencies=u.temperature_energy())
1090+
10681091
# Another TCL list, this time of the parameter start values for this model.
1069-
par_values = "{{{0} {1} {2} {3} {4} {5}}}".format(1., source.nH.to("10^22 cm^-2").value, start_temp.value,
1070-
start_met, source.redshift, 1.)
1092+
par_values = ("{{{0} {1} {2} {3} {4} {5} {6} {7} {8} "
1093+
"{9}}}").format(1., source.nH.to("10^22 cm^-2").value, start_temp_one.value, start_met_one,
1094+
source.redshift, 1., start_temp_two.value, start_met_two, source.redshift, 1.)
10711095

10721096
# Set up the TCL list that defines which parameters are frozen, dependent on user input - this can now
10731097
# include the temperature, if the user wants it fixed at the start value
1074-
freezing = "{{F {n} {t} {a} T F}}".format(n="T" if freeze_nh else "F",
1075-
t="T" if freeze_temp else "F",
1076-
a="T" if freeze_met else "F")
1098+
freezing = "{{F {n} {t1} {a2} T F {t2} {a2} T F}}".format(n="T" if freeze_nh else "F",
1099+
t1="T" if freeze_temp_one else "F",
1100+
a1="T" if freeze_met_one else "F",
1101+
t2="T" if freeze_temp_two else "F",
1102+
a2="T" if freeze_met_two else "F")
10771103

10781104
# Set up the TCL list that defines which parameters are linked across different spectra, only the
10791105
# multiplicative constant that accounts for variation in normalisation over different observations is not
10801106
# linked
1081-
linking = "{F T T T T T}"
1107+
linking = "{F T T T T T T T T T}"
10821108

10831109
# If the user wants the spectrum cleaning step to be run, then we have to setup some acceptable
10841110
# limits. For this function they will be hardcoded, for simplicities sake, and we're only going to
@@ -1135,5 +1161,5 @@ def n_temp_apec(sources: Union[BaseSource, BaseSample], outer_radius: Union[str,
11351161
'constant*tbabs*zpowerlw': power_law,
11361162
'constant*tbabs*powerlaw': power_law,
11371163
'constant*tbabs*zbbody': blackbody,
1138-
'constant*tbabs*bbody': blackbody
1139-
}
1164+
'constant*tbabs*bbody': blackbody,
1165+
'constant*tbabs*(apec+apec)': double_temp_apec}

xga/xspec/fitconfgen.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
# This code is a part of X-ray: Generate and Analyse (XGA), a module designed for the XMM Cluster Survey (XCS).
2-
# Last modified by David J Turner (turne540@msu.edu) 12/03/2025, 16:02. Copyright (c) The Contributors
2+
# Last modified by David J Turner (turne540@msu.edu) 27/03/2025, 22:31. Copyright (c) The Contributors
33

44
from inspect import signature, Parameter
55
from types import FunctionType
66
from typing import Union
77

88
from astropy.units import Quantity
99

10-
# This constant very importantly defined whether each argument to the XGA XSPEC fitting functions should be included
10+
# This constant very importantly defines whether each argument to the XGA XSPEC fitting functions should be included
1111
# in the fit configuration storage key - we define it here so that this information can be accessed outside the
1212
# actual fit function. We will also check the arguments of each fit function against these entries to see whether
1313
# an argument has been added that is not accounted for here.
@@ -18,6 +18,14 @@
1818
'group_spec': False, 'min_counts': False, 'min_sn': False, 'over_sample': False,
1919
'one_rmf': False, 'num_cores': False, 'spectrum_checking': False, 'timeout': False},
2020

21+
'double_temp_apec': {'inner_radius': False, 'start_temp_one': True, 'start_temp_two': True,
22+
'start_met_one': True, 'start_met_two': True, 'lum_en': False, 'freeze_nh': True,
23+
'freeze_met_one': True, 'freeze_met_two': True, 'freeze_temp_one': True,
24+
'freeze_temp_two': True, 'lo_en': True, 'hi_en': True, 'par_fit_stat': True,
25+
'lum_conf': False, 'abund_table': True, 'fit_method': True,
26+
'group_spec': False, 'min_counts': False, 'min_sn': False, 'over_sample': False,
27+
'one_rmf': False, 'num_cores': False, 'spectrum_checking': False, 'timeout': False},
28+
2129
'single_temp_mekal': {'inner_radius': False, 'start_temp': True, 'start_met': True, 'lum_en': False,
2230
'freeze_nh': True, 'freeze_met': True, 'freeze_temp': True, 'lo_en': True, 'hi_en': True,
2331
'par_fit_stat': True, 'lum_conf': False, 'abund_table': True, 'fit_method': True,

0 commit comments

Comments
 (0)