py-lfkit 0.1.4__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.
- lfkit/__init__.py +19 -0
- lfkit/_version.py +24 -0
- lfkit/api/__init__.py +0 -0
- lfkit/api/corrections.py +308 -0
- lfkit/api/lumfunc.py +914 -0
- lfkit/corrections/__init__.py +0 -0
- lfkit/corrections/color_anchors.py +176 -0
- lfkit/corrections/filters.py +185 -0
- lfkit/corrections/kcorrect_backend.py +149 -0
- lfkit/corrections/kcorrect_from_color.py +111 -0
- lfkit/corrections/kcorrect_grids.py +242 -0
- lfkit/corrections/poggianti1997.py +386 -0
- lfkit/corrections/responses.py +183 -0
- lfkit/cosmo/__init__.py +0 -0
- lfkit/cosmo/cosmology.py +211 -0
- lfkit/data/demo_catalogs/fake_magnitude_limited_catalog.csv +201 -0
- lfkit/data/kcorrect/grids/kcorrect__bessell__z0.0000_4.0__Nz801__bsnone.npz +0 -0
- lfkit/data/kcorrect/grids/kcorrect__decam__z0.0000_4.0__Nz801__bsnone.npz +0 -0
- lfkit/data/kcorrect/grids/kcorrect__sdss__z0.0000_4.0__Nz801__bsnone.npz +0 -0
- lfkit/data/kcorrect/grids/kcorrect__subaru_suprimecam__z0.0000_4.0__Nz801__bsnone.npz +0 -0
- lfkit/data/poggianti1997/__init__.py +0 -0
- lfkit/data/poggianti1997/ecorr.csv +603 -0
- lfkit/data/poggianti1997/filters.csv +516 -0
- lfkit/data/poggianti1997/kcorr.csv +490 -0
- lfkit/data/poggianti1997/kcorrv.csv +37 -0
- lfkit/data/poggianti1997/sed.csv +295 -0
- lfkit/photometry/__init__.py +0 -0
- lfkit/photometry/catalog_completeness.py +381 -0
- lfkit/photometry/lf_integrals.py +500 -0
- lfkit/photometry/lf_parameter_models.py +386 -0
- lfkit/photometry/lf_redshift_density.py +238 -0
- lfkit/photometry/luminosities.py +426 -0
- lfkit/photometry/luminosity_function.py +707 -0
- lfkit/photometry/magnitudes.py +214 -0
- lfkit/utils/__init__.py +0 -0
- lfkit/utils/download_poggianti97_data.py +70 -0
- lfkit/utils/evaluators.py +104 -0
- lfkit/utils/interpolation.py +216 -0
- lfkit/utils/io.py +240 -0
- lfkit/utils/types.py +27 -0
- lfkit/utils/units.py +160 -0
- lfkit/utils/validators.py +63 -0
- py_lfkit-0.1.4.dist-info/METADATA +94 -0
- py_lfkit-0.1.4.dist-info/RECORD +47 -0
- py_lfkit-0.1.4.dist-info/WHEEL +5 -0
- py_lfkit-0.1.4.dist-info/licenses/LICENSE +21 -0
- py_lfkit-0.1.4.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Color-anchor utilities for ``kcorrect``.
|
|
2
|
+
|
|
3
|
+
Core concept:
|
|
4
|
+
An "anchor" is defined by a single two-band color constraint:
|
|
5
|
+
|
|
6
|
+
color = (band_a - band_b) = color_value
|
|
7
|
+
|
|
8
|
+
where band_a and band_b are *kcorrect response names* (e.g. "sdss_g0").
|
|
9
|
+
|
|
10
|
+
This module is intentionally agnostic:
|
|
11
|
+
- no galaxy "types"
|
|
12
|
+
- no "red/blue" naming
|
|
13
|
+
- no survey assumptions beyond optional filter mapping wrappers
|
|
14
|
+
|
|
15
|
+
What this module does:
|
|
16
|
+
The goal is simply to obtain a set of kcorrect template coefficients that
|
|
17
|
+
reproduce a specified two-band color at a given redshift. Since a color only
|
|
18
|
+
fixes a *flux ratio*, the overall normalization of the SED is arbitrary.
|
|
19
|
+
To make the problem well defined we choose an arbitrary reference magnitude
|
|
20
|
+
("anchor") and construct a minimal synthetic photometry vector consistent
|
|
21
|
+
with the requested color. Those fluxes are then passed to kcorrect to solve
|
|
22
|
+
for the template mixture.
|
|
23
|
+
|
|
24
|
+
Important limitations:
|
|
25
|
+
The resulting coefficients are **not a unique physical SED fit**. A single
|
|
26
|
+
color constraint leaves large degeneracies in template space. The anchor
|
|
27
|
+
normalization is purely a gauge choice, and no attempt is made to infer
|
|
28
|
+
galaxy type, stellar population parameters, dust, or luminosity evolution.
|
|
29
|
+
The coefficients are therefore best interpreted as a convenient internal
|
|
30
|
+
representation for evaluating K(z) curves consistent with the specified
|
|
31
|
+
color constraint.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
|
|
40
|
+
from lfkit.utils.units import mag_to_maggies
|
|
41
|
+
|
|
42
|
+
from .kcorrect_backend import build_kcorrect
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"fit_coeffs_from_bandcolor",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Internal normalization (gauge) used to build a concrete photometry vector
|
|
49
|
+
# from a pure color (flux ratio) constraint.
|
|
50
|
+
_ANCHOR_MAG_DEFAULT = 22.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fit_coeffs_from_bandcolor(
|
|
54
|
+
*,
|
|
55
|
+
color: tuple[str, str],
|
|
56
|
+
color_value: float,
|
|
57
|
+
z_phot: float = 0.0,
|
|
58
|
+
anchor_band: str | None = None,
|
|
59
|
+
anchor_mag: float = _ANCHOR_MAG_DEFAULT,
|
|
60
|
+
responses: list[str] | None = None,
|
|
61
|
+
ivar_level: float = 1e10,
|
|
62
|
+
response_dir: str | Path | None = None,
|
|
63
|
+
redshift_range: tuple[float, float] = (0.0, 2.0),
|
|
64
|
+
nredshift: int = 4000,
|
|
65
|
+
rescale_maggies: bool = True,
|
|
66
|
+
) -> tuple[np.ndarray, list[str]]:
|
|
67
|
+
"""Fit kcorrect coefficients from a single two-band color constraint.
|
|
68
|
+
|
|
69
|
+
This routine constructs the minimal synthetic photometry required to
|
|
70
|
+
reproduce a given color. Because a color only fixes a flux ratio, the
|
|
71
|
+
overall normalization is arbitrary; we therefore choose an internal
|
|
72
|
+
anchor magnitude to define a concrete flux scale. The resulting fluxes
|
|
73
|
+
are passed to kcorrect to solve for a template mixture whose predicted
|
|
74
|
+
photometry matches the requested color at the specified redshift.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
color: Tuple (band_a, band_b) meaning color = m_a - m_b.
|
|
78
|
+
These must be kcorrect response names (file stems).
|
|
79
|
+
color_value: Target color value in magnitudes.
|
|
80
|
+
z_phot: Redshift at which to fit the coefficients.
|
|
81
|
+
anchor_band: Band used to set the arbitrary flux normalization.
|
|
82
|
+
If None, defaults to band_b.
|
|
83
|
+
anchor_mag: Magnitude used to set the arbitrary flux normalization.
|
|
84
|
+
responses: Optional explicit list of responses to use in the fit.
|
|
85
|
+
If None, uses the minimal set {band_a, band_b, anchor_band}.
|
|
86
|
+
ivar_level: Inverse-variance weight for constrained bands.
|
|
87
|
+
response_dir: Optional directory containing custom response .dat files.
|
|
88
|
+
redshift_range: Internal kcorrect lookup redshift range.
|
|
89
|
+
nredshift: Internal kcorrect lookup grid size.
|
|
90
|
+
rescale_maggies: If True, rescale synthetic maggies to O(1) to reduce
|
|
91
|
+
numerical issues; adjusts ivar accordingly.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
(coeffs, fit_responses)
|
|
95
|
+
- coeffs: array (n_templates,)
|
|
96
|
+
- fit_responses: list of responses actually used in the fit
|
|
97
|
+
"""
|
|
98
|
+
band_a, band_b = map(str, color) # color = m_a - m_b
|
|
99
|
+
|
|
100
|
+
if anchor_band is None:
|
|
101
|
+
anchor_band = band_b
|
|
102
|
+
|
|
103
|
+
# responses used for fitting
|
|
104
|
+
if responses is None:
|
|
105
|
+
fit_responses = list(dict.fromkeys([band_a, band_b, str(anchor_band)]))
|
|
106
|
+
else:
|
|
107
|
+
fit_responses = list(map(str, responses))
|
|
108
|
+
missing = [
|
|
109
|
+
x for x in (band_a, band_b, str(anchor_band)) if x not in fit_responses
|
|
110
|
+
]
|
|
111
|
+
if missing:
|
|
112
|
+
raise ValueError(f"responses is missing required bands: {missing}")
|
|
113
|
+
|
|
114
|
+
kc = build_kcorrect(
|
|
115
|
+
responses_in=fit_responses,
|
|
116
|
+
responses_out=fit_responses,
|
|
117
|
+
responses_map=fit_responses,
|
|
118
|
+
response_dir=response_dir,
|
|
119
|
+
redshift_range=redshift_range,
|
|
120
|
+
nredshift=nredshift,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# anchor magnitude -> anchor flux (maggies)
|
|
124
|
+
f_anchor = float(mag_to_maggies(anchor_mag))
|
|
125
|
+
if (not np.isfinite(f_anchor)) or f_anchor <= 0:
|
|
126
|
+
raise ValueError("anchor_mag must map to positive finite maggies.")
|
|
127
|
+
|
|
128
|
+
# color = m_a - m_b => f_a / f_b = 10^(-0.4*color)
|
|
129
|
+
ratio = 10.0 ** (-0.4 * float(color_value))
|
|
130
|
+
if (not np.isfinite(ratio)) or ratio <= 0:
|
|
131
|
+
raise ValueError("color_value must be finite.")
|
|
132
|
+
|
|
133
|
+
# choose fluxes consistent with color and anchor band choice
|
|
134
|
+
if anchor_band == band_a:
|
|
135
|
+
f_a = f_anchor
|
|
136
|
+
f_b = f_a / ratio
|
|
137
|
+
elif anchor_band == band_b:
|
|
138
|
+
f_b = f_anchor
|
|
139
|
+
f_a = f_b * ratio
|
|
140
|
+
else:
|
|
141
|
+
# If anchor_band is distinct, anchor it and set (a,b) relative in a simple way.
|
|
142
|
+
f_b = f_anchor
|
|
143
|
+
f_a = f_b * ratio
|
|
144
|
+
|
|
145
|
+
flux_map = {band_a: f_a, band_b: f_b, str(anchor_band): f_anchor}
|
|
146
|
+
|
|
147
|
+
maggies = np.full(len(fit_responses), np.nan, dtype=float)
|
|
148
|
+
ivar = np.zeros(len(fit_responses), dtype=float)
|
|
149
|
+
|
|
150
|
+
w = float(ivar_level)
|
|
151
|
+
for i, band in enumerate(fit_responses):
|
|
152
|
+
if band in flux_map:
|
|
153
|
+
maggies[i] = float(flux_map[band])
|
|
154
|
+
ivar[i] = w
|
|
155
|
+
|
|
156
|
+
if rescale_maggies:
|
|
157
|
+
pos = maggies[np.isfinite(maggies) & (maggies > 0)]
|
|
158
|
+
scale = float(np.nanmedian(pos)) if pos.size else 1.0
|
|
159
|
+
if np.isfinite(scale) and scale > 0:
|
|
160
|
+
maggies = maggies / scale
|
|
161
|
+
ivar = ivar * (scale**2)
|
|
162
|
+
|
|
163
|
+
coeffs = kc.fit_coeffs(redshift=float(z_phot), maggies=maggies, ivar=ivar)
|
|
164
|
+
coeffs = np.asarray(coeffs, float)
|
|
165
|
+
|
|
166
|
+
nt = int(kc.templates.restframe_flux.shape[0])
|
|
167
|
+
if coeffs.shape != (nt,):
|
|
168
|
+
raise ValueError(f"fit_coeffs returned shape {coeffs.shape}, expected ({nt},)")
|
|
169
|
+
if not np.all(np.isfinite(coeffs)):
|
|
170
|
+
raise ValueError("fit_coeffs returned non-finite coeffs.")
|
|
171
|
+
if np.any(coeffs < 0):
|
|
172
|
+
raise ValueError("fit_coeffs returned negative coeffs.")
|
|
173
|
+
if float(np.sum(coeffs)) <= 0:
|
|
174
|
+
raise ValueError("fit_coeffs returned coeffs with sum<=0.")
|
|
175
|
+
|
|
176
|
+
return coeffs, fit_responses
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Filter / response mapping utilities.
|
|
2
|
+
|
|
3
|
+
LFKit uses a survey-oriented way to specify photometric bands:
|
|
4
|
+
|
|
5
|
+
(filterset, band)
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
("sdss", "r")
|
|
9
|
+
("hsc", "i")
|
|
10
|
+
("decam", "r")
|
|
11
|
+
("bessell", "V")
|
|
12
|
+
|
|
13
|
+
Internally, kcorrect does not work with survey names. Instead it expects
|
|
14
|
+
**response curve identifiers**, which correspond to filter throughput
|
|
15
|
+
files distributed with the package.
|
|
16
|
+
|
|
17
|
+
This module provides a lightweight mapping between the LFKit public
|
|
18
|
+
notation
|
|
19
|
+
|
|
20
|
+
(filterset, band)
|
|
21
|
+
|
|
22
|
+
and the corresponding kcorrect response names. The mapping can also be
|
|
23
|
+
extended or overridden to support custom filter curves.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Iterable
|
|
29
|
+
|
|
30
|
+
KNOWN_FILTERSETS: tuple[str, ...] = (
|
|
31
|
+
"sdss",
|
|
32
|
+
"hsc",
|
|
33
|
+
"decam",
|
|
34
|
+
"bessell",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
DEFAULT_RESPONSE_MAP: dict[tuple[str, str], str] = {
|
|
38
|
+
("sdss", "u"): "sdss_u0",
|
|
39
|
+
("sdss", "g"): "sdss_g0",
|
|
40
|
+
("sdss", "r"): "sdss_r0",
|
|
41
|
+
("sdss", "i"): "sdss_i0",
|
|
42
|
+
("sdss", "z"): "sdss_z0",
|
|
43
|
+
("decam", "u"): "decam_u",
|
|
44
|
+
("decam", "g"): "decam_g",
|
|
45
|
+
("decam", "r"): "decam_r",
|
|
46
|
+
("decam", "i"): "decam_i",
|
|
47
|
+
("decam", "z"): "decam_z",
|
|
48
|
+
("decam", "Y"): "decam_Y",
|
|
49
|
+
("hsc", "g"): "subaru_suprimecam_g",
|
|
50
|
+
("hsc", "r"): "subaru_suprimecam_r",
|
|
51
|
+
("hsc", "i"): "subaru_suprimecam_i",
|
|
52
|
+
("hsc", "z"): "subaru_suprimecam_z",
|
|
53
|
+
("bessell", "U"): "bessell_U",
|
|
54
|
+
("bessell", "B"): "bessell_B",
|
|
55
|
+
("bessell", "V"): "bessell_V",
|
|
56
|
+
("bessell", "R"): "bessell_R",
|
|
57
|
+
("bessell", "I"): "bessell_I",
|
|
58
|
+
("2mass", "J"): "twomass_J",
|
|
59
|
+
("2mass", "H"): "twomass_H",
|
|
60
|
+
("2mass", "K"): "twomass_Ks",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_filterset(filterset: str) -> str:
|
|
65
|
+
"""Return the canonical LFKit representation of a filterset name.
|
|
66
|
+
|
|
67
|
+
Filterset names are normalized to lowercase and stripped of
|
|
68
|
+
surrounding whitespace so that user input such as "SDSS", "sdss",
|
|
69
|
+
or " sdss " all resolve to the same canonical form.
|
|
70
|
+
"""
|
|
71
|
+
return str(filterset).strip().lower()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_band(band: str) -> str:
|
|
75
|
+
"""Normalize a band label.
|
|
76
|
+
|
|
77
|
+
Band names are stripped of surrounding whitespace while preserving
|
|
78
|
+
their case, since some filter systems distinguish between
|
|
79
|
+
uppercase and lowercase band identifiers.
|
|
80
|
+
"""
|
|
81
|
+
return str(band).strip()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def make_response_map(
|
|
85
|
+
*,
|
|
86
|
+
base: dict[tuple[str, str], str] | None = None,
|
|
87
|
+
extra: dict[tuple[str, str], str] | None = None,
|
|
88
|
+
) -> dict[tuple[str, str], str]:
|
|
89
|
+
"""Create a response mapping dictionary.
|
|
90
|
+
|
|
91
|
+
The returned mapping associates (filterset, band) pairs with
|
|
92
|
+
kcorrect response identifiers. A custom mapping can be created
|
|
93
|
+
by starting from a base map and overriding or extending entries
|
|
94
|
+
with the ``extra`` dictionary.
|
|
95
|
+
"""
|
|
96
|
+
out = dict(DEFAULT_RESPONSE_MAP if base is None else base)
|
|
97
|
+
if extra:
|
|
98
|
+
out.update(extra)
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_response_name(
|
|
103
|
+
*,
|
|
104
|
+
filterset: str,
|
|
105
|
+
band: str,
|
|
106
|
+
response_map: dict[tuple[str, str], str] | None = None,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Resolve a survey band to a kcorrect response identifier.
|
|
109
|
+
|
|
110
|
+
Given a (filterset, band) pair, this function returns the
|
|
111
|
+
corresponding kcorrect response name used to load the filter
|
|
112
|
+
throughput curve. A custom mapping can be provided to support
|
|
113
|
+
additional surveys or user-defined filters.
|
|
114
|
+
"""
|
|
115
|
+
fs = normalize_filterset(filterset)
|
|
116
|
+
b = normalize_band(band)
|
|
117
|
+
m = DEFAULT_RESPONSE_MAP if response_map is None else response_map
|
|
118
|
+
|
|
119
|
+
key = (fs, b)
|
|
120
|
+
if key in m:
|
|
121
|
+
return str(m[key])
|
|
122
|
+
|
|
123
|
+
available_for_fs = sorted([bb for (ffs, bb) in m.keys() if ffs == fs])
|
|
124
|
+
if available_for_fs:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"No response mapping for (filterset, band)=({fs!r}, {b!r}). "
|
|
127
|
+
f"Known bands for filterset={fs!r}: {available_for_fs}. "
|
|
128
|
+
"Provide response_map=... to extend the mapping."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
known_filtersets = sorted(set(ffs for (ffs, _) in m.keys()))
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"No response mapping for filterset={fs!r}. "
|
|
134
|
+
f"Known filtersets in mapping: {known_filtersets}. "
|
|
135
|
+
"Provide response_map=... to extend the mapping."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def list_supported(
|
|
140
|
+
response_map: dict[tuple[str, str], str] | None = None,
|
|
141
|
+
) -> dict[str, list[str]]:
|
|
142
|
+
"""Return the set of supported bands grouped by filterset.
|
|
143
|
+
|
|
144
|
+
The output lists all (filterset, band) combinations available
|
|
145
|
+
in the current response mapping. This is useful for inspecting
|
|
146
|
+
which survey filters are currently recognized by LFKit.
|
|
147
|
+
"""
|
|
148
|
+
m = DEFAULT_RESPONSE_MAP if response_map is None else response_map
|
|
149
|
+
out: dict[str, list[str]] = {}
|
|
150
|
+
for (fs, band), _resp in m.items():
|
|
151
|
+
out.setdefault(fs, []).append(band)
|
|
152
|
+
for fs in out:
|
|
153
|
+
out[fs] = sorted(set(out[fs]))
|
|
154
|
+
return dict(sorted(out.items()))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def validate_coverage(
|
|
158
|
+
*,
|
|
159
|
+
filterset: str,
|
|
160
|
+
bands: Iterable[str],
|
|
161
|
+
response_map: dict[tuple[str, str], str] | None = None,
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Check that a set of bands exists in the response mapping.
|
|
164
|
+
|
|
165
|
+
This function verifies that all requested bands are defined for
|
|
166
|
+
the given filterset in the response mapping. If any band is
|
|
167
|
+
missing, an informative error is raised listing the supported
|
|
168
|
+
bands for that filterset.
|
|
169
|
+
"""
|
|
170
|
+
fs = normalize_filterset(filterset)
|
|
171
|
+
m = DEFAULT_RESPONSE_MAP if response_map is None else response_map
|
|
172
|
+
|
|
173
|
+
missing: list[str] = []
|
|
174
|
+
for b in bands:
|
|
175
|
+
key = (fs, normalize_band(b))
|
|
176
|
+
if key not in m:
|
|
177
|
+
missing.append(str(b))
|
|
178
|
+
|
|
179
|
+
if missing:
|
|
180
|
+
supported = sorted([bb for (ffs, bb) in m.keys() if ffs == fs])
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"Missing response mappings for filterset={fs!r}: {missing}. "
|
|
183
|
+
f"Supported bands: {supported}. "
|
|
184
|
+
"Provide response_map=... to extend."
|
|
185
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""``kcorrect`` backend construction utilities.
|
|
2
|
+
|
|
3
|
+
This module provides a small wrapper around ``kcorrect.Kcorrect`` that
|
|
4
|
+
standardizes how LFKit creates and reuses backend instances.
|
|
5
|
+
|
|
6
|
+
A kcorrect backend depends on the set of input responses, output responses,
|
|
7
|
+
and the redshift grid used internally by the solver. Constructing this object
|
|
8
|
+
can be relatively expensive, so LFKit builds and caches instances associated
|
|
9
|
+
with a specific response configuration.
|
|
10
|
+
|
|
11
|
+
The wrapper also ensures that requested filter response names exist before
|
|
12
|
+
initializing the backend and allows optional use of custom response
|
|
13
|
+
directories when supported by the installed kcorrect version.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import inspect
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import kcorrect.kcorrect as kk
|
|
24
|
+
|
|
25
|
+
from .responses import require_responses
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _kc_cache_key(
|
|
29
|
+
*,
|
|
30
|
+
responses_in: tuple[str, ...],
|
|
31
|
+
responses_out: tuple[str, ...],
|
|
32
|
+
responses_map: tuple[str, ...],
|
|
33
|
+
response_dir: str | Path | None,
|
|
34
|
+
redshift_range: tuple[float, float],
|
|
35
|
+
nredshift: int,
|
|
36
|
+
abcorrect: bool,
|
|
37
|
+
) -> tuple:
|
|
38
|
+
"""Build a normalized cache key for a kcorrect backend configuration.
|
|
39
|
+
|
|
40
|
+
The key uniquely identifies a backend setup based on the requested
|
|
41
|
+
filter responses, redshift grid configuration, and optional response
|
|
42
|
+
directory. This normalized representation allows identical backend
|
|
43
|
+
configurations to reuse the same cached ``kcorrect.Kcorrect`` instance.
|
|
44
|
+
"""
|
|
45
|
+
if response_dir is None:
|
|
46
|
+
rdir_key = "__AUTO__"
|
|
47
|
+
else:
|
|
48
|
+
rdir_key = str(Path(response_dir).resolve())
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
responses_in,
|
|
52
|
+
responses_out,
|
|
53
|
+
responses_map,
|
|
54
|
+
rdir_key,
|
|
55
|
+
(float(redshift_range[0]), float(redshift_range[1])),
|
|
56
|
+
int(nredshift),
|
|
57
|
+
bool(abcorrect),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@lru_cache(maxsize=8)
|
|
62
|
+
def _build_kcorrect_cached(key: tuple) -> kk.Kcorrect:
|
|
63
|
+
"""Construct and cache a ``kcorrect.Kcorrect`` backend instance.
|
|
64
|
+
|
|
65
|
+
The input key encodes the response configuration and solver settings.
|
|
66
|
+
For each unique configuration the corresponding backend is created
|
|
67
|
+
once and stored in the cache, allowing repeated k(z) evaluations to
|
|
68
|
+
reuse the same initialized solver.
|
|
69
|
+
"""
|
|
70
|
+
(
|
|
71
|
+
responses_in,
|
|
72
|
+
responses_out,
|
|
73
|
+
responses_map,
|
|
74
|
+
rdir_key,
|
|
75
|
+
redshift_range,
|
|
76
|
+
nredshift,
|
|
77
|
+
abcorrect,
|
|
78
|
+
) = key
|
|
79
|
+
|
|
80
|
+
response_dir: Path | None
|
|
81
|
+
if rdir_key == "__AUTO__":
|
|
82
|
+
response_dir = None
|
|
83
|
+
else:
|
|
84
|
+
response_dir = Path(rdir_key)
|
|
85
|
+
|
|
86
|
+
# validate response names exist
|
|
87
|
+
require_responses(list(responses_in), response_dir)
|
|
88
|
+
require_responses(list(responses_out), response_dir)
|
|
89
|
+
require_responses(list(responses_map), response_dir)
|
|
90
|
+
|
|
91
|
+
kwargs: dict[str, Any] = dict(
|
|
92
|
+
responses=list(responses_in),
|
|
93
|
+
responses_out=list(responses_out),
|
|
94
|
+
responses_map=list(responses_map),
|
|
95
|
+
redshift_range=[float(redshift_range[0]), float(redshift_range[1])],
|
|
96
|
+
nredshift=int(nredshift),
|
|
97
|
+
abcorrect=bool(abcorrect),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if response_dir is not None:
|
|
101
|
+
sig = inspect.signature(kk.Kcorrect)
|
|
102
|
+
if "response_dir" in sig.parameters:
|
|
103
|
+
kwargs["response_dir"] = str(response_dir)
|
|
104
|
+
else:
|
|
105
|
+
raise TypeError(
|
|
106
|
+
"This kcorrect version does not support response_dir=... in kk.Kcorrect(...). "
|
|
107
|
+
"To use custom filters, install a kcorrect build that supports response_dir, "
|
|
108
|
+
"or place your *.dat filter files into kcorrect’s packaged response directory."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return kk.Kcorrect(**kwargs)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_kcorrect(
|
|
115
|
+
*,
|
|
116
|
+
responses_in: list[str],
|
|
117
|
+
responses_out: list[str] | None = None,
|
|
118
|
+
responses_map: list[str] | None = None,
|
|
119
|
+
response_dir: str | Path | None = None,
|
|
120
|
+
redshift_range: tuple[float, float] = (0.0, 2.0),
|
|
121
|
+
nredshift: int = 4000,
|
|
122
|
+
abcorrect: bool = False,
|
|
123
|
+
) -> kk.Kcorrect:
|
|
124
|
+
"""Create a kcorrect backend configured for a specific response setup.
|
|
125
|
+
|
|
126
|
+
This function returns a ``kcorrect.Kcorrect`` instance configured with the
|
|
127
|
+
requested input and output filter responses. The backend defines the template
|
|
128
|
+
set and internal redshift grid used to evaluate k-corrections.
|
|
129
|
+
|
|
130
|
+
Backend objects are cached so that repeated calls with the same configuration
|
|
131
|
+
reuse the existing instance rather than rebuilding the solver each time. This
|
|
132
|
+
keeps repeated k(z) evaluations fast while ensuring that the requested filter
|
|
133
|
+
responses are validated before use.
|
|
134
|
+
"""
|
|
135
|
+
if responses_out is None:
|
|
136
|
+
responses_out = responses_in
|
|
137
|
+
if responses_map is None:
|
|
138
|
+
responses_map = responses_in
|
|
139
|
+
|
|
140
|
+
key = _kc_cache_key(
|
|
141
|
+
responses_in=tuple(map(str, responses_in)),
|
|
142
|
+
responses_out=tuple(map(str, responses_out)),
|
|
143
|
+
responses_map=tuple(map(str, responses_map)),
|
|
144
|
+
response_dir=response_dir,
|
|
145
|
+
redshift_range=(float(redshift_range[0]), float(redshift_range[1])),
|
|
146
|
+
nredshift=int(nredshift),
|
|
147
|
+
abcorrect=bool(abcorrect),
|
|
148
|
+
)
|
|
149
|
+
return _build_kcorrect_cached(key)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""kcorrect k(z) evaluation from a single color anchor.
|
|
2
|
+
|
|
3
|
+
This module provides a minimal path from a **two-band color constraint**
|
|
4
|
+
to a k(z) curve. The idea is straightforward: a rest-frame color fixes a
|
|
5
|
+
flux ratio between two bands, which constrains the mixture of kcorrect
|
|
6
|
+
SED templates. Once the corresponding template coefficients are obtained,
|
|
7
|
+
kcorrect can be evaluated across redshift to produce the resulting k(z).
|
|
8
|
+
|
|
9
|
+
Only a single color constraint is used. No galaxy types or population
|
|
10
|
+
labels are introduced, and no attempt is made to infer a physical galaxy
|
|
11
|
+
classification. The color simply defines a template mixture that is
|
|
12
|
+
consistent with the specified flux ratio, which is then used to evaluate
|
|
13
|
+
k(z) for the requested output response band.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .color_anchors import fit_coeffs_from_bandcolor
|
|
23
|
+
from .kcorrect_backend import build_kcorrect
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"kcorrect_from_bandcolor",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Internal normalization used to construct a concrete photometry vector
|
|
30
|
+
# for the fitter. This is a gauge choice in one-color mode.
|
|
31
|
+
_ANCHOR_MAG_DEFAULT = 22.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def kcorrect_from_bandcolor(
|
|
35
|
+
*,
|
|
36
|
+
z: np.ndarray,
|
|
37
|
+
response_out: str,
|
|
38
|
+
color: tuple[str, str],
|
|
39
|
+
color_value: float,
|
|
40
|
+
z_phot: float = 0.0,
|
|
41
|
+
anchor_band: str | None = None,
|
|
42
|
+
anchor_mag: float = _ANCHOR_MAG_DEFAULT,
|
|
43
|
+
band_shift: float | None = None,
|
|
44
|
+
response_dir: str | Path | None = None,
|
|
45
|
+
redshift_range: tuple[float, float] = (0.0, 2.0),
|
|
46
|
+
nredshift: int = 4000,
|
|
47
|
+
ivar_level: float = 1e10,
|
|
48
|
+
anchor_z0: bool = True,
|
|
49
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
50
|
+
"""Compute k(z) for a single output response from a two-band color.
|
|
51
|
+
|
|
52
|
+
This routine converts a color constraint into kcorrect template
|
|
53
|
+
coefficients and evaluates the resulting k(z) curve on the supplied
|
|
54
|
+
redshift grid. The color defines the relative flux between two bands,
|
|
55
|
+
which constrains the template mixture used by kcorrect.
|
|
56
|
+
|
|
57
|
+
Because a color only fixes a flux ratio, the overall normalization of
|
|
58
|
+
the spectrum is arbitrary. An internal anchor magnitude is therefore
|
|
59
|
+
used to define a concrete photometry vector for the fit. The resulting
|
|
60
|
+
coefficients should be interpreted as a convenient representation of
|
|
61
|
+
an SED consistent with the specified color rather than a unique
|
|
62
|
+
physical galaxy model.
|
|
63
|
+
"""
|
|
64
|
+
z = np.asarray(z, float)
|
|
65
|
+
if z.ndim != 1 or z.size < 2 or np.any(~np.isfinite(z)):
|
|
66
|
+
raise ValueError("z must be a finite 1D array with >=2 points.")
|
|
67
|
+
|
|
68
|
+
coeffs, _fit_responses = fit_coeffs_from_bandcolor(
|
|
69
|
+
color=color,
|
|
70
|
+
color_value=float(color_value),
|
|
71
|
+
z_phot=float(z_phot),
|
|
72
|
+
anchor_band=anchor_band,
|
|
73
|
+
anchor_mag=float(anchor_mag),
|
|
74
|
+
responses=None,
|
|
75
|
+
ivar_level=float(ivar_level),
|
|
76
|
+
response_dir=response_dir,
|
|
77
|
+
redshift_range=redshift_range,
|
|
78
|
+
nredshift=int(nredshift),
|
|
79
|
+
rescale_maggies=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
kc = build_kcorrect(
|
|
83
|
+
responses_in=[str(response_out)],
|
|
84
|
+
responses_out=[str(response_out)],
|
|
85
|
+
responses_map=[str(response_out)],
|
|
86
|
+
response_dir=response_dir,
|
|
87
|
+
redshift_range=redshift_range,
|
|
88
|
+
nredshift=nredshift,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
K = np.full_like(z, np.nan, dtype=float)
|
|
92
|
+
for i, zi in enumerate(z):
|
|
93
|
+
if band_shift is None:
|
|
94
|
+
kval = kc.kcorrect(redshift=float(zi), coeffs=coeffs)
|
|
95
|
+
else:
|
|
96
|
+
kval = kc.kcorrect(
|
|
97
|
+
redshift=float(zi), coeffs=coeffs, band_shift=float(band_shift)
|
|
98
|
+
)
|
|
99
|
+
K[i] = float(np.asarray(kval, float)[0])
|
|
100
|
+
|
|
101
|
+
ok = np.isfinite(K)
|
|
102
|
+
if np.count_nonzero(ok) < 2:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Too few finite K(z) points for response_out={response_out!r}."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if anchor_z0:
|
|
108
|
+
i0 = int(np.where(ok)[0][0])
|
|
109
|
+
K[ok] = K[ok] - K[i0]
|
|
110
|
+
|
|
111
|
+
return z[ok], K[ok]
|