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
@@ -0,0 +1,242 @@
1
+ """kcorrect k(z) grid generation and interpolation utilities.
2
+
3
+ This module builds tables of k(z) values for one or more **anchors** on a
4
+ specified redshift grid and provides helpers to interpolate those tables.
5
+
6
+ An *anchor* is simply a label associated with a vector of kcorrect template
7
+ coefficients. Each coefficient vector represents a particular template
8
+ mixture and therefore defines a specific spectral energy distribution.
9
+ Evaluating kcorrect with those coefficients produces the corresponding
10
+ k(z) curve for a given set of filter responses.
11
+
12
+ The main purpose of this module is to turn a small set of anchors into
13
+ reusable k(z) grids that can be cached and quickly interpolated during
14
+ analysis. The resulting tables store k(z) as a function of redshift for
15
+ each anchor and response band, allowing fast lookup without repeatedly
16
+ calling the kcorrect solver.
17
+
18
+ Anchors intentionally carry **no semantic meaning** here. They are not
19
+ interpreted as galaxy types or populations; they are simply convenient
20
+ labels for different template mixtures used to generate k(z) curves.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+
29
+ from lfkit.utils.interpolation import Interpolator, build_1d_interpolator
30
+
31
+ from .kcorrect_backend import build_kcorrect
32
+
33
+
34
+ def compute_k_table(
35
+ *,
36
+ kc,
37
+ z_grid: np.ndarray,
38
+ coeffs_by_anchor: dict[str, np.ndarray],
39
+ band_shift: float | None = None,
40
+ anchor_z0: bool = True,
41
+ ) -> dict[str, np.ndarray]:
42
+ """Compute k(z) values on a redshift grid for each anchor.
43
+
44
+ This function evaluates k-corrections on a fixed redshift grid using a set
45
+ of precomputed template coefficient vectors (“anchors”). Each anchor
46
+ represents a particular SED mixture and therefore defines a specific
47
+ k(z) curve. The output is a table of k(z) values for every anchor across
48
+ the provided grid, which can later be used for interpolation or lookup.
49
+
50
+ By default the table is normalized so that k(z=0)=0 for each band. This
51
+ convention is commonly used when working with k-corrections because it
52
+ removes arbitrary offsets and ensures that all curves are anchored to
53
+ the same reference point.
54
+ """
55
+ z = np.asarray(z_grid, float)
56
+ if z.ndim != 1 or z.size < 2 or np.any(~np.isfinite(z)):
57
+ raise ValueError("z_grid must be a finite 1D array with >=2 points.")
58
+
59
+ out: dict[str, np.ndarray] = {}
60
+
61
+ for label, coeffs in coeffs_by_anchor.items():
62
+ c = np.asarray(coeffs, float).reshape(-1)
63
+
64
+ rows: list[np.ndarray] = []
65
+ nband: int | None = None
66
+
67
+ for zi in z:
68
+ zi = float(zi)
69
+
70
+ if zi == 0.0 and anchor_z0:
71
+ if nband is None:
72
+ if band_shift is None:
73
+ test = kc.kcorrect(redshift=1e-6, coeffs=c)
74
+ else:
75
+ test = kc.kcorrect(
76
+ redshift=1e-6, coeffs=c, band_shift=float(band_shift)
77
+ )
78
+ nband = int(np.asarray(test, float).size)
79
+ kcorr = np.zeros(nband, dtype=float)
80
+ else:
81
+ if band_shift is None:
82
+ kcorr = kc.kcorrect(redshift=zi, coeffs=c)
83
+ else:
84
+ kcorr = kc.kcorrect(
85
+ redshift=zi, coeffs=c, band_shift=float(band_shift)
86
+ )
87
+ kcorr = np.asarray(kcorr, float)
88
+ if nband is None:
89
+ nband = int(kcorr.size)
90
+
91
+ rows.append(np.asarray(kcorr, float))
92
+
93
+ karr = np.vstack(rows)
94
+ if karr.shape[0] != z.size:
95
+ raise ValueError(
96
+ f"BUG: built K with Nz={karr.shape[0]} but z has Nz={z.size}"
97
+ )
98
+
99
+ if anchor_z0:
100
+ if not np.any(np.isfinite(karr)):
101
+ raise ValueError(f"All-NaN K grid for anchor={label!r}.")
102
+ karr = karr - karr[0:1, :]
103
+
104
+ out[str(label)] = karr
105
+
106
+ return out
107
+
108
+
109
+ def build_kcorr_grid_package(
110
+ *,
111
+ responses_in: list[str],
112
+ responses_out: list[str] | None,
113
+ responses_map: list[str] | None,
114
+ coeffs_by_anchor: dict[str, np.ndarray],
115
+ z_grid: np.ndarray,
116
+ band_shift: float | None = 0.1,
117
+ response_dir: str | None = None,
118
+ redshift_range: tuple[float, float] = (0.0, 3.5),
119
+ nredshift: int = 4000,
120
+ ) -> dict[str, Any]:
121
+ """Build a packaged k(z) grid for a set of anchors.
122
+
123
+ This function generates a self-contained data package holding k(z)
124
+ tables evaluated on a common redshift grid for multiple anchors.
125
+ Each anchor corresponds to a template mixture that defines a specific
126
+ spectral energy distribution and therefore a specific k(z) curve.
127
+
128
+ The resulting package contains the redshift grid, the computed k(z)
129
+ tables, the filter responses used, and basic metadata describing the
130
+ setup. It is intended to serve as a reusable object that can be saved,
131
+ cached, or passed to interpolation routines without recomputing the
132
+ k-corrections.
133
+ """
134
+ if responses_out is None:
135
+ responses_out = responses_in
136
+ if responses_map is None:
137
+ responses_map = responses_in
138
+
139
+ kc = build_kcorrect(
140
+ responses_in=responses_in,
141
+ responses_out=responses_out,
142
+ responses_map=responses_map,
143
+ response_dir=response_dir,
144
+ redshift_range=redshift_range,
145
+ nredshift=nredshift,
146
+ )
147
+
148
+ nt = int(kc.templates.restframe_flux.shape[0])
149
+
150
+ for label, c in coeffs_by_anchor.items():
151
+ c = np.asarray(c, float)
152
+ if c.shape != (nt,):
153
+ raise ValueError(f"{label}: coeff shape {c.shape} != ({nt},)")
154
+ if not np.all(np.isfinite(c)):
155
+ raise ValueError(f"{label}: coeffs contain non-finite values.")
156
+ if np.any(c < 0):
157
+ raise ValueError(f"{label}: negative coeffs not allowed.")
158
+ if float(np.sum(c)) <= 0:
159
+ raise ValueError(f"{label}: coeffs sum <= 0.")
160
+
161
+ z_grid = np.asarray(z_grid, float)
162
+ K = compute_k_table(
163
+ kc=kc,
164
+ z_grid=z_grid,
165
+ coeffs_by_anchor=coeffs_by_anchor,
166
+ band_shift=band_shift,
167
+ anchor_z0=True,
168
+ )
169
+
170
+ return dict(
171
+ meta=dict(
172
+ backend="kcorrect",
173
+ band_shift=band_shift,
174
+ redshift_range=(float(z_grid[0]), float(z_grid[-1])),
175
+ nredshift=int(nredshift),
176
+ response_dir=str(response_dir) if response_dir is not None else None,
177
+ ),
178
+ z=z_grid,
179
+ responses_in=list(map(str, responses_in)),
180
+ responses_out=list(map(str, responses_out)),
181
+ responses_map=list(map(str, responses_map)),
182
+ anchors=sorted(list(map(str, coeffs_by_anchor.keys()))),
183
+ K=K, # mapping: anchor_label -> (Nz, Nband)
184
+ )
185
+
186
+
187
+ def kcorr_interpolators(
188
+ pkg: dict[str, Any],
189
+ *,
190
+ method: str = "pchip",
191
+ extrapolate: bool = True,
192
+ ) -> dict[str, dict[str, Interpolator | None]]:
193
+ """Create interpolation functions for k(z).
194
+
195
+ This function converts a precomputed k(z) grid package into a set of
196
+ interpolators that return k(z) for any redshift within the grid range.
197
+ An interpolator is produced for each combination of anchor and output
198
+ response band.
199
+
200
+ The resulting structure allows k-corrections to be evaluated quickly
201
+ without recomputing the underlying tables, making it convenient to
202
+ integrate precomputed grids into analysis workflows that repeatedly
203
+ query k(z) values.
204
+ """
205
+ z = np.asarray(pkg["z"], float)
206
+ responses_out = list(pkg["responses_out"])
207
+ anchors = list(pkg["anchors"])
208
+
209
+ out: dict[str, dict[str, Interpolator | None]] = {}
210
+ for label in anchors:
211
+ ktz = np.asarray(pkg["K"][label], float) # (Nz, Nband)
212
+
213
+ if ktz.shape[0] != z.size:
214
+ raise ValueError(
215
+ f"Shape mismatch for anchor={label!r}:"
216
+ f"K has Nz={ktz.shape[0]} vs z={z.size}."
217
+ )
218
+ if ktz.shape[1] != len(responses_out):
219
+ raise ValueError(
220
+ f"Shape mismatch for anchor={label!r}: "
221
+ f"K has Nband={ktz.shape[1]} "
222
+ f"vs responses_out={len(responses_out)}."
223
+ )
224
+
225
+ out[str(label)] = {}
226
+ for j, band in enumerate(responses_out):
227
+ y = np.asarray(ktz[:, j], float)
228
+ ok = np.isfinite(z) & np.isfinite(y)
229
+
230
+ if np.count_nonzero(ok) < 2:
231
+ out[str(label)][str(band)] = None
232
+ continue
233
+
234
+ out[str(label)][str(band)] = build_1d_interpolator(
235
+ z[ok],
236
+ y[ok],
237
+ method=method,
238
+ extrapolate=extrapolate,
239
+ extrap_mode="linear_tail",
240
+ )
241
+
242
+ return out
@@ -0,0 +1,386 @@
1
+ """Poggianti (1997) k- and e-correction tables and interpolators.
2
+
3
+ Loads Poggianti (1997) k- and e-correction curves from CSV tables, builds
4
+ interpolators for those curves (PCHIP, Akima, or linear), and optionally remaps
5
+ the e-correction redshift grid to a target cosmology by matching lookback time.
6
+
7
+ Table I/O and parsing live in ``lfkit.utils.io``.
8
+ Interpolation utilities live in ``lfkit.utils.interpolation``.
9
+ Unit conversions live in ``lfkit.utils.units``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+
18
+ from lfkit.cosmo.cosmology import cosmo_object as build_cosmo, lookback_time_gyr
19
+ from lfkit.utils.interpolation import (
20
+ InterpMethod,
21
+ Interpolator,
22
+ build_1d_interpolator,
23
+ prep_strictly_increasing_xy,
24
+ )
25
+ from lfkit.utils.io import (
26
+ POGGIANTI1997_PKG,
27
+ available_from_table,
28
+ extract_series,
29
+ load_vizier_csv,
30
+ resolve_packaged_csv,
31
+ )
32
+ from lfkit.utils.units import h0_km_s_mpc_to_gyr_inv
33
+
34
+ __all__ = (
35
+ "available_pairs",
36
+ "load_poggianti1997_tables",
37
+ "describe_poggianti1997_available",
38
+ "poggianti1997_time_since_bb_gyr",
39
+ "poggianti1997_lookback_time_gyr",
40
+ "z_from_lookback_time",
41
+ "poggianti1997_to_accelerating_redshift",
42
+ "make_kcorr_interpolator",
43
+ "make_ecorr_interpolator",
44
+ "extract_sed_spectrum",
45
+ )
46
+
47
+
48
+ def available_pairs(tab: np.ndarray, *, min_points: int = 5) -> dict[str, list[str]]:
49
+ """List usable (band -> SEDs) pairs in a Poggianti-style table.
50
+
51
+ Args:
52
+ tab: Structured array for a single correction table (k-corr or e-corr).
53
+ min_points: Minimum number of samples required per extracted series.
54
+
55
+ Returns:
56
+ Mapping from band label to a list of SED labels that have usable data.
57
+ """
58
+ bands, seds = available_from_table(tab)
59
+ out: dict[str, list[str]] = {b: [] for b in bands}
60
+ for b in bands:
61
+ for s in seds:
62
+ try:
63
+ extract_series(tab, band=b, sed=s, min_points=min_points)
64
+ except ValueError:
65
+ continue
66
+ out[b].append(s)
67
+ return out
68
+
69
+
70
+ def load_poggianti1997_tables(
71
+ *,
72
+ band: str = "r",
73
+ sed: str = "E",
74
+ kcorr_path: str | Path | None = None,
75
+ ecorr_path: str | Path | None = None,
76
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
77
+ """Load Poggianti (1997) k- and e-correction curves for a band and SED.
78
+
79
+ Args:
80
+ band: Band/filter label in the tables (e.g. ``"r"``, ``"B"``, ``"V"``).
81
+ sed: SED column label (e.g. ``"E"``, ``"Sa"``, ``"Sc"``).
82
+ kcorr_path: Optional path to ``kcorr.csv``. If not provided, the
83
+ packaged file is used.
84
+ ecorr_path: Optional path to ``ecorr.csv``. If not provided, the
85
+ packaged file is used.
86
+
87
+ Returns:
88
+ Tuple ``(z_k, kcorr, z_e, ecorr)``:
89
+ - ``z_k`` and ``kcorr`` are the k-correction curve.
90
+ - ``z_e`` and ``ecorr`` are the e-correction curve.
91
+
92
+ Raises:
93
+ ValueError: If the requested (band, sed) is not available or contains
94
+ insufficient usable samples.
95
+ """
96
+ if kcorr_path is None:
97
+ kcorr_path = resolve_packaged_csv("kcorr.csv", pkg=POGGIANTI1997_PKG)
98
+ if ecorr_path is None:
99
+ ecorr_path = resolve_packaged_csv("ecorr.csv", pkg=POGGIANTI1997_PKG)
100
+
101
+ ktab = load_vizier_csv(kcorr_path)
102
+ etab = load_vizier_csv(ecorr_path)
103
+
104
+ z_k, kcorr = extract_series(ktab, band=band, sed=sed)
105
+ z_e, ecorr = extract_series(etab, band=band, sed=sed)
106
+ return z_k, kcorr, z_e, ecorr
107
+
108
+
109
+ def describe_poggianti1997_available(
110
+ *,
111
+ kcorr_path: str | Path | None = None,
112
+ ecorr_path: str | Path | None = None,
113
+ ) -> dict[str, dict[str, list[str]]]:
114
+ """Summarize available bands and SED columns in Poggianti CSV tables.
115
+
116
+ Args:
117
+ kcorr_path: Optional path to ``kcorr.csv``. If not provided, the
118
+ packaged file is used.
119
+ ecorr_path: Optional path to ``ecorr.csv``. If not provided, the
120
+ packaged file is used.
121
+
122
+ Returns:
123
+ A dictionary with keys ``"kcorr"`` and ``"ecorr"``. Each contains:
124
+ - ``"bands"``: list of available band labels
125
+ - ``"seds"``: list of available SED column labels
126
+ """
127
+ if kcorr_path is None:
128
+ kcorr_path = resolve_packaged_csv("kcorr.csv", pkg=POGGIANTI1997_PKG)
129
+ if ecorr_path is None:
130
+ ecorr_path = resolve_packaged_csv("ecorr.csv", pkg=POGGIANTI1997_PKG)
131
+
132
+ ktab = load_vizier_csv(kcorr_path)
133
+ etab = load_vizier_csv(ecorr_path)
134
+
135
+ k_bands, k_seds = available_from_table(ktab)
136
+ e_bands, e_seds = available_from_table(etab)
137
+ return {
138
+ "kcorr": {"bands": k_bands, "seds": k_seds},
139
+ "ecorr": {"bands": e_bands, "seds": e_seds},
140
+ }
141
+
142
+
143
+ def poggianti1997_time_since_bb_gyr(z: np.ndarray | float) -> np.ndarray:
144
+ """Return cosmic time since the Big Bang in the Poggianti (1997) cosmology.
145
+
146
+ Args:
147
+ z: Redshift value(s).
148
+
149
+ Returns:
150
+ Cosmic time since the Big Bang at each redshift, in Gyr.
151
+
152
+ Notes:
153
+ This uses the decelerating cosmology assumed by Poggianti (1997)
154
+ (q0 = 0.225, H0 = 50 km/s/Mpc). It is intended for lookback-time
155
+ matching when remapping e-about to a different cosmology.
156
+ """
157
+ q0 = 0.225
158
+ h0_km_s_mpc = 50.0
159
+
160
+ z = np.asarray(z, dtype=float)
161
+ h0_gyr_inv = h0_km_s_mpc_to_gyr_inv(h0_km_s_mpc)
162
+
163
+ term1 = -4.0 * q0 / (h0_gyr_inv * np.power(1.0 - 2.0 * q0, 1.5))
164
+ root_val = np.sqrt((1.0 + 2.0 * q0 * z) / (1.0 - 2.0 * q0))
165
+ term2 = root_val
166
+ term3 = 2.0 * (1.0 - (1.0 + 2.0 * q0 * z) / (1.0 - 2.0 * q0))
167
+ term4 = 0.25 * np.log(np.abs((1.0 + root_val) / (1.0 - root_val)))
168
+
169
+ return term1 * (term2 / term3 + term4)
170
+
171
+
172
+ def poggianti1997_lookback_time_gyr(z: np.ndarray | float) -> np.ndarray:
173
+ """Return lookback time in the Poggianti (1997) cosmology.
174
+
175
+ Args:
176
+ z: Redshift value(s).
177
+
178
+ Returns:
179
+ Lookback time to each redshift (relative to z=0), in Gyr.
180
+ """
181
+ z = np.asarray(z, dtype=float)
182
+ return poggianti1997_time_since_bb_gyr(0.0) - poggianti1997_time_since_bb_gyr(z)
183
+
184
+
185
+ def _build_tlb_to_z_grid(
186
+ cosmo_obj, *, zmax: float, nz: int
187
+ ) -> tuple[np.ndarray, np.ndarray]:
188
+ """Build a monotonic mapping from lookback time to redshift for a cosmology.
189
+
190
+ Args:
191
+ cosmo_obj: Cosmology object accepted by ``lookback_time_gyr``.
192
+ zmax: Maximum redshift for the mapping grid.
193
+ nz: Number of samples used to build the mapping grid.
194
+
195
+ Returns:
196
+ Tuple ``(t_grid, z_grid)`` with strictly increasing ``t_grid``.
197
+ """
198
+ if zmax <= 0:
199
+ raise ValueError("zmax must be > 0.")
200
+ if nz < 32:
201
+ raise ValueError("nz is too small; use at least ~256 in practice.")
202
+
203
+ z_grid = np.linspace(0.0, float(zmax), int(nz))
204
+ t_grid = lookback_time_gyr(cosmo_obj, z_grid)
205
+
206
+ order = np.argsort(t_grid)
207
+ t_grid = t_grid[order]
208
+ z_grid = z_grid[order]
209
+
210
+ keep = np.ones_like(t_grid, dtype=bool)
211
+ keep[1:] = t_grid[1:] > t_grid[:-1]
212
+ return t_grid[keep], z_grid[keep]
213
+
214
+
215
+ def z_from_lookback_time(
216
+ cosmo_obj,
217
+ t_lb_gyr: np.ndarray | float,
218
+ *,
219
+ zmax: float = 20.0,
220
+ nz: int = 4096,
221
+ ) -> np.ndarray:
222
+ """Invert lookback time to redshift using a precomputed interpolation grid.
223
+
224
+ Args:
225
+ cosmo_obj: Cosmology object accepted by ``lookback_time_gyr``.
226
+ t_lb_gyr: Lookback time value(s) in Gyr.
227
+ zmax: Maximum redshift used to build the inversion grid.
228
+ nz: Number of samples used to build the inversion grid.
229
+
230
+ Returns:
231
+ Redshift value(s) corresponding to ``t_lb_gyr``.
232
+
233
+ Raises:
234
+ ValueError: If requested lookback times fall outside the grid range.
235
+ """
236
+ t_lb = np.asarray(t_lb_gyr, dtype=float)
237
+ t_grid, z_grid = _build_tlb_to_z_grid(cosmo_obj, zmax=zmax, nz=nz)
238
+
239
+ if np.any(t_lb < t_grid[0]) or np.any(t_lb > t_grid[-1]):
240
+ raise ValueError(
241
+ "Target lookback time outside inversion range. "
242
+ "Increase zmax or check inputs."
243
+ )
244
+
245
+ return np.interp(t_lb, t_grid, z_grid)
246
+
247
+
248
+ def poggianti1997_to_accelerating_redshift(
249
+ z_dec: np.ndarray | float,
250
+ *,
251
+ cosmo_obj,
252
+ zmax: float = 20.0,
253
+ nz: int = 4096,
254
+ ) -> np.ndarray:
255
+ """Map Poggianti (1997) redshifts to a target cosmology via lookback time.
256
+
257
+ Args:
258
+ z_dec: Redshift value(s) in the Poggianti (1997) cosmology.
259
+ cosmo_obj: Target cosmology used to compute lookback times.
260
+ zmax: Maximum redshift used to build the inversion grid.
261
+ nz: Number of samples used to build the inversion grid.
262
+
263
+ Returns:
264
+ Redshift value(s) in the target cosmology with matched lookback time.
265
+ """
266
+ z_dec = np.asarray(z_dec, dtype=float)
267
+ t_lb = poggianti1997_lookback_time_gyr(z_dec)
268
+ return z_from_lookback_time(cosmo_obj, t_lb, zmax=zmax, nz=nz)
269
+
270
+
271
+ def make_kcorr_interpolator(
272
+ z_k: np.ndarray,
273
+ kcorr: np.ndarray,
274
+ *,
275
+ method: InterpMethod = "pchip",
276
+ extrapolate: bool = True,
277
+ z_end: float = 20.0,
278
+ tail: bool = True,
279
+ ) -> Interpolator:
280
+ """Create an interpolator for a Poggianti k-correction curve.
281
+
282
+ Args:
283
+ z_k: Redshift samples for the k-correction table.
284
+ kcorr: K-correction values at ``z_k``.
285
+ method: Interpolation method (``"pchip"``, ``"akima"``, or ``"linear"``).
286
+ extrapolate: Whether to allow evaluation outside the tabulated range.
287
+ z_end: Redshift endpoint for the optional high-z tail.
288
+ tail: Whether to append a linear high-z tail out to ``z_end``.
289
+
290
+ Returns:
291
+ An interpolator callable ``K(z)``.
292
+ """
293
+ z = np.r_[0.0, np.asarray(z_k, float)]
294
+ y = np.r_[0.0, np.asarray(kcorr, float)]
295
+ z, y = prep_strictly_increasing_xy(z, y)
296
+
297
+ if tail and z[-1] < z_end:
298
+ i0 = max(0, z.size - 3)
299
+ dz = z[-1] - z[i0]
300
+ if dz <= 0:
301
+ raise ValueError("Non-increasing z near the tail; cannot form slope.")
302
+ slope = (y[-1] - y[i0]) / dz
303
+ y_end = y[-1] + slope * (z_end - z[-1])
304
+ z = np.r_[z, z_end]
305
+ y = np.r_[y, y_end]
306
+
307
+ return build_1d_interpolator(z, y, method=method, extrapolate=extrapolate)
308
+
309
+
310
+ def make_ecorr_interpolator(
311
+ z_e: np.ndarray,
312
+ ecorr: np.ndarray,
313
+ *,
314
+ original_z: bool,
315
+ cosmo=None,
316
+ zmap_zmax: float = 20.0,
317
+ zmap_nz: int = 4096,
318
+ method: InterpMethod = "pchip",
319
+ extrapolate: bool = True,
320
+ ) -> Interpolator:
321
+ """Create an interpolator for a Poggianti e-correction curve.
322
+
323
+ Args:
324
+ z_e: Redshift samples for the e-correction table.
325
+ ecorr: E-correction values at ``z_e``.
326
+ original_z: If True, interpret ``z_e`` as Poggianti (1997) redshifts.
327
+ If False, remap ``z_e`` to the target cosmology via lookback time.
328
+ cosmo: Target cosmology used for the remapping when ``original_z=False``.
329
+ If not provided, lfkit's default cosmology is used.
330
+ zmap_zmax: Maximum redshift used to build the remapping inversion grid.
331
+ zmap_nz: Number of samples used to build the remapping inversion grid.
332
+ method: Interpolation method (``"pchip"``, ``"akima"``, or ``"linear"``).
333
+ extrapolate: Whether to extrapolate beyond the tabulated domain.
334
+
335
+ Returns:
336
+ An interpolator callable ``E(z)``.
337
+ """
338
+ z_e = np.asarray(z_e, float)
339
+ ecorr = np.asarray(ecorr, float)
340
+
341
+ if not original_z:
342
+ if cosmo is None:
343
+ cosmo = build_cosmo()
344
+ z_e = poggianti1997_to_accelerating_redshift(
345
+ z_e, cosmo_obj=cosmo, zmax=zmap_zmax, nz=zmap_nz
346
+ )
347
+
348
+ z = np.r_[0.0, z_e]
349
+ y = np.r_[0.0, ecorr]
350
+ return build_1d_interpolator(z, y, method=method, extrapolate=extrapolate)
351
+
352
+
353
+ def extract_sed_spectrum(
354
+ sed_tab: np.ndarray,
355
+ sed_col: str,
356
+ ) -> tuple[np.ndarray, np.ndarray]:
357
+ """Extract a Poggianti sed.csv column as (wave_A, flux) in rest frame.
358
+
359
+ Args:
360
+ sed_tab: Table returned by lfkit.utils.io.load_vizier_csv for sed.csv.
361
+ sed_col: Column name in sed.csv, e.g. "logF03".
362
+
363
+ Returns:
364
+ wave_A: Wavelength grid in Å (sorted ascending).
365
+ flux: Linear flux values (10**logF) on the same grid.
366
+
367
+ Raises:
368
+ ValueError: If required columns are missing or sed_col not present.
369
+ """
370
+ cols = list(sed_tab.dtype.names or [])
371
+ if "Lam" not in cols:
372
+ raise ValueError(f"sed.csv missing Lam column. cols={cols}")
373
+ if sed_col not in cols:
374
+ raise ValueError(
375
+ f"sed.csv: sed_col={sed_col!r} not present. Available: {cols[:20]} ..."
376
+ )
377
+
378
+ wave_nm = np.asarray(sed_tab["Lam"], float)
379
+ logf = np.asarray(sed_tab[sed_col], float)
380
+
381
+ ok = np.isfinite(wave_nm) & np.isfinite(logf)
382
+ wave_A = 10.0 * wave_nm[ok] # nm -> Å
383
+ flux = 10.0 ** logf[ok] # log10 -> linear
384
+
385
+ order = np.argsort(wave_A)
386
+ return wave_A[order], flux[order]