Coverage for tests/test_build_camera.py: 98%

156 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-10 01:57 -0800

1# This file is part of drp_tasks 

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

21import os.path 

22import re 

23import unittest 

24 

25import astropy.units as u 

26import numpy as np 

27import yaml 

28from scipy.linalg import lstsq 

29from scipy.optimize import minimize 

30 

31import lsst.drp.tasks 

32import lsst.drp.tasks.gbdesAstrometricFit 

33import lsst.utils 

34from lsst.afw.cameraGeom.testUtils import FIELD_ANGLE, PIXELS 

35from lsst.afw.table import ExposureCatalog 

36from lsst.drp.tasks.build_camera import ( 

37 BuildCameraFromAstrometryConfig, 

38 BuildCameraFromAstrometryTask, 

39 _z_function, 

40) 

41from lsst.obs.subaru import HyperSuprimeCam 

42 

43TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

44 

45 

46class TestBuildCameraFromAstrometry(lsst.utils.tests.TestCase): 

47 

48 def setUp(self): 

49 

50 rng = np.random.default_rng(seed=54321) 

51 

52 # The tests will be done with data simulated to look like HSC data. 

53 self.camera = HyperSuprimeCam().getCamera() 

54 self.detectorList = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] 

55 self.visitList = [1176, 17900, 17930, 17934] 

56 self.rotationAngle = (270 * u.degree).to(u.radian).value 

57 self.task = BuildCameraFromAstrometryTask() 

58 

59 datadir = os.path.join(TESTDIR, "data") 

60 with open(os.path.join(datadir, "sample_wcs.yaml"), "r") as f: 

61 modelParams = yaml.load(f, Loader=yaml.Loader) 

62 self.mapParams = {} 

63 for key, value in modelParams.items(): 

64 if isinstance(key, str) and re.fullmatch(".+/poly", key): 

65 if value["Type"] == "Identity": 

66 continue 

67 polyCoefficients = np.concatenate( 

68 [value["XPoly"]["Coefficients"], value["YPoly"]["Coefficients"]] 

69 ) 

70 self.mapParams[key] = polyCoefficients 

71 

72 # Make semi-random visit maps: 

73 for visit in self.visitList: 

74 positionOffset = rng.random(2) 

75 angleOffset = rng.random() * (2 * np.pi) 

76 params = np.array( 

77 [ 

78 positionOffset[0], 

79 np.cos(angleOffset), 

80 -np.sin(angleOffset), 

81 positionOffset[1], 

82 np.sin(angleOffset), 

83 np.cos(angleOffset), 

84 ] 

85 ) 

86 self.mapParams[f"{visit}/poly"] = params 

87 

88 visitSummary = ExposureCatalog.readFits(os.path.join(datadir, "visitSummary_1176.fits")) 

89 gbdesTask = lsst.drp.tasks.gbdesAstrometricFit.GbdesAstrometricFitTask() 

90 _, self.mapTemplate = gbdesTask.make_yaml(visitSummary) 

91 self.mapTemplate["BAND/DEVICE/poly"]["XMax"] = self.camera[10].getBBox().endX 

92 self.mapTemplate["BAND/DEVICE/poly"]["YMax"] = self.camera[10].getBBox().endY 

93 

94 recenteredModel = self.task._recenter_model( 

95 self.mapParams, self.mapTemplate, self.detectorList, self.camera 

96 ) 

97 self.originalFieldAngleX = (recenteredModel.newTPx * u.degree).to(u.radian).value 

98 self.originalFieldAngleY = (recenteredModel.newTPy * u.degree).to(u.radian).value 

99 

100 bbox = self.camera[self.detectorList[0]].getBBox() 

101 self.xPix = (self.task.x + 1) * bbox.endX / 2 

102 self.yPix = (self.task.y + 1) * bbox.endY / 2 

103 

104 def test_recenter_model(self): 

105 deviceParams = [] 

106 for element in self.mapTemplate["BAND/DEVICE"]["Elements"]: 

107 for detector in self.detectorList: 

108 detectorTemplate = element.replace("DEVICE", str(detector)) 

109 detectorTemplate = detectorTemplate.replace("BAND", ".+") 

110 for k, params in self.mapParams.items(): 

111 if re.fullmatch(detectorTemplate, k): 

112 deviceParams.append(params) 

113 deviceArray = np.vstack(deviceParams) 

114 

115 visitParams = [] 

116 for element in self.mapTemplate["EXPOSURE"]["Elements"]: 

117 for visit in self.visitList: 

118 visitTemplate = element.replace("EXPOSURE", str(visit)) 

119 for k, params in self.mapParams.items(): 

120 if re.fullmatch(visitTemplate, k): 

121 visitParams.append(params) 

122 identityVisitParams = np.array([0, 1, 0, 0, 0, 1]) 

123 visitParams.append(identityVisitParams) 

124 expoArray = np.vstack(visitParams) 

125 

126 # Get the tangent plane positions from the original device maps: 

127 origTpX = [] 

128 origTpY = [] 

129 for deviceMap in deviceArray: 

130 nCoeffsDev = len(deviceMap) // 2 

131 deviceDegree = lsst.drp.tasks.gbdesAstrometricFit._degreeFromNCoeffs(nCoeffsDev) 

132 intX = _z_function(deviceMap[:nCoeffsDev], self.task.x, self.task.y, order=deviceDegree) 

133 intY = _z_function(deviceMap[nCoeffsDev:], self.task.x, self.task.y, order=deviceDegree) 

134 origTpX.append(intX) 

135 origTpY.append(intY) 

136 

137 origTpX = np.array(origTpX).ravel() 

138 origTpY = np.array(origTpY).ravel() 

139 

140 for method in ["affine", "rigid"]: 

141 # Get the interim positions with the new device maps: 

142 recenteredModel = self.task._recenter_model( 

143 self.mapParams, 

144 self.mapTemplate, 

145 self.detectorList, 

146 self.camera, 

147 transform=method, 

148 ) 

149 origTpX, origTpY = recenteredModel.inputCoords 

150 newIntX = recenteredModel.newTPx 

151 newIntY = recenteredModel.newTPy 

152 

153 # Now fit the per-visit parameters with the constraint that the new 

154 # tangent plane positions match the old tangent plane positions, 

155 # and then check they are sufficiently close to the old values. 

156 newExpoArrayEmp = np.zeros(expoArray.shape) 

157 for e, expo in enumerate(expoArray): 

158 origMapX = expo[0] + origTpX * expo[1] + origTpY * expo[2] 

159 origMapY = expo[3] + origTpX * expo[4] + origTpY * expo[5] 

160 

161 def min_function(params): 

162 tpX = params[0] + params[1] * newIntX + params[2] * newIntY 

163 tpY = params[3] + params[4] * newIntX + params[5] * newIntY 

164 

165 diff = ((origMapX - tpX)) ** 2 + ((origMapY - tpY)) ** 2 

166 return 1000 * diff.sum() 

167 

168 def jac(params): 

169 tpX = params[0] + params[1] * newIntX + params[2] * newIntY 

170 tpY = params[3] + params[4] * newIntX + params[5] * newIntY 

171 dX = 2 * 1000 * (origMapX - tpX) 

172 dY = 2 * 1000 * (origMapY - tpY) 

173 jacobian = [ 

174 -dX.sum(), 

175 -(dX * newIntX).sum(), 

176 -(dX * newIntY).sum(), 

177 -dY.sum(), 

178 -(dY * newIntX).sum(), 

179 -(dY * newIntY).sum(), 

180 ] 

181 return jacobian 

182 

183 res = minimize(min_function, expo, method="Newton-CG", jac=jac, options={"xtol": 1e-14}) 

184 newExpoArrayEmp[e] = res.x 

185 

186 newMapX = ( 

187 newExpoArrayEmp[e][0] + newIntX * newExpoArrayEmp[e][1] + newIntY * newExpoArrayEmp[e][2] 

188 ) 

189 newMapY = ( 

190 newExpoArrayEmp[e][3] + newIntX * newExpoArrayEmp[e][4] + newIntY * newExpoArrayEmp[e][5] 

191 ) 

192 self.assertFloatsAlmostEqual(origMapX, newMapX, atol=1e-10) 

193 self.assertFloatsAlmostEqual(origMapY, newMapY, atol=1e-10) 

194 

195 baselineCameraTPx, baselineCameraTPy = recenteredModel.targetCoords 

196 

197 # Check that the average offset between the input camera and the 

198 # new camera is close to zero. 

199 dX = newIntX - baselineCameraTPx 

200 dY = newIntY - baselineCameraTPy 

201 

202 self.assertAlmostEqual(dX.mean(), 0) 

203 self.assertAlmostEqual(dY.mean(), 0) 

204 

205 # Check that the average rotation the input camera and the new 

206 # camera is close to zero. 

207 norm = (baselineCameraTPx**2 + baselineCameraTPy**2) ** 0.5 * (newIntX**2 + newIntY**2) ** 0.5 

208 cosTheta = (baselineCameraTPx * newIntX + baselineCameraTPy * newIntY) / norm 

209 self.assertAlmostEqual(cosTheta.mean(), 1, places=4) 

210 

211 if method == "affine": 

212 # Check that there is no slope on average between input camera 

213 # and new camera. 

214 A = np.column_stack([np.ones_like(baselineCameraTPx), baselineCameraTPx, baselineCameraTPy]) 

215 xSolution, *_ = lstsq(A, newIntX) 

216 ySolution, *_ = lstsq(A, newIntY) 

217 self.assertFloatsAlmostEqual(xSolution, np.array([0, 1, 0]), atol=1e-6) 

218 self.assertFloatsAlmostEqual(ySolution, np.array([0, 0, 1]), atol=1e-6) 

219 

220 def test_run_with_basic_model(self): 

221 

222 config = BuildCameraFromAstrometryConfig() 

223 config.modelSplitting = "basic" 

224 task = BuildCameraFromAstrometryTask(config=config) 

225 

226 camera = task.run( 

227 self.mapParams, 

228 self.mapTemplate, 

229 self.detectorList, 

230 self.visitList, 

231 self.camera, 

232 ) 

233 testX, testY = [], [] 

234 for dev in camera: 

235 faX, faY = ( 

236 dev.getTransform(PIXELS, FIELD_ANGLE) 

237 .getMapping() 

238 .applyForward(np.array([self.xPix, self.yPix])) 

239 ) 

240 testX.append(faX) 

241 testY.append(faY) 

242 testX = np.concatenate(testX) 

243 testY = np.concatenate(testY) 

244 

245 self.assertFloatsAlmostEqual(self.originalFieldAngleX, testX, atol=1e-12) 

246 self.assertFloatsAlmostEqual(self.originalFieldAngleY, testY, atol=1e-12) 

247 

248 def test_run_with_splitModel(self): 

249 

250 config = BuildCameraFromAstrometryConfig() 

251 config.modelSplitting = "physical" 

252 config.modelSplittingTolerance = 1e-6 

253 task = BuildCameraFromAstrometryTask(config=config) 

254 camera = task.run( 

255 self.mapParams, 

256 self.mapTemplate, 

257 self.detectorList, 

258 self.visitList, 

259 self.camera, 

260 ) 

261 

262 testX, testY = [], [] 

263 for dev in camera: 

264 faX, faY = ( 

265 dev.getTransform(PIXELS, FIELD_ANGLE) 

266 .getMapping() 

267 .applyForward(np.array([self.xPix, self.yPix])) 

268 ) 

269 testX.append(faX) 

270 testY.append(faY) 

271 testX = np.concatenate(testX) 

272 testY = np.concatenate(testY) 

273 

274 # The "physical" model splitting is not expected to 

275 # reconstruct the input field angles perfectly. 

276 self.assertFloatsAlmostEqual(self.originalFieldAngleX, testX, atol=1e-4) 

277 self.assertFloatsAlmostEqual(self.originalFieldAngleY, testY, atol=1e-4) 

278 

279 

280def setup_module(module): 

281 lsst.utils.tests.init() 

282 

283 

284if __name__ == "__main__": 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 lsst.utils.tests.init() 

286 unittest.main()