Coverage for python/lsst/images/cameras/_amplifier.py: 46%

135 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:36 -0700

1# This file is part of lsst-images. 

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# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11from __future__ import annotations 

12 

13__all__ = ( 

14 "Amplifier", 

15 "AmplifierCalibrations", 

16 "AmplifierRawGeometry", 

17 "ReadoutCorner", 

18) 

19 

20import enum 

21from typing import TYPE_CHECKING, Any, final 

22 

23import numpy as np 

24import pydantic 

25 

26from .._geom import YX, Box 

27from ..serialization import InlineArray 

28 

29if TYPE_CHECKING: 

30 try: 

31 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

32 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

33 except ImportError: 

34 type LegacyReadoutCorner = Any # type: ignore[no-redef] 

35 type LegacyAmplifier = Any # type: ignore[no-redef] 

36 

37 

38class ReadoutCorner(enum.StrEnum): 

39 """Enumeration of the possible readout corners of an amplifier.""" 

40 

41 LL = "LL" 

42 LR = "LR" 

43 UR = "UR" 

44 UL = "UL" 

45 

46 def to_legacy(self) -> LegacyReadoutCorner: 

47 """Convert to `lsst.afw.cameraGeom.ReadoutCorner`.""" 

48 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

49 

50 return getattr(LegacyReadoutCorner, self.value) 

51 

52 @classmethod 

53 def from_legacy(cls, legacy_readout_corner: LegacyReadoutCorner) -> ReadoutCorner: 

54 """Convert from `lsst.afw.cameraGeom.ReadoutCorner`. 

55 

56 Parameters 

57 ---------- 

58 legacy_readout_corner 

59 Legacy readout corner to convert. 

60 """ 

61 return getattr(cls, legacy_readout_corner.name) 

62 

63 def as_flips(self) -> YX[bool]: 

64 """Return a tuple indicating how the image needs to be flipped to 

65 bring the readout corner to ``LL``. 

66 """ 

67 return YX( 

68 y=self is ReadoutCorner.UL or self is ReadoutCorner.UR, 

69 x=self is ReadoutCorner.LR or self is ReadoutCorner.UR, 

70 ) 

71 

72 @classmethod 

73 def from_flips(cls, *, y: bool, x: bool) -> ReadoutCorner: 

74 """Construct from booleans indicating how the image needs to be 

75 flipped to bring the readout corner to ``LL``. 

76 

77 Parameters 

78 ---------- 

79 y 

80 Whether the image is flipped in the y direction. 

81 x 

82 Whether the image is flipped in the x direction. 

83 """ 

84 match y, x: 

85 case False, False: 

86 return cls.LL 

87 case False, True: 

88 return cls.LR 

89 case True, True: 

90 return cls.UR 

91 case True, False: 91 ↛ 93line 91 didn't jump to line 93 because the pattern on line 91 always matched

92 return cls.UL 

93 raise TypeError(f"Invalid arguments: y={y}, x={x} (expected booleans).") 

94 

95 def apply_flips(self, *, y: bool, x: bool) -> ReadoutCorner: 

96 """Return the new readout corner after applying the given flips. 

97 

98 Parameters 

99 ---------- 

100 y 

101 Whether to flip in the y direction. 

102 x 

103 Whether to flip in the x direction. 

104 """ 

105 current = self.as_flips() 

106 return self.from_flips(y=current.y ^ y, x=current.x ^ x) 

107 

108 

109@final 

110class AmplifierRawGeometry(pydantic.BaseModel): 

111 """A struct that describes the geometry of an amplifire in a raw image.""" 

112 

113 bbox: Box = pydantic.Field(description="Bounding box of the full untrimmed amplifier in the raw image.") 

114 data_bbox: Box = pydantic.Field(description="Bounding box of the data section in the raw image.") 

115 flip_x: bool = pydantic.Field(False, description="Whether to flip the X coordinates during assembly.") 

116 flip_y: bool = pydantic.Field(False, description="Whether to flip the Y coordinates during assembly.") 

117 x_offset: int = pydantic.Field( 

118 0, 

119 description=( 

120 "X offset between the raw position of this amplifier and the trimmed, " 

121 "assembled position of the amplifier." 

122 ), 

123 ) 

124 y_offset: int = pydantic.Field( 

125 0, 

126 description=( 

127 "Y offset between the raw position of this amplifier and the trimmed, " 

128 "assembled position of the amplifier." 

129 ), 

130 ) 

131 serial_overscan_bbox: Box = pydantic.Field( 

132 description="Bounding box of the serial (horizontal) overscan region in the raw image." 

133 ) 

134 parallel_overscan_bbox: Box = pydantic.Field( 

135 description="Bounding box of the parallel (vertical) overscan region in the raw image." 

136 ) 

137 prescan_bbox: Box | None = pydantic.Field( 

138 None, 

139 description=( 

140 "Bounding box of the serial (horizontal) pre-scan region in the raw image, " 

141 "or `None` for an amplifier that has no pre-scan region." 

142 ), 

143 ) 

144 readout_corner: ReadoutCorner = pydantic.Field( 

145 description=( 

146 "Readout corner of the amplifier in the raw image " 

147 "(with x increasing to the right and y increasing up)." 

148 ) 

149 ) 

150 

151 @property 

152 def horizontal_overscan_bbox(self) -> Box: 

153 """Bounding box of the serial (horizon) overscan region in the raw 

154 image (`.Box`). 

155 """ 

156 return self.serial_overscan_bbox 

157 

158 @horizontal_overscan_bbox.setter 

159 def horizontal_overscan_bbox(self, value: Box) -> None: 

160 self.serial_overscan_bbox = value 

161 

162 @property 

163 def vertical_overscan_bbox(self) -> Box: 

164 """Bounding box of the parallel (vertical) overscan region in the raw 

165 image (`.Box`). 

166 """ 

167 return self.parallel_overscan_bbox 

168 

169 @vertical_overscan_bbox.setter 

170 def vertical_overscan_bbox(self, value: Box) -> None: 

171 self.parallel_overscan_bbox = value 

172 

173 @property 

174 def horizontal_prescan_bbox(self) -> Box | None: 

175 """Bounding box of the serial (horizon) prescan region in the raw 

176 image (`.Box`). 

177 """ 

178 return self.prescan_bbox 

179 

180 @horizontal_prescan_bbox.setter 

181 def horizontal_prescan_bbox(self, value: Box | None) -> None: 

182 self.prescan_bbox = value 

183 

184 @property 

185 def serial_prescan_bbox(self) -> Box | None: 

186 """Bounding box of the serial (horizon) prescan region in the raw 

187 image (`.Box`). 

188 """ 

189 return self.prescan_bbox 

190 

191 @serial_prescan_bbox.setter 

192 def serial_prescan_bbox(self, value: Box | None) -> None: 

193 self.prescan_bbox = value 

194 

195 @staticmethod 

196 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierRawGeometry: 

197 """Convert from a `lsst.afw.cameraGeom.Amplifier`. 

198 

199 Parameters 

200 ---------- 

201 legacy_amplifier 

202 Legacy amplifier to convert. 

203 

204 Notes 

205 ----- 

206 `AmplifierRawGeometry.prescan_bbox` is `None` when the legacy 

207 amplifier reports an empty prescan region, since `.Box` requires a 

208 positive size. 

209 """ 

210 legacy_prescan_bbox = legacy_amplifier.getRawPrescanBBox() 

211 x_offset, y_offset = legacy_amplifier.getRawXYOffset() 

212 return AmplifierRawGeometry( 

213 bbox=Box.from_legacy(legacy_amplifier.getRawBBox()), 

214 data_bbox=Box.from_legacy(legacy_amplifier.getRawDataBBox()), 

215 flip_x=legacy_amplifier.getRawFlipX(), 

216 flip_y=legacy_amplifier.getRawFlipY(), 

217 x_offset=x_offset, 

218 y_offset=y_offset, 

219 serial_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawSerialOverscanBBox()), 

220 parallel_overscan_bbox=Box.from_legacy(legacy_amplifier.getRawParallelOverscanBBox()), 

221 prescan_bbox=(None if legacy_prescan_bbox.isEmpty() else Box.from_legacy(legacy_prescan_bbox)), 

222 readout_corner=ReadoutCorner.from_legacy(legacy_amplifier.getReadoutCorner()), 

223 ) 

224 

225 

226@final 

227class AmplifierCalibrations(pydantic.BaseModel, ser_json_inf_nan="constants"): 

228 """A struct that holds nominal information about an amplifier that is 

229 often superseded by separate calibration datasets. 

230 """ 

231 

232 gain: float 

233 read_noise: float 

234 saturation: float 

235 suspect_level: float 

236 linearity_coefficients: InlineArray 

237 linearity_type: str 

238 

239 def __eq__(self, other: object) -> bool: 

240 if type(other) is not AmplifierCalibrations: 240 ↛ 241line 240 didn't jump to line 241 because the condition on line 240 was never true

241 return NotImplemented 

242 # ``suspect_level`` is a float whose "unset" sentinel is ``NaN``; 

243 # treat NaN==NaN as equal here so a round-tripped calibration 

244 # block does not spuriously compare unequal to its source. 

245 return ( 

246 self.gain == other.gain 

247 and self.read_noise == other.read_noise 

248 and self.saturation == other.saturation 

249 and ( 

250 self.suspect_level == other.suspect_level 

251 or (np.isnan(self.suspect_level) and np.isnan(other.suspect_level)) 

252 ) 

253 and np.array_equal(self.linearity_coefficients, other.linearity_coefficients) 

254 and self.linearity_type == other.linearity_type 

255 ) 

256 

257 @staticmethod 

258 def from_legacy_amplifier(legacy_amplifier: LegacyAmplifier) -> AmplifierCalibrations: 

259 """Convert from a `lsst.afw.cameraGeom.Amplifier`. 

260 

261 Parameters 

262 ---------- 

263 legacy_amplifier 

264 Legacy amplifier to convert. 

265 """ 

266 return AmplifierCalibrations( 

267 gain=legacy_amplifier.getGain(), 

268 read_noise=legacy_amplifier.getReadNoise(), 

269 saturation=legacy_amplifier.getSaturation(), 

270 suspect_level=legacy_amplifier.getSuspectLevel(), 

271 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(), 

272 linearity_type=legacy_amplifier.getLinearityType(), 

273 ) 

274 

275 

276@final 

277class Amplifier(pydantic.BaseModel, ser_json_inf_nan="constants"): 

278 """A struct that holds information about an amplifier.""" 

279 

280 name: str = pydantic.Field(description="Name of the amplifier.") 

281 bbox: Box = pydantic.Field( 

282 description="Bounding box of the amplifier data region in a trimmed, assembled detector." 

283 ) 

284 readout_corner: ReadoutCorner = pydantic.Field( 

285 description=( 

286 "Readout corner of the amplifier in the final assembled, trimmed " 

287 "image (with x increasing to the right and y increasing up). " 

288 ) 

289 ) 

290 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

291 None, 

292 description=( 

293 "Geometry of this amplifier in an assembled but untrimmed raw image that has all amplifiers." 

294 ), 

295 ) 

296 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

297 None, 

298 description=( 

299 "Geometry of this amplifier in an unassembled, untrimmed raw image that has just this amplifier." 

300 ), 

301 ) 

302 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field( 

303 None, 

304 description=( 

305 "Nominal calibration information that may be superseded by separate calibration datasets." 

306 ), 

307 ) 

308 

309 def to_legacy_builder(self, is_raw_assembled: bool) -> LegacyAmplifier.Builder: 

310 """Convert to a `lsst.afw.cameraGeom.Amplifier.Builder`. 

311 

312 Parameters 

313 ---------- 

314 is_raw_assembled 

315 Whether to use `Amplifier.assembled_raw_geometry` (`True`) or 

316 `Amplifier.unassembled_raw_geometry` (`False`). If `None`, this 

317 is set to ``self.visit is not None``, since we expect to only add 

318 a visit ID to detectors that have been assembled. 

319 """ 

320 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

321 from lsst.geom import Extent2I 

322 

323 builder = LegacyAmplifier.Builder() 

324 builder.setName(self.name) 

325 builder.setBBox(self.bbox.to_legacy()) 

326 if is_raw_assembled: 

327 if (raw_geom := self.assembled_raw_geometry) is None: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true

328 raise ValueError( 

329 f"is_raw_assembled=True but assembled_raw_geometry is None for amp {self.name}." 

330 ) 

331 else: 

332 if (raw_geom := self.unassembled_raw_geometry) is None: 332 ↛ 339line 332 didn't jump to line 339 because the condition on line 332 was always true

333 raise ValueError( 

334 f"is_raw_assembled=False but unassembled_raw_geometry is None for amp {self.name}." 

335 ) 

336 # The afw readout corner definition corresponds to the image it is 

337 # attached to (which might be a raw), not the final trimmed image 

338 # (despite the docs, until a change on this ticket). 

339 builder.setReadoutCorner(raw_geom.readout_corner.to_legacy()) 

340 builder.setRawBBox(raw_geom.bbox.to_legacy()) 

341 builder.setRawDataBBox(raw_geom.data_bbox.to_legacy()) 

342 builder.setRawFlipX(raw_geom.flip_x) 

343 builder.setRawFlipY(raw_geom.flip_y) 

344 builder.setRawXYOffset(Extent2I(raw_geom.x_offset, raw_geom.y_offset)) 

345 builder.setRawSerialOverscanBBox(raw_geom.serial_overscan_bbox.to_legacy()) 

346 builder.setRawParallelOverscanBBox(raw_geom.parallel_overscan_bbox.to_legacy()) 

347 # An amplifier with no prescan region keeps the builder's own default, 

348 # the empty box afw uses to spell that. 

349 if raw_geom.prescan_bbox is not None: 

350 builder.setRawPrescanBBox(raw_geom.prescan_bbox.to_legacy()) 

351 if self.nominal_calibrations is not None: 351 ↛ 358line 351 didn't jump to line 358 because the condition on line 351 was always true

352 builder.setGain(self.nominal_calibrations.gain) 

353 builder.setReadNoise(self.nominal_calibrations.read_noise) 

354 builder.setSaturation(self.nominal_calibrations.saturation) 

355 builder.setSuspectLevel(self.nominal_calibrations.suspect_level) 

356 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients) 

357 builder.setLinearityType(self.nominal_calibrations.linearity_type) 

358 return builder 

359 

360 @staticmethod 

361 def from_legacy(legacy_amplifier: LegacyAmplifier, is_raw_assembled: bool) -> Amplifier: 

362 """Convert from a `lsst.afw.cameraGeom.Amplifier`. 

363 

364 Parameters 

365 ---------- 

366 legacy_amplifier 

367 Legacy amplifier to convert. 

368 is_raw_assembled 

369 Whether to populate `Amplifier.assembled_raw_geometry` (`True`) or 

370 `Amplifier.unassembled_raw_geometry` (`False`). 

371 """ 

372 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier) 

373 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier) 

374 readout_corner = raw_geometry.readout_corner.apply_flips(y=raw_geometry.flip_y, x=raw_geometry.flip_x) 

375 return Amplifier( 

376 name=legacy_amplifier.getName(), 

377 bbox=Box.from_legacy(legacy_amplifier.getBBox()), 

378 readout_corner=readout_corner, 

379 assembled_raw_geometry=raw_geometry if is_raw_assembled else None, 

380 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None, 

381 nominal_calibrations=nominal_calibrations, 

382 )