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

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

103 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 

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

27 

28import collections.abc 

29import dataclasses 

30import re 

31import warnings 

32 

33import lsst.afw.image.utils 

34import numpy as np 

35 

36 

37class FilterDefinitionCollection(collections.abc.Sequence): 

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

39 

40 Parameters 

41 ---------- 

42 filters : `~collections.abc.Sequence` 

43 The filters in this collection. 

44 """ 

45 

46 _defined = None 

47 """Whether these filters have been defined via 

48 `~lsst.afw.image.utils.defineFilter`. If so, set to ``self`` to identify 

49 the filter collection that defined them. 

50 """ 

51 

52 physical_to_band = {} 

53 """A mapping from physical filter name to band name. 

54 This is a convenience feature to allow file readers to create a FilterLabel 

55 when reading a raw file that only has a physical filter name, without 

56 iterating over the entire collection. 

57 """ 

58 

59 def __init__(self, *filters): 

60 self._filters = list(filters) 

61 self.physical_to_band = {filter.physical_filter: filter.band for filter in self._filters} 

62 

63 def __getitem__(self, key): 

64 return self._filters[key] 

65 

66 def __len__(self): 

67 return len(self._filters) 

68 

69 def __str__(self): 

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

71 

72 def defineFilters(self): 

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

74 

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

76 filters being defined multiple times. 

77 

78 Raises 

79 ------ 

80 RuntimeError 

81 Raised if any other `FilterDefinitionCollection` has already called 

82 ``defineFilters``. 

83 """ 

84 if self._defined is None: 84 ↛ 92line 84 didn't jump to line 92, because the condition on line 84 was never false

85 with warnings.catch_warnings(): 

86 # suppress Filter warnings; we already know this is deprecated 

87 warnings.simplefilter("ignore", category=FutureWarning) 

88 self.reset() 

89 for filter in self._filters: 

90 filter.defineFilter() 

91 FilterDefinitionCollection._defined = self 

92 elif self._defined is self: 

93 # noop: we've already defined these filters, so do nothing 

94 pass 

95 else: 

96 msg = f"afw Filters were already defined on: {self._defined}" 

97 raise RuntimeError(msg) 

98 

99 @classmethod 

100 def reset(cls): 

101 """Reset the afw Filter definitions and clear the `defined` singleton. 

102 Use this in unittests that define different filters. 

103 """ 

104 with warnings.catch_warnings(): 

105 # suppress Filter warnings; we already know this is deprecated 

106 warnings.simplefilter("ignore", category=FutureWarning) 

107 lsst.afw.image.utils.resetFilters() 

108 cls._defined = None 

109 

110 def findAll(self, name): 

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

112 

113 This method makes no attempt to prioritize, e.g., band names over 

114 physical filter names; any definition that makes *any* reference 

115 to the name is returned. 

116 

117 Parameters 

118 ---------- 

119 name : `str` 

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

121 

122 Returns 

123 ------- 

124 matches : `set` [`FilterDefinition`] 

125 All FilterDefinitions containing ``name`` as one of their 

126 filter names. 

127 """ 

128 matches = set() 

129 for filter in self._filters: 

130 if ( 

131 name == filter.physical_filter 

132 or name == filter.band 

133 or name == filter.afw_name 

134 or name in filter.alias 

135 ): 

136 matches.add(filter) 

137 return matches 

138 

139 

140@dataclasses.dataclass(frozen=True) 

141class FilterDefinition: 

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

143 

144 This class is used to interface between the `~lsst.afw.image.Filter` class 

145 and the Gen2 `~lsst.daf.persistence.CameraMapper` and Gen3 

146 `~lsst.obs.base.Instruments` and ``physical_filter``/``band`` 

147 `~lsst.daf.butler.Dimension`. 

148 

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

150 definition system that includes complete transmission information. 

151 """ 

152 

153 physical_filter: str 

154 """The name of a filter associated with a particular instrument: unique for 

155 each piece of glass. This should match the exact filter name used in the 

156 observatory's metadata. 

157 

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

159 

160 If neither ``band`` or ``afw_name`` is defined, this is used 

161 as the `~lsst.afw.image.Filter` ``name``, otherwise it is added to the 

162 list of `~lsst.afw.image.Filter` aliases. 

163 """ 

164 

165 lambdaEff: float 

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

167 

168 band: str = None 

169 """The generic name of a filter not associated with a particular instrument 

170 (e.g. `r` for the SDSS Gunn r-band, which could be on SDSS, LSST, or HSC). 

171 

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

173 not have a genericly-termed filter name. 

174 

175 If specified and if `afw_name` is None, this is used as the 

176 `~lsst.afw.image.Filter` ``name`` field, otherwise it is added to the list 

177 of `~lsst.afw.image.Filter` aliases. 

178 """ 

179 

180 doc: str = None 

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

182 information. 

183 """ 

184 

185 afw_name: str = None 

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

187 

188 This is distinct from physical_filter and band to maintain 

189 backwards compatibility in some obs packages. 

190 For example, for HSC there are two distinct ``r`` and ``i`` filters, named 

191 ``r/r2`` and ``i/i2``. 

192 """ 

193 

194 lambdaMin: float = np.nan 

195 """The minimum wavelength of this filter (nm; defined as 1% throughput)""" 

196 lambdaMax: float = np.nan 

197 """The maximum wavelength of this filter (nm; defined as 1% throughput)""" 

198 

199 alias: set = frozenset() 

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

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

202 """ 

203 

204 def __post_init__(self): 

205 # force alias to be immutable, so that hashing works 

206 if not isinstance(self.alias, frozenset): 206 ↛ 207line 206 didn't jump to line 207, because the condition on line 206 was never true

207 object.__setattr__(self, "alias", frozenset(self.alias)) 

208 

209 def __str__(self): 

210 txt = f"FilterDefinition(physical_filter='{self.physical_filter}', lambdaEff='{self.lambdaEff}'" 

211 if self.band is not None: 

212 txt += f", band='{self.band}'" 

213 if self.afw_name is not None: 

214 txt += f", afw_name='{self.afw_name}'" 

215 if not np.isnan(self.lambdaMin): 

216 txt += f", lambdaMin='{self.lambdaMin}'" 

217 if not np.isnan(self.lambdaMax): 

218 txt += f", lambdaMax='{self.lambdaMax}'" 

219 if len(self.alias) != 0: 

220 txt += f", alias='{self.alias}'" 

221 return txt + ")" 

222 

223 def defineFilter(self): 

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

225 aliases = set(self.alias) 

226 name = self.physical_filter 

227 

228 current_names = set(lsst.afw.image.Filter.getNames()) 

229 # band can be defined multiple times -- only use the first 

230 # occurrence 

231 band = self.band 

232 if band is not None: 232 ↛ 256line 232 didn't jump to line 256, because the condition on line 232 was never false

233 # Special case code that uses afw_name to override the band. 

234 # This was generally used as a workaround. 

235 if self.afw_name is not None and (mat := re.match(fr"{band}(\d)+$", self.afw_name)): 235 ↛ 236line 235 didn't jump to line 236, because the condition on line 235 was never true

236 i = int(mat.group(1)) 

237 else: 

238 i = 0 

239 while i < 50: # in some instruments gratings or ND filters are combined 239 ↛ 251line 239 didn't jump to line 251, because the condition on line 239 was never false

240 if i == 0: 240 ↛ 243line 240 didn't jump to line 243, because the condition on line 240 was never false

241 nband = band 

242 else: 

243 nband = f"{band}{i}" 

244 if nband not in current_names: 244 ↛ 249line 244 didn't jump to line 249, because the condition on line 244 was never false

245 band = nband 

246 name = band 

247 aliases.add(self.physical_filter) 

248 break 

249 i += 1 

250 else: 

251 warnings.warn( 

252 f"Too many band aliases found for physical_filter {self.physical_filter}" 

253 f" with band {band}" 

254 ) 

255 

256 if self.physical_filter == self.band and self.physical_filter in current_names: 256 ↛ 258line 256 didn't jump to line 258, because the condition on line 256 was never true

257 # We have already defined a filter like this 

258 return 

259 

260 # Do not add an alias for band if the given afw_name matches 

261 # the dynamically calculated band. 

262 if self.afw_name is not None and self.afw_name != band and self.afw_name not in current_names: 262 ↛ 265line 262 didn't jump to line 265, because the condition on line 262 was never true

263 # This will override the band setting above but it 

264 # is still used as an alias below 

265 name = self.afw_name 

266 aliases.add(self.physical_filter) 

267 

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

269 if band is not None: 

270 aliases.add(band) 

271 

272 # Aliases are a serious issue so as a last attempt to clean up 

273 # remove any registered names from the new aliases 

274 # This usually means some variant filter name is being used 

275 aliases.difference_update(current_names) 

276 

277 with warnings.catch_warnings(): 

278 # suppress Filter warnings; we already know this is deprecated 

279 warnings.simplefilter("ignore", category=FutureWarning) 

280 lsst.afw.image.utils.defineFilter( 

281 name, 

282 lambdaEff=self.lambdaEff, 

283 lambdaMin=self.lambdaMin, 

284 lambdaMax=self.lambdaMax, 

285 alias=sorted(aliases), 

286 ) 

287 

288 def makeFilterLabel(self): 

289 """Create a complete FilterLabel for this filter.""" 

290 return lsst.afw.image.FilterLabel(band=self.band, physical=self.physical_filter)