Coverage for python/lsst/images/cameras/_detector.py: 38%

170 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-24 09:07 +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 "Detector", 

15 "DetectorAttributes", 

16 "DetectorSerializationModel", 

17 "DetectorType", 

18 "Orientation", 

19) 

20 

21import builtins 

22import enum 

23from collections.abc import Iterable 

24from typing import TYPE_CHECKING, Any, ClassVar, final 

25 

26import astropy.units 

27import numpy as np 

28import pydantic 

29 

30from .._geom import Box 

31from .._transforms import ( 

32 DetectorFrame, 

33 FieldAngleFrame, 

34 FocalPlaneFrame, 

35 Transform, 

36) 

37from ..describe import DescribableMixin, DescribeOptions, FieldRole, Report, ReportField 

38from ..serialization import ( 

39 ArchiveReadError, 

40 ArchiveTree, 

41 InputArchive, 

42 InvalidParameterError, 

43 OutputArchive, 

44 Quantity, 

45) 

46from ._amplifier import Amplifier 

47from ._camera_frame_set import CameraFrameSet, CameraFrameSetSerializationModel 

48 

49if TYPE_CHECKING: 

50 try: 

51 from lsst.afw.cameraGeom import Detector as LegacyDetector 

52 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType 

53 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

54 except ImportError: 

55 type LegacyDetector = Any # type: ignore[no-redef] 

56 type LegacyDetectorType = Any # type: ignore[no-redef] 

57 type LegacyOrientation = Any # type: ignore[no-redef] 

58 

59 

60class DetectorType(enum.StrEnum): 

61 """Enumeration of the types of a detector.""" 

62 

63 SCIENCE = "SCIENCE" 

64 FOCUS = "FOCUS" 

65 GUIDER = "GUIDER" 

66 WAVEFRONT = "WAVEFRONT" 

67 

68 def to_legacy(self) -> LegacyDetectorType: 

69 """Convert to `lsst.afw.cameraGeom.DetectorType`.""" 

70 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType 

71 

72 return getattr(LegacyDetectorType, self.value) 

73 

74 @classmethod 

75 def from_legacy(cls, legacy_detector_type: LegacyDetectorType) -> DetectorType: 

76 """Convert from `lsst.afw.cameraGeom.DetectorType`. 

77 

78 Parameters 

79 ---------- 

80 legacy_detector_type 

81 Legacy detector type to convert. 

82 """ 

83 return getattr(cls, legacy_detector_type.name) 

84 

85 

86@final 

87class Orientation(pydantic.BaseModel, ser_json_inf_nan="constants"): 

88 """A struct that represents the nominal position and rotation of a 

89 detector within a camera focal plane. 

90 """ 

91 

92 focal_plane_x: float = pydantic.Field(description="Focal plane X coordinate of the reference position.") 

93 focal_plane_y: float = pydantic.Field(description="Focal plane Y coordinate of the reference position.") 

94 focal_plane_z: float = pydantic.Field(description="Focal plane Z coordinate of the reference position.") 

95 pixel_reference_x: float = pydantic.Field(0.5, description="Pixel X coordinate of the reference point.") 

96 pixel_reference_y: float = pydantic.Field(0.5, description="Pixel Y coordinate of the reference point.") 

97 yaw: Quantity = pydantic.Field( 

98 default_factory=lambda: 0.0 * astropy.units.radian, 

99 description="Rotation about the Z axis.", 

100 ) 

101 pitch: Quantity = pydantic.Field( 

102 default_factory=lambda: 0.0 * astropy.units.radian, 

103 description="Rotation about the Y axis (as defined after applying 'yaw').", 

104 ) 

105 roll: Quantity = pydantic.Field( 

106 default_factory=lambda: 0.0 * astropy.units.radian, 

107 description="Rotation about the X axis (as defined after applying 'yaw' and 'pitch').", 

108 ) 

109 

110 def to_legacy(self) -> LegacyOrientation: 

111 """Convert to `lsst.afw.cameraGeom.Orientation`.""" 

112 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

113 from lsst.geom import Point2D, Point3D, radians 

114 

115 return LegacyOrientation( 

116 Point3D(self.focal_plane_x, self.focal_plane_y, self.focal_plane_z), 

117 Point2D(self.pixel_reference_x, self.pixel_reference_y), 

118 self.yaw.to_value(astropy.units.radian) * radians, 

119 self.pitch.to_value(astropy.units.radian) * radians, 

120 self.roll.to_value(astropy.units.radian) * radians, 

121 ) 

122 

123 @staticmethod 

124 def from_legacy(legacy_orientation: LegacyOrientation) -> Orientation: 

125 """Convert from `lsst.afw.cameraGeom.Orientation`. 

126 

127 Parameters 

128 ---------- 

129 legacy_orientation 

130 Legacy orientation to convert. 

131 """ 

132 focal_plane_x, focal_plane_y, focal_plane_z = legacy_orientation.getFpPosition3() 

133 pixel_reference_x, pixel_reference_y = legacy_orientation.getReferencePoint() 

134 return Orientation( 

135 focal_plane_x=focal_plane_x, 

136 focal_plane_y=focal_plane_y, 

137 focal_plane_z=focal_plane_z, 

138 pixel_reference_x=pixel_reference_x, 

139 pixel_reference_y=pixel_reference_y, 

140 yaw=legacy_orientation.getYaw().asRadians() * astropy.units.radian, 

141 pitch=legacy_orientation.getPitch().asRadians() * astropy.units.radian, 

142 roll=legacy_orientation.getRoll().asRadians() * astropy.units.radian, 

143 ) 

144 

145 

146@final 

147class DetectorAttributes(pydantic.BaseModel, ser_json_inf_nan="constants"): 

148 """Struct holding the plain-old-data attributes of a detector.""" 

149 

150 name: str = pydantic.Field(description="Name of the detector.") 

151 id: int = pydantic.Field(description="ID of the detector.") 

152 type: DetectorType = pydantic.Field(description="Enumerated type of the detector.") 

153 serial: str = pydantic.Field(description="Serial number for the detector.") 

154 bbox: Box = pydantic.Field( 

155 description="Bounding box of the detector's science data region after amplifier assembly." 

156 ) 

157 orientation: Orientation = pydantic.Field(description="Nominal position and rotation of the detector.") 

158 pixel_size: float = pydantic.Field( 

159 description="Nominal size of a pixel (assumed square) in focal plane coordinate units." 

160 ) 

161 physical_type: str = pydantic.Field( 

162 description=( 

163 "Vendor name or technology type for this detector " 

164 "(may have a different interpretation for different cameras)." 

165 ) 

166 ) 

167 

168 

169@final 

170class Detector(DescribableMixin): 

171 """Information about a detector in a camera. 

172 

173 Parameters 

174 ---------- 

175 attributes 

176 Identifying attributes and metadata for the detector. 

177 amplifiers 

178 Amplifiers that make up the detector. 

179 frames 

180 Coordinate systems and transforms for the camera. 

181 visit 

182 Visit number whose geometry to use, or `None` for the nominal 

183 detector geometry. 

184 """ 

185 

186 def __init__( 

187 self, 

188 attributes: DetectorAttributes, 

189 amplifiers: Iterable[Amplifier], 

190 frames: CameraFrameSet, 

191 visit: int | None = None, 

192 ) -> None: 

193 self._attributes = attributes 

194 self._amplifiers = list(amplifiers) 

195 self._frames = frames 

196 self._frame = frames.detector(attributes.id, visit=visit) 

197 

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

199 if type(other) is not Detector: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 return NotImplemented 

201 return ( 

202 self._attributes == other._attributes 

203 and self._amplifiers == other._amplifiers 

204 and self._frames == other._frames 

205 and self.visit == other.visit 

206 ) 

207 

208 __hash__ = None # type: ignore[assignment] 

209 

210 @property 

211 def instrument(self) -> str: 

212 """The name of the instrument this detector belongs to (`str`).""" 

213 return self._frame.instrument 

214 

215 @property 

216 def visit(self) -> int | None: 

217 """The ID of the visit this detector is associated with (`int` or 

218 `None`). 

219 """ 

220 return self._frame.visit 

221 

222 @property 

223 def name(self) -> str: 

224 """Name of the detector (`str`).""" 

225 return self._attributes.name 

226 

227 @property 

228 def id(self) -> int: 

229 """ID of the detector (`int`).""" 

230 return self._attributes.id 

231 

232 @property 

233 def type(self) -> DetectorType: 

234 """Enumerated type of the detector (`DetectorType`).""" 

235 return self._attributes.type 

236 

237 @property 

238 def serial(self) -> str: 

239 """Serial number for the detector (`str`).""" 

240 return self._attributes.serial 

241 

242 @property 

243 def bbox(self) -> Box: 

244 """Bounding box of the detector's science data region after amplifier 

245 assembly (`.Box`). 

246 """ 

247 return self._attributes.bbox 

248 

249 @property 

250 def orientation(self) -> Orientation: 

251 """Nominal position and rotation of the detector 

252 (`Orientation`). 

253 """ 

254 return self._attributes.orientation 

255 

256 @property 

257 def pixel_size(self) -> float: 

258 """Nominal size of a pixel (assumed square) in focal plane coordinate 

259 units (`float`). 

260 """ 

261 return self._attributes.pixel_size 

262 

263 @property 

264 def physical_type(self) -> str: 

265 """Vendor name or technology type for this detector (`str`). 

266 

267 This may have a different interpretation for different cameras. 

268 """ 

269 return self._attributes.physical_type 

270 

271 @property 

272 def frame(self) -> DetectorFrame: 

273 """The coordinate system of this detector's trimmed, assembled pixel 

274 grid (`.DetectorFrame`). 

275 """ 

276 return self._frame 

277 

278 @property 

279 def to_focal_plane(self) -> Transform[DetectorFrame, FocalPlaneFrame]: 

280 """The transform from pixels to focal-plane coordinates 

281 (`.Transform` [`.DetectorFrame`, `.FocalPlaneFrame`]). 

282 """ 

283 return self._frames[self._frame, self._frames.focal_plane(self.visit)] 

284 

285 @property 

286 def to_field_angle(self) -> Transform[DetectorFrame, FieldAngleFrame]: 

287 """The transform from pixels to field angle coordinates 

288 (`.Transform` [`.DetectorFrame`, `.FieldAngleFrame`]). 

289 """ 

290 return self._frames[self._frame, self._frames.field_angle(self.visit)] 

291 

292 @property 

293 def amplifiers(self) -> list[Amplifier]: 

294 """The amplifiers of this detectors (`list` [`Amplifier`]).""" 

295 return self._amplifiers 

296 

297 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

298 """Return a `Report` describing this detector. 

299 

300 Parameters 

301 ---------- 

302 options : `DescribeOptions`, optional 

303 Unused; accepted for interface compatibility. 

304 """ 

305 return Report( 

306 type_name="Detector", 

307 summary=f"Detector {self.name!r} ({self.instrument})", 

308 fields=[ 

309 ReportField(label="instrument", value=self.instrument, role=FieldRole.DERIVED), 

310 ReportField(label="name", value=self.name, role=FieldRole.DERIVED), 

311 ReportField(label="id", value=self.id, role=FieldRole.DERIVED), 

312 ReportField(label="type", value=self.type, role=FieldRole.DERIVED), 

313 ReportField(label="serial", value=self.serial, role=FieldRole.DERIVED), 

314 ReportField(label="bbox", value=self.bbox, role=FieldRole.DERIVED), 

315 ], 

316 ) 

317 

318 def copy(self) -> Detector: 

319 """Copy the detector. 

320 

321 This deep-copies all data fields and amplifiers, but only 

322 shallow-copies the internal `.CameraFrameSet`, as that's conceptually 

323 immutable. 

324 """ 

325 return Detector( 

326 self._attributes.model_copy(deep=True), 

327 amplifiers=[a.model_copy(deep=True) for a in self._amplifiers], 

328 frames=self._frames, 

329 ) 

330 

331 def serialize(self, archive: OutputArchive[Any], save_frames: bool = True) -> DetectorSerializationModel: 

332 """Serialize this detector to an archive. 

333 

334 Parameters 

335 ---------- 

336 archive 

337 Archive to save to. 

338 save_frames 

339 Whether to save the `.CameraFrameSet` held by this detector. This 

340 allows the frame set to be saved once for multiple detectors when 

341 they are part of a multi-detector object. 

342 """ 

343 return DetectorSerializationModel( 

344 attributes=self._attributes, 

345 amplifiers=self._amplifiers, 

346 frames=archive.serialize_direct("frames", self._frames.serialize) if save_frames else None, 

347 visit=self.visit, 

348 ) 

349 

350 @staticmethod 

351 def _get_archive_tree_type( 

352 pointer_type: builtins.type[Any], 

353 ) -> builtins.type[DetectorSerializationModel]: 

354 """Return the serialization model type for this object for an archive 

355 type that uses the given pointer type. 

356 """ 

357 return DetectorSerializationModel 

358 

359 def to_legacy(self, *, is_raw_assembled: bool | None = None) -> LegacyDetector: 

360 """Convert to a legacy `lsst.afw.cameraGeom.Detector` instance. 

361 

362 Parameters 

363 ---------- 

364 is_raw_assembled 

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

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

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

368 a visit ID to detectors that have been assembled. 

369 """ 

370 from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, Camera 

371 from lsst.geom import Extent2D, Point2D 

372 

373 if is_raw_assembled is None: 373 ↛ 377line 373 didn't jump to line 377 because the condition on line 373 was always true

374 is_raw_assembled = self.visit is not None 

375 # Legacy Detectors can only be built from scratch as a part of a 

376 # camera. 

377 camera_builder = Camera.Builder(self.name) 

378 fp_to_fa = self._frames[self._frames.focal_plane(), self._frames.field_angle()] 

379 legacy_fp_to_fa = fp_to_fa.to_legacy() 

380 camera_builder.setFocalPlaneParity(np.linalg.det(legacy_fp_to_fa.getJacobian(Point2D(0.0, 0.0))) < 0) 

381 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa) 

382 detector_builder = camera_builder.add(self.name, self.id) 

383 detector_builder.setBBox(self.bbox.to_legacy()) 

384 detector_builder.setType(self.type.to_legacy()) 

385 detector_builder.setSerial(self.serial) 

386 detector_builder.setPhysicalType(self.physical_type) 

387 detector_builder.setOrientation(self.orientation.to_legacy()) 

388 detector_builder.setPixelSize(Extent2D(self.pixel_size, self.pixel_size)) 

389 detector_builder.setTransformFromPixelsTo(FOCAL_PLANE, self.to_focal_plane.to_legacy()) 

390 for amp in self.amplifiers: 

391 try: 

392 detector_builder.append(amp.to_legacy_builder(is_raw_assembled)) 

393 except Exception as err: 

394 err.add_note(f"On detector {self.id}/{self.name}.") 

395 raise 

396 camera = camera_builder.finish() 

397 return camera[self.id] 

398 

399 @staticmethod 

400 def from_legacy( 

401 legacy_detector: LegacyDetector, 

402 *, 

403 instrument: str, 

404 visit: int | None = None, 

405 is_raw_assembled: bool | None = None, 

406 ) -> Detector: 

407 """Convert from a legacy `lsst.afw.cameraGeom.Detector` instance. 

408 

409 Parameters 

410 ---------- 

411 legacy_detector 

412 Legacy detector to convert. 

413 instrument 

414 Name of the instrument this detector belongs to. 

415 visit 

416 Visit ID, if this camera geometry can be associated with a 

417 particular visit. 

418 is_raw_assembled 

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

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

421 is set to ``visit is not None``, since we expect to only add 

422 a visit ID to detectors that have been assembled. 

423 """ 

424 if is_raw_assembled is None: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true

425 is_raw_assembled = visit is not None 

426 attributes = DetectorAttributes( 

427 name=legacy_detector.getName(), 

428 id=legacy_detector.getId(), 

429 type=DetectorType.from_legacy(legacy_detector.getType()), 

430 bbox=Box.from_legacy(legacy_detector.getBBox()), 

431 serial=legacy_detector.getSerial(), 

432 orientation=Orientation.from_legacy(legacy_detector.getOrientation()), 

433 pixel_size=legacy_detector.getPixelSize().getX(), 

434 physical_type=legacy_detector.getPhysicalType(), 

435 ) 

436 amplifiers = [ 

437 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled) 

438 for legacy_amp in legacy_detector.getAmplifiers() 

439 ] 

440 transform_map = legacy_detector.getTransformMap() 

441 frames = CameraFrameSet(instrument, transform_map.makeFrameSet([legacy_detector])) 

442 return Detector(attributes, amplifiers, frames, visit=visit) 

443 

444 

445class DetectorSerializationModel(ArchiveTree): 

446 """Serialization model for `Detector`.""" 

447 

448 SCHEMA_NAME: ClassVar[str] = "detector" 

449 SCHEMA_VERSION: ClassVar[str] = "1.1.0" 

450 MIN_READ_VERSION: ClassVar[int] = 1 

451 PUBLIC_TYPE: ClassVar[type] = Detector 

452 

453 attributes: DetectorAttributes = pydantic.Field( 

454 description="The simple plain-old-data attributes of the detector." 

455 ) 

456 

457 amplifiers: list[Amplifier] = pydantic.Field( 

458 default_factory=list, 

459 description="Descriptions of the amplifiers.", 

460 ) 

461 

462 frames: CameraFrameSetSerializationModel | None = pydantic.Field( 

463 default=None, description="Mappings to other camera coordinate systems." 

464 ) 

465 

466 visit: int | None = pydantic.Field(description="ID of the visit this detector is associated with.") 

467 

468 def deserialize( 

469 self, archive: InputArchive[Any], frames: CameraFrameSet | None = None, **kwargs: Any 

470 ) -> Detector: 

471 """Deserialize this detector from an archive. 

472 

473 Parameters 

474 ---------- 

475 archive 

476 Serialization model instance for this detector. 

477 frames 

478 Coordinate systems and transforms to use instead of what is saved 

479 in ``model``. Must be provided if ``model.frames`` is `None`. 

480 **kwargs 

481 Unsupported keyword arguments are accepted only to provide 

482 better error messages (raising 

483 `.serialization.InvalidParameterError`). 

484 """ 

485 if kwargs: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true

486 raise InvalidParameterError(f"Unrecognized parameters for Detector: {set(kwargs.keys())}.") 

487 if frames is None: 487 ↛ 494line 487 didn't jump to line 494 because the condition on line 487 was always true

488 if self.frames is None: 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true

489 raise ArchiveReadError( 

490 "Serialized detector did not include coordinate transforms, " 

491 "and 'frames' was not provided." 

492 ) 

493 frames = self.frames.deserialize(archive) 

494 return Detector(self.attributes, self.amplifiers, frames, visit=self.visit)