Coverage for python / lsst / skymap / packers.py: 21%

76 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-23 08:29 +0000

1# This file is part of skymap. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://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 <http://www.gnu.org/licenses/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ("SkyMapDimensionPacker",) 

25 

26from collections.abc import Mapping 

27 

28from lsst.pex.config import Config, Field, DictField, ConfigurableField 

29from lsst.daf.butler import DimensionPacker, DimensionGroup, DataCoordinate 

30 

31 

32class SkyMapDimensionPackerConfig(Config): 

33 bands = DictField( 

34 "Mapping from band name to integer to use in the packed ID. " 

35 "The default (None) is to use a hard-coded list of common bands; " 

36 "pipelines that can enumerate the set of bands they are likely to see " 

37 "should override this.", 

38 keytype=str, 

39 itemtype=int, 

40 default=None, 

41 optional=True, 

42 ) 

43 n_bands = Field( 

44 "Number of bands to reserve space for. " 

45 "If zero, bands are not included in the packed integer at all. " 

46 "If `None`, the size of 'bands' is used.", 

47 dtype=int, 

48 optional=True, 

49 default=0, 

50 ) 

51 n_tracts = Field( 

52 "Number of tracts, or, more precisely, one greater than the maximum tract ID." 

53 "Default (None) obtains this value from the skymap dimension record.", 

54 dtype=int, 

55 optional=True, 

56 default=None, 

57 ) 

58 n_patches = Field( 

59 "Number of patches per tract, or, more precisely, one greater than the maximum patch ID." 

60 "Default (None) obtains this value from the skymap dimension record.", 

61 dtype=int, 

62 optional=True, 

63 default=None, 

64 ) 

65 

66 

67class SkyMapDimensionPacker(DimensionPacker): 

68 """A `DimensionPacker` for tract, patch and optionally band, 

69 given a SkyMap. 

70 

71 Parameters 

72 ---------- 

73 fixed : `lsst.daf.butler.DataCoordinate` 

74 Data ID that identifies just the ``skymap`` dimension. Must have 

75 dimension records attached unless ``n_tracts`` and ``n_patches`` are 

76 not `None`. 

77 dimensions : `lsst.daf.butler.DimensionGroup`, optional 

78 The dimensions of data IDs packed by this instance. Must include 

79 ``{skymap, tract, patch}``, and may include ``band``. If not provided, 

80 this will be set to include ``band`` if ``n_bands != 0``. 

81 bands : `~collections.abc.Mapping` [ `str`, `int` ] or `None`, optional 

82 Mapping from band name to integer to use in the packed ID. `None` uses 

83 a fixed set of bands defined in this class. When calling code can 

84 enumerate the bands it is likely to see, providing an explicit mapping 

85 is preferable. 

86 n_bands : `int` or `None`, optional 

87 The number of bands to leave room for in the packed ID. If `None`, 

88 this will be set to ``len(bands)``. If ``0``, the band will not be 

89 included in the dimensions at all. If ``1``, the band will be included 

90 in the dimensions but will not occupy any extra bits in the packed ID. 

91 This may be larger or smaller than ``len(bands)``, to reserve extra 

92 space for the future or align to byte boundaries, or support a subset 

93 of a larger mapping, respectively. 

94 n_tracts : `int` or `None`, optional 

95 The number of tracts to leave room for in the packed ID. If `None`, 

96 this will be set via the ``skymap`` dimension record in ``fixed``. 

97 n_patches : `int` or `None`, optional 

98 The number of patches (per tract) to leave room for in the packed ID. 

99 If `None`, this will be set via the ``skymap`` dimension record in 

100 ``fixed``. 

101 

102 Notes 

103 ----- 

104 The standard pattern for constructing instances of this class is to use 

105 `make_config_field`:: 

106 

107 class SomeConfig(lsst.pex.config.Config): 

108 packer = ObservationDimensionPacker.make_config_field() 

109 

110 class SomeTask(lsst.pipe.base.Task): 

111 ConfigClass = SomeConfig 

112 

113 def run(self, ..., data_id: DataCoordinate): 

114 packer = self.config.packer.apply(data_id) 

115 packed_id = packer.pack(data_id) 

116 ... 

117 

118 """ 

119 

120 SUPPORTED_FILTERS = ( 

121 [None] 

122 + list("ugrizyUBGVRIZYJHK") # split string into single chars 

123 + [f"N{d}" for d in (387, 515, 656, 816, 921, 1010)] # HSC narrow-bands 

124 + [f"N{d}" for d in (419, 540, 708, 964)] # DECam narrow-bands 

125 ) 

126 """Sequence of supported bands used to construct a mapping from band name 

127 to integer when the 'bands' config option is `None` or no config is 

128 provided. 

129 

130 This variable should no longer be modified to add new filters; pass 

131 ``bands`` at construction or use `from_config` instead. 

132 """ 

133 

134 ConfigClass = SkyMapDimensionPackerConfig 

135 

136 def __init__( 

137 self, 

138 fixed: DataCoordinate, 

139 dimensions: DimensionGroup | None = None, 

140 bands: Mapping[str, int] | None = None, 

141 n_bands: int | None = None, 

142 n_tracts: int | None = None, 

143 n_patches: int | None = None, 

144 ): 

145 if bands is None: 

146 bands = {b: i for i, b in enumerate(self.SUPPORTED_FILTERS)} 

147 if dimensions is None: 

148 if n_bands is None: 

149 n_bands = len(bands) 

150 dimension_names = ["tract", "patch"] 

151 if n_bands != 0: 

152 dimension_names.append("band") 

153 dimensions = fixed.universe.conform(dimension_names) 

154 else: 

155 if "band" not in dimensions.names: 

156 n_bands = 0 

157 if dimensions.names != {"tract", "patch", "skymap"}: 

158 raise ValueError( 

159 f"Invalid dimensions for skymap dimension packer with n_bands=0: {dimensions}." 

160 ) 

161 else: 

162 if dimensions.names != {"tract", "patch", "skymap", "band"}: 

163 raise ValueError( 

164 f"Invalid dimensions for skymap dimension packer with n_bands>0: {dimensions}." 

165 ) 

166 if n_bands is None: 

167 n_bands = len(bands) 

168 if n_tracts is None: 

169 n_tracts = fixed.records["skymap"].tract_max 

170 if n_patches is None: 

171 n_patches = ( 

172 fixed.records["skymap"].patch_nx_max 

173 * fixed.records["skymap"].patch_ny_max 

174 ) 

175 super().__init__(fixed, dimensions) 

176 self._bands = bands 

177 self._n_bands = n_bands 

178 self._n_tracts = n_tracts 

179 self._n_patches = n_patches 

180 self._bands_list = None 

181 

182 @classmethod 

183 def make_config_field( 

184 cls, 

185 doc: str = "How to pack tract, patch, and possibly band into an integer." 

186 ) -> ConfigurableField: 

187 """Make a config field to control how skymap data IDs are packed. 

188 

189 Parameters 

190 ---------- 

191 doc : `str`, optional 

192 Documentation for the config field. 

193 

194 Returns 

195 ------- 

196 field : `lsst.pex.config.ConfigurableField` 

197 A config field whose instance values are [wrapper proxies to] 

198 `SkyMapDimensionPackerConfig` instances. 

199 """ 

200 return ConfigurableField(doc, target=cls.from_config, ConfigClass=cls.ConfigClass) 

201 

202 @classmethod 

203 def from_config( 

204 cls, data_id: DataCoordinate, config: SkyMapDimensionPackerConfig 

205 ) -> SkyMapDimensionPacker: 

206 """Construct a dimension packer from a config object and a data ID. 

207 

208 Parameters 

209 ---------- 

210 data_id : `lsst.daf.butler.DataCoordinate` 

211 Data ID that identifies at least the ``skymap`` dimension. Must 

212 have dimension records attached unless ``config.n_tracts`` and 

213 ``config.n_patches`` are both not `None`. 

214 config : `SkyMapDimensionPackerConfig` 

215 Configuration object. 

216 

217 Returns 

218 ------- 

219 packer : `SkyMapDimensionPackerConfig` 

220 New dimension packer. 

221 

222 Notes 

223 ----- 

224 This interface is provided for consistency with the `lsst.pex.config` 

225 "Configurable" concept, and is invoked when ``apply(data_id)`` is 

226 called on a config instance attribute that corresponds to a field 

227 created by `make_config_field`. The constructor signature cannot play 

228 this role easily for backwards compatibility reasons. 

229 """ 

230 return cls( 

231 data_id.subset(data_id.universe.conform(["skymap"])), 

232 n_bands=config.n_bands, 

233 bands=config.bands, 

234 n_tracts=config.n_tracts, 

235 n_patches=config.n_patches, 

236 ) 

237 

238 @property 

239 def maxBits(self) -> int: 

240 # Docstring inherited from DataIdPacker.maxBits 

241 packedMax = self._n_tracts * self._n_patches 

242 if self._n_bands: 

243 packedMax *= self._n_bands 

244 return (packedMax - 1).bit_length() 

245 

246 def _pack(self, dataId: DataCoordinate) -> int: 

247 # Docstring inherited from DataIdPacker.pack 

248 if dataId["patch"] >= self._n_patches: 

249 raise ValueError(f"Patch ID {dataId['patch']} is out of bounds; expected <{self._n_patches}.") 

250 if dataId["tract"] >= self._n_tracts: 

251 raise ValueError(f"Tract ID {dataId['tract']} is out of bounds; expected <{self._n_tracts}.") 

252 packed = dataId["patch"] + self._n_patches * dataId["tract"] 

253 if self._n_bands: 

254 if (band_index := self._bands.get(dataId["band"])) is None: 

255 raise ValueError( 

256 f"Band {dataId['band']!r} is not supported by SkyMapDimensionPacker " 

257 f"configuration; expected one of {list(self._bands)}." 

258 ) 

259 if band_index >= self._n_bands: 

260 raise ValueError( 

261 f"Band index {band_index} for {dataId['band']!r} is out of bounds; " 

262 f"expected <{self._n_bands}." 

263 ) 

264 packed += self._bands[dataId["band"]] * self._n_patches * self._n_tracts 

265 return packed 

266 

267 def unpack(self, packedId: int) -> DataCoordinate: 

268 # Docstring inherited from DataIdPacker.unpack 

269 d = {"skymap": self.fixed["skymap"]} 

270 if self._n_bands: 

271 index, packedId = divmod(packedId, (self._n_tracts * self._n_patches)) 

272 if self._bands_list is None: 

273 self._bands_list = list(self._bands) 

274 d["band"] = self._bands_list[index] 

275 d["tract"], d["patch"] = divmod(packedId, self._n_patches) 

276 return DataCoordinate.standardize(d, dimensions=self.dimensions)