Coverage for python/lsst/obs/base/filters.py: 62%
120 statements
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-05 02:41 -0700
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-05 02:41 -0700
1# This file is part of obs_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/>.
22"""Classes to allow obs packages to define the filters used by an Instrument
23and for use by `lsst.afw.image.Filter`, gen2 dataIds, and gen3 Dimensions.
24"""
26from __future__ import annotations
28__all__ = ("FilterDefinition", "FilterDefinitionCollection")
30import dataclasses
31import re
32import warnings
33from typing import AbstractSet, Any, ClassVar, Dict, Optional, Sequence, Set, overload
35import lsst.afw.image.utils
36import numpy as np
39@dataclasses.dataclass(frozen=True)
40class FilterDefinition:
41 """The definition of an instrument's filter bandpass.
43 This class is used to interface between the `~lsst.afw.image.Filter` class
44 and the Gen2 `~lsst.daf.persistence.CameraMapper` and Gen3
45 `~lsst.obs.base.Instruments` and ``physical_filter``/``band``
46 `~lsst.daf.butler.Dimension`.
48 This class is likely temporary, until we have a better versioned filter
49 definition system that includes complete transmission information.
50 """
52 physical_filter: str
53 """The name of a filter associated with a particular instrument: unique for
54 each piece of glass. This should match the exact filter name used in the
55 observatory's metadata.
57 This name is used to define the ``physical_filter`` gen3 Butler Dimension.
59 If neither ``band`` or ``afw_name`` is defined, this is used
60 as the `~lsst.afw.image.Filter` ``name``, otherwise it is added to the
61 list of `~lsst.afw.image.Filter` aliases.
62 """
64 lambdaEff: float
65 """The effective wavelength of this filter (nm)."""
67 band: Optional[str] = None
68 """The generic name of a filter not associated with a particular instrument
69 (e.g. `r` for the SDSS Gunn r-band, which could be on SDSS, LSST, or HSC).
71 Not all filters have an abstract filter: engineering or test filters may
72 not have a genericly-termed filter name.
74 If specified and if `afw_name` is None, this is used as the
75 `~lsst.afw.image.Filter` ``name`` field, otherwise it is added to the list
76 of `~lsst.afw.image.Filter` aliases.
77 """
79 doc: Optional[str] = None
80 """A short description of this filter, possibly with a link to more
81 information.
82 """
84 afw_name: Optional[str] = None
85 """If not None, the name of the `~lsst.afw.image.Filter` object.
87 This is distinct from physical_filter and band to maintain
88 backwards compatibility in some obs packages.
89 For example, for HSC there are two distinct ``r`` and ``i`` filters, named
90 ``r/r2`` and ``i/i2``.
91 """
93 lambdaMin: float = np.nan
94 """The minimum wavelength of this filter (nm; defined as 1% throughput)"""
95 lambdaMax: float = np.nan
96 """The maximum wavelength of this filter (nm; defined as 1% throughput)"""
98 alias: AbstractSet[str] = frozenset()
99 """Alternate names for this filter. These are added to the
100 `~lsst.afw.image.Filter` alias list.
101 """
103 def __post_init__(self) -> None:
104 # force alias to be immutable, so that hashing works
105 if not isinstance(self.alias, frozenset): 105 ↛ 106line 105 didn't jump to line 106, because the condition on line 105 was never true
106 object.__setattr__(self, "alias", frozenset(self.alias))
108 def __str__(self) -> str:
109 txt = f"FilterDefinition(physical_filter='{self.physical_filter}', lambdaEff='{self.lambdaEff}'"
110 if self.band is not None:
111 txt += f", band='{self.band}'"
112 if self.afw_name is not None:
113 txt += f", afw_name='{self.afw_name}'"
114 if not np.isnan(self.lambdaMin):
115 txt += f", lambdaMin='{self.lambdaMin}'"
116 if not np.isnan(self.lambdaMax):
117 txt += f", lambdaMax='{self.lambdaMax}'"
118 if len(self.alias) != 0:
119 txt += f", alias='{self.alias}'"
120 return txt + ")"
122 def defineFilter(self) -> None:
123 """Declare the filters via afw.image.Filter."""
124 aliases = set(self.alias)
125 name = self.physical_filter
127 current_names = set(lsst.afw.image.Filter.getNames())
128 # band can be defined multiple times -- only use the first
129 # occurrence
130 band = self.band
131 if band is not None: 131 ↛ 155line 131 didn't jump to line 155, because the condition on line 131 was never false
132 # Special case code that uses afw_name to override the band.
133 # This was generally used as a workaround.
134 if self.afw_name is not None and (mat := re.match(rf"{band}(\d)+$", self.afw_name)): 134 ↛ 135line 134 didn't jump to line 135, because the condition on line 134 was never true
135 i = int(mat.group(1))
136 else:
137 i = 0
138 while i < 50: # in some instruments gratings or ND filters are combined 138 ↛ 150line 138 didn't jump to line 150, because the condition on line 138 was never false
139 if i == 0: 139 ↛ 142line 139 didn't jump to line 142, because the condition on line 139 was never false
140 nband = band
141 else:
142 nband = f"{band}{i}"
143 if nband not in current_names: 143 ↛ 148line 143 didn't jump to line 148, because the condition on line 143 was never false
144 band = nband
145 name = band
146 aliases.add(self.physical_filter)
147 break
148 i += 1
149 else:
150 warnings.warn(
151 f"Too many band aliases found for physical_filter {self.physical_filter}"
152 f" with band {band}"
153 )
155 if self.physical_filter == self.band and self.physical_filter in current_names: 155 ↛ 157line 155 didn't jump to line 157, because the condition on line 155 was never true
156 # We have already defined a filter like this
157 return
159 # Do not add an alias for band if the given afw_name matches
160 # the dynamically calculated band.
161 if self.afw_name is not None and self.afw_name != band and self.afw_name not in current_names: 161 ↛ 164line 161 didn't jump to line 164, because the condition on line 161 was never true
162 # This will override the band setting above but it
163 # is still used as an alias below
164 name = self.afw_name
165 aliases.add(self.physical_filter)
167 # Only add physical_filter/band as an alias if afw_name is defined.
168 if band is not None:
169 aliases.add(band)
171 # Aliases are a serious issue so as a last attempt to clean up
172 # remove any registered names from the new aliases
173 # This usually means some variant filter name is being used
174 aliases.difference_update(current_names)
176 with warnings.catch_warnings():
177 # suppress Filter warnings; we already know this is deprecated
178 warnings.simplefilter("ignore", category=FutureWarning)
179 lsst.afw.image.utils.defineFilter(
180 name,
181 lambdaEff=self.lambdaEff,
182 lambdaMin=self.lambdaMin,
183 lambdaMax=self.lambdaMax,
184 alias=sorted(aliases),
185 )
187 def makeFilterLabel(self) -> lsst.afw.image.FilterLabel:
188 """Create a complete FilterLabel for this filter."""
189 return lsst.afw.image.FilterLabel(band=self.band, physical=self.physical_filter)
192class FilterDefinitionCollection(Sequence[FilterDefinition]):
193 """An order-preserving collection of multiple `FilterDefinition`.
195 Parameters
196 ----------
197 filters : `Sequence`
198 The filters in this collection.
199 """
201 _defined: ClassVar[Optional[FilterDefinitionCollection]] = None
202 """Whether these filters have been defined via
203 `~lsst.afw.image.utils.defineFilter`. If so, set to ``self`` to identify
204 the filter collection that defined them.
205 """
207 physical_to_band: Dict[str, Optional[str]]
208 """A mapping from physical filter name to band name.
209 This is a convenience feature to allow file readers to create a FilterLabel
210 when reading a raw file that only has a physical filter name, without
211 iterating over the entire collection.
212 """
214 def __init__(self, *filters: FilterDefinition):
215 self._filters = list(filters)
216 self.physical_to_band = {filter.physical_filter: filter.band for filter in self._filters}
218 @overload
219 def __getitem__(self, i: int) -> FilterDefinition:
220 pass
222 @overload
223 def __getitem__(self, s: slice) -> Sequence[FilterDefinition]:
224 pass
226 def __getitem__(self, index: Any) -> Any:
227 return self._filters[index]
229 def __len__(self) -> int:
230 return len(self._filters)
232 def __str__(self) -> str:
233 return "FilterDefinitions(" + ", ".join(str(f) for f in self._filters) + ")"
235 def defineFilters(self) -> None:
236 """Define all the filters to `lsst.afw.image.Filter`.
238 `~lsst.afw.image.Filter` objects are singletons, so we protect against
239 filters being defined multiple times.
241 Raises
242 ------
243 RuntimeError
244 Raised if any other `FilterDefinitionCollection` has already called
245 ``defineFilters``.
246 """
247 if self._defined is None: 247 ↛ 255line 247 didn't jump to line 255, because the condition on line 247 was never false
248 with warnings.catch_warnings():
249 # suppress Filter warnings; we already know this is deprecated
250 warnings.simplefilter("ignore", category=FutureWarning)
251 self.reset()
252 for filter in self._filters:
253 filter.defineFilter()
254 FilterDefinitionCollection._defined = self
255 elif self._defined is self:
256 # noop: we've already defined these filters, so do nothing
257 pass
258 else:
259 msg = f"afw Filters were already defined on: {self._defined}"
260 raise RuntimeError(msg)
262 @classmethod
263 def reset(cls) -> None:
264 """Reset the afw Filter definitions and clear the `defined` singleton.
265 Use this in unittests that define different filters.
266 """
267 with warnings.catch_warnings():
268 # suppress Filter warnings; we already know this is deprecated
269 warnings.simplefilter("ignore", category=FutureWarning)
270 lsst.afw.image.utils.resetFilters()
271 cls._defined = None
273 def findAll(self, name: str) -> Set[FilterDefinition]:
274 """Return the FilterDefinitions that match a particular name.
276 This method makes no attempt to prioritize, e.g., band names over
277 physical filter names; any definition that makes *any* reference
278 to the name is returned.
280 Parameters
281 ----------
282 name : `str`
283 The name to search for. May be any band, physical, or alias name.
285 Returns
286 -------
287 matches : `set` [`FilterDefinition`]
288 All FilterDefinitions containing ``name`` as one of their
289 filter names.
290 """
291 matches = set()
292 for filter in self._filters:
293 if (
294 name == filter.physical_filter
295 or name == filter.band
296 or name == filter.afw_name
297 or name in filter.alias
298 ):
299 matches.add(filter)
300 return matches