Coverage for python/lsst/obs/lsst/assembly.py: 12%

105 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-02-16 03:52 -0800

1# This file is part of obs_lsst 

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__all__ = ("attachRawWcsFromBoresight", "fixAmpGeometry", "assembleUntrimmedCcd", 

23 "fixAmpsAndAssemble", "readRawAmps") 

24 

25import logging 

26from contextlib import contextmanager 

27import numpy as np 

28import lsst.afw.image as afwImage 

29from lsst.obs.base import bboxFromIraf, MakeRawVisitInfoViaObsInfo, createInitialSkyWcs 

30from lsst.geom import Box2I, Extent2I 

31from lsst.ip.isr import AssembleCcdTask 

32from astro_metadata_translator import ObservationInfo 

33 

34logger = logging.getLogger(__name__) 

35 

36 

37def attachRawWcsFromBoresight(exposure, dataIdForErrMsg=None): 

38 """Attach a WCS by extracting boresight, rotation, and camera geometry from 

39 an Exposure. 

40 

41 Parameters 

42 ---------- 

43 exposure : `lsst.afw.image.Exposure` 

44 Image object with attached metadata and detector components. 

45 

46 Return 

47 ------ 

48 attached : `bool` 

49 If True, a WCS component was successfully created and attached to 

50 ``exposure``. 

51 """ 

52 md = exposure.getMetadata() 

53 # Use the generic version since we do not have a mapper available to 

54 # tell us a specific translator to use. 

55 obsInfo = ObservationInfo(md) 

56 visitInfo = MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(obsInfo, log=logger) 

57 exposure.getInfo().setVisitInfo(visitInfo) 

58 exposure.info.id = obsInfo.detector_exposure_id 

59 

60 # LATISS (and likely others) need flipping, DC2 etc do not 

61 flipX = False 

62 if obsInfo.instrument in ("LATISS",): 

63 flipX = True 

64 

65 if visitInfo.getBoresightRaDec().isFinite(): 

66 exposure.setWcs(createInitialSkyWcs(visitInfo, exposure.getDetector(), flipX=flipX)) 

67 return True 

68 

69 if obsInfo.observation_type == "science": 

70 logger.warning("Unable to set WCS from header as RA/Dec/Angle are unavailable%s", 

71 ("" if dataIdForErrMsg is None else " for dataId %s" % dataIdForErrMsg)) 

72 return False 

73 

74 

75def fixAmpGeometry(inAmp, bbox, metadata, logCmd=None): 

76 """Make sure a camera geometry amplifier matches an on-disk bounding box. 

77 

78 Bounding box differences that are consistent with differences in overscan 

79 regions are assumed to be overscan regions, which gives us enough 

80 information to correct the camera geometry. 

81 

82 Parameters 

83 ---------- 

84 inAmp : `lsst.afw.cameraGeom.Amplifier` 

85 Amplifier description from camera geometry. 

86 bbox : `lsst.geom.Box2I` 

87 The on-disk bounding box of the amplifer image. 

88 metadata : `lsst.daf.base.PropertyList` 

89 FITS header metadata from the amplifier HDU. 

90 logCmd : `function`, optional 

91 Call back to use to issue log messages about patching. Arguments to 

92 this function should match arguments to be accepted by normal logging 

93 functions. Warnings about bad EXTNAMES are always sent directly to 

94 the module-level logger. 

95 

96 Return 

97 ------ 

98 outAmp : `~lsst.afw.cameraGeom.Amplifier.Builder` 

99 modified : `bool` 

100 `True` if ``amp`` was modified; `False` otherwise. 

101 

102 Raises 

103 ------ 

104 RuntimeError 

105 Raised if the bounding boxes differ in a way that is not consistent 

106 with just a change in overscan. 

107 """ 

108 if logCmd is None: 

109 # Define a null log command 

110 def logCmd(*args): 

111 return 

112 

113 # check that the book-keeping worked and we got the correct EXTNAME 

114 extname = metadata.get("EXTNAME") 

115 predictedExtname = f"Segment{inAmp.getName()[1:]}" 

116 if extname is not None and predictedExtname != extname: 

117 logger.warning('expected to see EXTNAME == "%s", but saw "%s"', predictedExtname, extname) 

118 

119 modified = False 

120 

121 outAmp = inAmp.rebuild() 

122 if outAmp.getRawBBox() != bbox: # Oh dear. cameraGeom is wrong -- probably overscan 

123 if outAmp.getRawDataBBox().getDimensions() != outAmp.getBBox().getDimensions(): 

124 raise RuntimeError("Active area is the wrong size: %s v. %s" % 

125 (outAmp.getRawDataBBox().getDimensions(), outAmp.getBBox().getDimensions())) 

126 

127 logCmd("outAmp.getRawBBox() != data.getBBox(); patching. (%s v. %s)", outAmp.getRawBBox(), bbox) 

128 

129 w, h = bbox.getDimensions() 

130 ow, oh = outAmp.getRawBBox().getDimensions() # "old" (cameraGeom) dimensions 

131 # 

132 # We could trust the BIASSEC keyword, or we can just assume that 

133 # they've changed the number of overscan pixels (serial and/or 

134 # parallel). As Jim Chiang points out, the latter is safer 

135 # 

136 fromCamGeom = outAmp.getRawHorizontalOverscanBBox() 

137 hOverscanBBox = Box2I(fromCamGeom.getBegin(), 

138 Extent2I(w - fromCamGeom.getBeginX(), fromCamGeom.getHeight())) 

139 fromCamGeom = outAmp.getRawVerticalOverscanBBox() 

140 vOverscanBBox = Box2I(fromCamGeom.getBegin(), 

141 Extent2I(fromCamGeom.getWidth(), h - fromCamGeom.getBeginY())) 

142 outAmp.setRawBBox(bbox) 

143 outAmp.setRawHorizontalOverscanBBox(hOverscanBBox) 

144 outAmp.setRawVerticalOverscanBBox(vOverscanBBox) 

145 # 

146 # This gets all the geometry right for the amplifier, but the size 

147 # of the untrimmed image will be wrong and we'll put the amp sections 

148 # in the wrong places, i.e. 

149 # outAmp.getRawXYOffset() 

150 # will be wrong. So we need to recalculate the offsets. 

151 # 

152 xRawExtent, yRawExtent = outAmp.getRawBBox().getDimensions() 

153 

154 x0, y0 = outAmp.getRawXYOffset() 

155 ix, iy = x0//ow, y0/oh 

156 x0, y0 = ix*xRawExtent, iy*yRawExtent 

157 outAmp.setRawXYOffset(Extent2I(ix*xRawExtent, iy*yRawExtent)) 

158 

159 modified = True 

160 

161 # 

162 # Check the "IRAF" keywords, but don't abort if they're wrong 

163 # 

164 # Only warn about the first amp, use debug for the others 

165 # 

166 d = metadata.toDict() 

167 detsec = bboxFromIraf(d["DETSEC"]) if "DETSEC" in d else None 

168 datasec = bboxFromIraf(d["DATASEC"]) if "DATASEC" in d else None 

169 biassec = bboxFromIraf(d["BIASSEC"]) if "BIASSEC" in d else None 

170 

171 # 2022-11-11: There is a known issue that the header DETSEC have 

172 # the y-axis values flipped between the C0x and C1x entries. This 

173 # is incorrect, and disagrees with the cameraGeom values. 

174 # DM-36115 contains additional details. This test has been 

175 # disabled to remove useless warnings until that is resolved. 

176 # if detsec and outAmp.getBBox() != detsec: 

177 # logCmd("DETSEC doesn't match (%s != %s)", 

178 # outAmp.getBBox(), detsec) 

179 if datasec and outAmp.getRawDataBBox() != datasec: 

180 logCmd("DATASEC doesn't match for (%s != %s)", outAmp.getRawDataBBox(), detsec) 

181 if biassec and outAmp.getRawHorizontalOverscanBBox() != biassec: 

182 logCmd("BIASSEC doesn't match for (%s != %s)", outAmp.getRawHorizontalOverscanBBox(), detsec) 

183 

184 return outAmp, modified 

185 

186 

187def assembleUntrimmedCcd(ccd, exposures): 

188 """Assemble an untrimmmed CCD from per-amp Exposure objects. 

189 

190 Parameters 

191 ---------- 

192 ccd : `~lsst.afw.cameraGeom.Detector` 

193 The detector geometry for this ccd that will be used as the 

194 framework for the assembly of the input amplifier exposures. 

195 exposures : sequence of `lsst.afw.image.Exposure` 

196 Per-amplifier images, in the same order as ``amps``. 

197 

198 Returns 

199 ------- 

200 ccd : `lsst.afw.image.Exposure` 

201 Assembled CCD image. 

202 """ 

203 ampDict = {} 

204 for amp, exposure in zip(ccd, exposures): 

205 ampDict[amp.getName()] = exposure 

206 config = AssembleCcdTask.ConfigClass() 

207 config.doTrim = False 

208 assembleTask = AssembleCcdTask(config=config) 

209 return assembleTask.assembleCcd(ampDict) 

210 

211 

212@contextmanager 

213def warn_once(msg): 

214 """Return a context manager around a log-like object that emits a warning 

215 the first time it is used and a debug message all subsequent times. 

216 

217 Parameters 

218 ---------- 

219 msg : `str` 

220 Message to prefix all log messages with. 

221 

222 Returns 

223 ------- 

224 logger 

225 A log-like object that takes a %-style format string and positional 

226 substition args. 

227 """ 

228 warned = False 

229 

230 def logCmd(s, *args): 

231 nonlocal warned 

232 log_msg = f"{msg}: {s}" 

233 if warned: 

234 logger.debug(log_msg, *args) 

235 else: 

236 logger.warning(log_msg, *args) 

237 warned = True 

238 

239 yield logCmd 

240 

241 

242def fixAmpsAndAssemble(ampExps, msg): 

243 """Fix amp geometry and assemble into exposure. 

244 

245 Parameters 

246 ---------- 

247 ampExps : sequence of `lsst.afw.image.Exposure` 

248 Per-amplifier images. 

249 msg : `str` 

250 Message to add to log and exception output. 

251 

252 Returns 

253 ------- 

254 exposure : `lsst.afw.image.Exposure` 

255 Exposure with the amps combined into a single image. 

256 

257 Notes 

258 ----- 

259 The returned exposure does not have any metadata or WCS attached. 

260 

261 """ 

262 if not len(ampExps): 

263 raise RuntimeError(f"Unable to read raw_amps for {msg}") 

264 

265 ccd = ampExps[0].getDetector() # the same (full, CCD-level) Detector is attached to all ampExps 

266 # 

267 # Check that the geometry in the metadata matches cameraGeom 

268 # 

269 with warn_once(msg) as logCmd: 

270 # Rebuild the detector and the amplifiers to use their corrected 

271 # geometry. 

272 tempCcd = ccd.rebuild() 

273 tempCcd.clear() 

274 for amp, ampExp in zip(ccd, ampExps): 

275 outAmp, _ = fixAmpGeometry(amp, 

276 bbox=ampExp.getBBox(), 

277 metadata=ampExp.getMetadata(), 

278 logCmd=logCmd) 

279 tempCcd.append(outAmp) 

280 ccd = tempCcd.finish() 

281 

282 # Update the data to be combined to point to the newly rebuilt detector. 

283 for ampExp in ampExps: 

284 ampExp.setDetector(ccd) 

285 

286 exposure = assembleUntrimmedCcd(ccd, ampExps) 

287 return exposure 

288 

289 

290def readRawAmps(fileName, detector): 

291 """Given a file name read the amps and attach the detector. 

292 

293 Parameters 

294 ---------- 

295 fileName : `str` 

296 The full path to a file containing data from a single CCD. 

297 detector : `lsst.afw.cameraGeom.Detector` 

298 Detector to associate with the amps. 

299 

300 Returns 

301 ------- 

302 ampExps : `list` of `lsst.afw.image.Exposure` 

303 All the individual amps read from the file. 

304 """ 

305 amps = [] 

306 for hdu in range(1, len(detector)+1): 

307 reader = afwImage.ImageFitsReader(fileName, hdu=hdu) 

308 exp = afwImage.makeExposure(afwImage.makeMaskedImage(reader.read(dtype=np.dtype(np.int32), 

309 allowUnsafe=True))) 

310 exp.setDetector(detector) 

311 exp.setMetadata(reader.readMetadata()) 

312 amps.append(exp) 

313 return amps