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

133 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 21:27 +0000

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 = pydantic.Field( 

138 description="Bounding box of the serial (horizontal) pre-scan region in the raw image." 

139 ) 

140 readout_corner: ReadoutCorner = pydantic.Field( 

141 description=( 

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

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

144 ) 

145 ) 

146 

147 @property 

148 def horizontal_overscan_bbox(self) -> Box: 

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

150 image (`.Box`). 

151 """ 

152 return self.serial_overscan_bbox 

153 

154 @horizontal_overscan_bbox.setter 

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

156 self.serial_overscan_bbox = value 

157 

158 @property 

159 def vertical_overscan_bbox(self) -> Box: 

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

161 image (`.Box`). 

162 """ 

163 return self.parallel_overscan_bbox 

164 

165 @vertical_overscan_bbox.setter 

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

167 self.parallel_overscan_bbox = value 

168 

169 @property 

170 def horizontal_prescan_bbox(self) -> Box: 

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

172 image (`.Box`). 

173 """ 

174 return self.prescan_bbox 

175 

176 @horizontal_prescan_bbox.setter 

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

178 self.prescan_bbox = value 

179 

180 @property 

181 def serial_prescan_bbox(self) -> Box: 

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

183 image (`.Box`). 

184 """ 

185 return self.prescan_bbox 

186 

187 @serial_prescan_bbox.setter 

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

189 self.prescan_bbox = value 

190 

191 @staticmethod 

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

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

194 

195 Parameters 

196 ---------- 

197 legacy_amplifier 

198 Legacy amplifier to convert. 

199 """ 

200 x_offset, y_offset = legacy_amplifier.getRawXYOffset() 

201 return AmplifierRawGeometry( 

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

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

204 flip_x=legacy_amplifier.getRawFlipX(), 

205 flip_y=legacy_amplifier.getRawFlipY(), 

206 x_offset=x_offset, 

207 y_offset=y_offset, 

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

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

210 prescan_bbox=Box.from_legacy(legacy_amplifier.getRawPrescanBBox()), 

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

212 ) 

213 

214 

215@final 

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

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

218 often superseded by separate calibration datasets. 

219 """ 

220 

221 gain: float 

222 read_noise: float 

223 saturation: float 

224 suspect_level: float 

225 linearity_coefficients: InlineArray 

226 linearity_type: str 

227 

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

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

230 return NotImplemented 

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

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

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

234 return ( 

235 self.gain == other.gain 

236 and self.read_noise == other.read_noise 

237 and self.saturation == other.saturation 

238 and ( 

239 self.suspect_level == other.suspect_level 

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

241 ) 

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

243 and self.linearity_type == other.linearity_type 

244 ) 

245 

246 @staticmethod 

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

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

249 

250 Parameters 

251 ---------- 

252 legacy_amplifier 

253 Legacy amplifier to convert. 

254 """ 

255 return AmplifierCalibrations( 

256 gain=legacy_amplifier.getGain(), 

257 read_noise=legacy_amplifier.getReadNoise(), 

258 saturation=legacy_amplifier.getSaturation(), 

259 suspect_level=legacy_amplifier.getSuspectLevel(), 

260 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(), 

261 linearity_type=legacy_amplifier.getLinearityType(), 

262 ) 

263 

264 

265@final 

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

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

268 

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

270 bbox: Box = pydantic.Field( 

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

272 ) 

273 readout_corner: ReadoutCorner = pydantic.Field( 

274 description=( 

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

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

277 ) 

278 ) 

279 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

280 None, 

281 description=( 

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

283 ), 

284 ) 

285 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

286 None, 

287 description=( 

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

289 ), 

290 ) 

291 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field( 

292 None, 

293 description=( 

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

295 ), 

296 ) 

297 

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

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

300 

301 Parameters 

302 ---------- 

303 is_raw_assembled 

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

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

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

307 a visit ID to detectors that have been assembled. 

308 """ 

309 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

310 from lsst.geom import Extent2I 

311 

312 builder = LegacyAmplifier.Builder() 

313 builder.setName(self.name) 

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

315 if is_raw_assembled: 

316 if (raw_geom := self.assembled_raw_geometry) is None: 

317 raise ValueError( 

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

319 ) 

320 else: 

321 if (raw_geom := self.unassembled_raw_geometry) is None: 

322 raise ValueError( 

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

324 ) 

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

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

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

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

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

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

331 builder.setRawFlipX(raw_geom.flip_x) 

332 builder.setRawFlipY(raw_geom.flip_y) 

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

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

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

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

337 if self.nominal_calibrations is not None: 

338 builder.setGain(self.nominal_calibrations.gain) 

339 builder.setReadNoise(self.nominal_calibrations.read_noise) 

340 builder.setSaturation(self.nominal_calibrations.saturation) 

341 builder.setSuspectLevel(self.nominal_calibrations.suspect_level) 

342 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients) 

343 builder.setLinearityType(self.nominal_calibrations.linearity_type) 

344 return builder 

345 

346 @staticmethod 

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

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

349 

350 Parameters 

351 ---------- 

352 legacy_amplifier 

353 Legacy amplifier to convert. 

354 is_raw_assembled 

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

356 `Amplifier.unassembled_raw_geometry` (`False`). 

357 """ 

358 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier) 

359 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier) 

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

361 return Amplifier( 

362 name=legacy_amplifier.getName(), 

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

364 readout_corner=readout_corner, 

365 assembled_raw_geometry=raw_geometry if is_raw_assembled else None, 

366 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None, 

367 nominal_calibrations=nominal_calibrations, 

368 )