Coverage for python/lsst/obs/base/filters.py: 58%

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

110 statements  

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/>. 

21 

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""" 

25 

26from __future__ import annotations 

27 

28__all__ = ("FilterDefinition", "FilterDefinitionCollection") 

29 

30import dataclasses 

31import re 

32import warnings 

33from typing import AbstractSet, Any, ClassVar, Dict, Optional, Sequence, Set, overload 

34 

35import lsst.afw.image.utils 

36import numpy as np 

37 

38 

39@dataclasses.dataclass(frozen=True) 

40class FilterDefinition: 

41 """The definition of an instrument's filter bandpass. 

42 

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`. 

47 

48 This class is likely temporary, until we have a better versioned filter 

49 definition system that includes complete transmission information. 

50 """ 

51 

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. 

56 

57 This name is used to define the ``physical_filter`` gen3 Butler Dimension. 

58 

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 """ 

63 

64 lambdaEff: float 

65 """The effective wavelength of this filter (nm).""" 

66 

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). 

70 

71 Not all filters have an abstract filter: engineering or test filters may 

72 not have a genericly-termed filter name. 

73 

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 """ 

78 

79 doc: Optional[str] = None 

80 """A short description of this filter, possibly with a link to more 

81 information. 

82 """ 

83 

84 afw_name: Optional[str] = None 

85 """If not None, the name of the `~lsst.afw.image.Filter` object. 

86 

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 """ 

92 

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)""" 

97 

98 alias: AbstractSet[str] = frozenset() 

99 """Alternate names for this filter. These are added to the 

100 `~lsst.afw.image.Filter` alias list. 

101 """ 

102 

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)) 

107 

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 + ")" 

121 

122 def defineFilter(self) -> None: 

123 """Declare the filters via afw.image.Filter.""" 

124 aliases = set(self.alias) 

125 name = self.physical_filter 

126 

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 ) 

154 

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 

158 

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) 

166 

167 # Only add physical_filter/band as an alias if afw_name is defined. 

168 if band is not None: 

169 aliases.add(band) 

170 

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) 

175 

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 ) 

186 

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) 

190 

191 

192class FilterDefinitionCollection(Sequence[FilterDefinition]): 

193 """An order-preserving collection of multiple `FilterDefinition`. 

194 

195 Parameters 

196 ---------- 

197 filters : `Sequence` 

198 The filters in this collection. 

199 """ 

200 

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 """ 

206 

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 """ 

213 

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} 

217 

218 @overload 

219 def __getitem__(self, i: int) -> FilterDefinition: 

220 pass 

221 

222 @overload 

223 def __getitem__(self, s: slice) -> Sequence[FilterDefinition]: 

224 pass 

225 

226 def __getitem__(self, index: Any) -> Any: 

227 return self._filters[index] 

228 

229 def __len__(self) -> int: 

230 return len(self._filters) 

231 

232 def __str__(self) -> str: 

233 return "FilterDefinitions(" + ", ".join(str(f) for f in self._filters) + ")" 

234 

235 def defineFilters(self) -> None: 

236 """Define all the filters to `lsst.afw.image.Filter`. 

237 

238 `~lsst.afw.image.Filter` objects are singletons, so we protect against 

239 filters being defined multiple times. 

240 

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) 

261 

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 

272 

273 def findAll(self, name: str) -> Set[FilterDefinition]: 

274 """Return the FilterDefinitions that match a particular name. 

275 

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. 

279 

280 Parameters 

281 ---------- 

282 name : `str` 

283 The name to search for. May be any band, physical, or alias name. 

284 

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