Coverage for python/lsst/meas/photoz/base/estimate_photoz_task.py: 80%
185 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-09 10:24 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-09 10:24 +0000
1# This file is part of meas_photoz_base.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22from __future__ import annotations
24__all__ = [
25 "EstimatePhotozAlgoConfigBase",
26 "EstimatePhotozAlgoTask",
27 "EstimatePhotozTask",
28 "EstimatePhotozTaskConfig",
29 "photozAlgoRegistry",
30]
32from abc import ABC, abstractmethod
33from typing import Any
35import numpy as np
36import qp
37from astropy.table import Table
38from ceci.config import StageConfig as CeciStageConfig
39from ceci.config import StageParameter as CeciParam
40from rail.core.model import Model
41from rail.estimation.estimator import CatEstimator
42from rail.interfaces import PZFactory
44import lsst.pex.config as pexConfig
45import lsst.pipe.base.connectionTypes as cT
46from lsst.pipe.base import (
47 PipelineTask,
48 PipelineTaskConfig,
49 PipelineTaskConnections,
50 Struct,
51 Task,
52)
54from .utils import chunk_iterator
57class EstimatePhotozConnections(
58 PipelineTaskConnections,
59 dimensions=("skymap", "tract"),
60 defaultTemplates={"algo": "trainz"},
61):
62 """Connections for tasks that make p(z) estimates.
64 These will take pickled model file as a "calibration-like" input,
65 an object table as input, and create a p(z) file in 'qp' format.
66 """
68 photoz_model = cT.PrerequisiteInput(
69 doc="Model for PZ Estimation",
70 name="photoz_model_{algo}",
71 storageClass="PhotozModel",
72 dimensions=["instrument"],
73 isCalibration=True,
74 )
75 objects = cT.Input(
76 doc="Object table",
77 name="object",
78 storageClass="ArrowAstropy",
79 dimensions=("skymap", "tract"),
80 deferLoad=True,
81 )
83 photoz_ensemble = cT.Output(
84 doc="Per-object p(z) estimates",
85 name="photoz_ensemble_{algo}",
86 storageClass="QPEnsemble",
87 dimensions=("skymap", "tract"),
88 )
91class EstimatePhotozAlgoConfigBase(
92 pexConfig.Config,
93):
94 """Base class for configurations of algorithm-specific p(z)
95 estimation tasks.
97 This class mostly just translates the RAIL configuration
98 parameters to pex.config parameters.
100 Subclasses will just have to set `estimator_class` and `stage_name`
101 and invoke `_make_fields` once in the module.
102 """
104 @classmethod
105 @abstractmethod
106 def estimator_class(cls) -> type[CatEstimator]:
107 """Return the type of the estimator's RAIL class."""
108 raise NotImplementedError("Subclasses must specify an estimator class")
110 # This should be a property but py3.13+ don't allow it
111 @classmethod
112 @abstractmethod
113 def stage_name(cls) -> str:
114 """Return the RAIL stage name for the estimator."""
115 raise NotImplementedError("Subclasses must define a RAIL stage name")
117 # Extinction coefficients; see https://ui.adsabs.harvard.edu/abs/1989ApJ...345..245C/abstract
118 # Also in rail.utils.catalog_utils.RubinCatalogConfig.a_env
119 default_a_env_values = dict(
120 u=4.81,
121 g=3.64,
122 r=2.70,
123 i=2.06,
124 z=1.58,
125 y=1.31,
126 )
128 # These appear in many DESC repos, for example:
129 # https://github.com/LSSTDESC/TXPipe/blob/00ebe7476fd5d9529f5bbc4d73fcef0629d134c7/examples/dp0.2/config.yml#L47
130 # They seem to be 10y WFD limits. Origin unclear.
131 default_mag_limit_10y_values = dict(
132 u=27.79,
133 g=29.04,
134 r=29.06,
135 i=28.62,
136 z=27.98,
137 y=27.05,
138 )
140 # These appear to be from Roman-Rubin simulations:
141 # https://github.com/LSSTDESC/rail_base/blob/v1.2.1/src/rail/utils/catalog_utils.py#L207
142 # Presumably max 5y depth, and more useful for now
143 default_mag_limit_values = dict(
144 u=24.0,
145 g=27.66,
146 r=27.25,
147 i=26.6,
148 z=26.24,
149 y=25.35,
150 )
152 def get_band_a_env_dict(self):
153 """Return the set of a_envs to use."""
154 return {band_: self.default_a_env_values[band_] for band_ in self.bands_to_convert}
156 def get_mag_lim_dict(self):
157 """Return the set of maglims to use."""
158 return {
159 self.mag_template.format(band=band): self.default_mag_limit_values[band]
160 for band in self.bands_to_convert
161 }
163 def get_flux_names(self) -> dict[str, str]:
164 """Return a dict mapping band to flux column name."""
165 return {band: self.flux_column_template.format(band=band) for band in self.bands_to_convert}
167 def get_flux_err_names(self) -> dict[str, str]:
168 """Return a dict mapping band to flux error column name."""
169 return {band: self.flux_err_column_template.format(band=band) for band in self.bands_to_convert}
171 def get_mag_names(self) -> dict[str, str]:
172 """Return a dict mapping band to mag column name."""
173 return {band: self.mag_template.format(band=band) for band in self.bands_to_convert}
175 def get_mag_err_names(self) -> dict[str, str]:
176 """Return a dict mapping band to mag error column name."""
177 return {band: self.mag_err_template.format(band=band) for band in self.bands_to_convert}
179 mag_offset = pexConfig.Field(doc="Magnitude offset", dtype=float, default=31.4)
180 deredden = pexConfig.Field[bool](
181 doc="Apply dereddening",
182 default=True,
183 )
184 band_ref = pexConfig.Field[str](
185 doc="Name of the most reliable reference band, if needed",
186 default="i",
187 )
188 bands_to_convert = pexConfig.ListField[str](
189 doc="Names of bands to convert fluxs to mags for RAIL",
190 default=["u", "g", "r", "i", "z", "y"],
191 )
192 flux_column_template = pexConfig.Field[str](
193 doc="Template for flux column names",
194 default="{band}_gaap1p0Flux",
195 # default="{band}_cModelFlux",
196 )
197 flux_err_column_template = pexConfig.Field[str](
198 doc="Template for flux error column names",
199 default="{band}_gaap1p0FluxErr",
200 # default="{band}_cModelFluxErr",
201 )
202 mag_template = pexConfig.Field[str](
203 doc="Template for magnitude names",
204 default="{band}_gaap1p0Mag",
205 # default="{band}_cModelMag",
206 )
207 mag_err_template = pexConfig.Field[str](
208 doc="Template for magntitude error names",
209 default="{band}_gaap1p0MagErr",
210 # default="{band}_cModelMagErr",
211 )
212 nondetect_val = pexConfig.Field[float](
213 doc="Magnitude to set for non-detections",
214 default=np.nan,
215 )
216 band_a_env = pexConfig.DictField[str, float](
217 doc="Reddening parameters",
218 default=default_a_env_values,
219 )
221 def freeze(self):
222 if not self._frozen: 222 ↛ 224line 222 didn't jump to line 224 because the condition on line 222 was always true
223 self._finalize()
224 super().freeze()
226 def _finalize(self):
227 # These calls will fail if it's already frozen.
228 if hasattr(self, "ref_band"):
229 self.ref_band = self.mag_template.format(band=self.band_ref)
230 if hasattr(self, "bands"):
231 # This is a list of mag columns in RAIL, not bands
232 self.bands = list(self.get_mag_names().values())
233 if hasattr(self, "err_bands"):
234 self.err_bands = list(self.get_mag_err_names().values())
235 if hasattr(self, "mag_limits"):
236 self.mag_limits = self.get_mag_lim_dict()
237 if hasattr(self, "band_a_env"): 237 ↛ exitline 237 didn't return from function '_finalize' because the condition on line 237 was always true
238 self.band_a_env = self.get_band_a_env_dict()
240 @classmethod
241 def _make_fields(cls) -> None:
242 """Import the RAIL estimation stage.
244 This method loops through the stage config parameters and converts
245 RAIL/Ceci parameters to corresponding pex.config parameters.
247 It should be called exactly once, immediately after the definition
248 of every subclass of this base class.
249 """
250 if hasattr(cls, "__fields_made__"): 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 if cls.__fields_made__ is not True:
252 raise RuntimeError(f"{cls.__fields_made__=} exists but is not True")
253 raise RuntimeError(f"{cls=} called _make_fields twice")
254 stage_class = cls.estimator_class()
255 for key, val in stage_class.config_options.items():
256 if isinstance(val, CeciStageConfig):
257 val = val.get(key)
258 if isinstance(val, CeciParam): 258 ↛ 255line 258 didn't jump to line 255 because the condition on line 258 was always true
259 if val.dtype in [bool, int, float, str]:
260 if (attr := getattr(cls, key, None)) is not None:
261 if not isinstance(attr, pexConfig.Field): 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 raise RuntimeError(f"{cls=} {key=} exists but is of {type(key)=}, not Field")
263 elif attr.dtype != val.dtype: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true
264 raise RuntimeError(f"{cls=} {key=} exists but {attr.dtype=} != {val.dtype=}")
265 attr.default = val.default
266 attr.doc = f"{val.msg} (overriding base doc='{attr.doc}')"
267 else:
268 setattr(
269 cls,
270 key,
271 pexConfig.Field(doc=val.msg, dtype=val.dtype, default=val.default),
272 )
273 elif val.dtype in [list]:
274 # this is a hack, but it works.
275 if val.default:
276 item_type = type(val.default[0])
277 else:
278 item_type = str
279 setattr(
280 cls,
281 key,
282 pexConfig.ListField(doc=val.msg, dtype=item_type, default=val.default),
283 )
284 elif val.dtype in [dict]: 284 ↛ 255line 284 didn't jump to line 255 because the condition on line 284 was always true
285 setattr(
286 cls,
287 key,
288 pexConfig.DictField(doc=val.msg, keytype=str, default=val.default),
289 )
290 cls.__fields_made__ = True
293photozAlgoRegistry = pexConfig.makeRegistry(
294 doc="A registry of photometric redshift estimation algorithm subtasks",
295)
298class EstimatePhotozAlgoTask(Task, ABC):
299 """Task for algorithm-specific p(z) estimation.
301 This provides almost all of the functionality
302 needed to run RAIL p(z) algorithms.
304 Parameters
305 ----------
306 **kwargs
307 Additional keyword arguments to pass to super().__init__.
308 """
310 ConfigClass = EstimatePhotozAlgoConfigBase
312 mag_conv = np.log(10) * 0.4
314 def __init__(self, **kwargs: Any):
315 super().__init__(**kwargs)
317 @staticmethod
318 def _flux_to_mag(
319 flux_vals: np.ndarray,
320 mag_offset: float,
321 nondetect_val: float,
322 ) -> np.ndarray:
323 """Convert flux to magnitude.
325 Parameters
326 ----------
327 flux_vals : np.array
328 Input flux values (units?)
330 mag_offset : float
331 Magnitude offset (corresponding to a flux of 1.)
333 nondetect_val : float
334 Value to set for non-detections
336 Returns
337 -------
338 mags : np.array
339 Magnitude values
340 """
341 vals = np.empty_like(flux_vals)
342 positive = flux_vals >= 0
343 vals[positive] = -2.5 * np.log10(flux_vals[positive]) + mag_offset
344 vals[~positive] = nondetect_val
345 return vals
347 @staticmethod
348 def _flux_err_to_mag_err(
349 flux_vals: np.ndarray,
350 flux_err_vals: np.ndarray,
351 mag_conv: float,
352 nondetect_val: float = np.nan,
353 ) -> np.ndarray:
354 """Config flux error to magnitude error.
356 Parameters
357 ----------
358 flux_vals : np.array
359 Input flux values (units?)
361 flux_err_vals : np.array
362 Input flux errors (units?)
364 mag_conv : float
365 Magnitude to flux conversion (typically np.log(10)*0.4)
367 nondetect_val : float
368 Value to set for non-detections
370 Returns
371 -------
372 mags_errs : np.array
373 Magnitude errors
374 """
375 vals = np.empty_like(flux_vals)
376 positive = flux_vals >= 0
377 vals[positive] = flux_err_vals[positive] / (flux_vals[positive] * mag_conv)
378 vals[~positive] = nondetect_val
379 return vals
381 @staticmethod
382 def _deredden_mags(
383 data: dict[str, np.ndarray],
384 a_env_dict: dict[str, float],
385 mag_names: dict[str, str],
386 nondetect_val: float,
387 ) -> dict[str, np.ndarray]:
388 """Deredden the magnitdues
390 Parameters
391 ----------
392 data: dict[str, np.array]
393 Input data
395 a_env_dict: dict[str, float],
396 Redenning parameters for bands
398 mag_names: dict[str, str]
399 Mapping from bands to magnitudes
401 nondetect_val : float
402 Value to set for non-detections
404 Returns
405 -------
406 mags: dict[str, np.array]
407 Udpated dict with dereddened mags
408 """
409 ebv = data["ebv"]
410 for band_, a_env_ in a_env_dict.items():
411 mag_name = mag_names[band_]
412 raw_mag = data[mag_name]
413 dered_mag = np.where(
414 np.isfinite(raw_mag),
415 raw_mag - ebv * a_env_,
416 nondetect_val,
417 )
418 data[mag_name] = dered_mag
419 return data
421 def _get_mags_and_errs(
422 self,
423 fluxes: Table,
424 mag_offset: float,
425 ) -> dict[str, np.ndarray]:
426 """Fill and return a numpy dict with mags and mag errors.
428 Parameters
429 ----------
430 fluxes : Table
431 Input fluxes and flux errors
433 mag_offset : float
434 Magnitude offset (corresponding to a flux of 1.)
436 Returns
437 -------
438 mags: dict[str, np.array]
439 Numpy dict with mags and mag errors
440 """
441 # get all the column names we will use
442 flux_names = self.config.get_flux_names()
443 mag_names = self.config.get_mag_names()
444 flux_err_names = self.config.get_flux_err_names()
445 mag_err_names = self.config.get_mag_err_names()
446 nondetect_val = self.config.nondetect_val
447 # output dict
448 mag_dict = {}
449 if object_id_name := self.config.id_col:
450 mag_dict[object_id_name] = fluxes[object_id_name]
452 # loop over bands, make mags and mag errors and fill dict
453 for band in flux_names.keys():
454 fluxVals = np.asarray(fluxes[flux_names[band]])
455 fluxErrVals = np.asarray(fluxes[flux_err_names[band]])
456 mag_dict[mag_names[band]] = self._flux_to_mag(
457 fluxVals,
458 mag_offset,
459 nondetect_val,
460 )
461 if flux_err_names:
462 mag_dict[mag_err_names[band]] = self._flux_err_to_mag_err(
463 fluxVals,
464 fluxErrVals,
465 self.mag_conv,
466 nondetect_val,
467 )
469 # return the dict with the mags
470 return mag_dict
472 def init(
473 self,
474 photoz_model: Model,
475 ) -> None:
476 """Set up the RAIL stage to compute photo-zs.
478 Parameters
479 ----------
480 photoz_model : Model
481 Model used by the p(z) estimation algorithm.
482 """
483 # pop the pipeline task config options
484 # so that we can pass the rest to RAIL
485 rail_kwargs = self.config.toDict().copy()
486 for key in ["saveLogOutput", "stage_name", "mag_offset", "connections"]:
487 rail_kwargs.pop(key, None)
488 rail_kwargs["output_mode"] = "return"
490 # Build the RAIL stage
491 self._stage = PZFactory.build_stage_instance(
492 self.config.stage_name(),
493 self.config.estimator_class(),
494 model_path=photoz_model.data,
495 input_path="dummy.in",
496 **rail_kwargs,
497 )
498 self._stage._initialize_run()
500 def col_names(
501 self,
502 ) -> list[str]:
503 """Get the list of column names to read from the input data."""
504 columns = list(self.config.get_flux_names().values()) + list(
505 self.config.get_flux_err_names().values()
506 )
507 if self.config.deredden:
508 columns += ["ebv"]
510 return columns
512 def run(
513 self,
514 fluxes: Table,
515 ) -> Struct:
516 """Run a p(z) estimation algorithm.
518 Parameters
519 ----------
520 fluxes : Table
521 Fluxes used to compute the redshifts.
523 Returns
524 -------
525 photoz_pdfs : qp.Ensemble
526 Object with the p(z) PDFs.
527 """
528 n_obj = len(fluxes)
529 # Convert fluxes to mags
530 mags = self._get_mags_and_errs(fluxes, self.config.mag_offset)
531 nondetect_val = self.config.nondetect_val if hasattr(self.config, "nondetect_val") else np.nan
533 # De-redden
534 if self.config.deredden: 534 ↛ 544line 534 didn't jump to line 544 because the condition on line 534 was always true
535 # asarray will convert an astropy column to an array w/o units
536 mags["ebv"] = np.asarray(fluxes["ebv"])
537 mags = self._deredden_mags(
538 mags,
539 self.config.band_a_env,
540 self.config.get_mag_names(),
541 nondetect_val,
542 )
544 pz_ensembles: list[qp.Ensemble] = []
546 # split processing into chunks to avoid memory issues in rail
547 iterator = chunk_iterator(self.config.chunk_size, mags, n_obj)
548 for a_chunk, chunk_size in iterator:
549 # Pass the mags to RAIL and get back the p(z) pdfs
550 # as a qp.Ensemble object
551 pz_ensembles.append(PZFactory.estimate_single_pz(self._stage, a_chunk, chunk_size))
553 photoz_pdfs = qp.concatenate(pz_ensembles)
554 return Struct(photoz_ensemble=photoz_pdfs)
557class EstimatePhotozTaskConfig(PipelineTaskConfig, pipelineConnections=EstimatePhotozConnections):
558 """Configuration for EstimatePhotozTask PipelineTask."""
560 photoz_algo = photozAlgoRegistry.makeField(
561 doc="Algorithm specific configuration p(z) estimation task",
562 )
565class EstimatePhotozTask(PipelineTask):
566 """PipelineTask for p(z) estimation.
568 Parameters
569 ----------
570 initInputs
571 Initialization inputs to pass to super().__init__.
572 **kwargs
573 Additional keyword arguments to pass to super().__init__.
574 """
576 ConfigClass = EstimatePhotozTaskConfig
577 _DefaultName = "estimatePhotoz"
579 def __init__(self, initInputs: dict, **kwargs):
580 super().__init__(initInputs=initInputs, **kwargs)
581 self._initialized = False
582 self.makeSubtask("photoz_algo")
584 def runQuantum(self, butlerQC, inputRefs, outputRefs):
585 inputs = butlerQC.get(inputRefs)
586 inputs["fluxes"] = inputs.pop("objects").get(
587 parameters=dict(columns=self.photoz_algo.col_names()),
588 )
589 outputs = self.run(**inputs, skip_init=self._initialized)
590 butlerQC.put(outputs, outputRefs)
592 def run(
593 self,
594 *,
595 photoz_model: Model,
596 fluxes: Table,
597 skip_init: bool = False,
598 ) -> Struct:
599 if not skip_init: 599 ↛ 603line 599 didn't jump to line 603 because the condition on line 599 was always true
600 self._initialized = True
601 self.photoz_algo.init(photoz_model)
603 ret_struct = self.photoz_algo.run(fluxes)
604 return Struct(photoz_ensemble=ret_struct.photoz_ensemble)