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.
Files changed (47) hide show
  1. lfkit/__init__.py +19 -0
  2. lfkit/_version.py +24 -0
  3. lfkit/api/__init__.py +0 -0
  4. lfkit/api/corrections.py +308 -0
  5. lfkit/api/lumfunc.py +914 -0
  6. lfkit/corrections/__init__.py +0 -0
  7. lfkit/corrections/color_anchors.py +176 -0
  8. lfkit/corrections/filters.py +185 -0
  9. lfkit/corrections/kcorrect_backend.py +149 -0
  10. lfkit/corrections/kcorrect_from_color.py +111 -0
  11. lfkit/corrections/kcorrect_grids.py +242 -0
  12. lfkit/corrections/poggianti1997.py +386 -0
  13. lfkit/corrections/responses.py +183 -0
  14. lfkit/cosmo/__init__.py +0 -0
  15. lfkit/cosmo/cosmology.py +211 -0
  16. lfkit/data/demo_catalogs/fake_magnitude_limited_catalog.csv +201 -0
  17. lfkit/data/kcorrect/grids/kcorrect__bessell__z0.0000_4.0__Nz801__bsnone.npz +0 -0
  18. lfkit/data/kcorrect/grids/kcorrect__decam__z0.0000_4.0__Nz801__bsnone.npz +0 -0
  19. lfkit/data/kcorrect/grids/kcorrect__sdss__z0.0000_4.0__Nz801__bsnone.npz +0 -0
  20. lfkit/data/kcorrect/grids/kcorrect__subaru_suprimecam__z0.0000_4.0__Nz801__bsnone.npz +0 -0
  21. lfkit/data/poggianti1997/__init__.py +0 -0
  22. lfkit/data/poggianti1997/ecorr.csv +603 -0
  23. lfkit/data/poggianti1997/filters.csv +516 -0
  24. lfkit/data/poggianti1997/kcorr.csv +490 -0
  25. lfkit/data/poggianti1997/kcorrv.csv +37 -0
  26. lfkit/data/poggianti1997/sed.csv +295 -0
  27. lfkit/photometry/__init__.py +0 -0
  28. lfkit/photometry/catalog_completeness.py +381 -0
  29. lfkit/photometry/lf_integrals.py +500 -0
  30. lfkit/photometry/lf_parameter_models.py +386 -0
  31. lfkit/photometry/lf_redshift_density.py +238 -0
  32. lfkit/photometry/luminosities.py +426 -0
  33. lfkit/photometry/luminosity_function.py +707 -0
  34. lfkit/photometry/magnitudes.py +214 -0
  35. lfkit/utils/__init__.py +0 -0
  36. lfkit/utils/download_poggianti97_data.py +70 -0
  37. lfkit/utils/evaluators.py +104 -0
  38. lfkit/utils/interpolation.py +216 -0
  39. lfkit/utils/io.py +240 -0
  40. lfkit/utils/types.py +27 -0
  41. lfkit/utils/units.py +160 -0
  42. lfkit/utils/validators.py +63 -0
  43. py_lfkit-0.1.4.dist-info/METADATA +94 -0
  44. py_lfkit-0.1.4.dist-info/RECORD +47 -0
  45. py_lfkit-0.1.4.dist-info/WHEEL +5 -0
  46. py_lfkit-0.1.4.dist-info/licenses/LICENSE +21 -0
  47. 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]