Coverage for python/lsst/obs/subaru/_instrument.py: 72%

125 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-14 19:32 +0000

1# This file is part of obs_subaru. 

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 

22"""Gen3 Butler registry declarations for Hyper Suprime-Cam. 

23""" 

24 

25__all__ = ("HyperSuprimeCam",) 

26 

27import importlib.resources 

28import os 

29import pickle 

30import logging 

31 

32from functools import lru_cache 

33 

34from astro_metadata_translator import HscTranslator 

35import astropy.time 

36from lsst.resources import ResourcePath 

37from lsst.afw.cameraGeom import makeCameraFromPath, CameraConfig 

38from lsst.daf.butler import (DatasetType, DataCoordinate, FileDataset, DatasetRef, 

39 CollectionType, Timespan) 

40from lsst.utils.introspection import get_full_type_name 

41from lsst.obs.base import Instrument, VisitSystem 

42 

43from ..hsc.hscPupil import HscPupilFactory 

44from ..hsc.hscFilters import HSC_FILTER_DEFINITIONS 

45from ..hsc.makeTransmissionCurves import (getSensorTransmission, getOpticsTransmission, 

46 getFilterTransmission, getAtmosphereTransmission) 

47from .strayLight.formatter import SubaruStrayLightDataFormatter 

48 

49log = logging.getLogger(__name__) 

50 

51 

52class HyperSuprimeCam(Instrument): 

53 """Gen3 Butler specialization class for Subaru's Hyper Suprime-Cam. 

54 """ 

55 

56 policyName = "hsc" 

57 obsDataPackage = "obs_subaru_data" 

58 filterDefinitions = HSC_FILTER_DEFINITIONS 

59 additionalCuratedDatasetTypes = ("bfKernel", "transmission_optics", "transmission_sensor", 

60 "transmission_filter", "transmission_atmosphere", "yBackground") 

61 translatorClass = HscTranslator 

62 

63 def __init__(self, **kwargs): 

64 super().__init__(**kwargs) 

65 self.configPaths = ["resource://lsst.obs.subaru/resources/config", 

66 f"resource://lsst.obs.subaru/resources/config/{self.policyName}"] 

67 

68 @classmethod 

69 def getName(cls): 

70 # Docstring inherited from Instrument.getName 

71 return "HSC" 

72 

73 def register(self, registry, update=False): 

74 # Docstring inherited from Instrument.register 

75 camera = self.getCamera() 

76 # The maximum values below make Gen3's ObservationDataIdPacker produce 

77 # outputs that match Gen2's ccdExposureId. 

78 obsMax = 21474800 

79 with registry.transaction(): 

80 registry.syncDimensionData( 

81 "instrument", 

82 { 

83 "name": self.getName(), 

84 "detector_max": 200, 

85 "visit_max": obsMax, 

86 "exposure_max": obsMax, 

87 "class_name": get_full_type_name(self), 

88 # Some schemas support default visit_system 

89 "visit_system": VisitSystem.ONE_TO_ONE.value, 

90 }, 

91 update=update 

92 ) 

93 for detector in camera: 

94 registry.syncDimensionData( 

95 "detector", 

96 { 

97 "instrument": self.getName(), 

98 "id": detector.getId(), 

99 "full_name": detector.getName(), 

100 # TODO: make sure these definitions are consistent with 

101 # those extracted by astro_metadata_translator, and 

102 # test that they remain consistent somehow. 

103 "name_in_raft": detector.getName().split("_")[1], 

104 "raft": detector.getName().split("_")[0], 

105 "purpose": str(detector.getType()).split(".")[-1], 

106 }, 

107 update=update 

108 ) 

109 self._registerFilters(registry, update=update) 

110 

111 def getRawFormatter(self, dataId): 

112 # Docstring inherited from Instrument.getRawFormatter 

113 # Import the formatter here to prevent a circular dependency. 

114 from .rawFormatter import HyperSuprimeCamRawFormatter, HyperSuprimeCamCornerRawFormatter 

115 if dataId["detector"] in (100, 101, 102, 103): 

116 return HyperSuprimeCamCornerRawFormatter 

117 else: 

118 return HyperSuprimeCamRawFormatter 

119 

120 def getCamera(self): 

121 """Retrieve the cameraGeom representation of HSC. 

122 

123 This is a temporary API that should go away once obs_ packages have 

124 a standardized approach to writing versioned cameras to a Gen3 repo. 

125 """ 

126 # The camera geometry is a directory of files (camera.py plus per-amp 

127 # FITS) consumed by makeCameraFromPath, so it must be presented as a 

128 # local directory. importlib.resources.as_file yields that path 

129 # without requiring an EUPS environment (unlike getPackageDir). 

130 with importlib.resources.as_file( 

131 importlib.resources.files("lsst.obs.subaru").joinpath( 

132 f"resources/{self.policyName}/camera" 

133 ) 

134 ) as path: 

135 return self._getCameraFromPath(str(path)) 

136 

137 @staticmethod 

138 @lru_cache() 

139 def _getCameraFromPath(path): 

140 """Return the camera geometry given solely the path to the location 

141 of that definition.""" 

142 config = CameraConfig() 

143 config.load(os.path.join(path, "camera.py")) 

144 return makeCameraFromPath( 

145 cameraConfig=config, 

146 ampInfoPath=path, 

147 shortNameFunc=lambda name: name.replace(" ", "_"), 

148 pupilFactoryClass=HscPupilFactory 

149 ) 

150 

151 def getBrighterFatterKernel(self): 

152 """Return the brighter-fatter kernel for HSC as a `numpy.ndarray`. 

153 

154 This is a temporary API that should go away once obs_ packages have 

155 a standardized approach to writing versioned kernels to a Gen3 repo. 

156 """ 

157 uri = ResourcePath(f"resource://lsst.obs.subaru/resources/{self.policyName}/brighter_fatter_kernel.pkl") 

158 with uri.open("rb") as fd: 

159 kernel = pickle.load(fd, encoding='latin1') # encoding for pickle written with Python 2 

160 return kernel 

161 

162 def writeAdditionalCuratedCalibrations(self, butler, collection=None, labels=()): 

163 # Register the CALIBRATION collection that adds validity ranges. 

164 # This does nothing if it is already registered. 

165 if collection is None: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 collection = self.makeCalibrationCollectionName(*labels) 

167 butler.registry.registerCollection(collection, type=CollectionType.CALIBRATION) 

168 

169 # Register the RUN collection that holds these datasets directly. We 

170 # only need one because all of these datasets have the same (unbounded) 

171 # validity range right now. 

172 run = self.makeUnboundedCalibrationRunName(*labels) 

173 butler.registry.registerRun(run) 

174 baseDataId = butler.registry.expandDataId(instrument=self.getName()) 

175 refs = [] 

176 

177 # Write brighter-fatter kernel, with an infinite validity range. 

178 datasetType = DatasetType("bfKernel", ("instrument",), "NumpyArray", 

179 universe=butler.dimensions, 

180 isCalibration=True) 

181 butler.registry.registerDatasetType(datasetType) 

182 

183 # Load and then put instead of just moving the file in part to ensure 

184 # the version in-repo is written with Python 3 and does not need 

185 # `encoding='latin1'` to be read. 

186 bfKernel = self.getBrighterFatterKernel() 

187 refs.append(butler.put(bfKernel, datasetType, baseDataId, run=run)) 

188 

189 # The following iterate over the values of the dictionaries returned 

190 # by the transmission functions and ignore the date that is supplied. 

191 # This is due to the dates not being ranges but single dates, 

192 # which do not give the proper notion of validity. As such unbounded 

193 # calibration labels are used when inserting into the database. 

194 # In the future these could and probably should be updated to 

195 # properly account for what ranges are considered valid. 

196 

197 # Write optical transmissions 

198 opticsTransmissions = getOpticsTransmission() 

199 datasetType = DatasetType("transmission_optics", 

200 ("instrument",), 

201 "TransmissionCurve", 

202 universe=butler.dimensions, 

203 isCalibration=True) 

204 butler.registry.registerDatasetType(datasetType) 

205 for entry in opticsTransmissions.values(): 

206 if entry is None: 

207 continue 

208 refs.append(butler.put(entry, datasetType, baseDataId, run=run)) 

209 

210 # Write transmission sensor 

211 sensorTransmissions = getSensorTransmission() 

212 datasetType = DatasetType("transmission_sensor", 

213 ("instrument", "detector",), 

214 "TransmissionCurve", 

215 universe=butler.dimensions, 

216 isCalibration=True) 

217 butler.registry.registerDatasetType(datasetType) 

218 for entry in sensorTransmissions.values(): 

219 if entry is None: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 continue 

221 for sensor, curve in entry.items(): 

222 dataId = DataCoordinate.standardize(baseDataId, detector=sensor) 

223 refs.append(butler.put(curve, datasetType, dataId, run=run)) 

224 

225 # Write filter transmissions 

226 filterTransmissions = getFilterTransmission() 

227 datasetType = DatasetType("transmission_filter", 

228 ("instrument", "physical_filter",), 

229 "TransmissionCurve", 

230 universe=butler.dimensions, 

231 isCalibration=True) 

232 butler.registry.registerDatasetType(datasetType) 

233 for entry in filterTransmissions.values(): 

234 if entry is None: 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true

235 continue 

236 for band, curve in entry.items(): 

237 dataId = DataCoordinate.standardize(baseDataId, physical_filter=band) 

238 refs.append(butler.put(curve, datasetType, dataId, run=run)) 

239 

240 # Write atmospheric transmissions 

241 atmosphericTransmissions = getAtmosphereTransmission() 

242 datasetType = DatasetType("transmission_atmosphere", ("instrument",), 

243 "TransmissionCurve", 

244 universe=butler.dimensions, 

245 isCalibration=True) 

246 butler.registry.registerDatasetType(datasetType) 

247 for entry in atmosphericTransmissions.values(): 

248 if entry is None: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 continue 

250 refs.append(butler.put(entry, datasetType, {"instrument": self.getName()}, run=run)) 

251 

252 # Associate all datasets with the unbounded validity range. 

253 butler.registry.certify(collection, refs, Timespan(begin=None, end=None)) 

254 

255 def ingestStrayLightData(self, butler, directory, *, transfer=None, collection=None, labels=()): 

256 """Ingest externally-produced y-band stray light data files into 

257 a data repository. 

258 

259 Parameters 

260 ---------- 

261 butler : `lsst.daf.butler.Butler` 

262 Butler to write with. Any collections associated with it are 

263 ignored in favor of ``collection`` and/or ``labels``. 

264 directory : `str` 

265 Directory containing yBackground-*.fits files. 

266 transfer : `str`, optional 

267 If not `None`, must be one of 'move', 'copy', 'hardlink', or 

268 'symlink', indicating how to transfer the files. 

269 collection : `str`, optional 

270 Name to use for the calibration collection that associates all 

271 datasets with a validity range. If this collection already exists, 

272 it must be a `~CollectionType.CALIBRATION` collection, and it must 

273 not have any datasets that would conflict with those inserted by 

274 this method. If `None`, a collection name is worked out 

275 automatically from the instrument name and other metadata by 

276 calling ``makeCuratedCalibrationCollectionName``, but this 

277 default name may not work well for long-lived repositories unless 

278 ``labels`` is also provided (and changed every time curated 

279 calibrations are ingested). 

280 labels : `Sequence` [ `str` ], optional 

281 Extra strings to include in collection names, after concatenating 

282 them with the standard collection name delimeter. If provided, 

283 these are inserted into to the names of the `~CollectionType.RUN` 

284 collections that datasets are inserted directly into, as well the 

285 `~CollectionType.CALIBRATION` collection if it is generated 

286 automatically (i.e. if ``collection is None``). Usually this is 

287 just the name of the ticket on which the calibration collection is 

288 being created. 

289 """ 

290 # Register the CALIBRATION collection that adds validity ranges. 

291 # This does nothing if it is already registered. 

292 if collection is None: 

293 collection = self.makeCalibrationCollectionName(*labels) 

294 butler.registry.registerCollection(collection, type=CollectionType.CALIBRATION) 

295 

296 # Register the RUN collection that holds these datasets directly. We 

297 # only need one because there is only one validity range and hence no 

298 # data ID conflicts even when there are no validity ranges. 

299 run = self.makeUnboundedCalibrationRunName(*labels) 

300 butler.registry.registerRun(run) 

301 

302 # LEDs covered up around 2018-01-01, no need for correctin after that 

303 # date. 

304 timespan = Timespan(begin=None, end=astropy.time.Time("2018-01-01", format="iso", scale="tai")) 

305 datasets = [] 

306 # TODO: should we use a more generic name for the dataset type? 

307 # This is just the (rather HSC-specific) name used in Gen2, and while 

308 # the instances of this dataset are camera-specific, the datasetType 

309 # (which is used in the generic IsrTask) should not be. 

310 datasetType = DatasetType("yBackground", 

311 dimensions=("physical_filter", "detector",), 

312 storageClass="StrayLightData", 

313 universe=butler.dimensions, 

314 isCalibration=True) 

315 for detector in self.getCamera(): 

316 path = os.path.join(directory, f"ybackground-{detector.getId():03d}.fits") 

317 if not os.path.exists(path): 

318 log.warning("No stray light data found for detector %s @ %s.", detector.getId(), path) 

319 continue 

320 ref = DatasetRef(datasetType, dataId={"instrument": self.getName(), 

321 "detector": detector.getId(), 

322 "physical_filter": "HSC-Y"}, 

323 run=run) 

324 datasets.append(FileDataset(refs=ref, path=path, formatter=SubaruStrayLightDataFormatter)) 

325 butler.registry.registerDatasetType(datasetType) 

326 with butler.transaction(): 

327 butler.ingest(*datasets, transfer=transfer) 

328 refs = [] 

329 for dataset in datasets: 

330 refs.extend(dataset.refs) 

331 butler.registry.certify(collection, refs, timespan)