Coverage for python/lsst/images/cameras.py: 48%

286 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-05-29 08:40 +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 "Detector", 

18 "DetectorAttributes", 

19 "DetectorSerializationModel", 

20 "DetectorType", 

21 "Orientation", 

22 "ReadoutCorner", 

23) 

24 

25import builtins 

26import enum 

27from collections.abc import Iterable 

28from typing import TYPE_CHECKING, Any, final 

29 

30import astropy.units 

31import numpy as np 

32import pydantic 

33 

34from ._geom import YX, Box 

35from ._transforms import ( 

36 CameraFrameSet, 

37 CameraFrameSetSerializationModel, 

38 DetectorFrame, 

39 FieldAngleFrame, 

40 FocalPlaneFrame, 

41 Transform, 

42) 

43from .serialization import ( 

44 ArchiveReadError, 

45 ArchiveTree, 

46 InlineArray, 

47 InputArchive, 

48 InvalidParameterError, 

49 OutputArchive, 

50 Quantity, 

51) 

52 

53if TYPE_CHECKING: 

54 try: 

55 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

56 from lsst.afw.cameraGeom import Detector as LegacyDetector 

57 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType 

58 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

59 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

60 except ImportError: 

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

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

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

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

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

66 

67 

68class DetectorType(enum.StrEnum): 

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

70 

71 SCIENCE = "SCIENCE" 

72 FOCUS = "FOCUS" 

73 GUIDER = "GUIDER" 

74 WAVEFRONT = "WAVEFRONT" 

75 

76 def to_legacy(self) -> LegacyDetectorType: 

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

78 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType 

79 

80 return getattr(LegacyDetectorType, self.value) 

81 

82 @classmethod 

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

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

85 return getattr(cls, legacy_detector_type.name) 

86 

87 

88@final 

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

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

91 detector within a camera focal plane. 

92 """ 

93 

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

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

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

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

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

99 yaw: Quantity = pydantic.Field( 

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

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

102 ) 

103 pitch: Quantity = pydantic.Field( 

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

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

106 ) 

107 roll: Quantity = pydantic.Field( 

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

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

110 ) 

111 

112 def to_legacy(self) -> LegacyOrientation: 

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

114 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

115 from lsst.geom import Point2D, Point3D, radians 

116 

117 return LegacyOrientation( 

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

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

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

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

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

123 ) 

124 

125 @staticmethod 

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

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

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

129 pixel_reference_x, pixel_reference_y = legacy_orientation.getReferencePoint() 

130 return Orientation( 

131 focal_plane_x=focal_plane_x, 

132 focal_plane_y=focal_plane_y, 

133 focal_plane_z=focal_plane_z, 

134 pixel_reference_x=pixel_reference_x, 

135 pixel_reference_y=pixel_reference_y, 

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

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

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

139 ) 

140 

141 

142@final 

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

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

145 

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

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

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

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

150 bbox: Box = pydantic.Field( 

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

152 ) 

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

154 pixel_size: float = pydantic.Field( 

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

156 ) 

157 physical_type: str = pydantic.Field( 

158 description=( 

159 "Vendor name or technology type for this detector " 

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

161 ) 

162 ) 

163 

164 

165class ReadoutCorner(enum.StrEnum): 

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

167 

168 LL = "LL" 

169 LR = "LR" 

170 UR = "UR" 

171 UL = "UL" 

172 

173 def to_legacy(self) -> LegacyReadoutCorner: 

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

175 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

176 

177 return getattr(LegacyReadoutCorner, self.value) 

178 

179 @classmethod 

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

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

182 return getattr(cls, legacy_readout_corner.name) 

183 

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

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

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

187 """ 

188 return YX( 

189 y=self is ReadoutCorner.LL or self is ReadoutCorner.LR, 

190 x=self is ReadoutCorner.UR or self is ReadoutCorner.UR, 

191 ) 

192 

193 @classmethod 

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

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

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

197 """ 

198 match y, x: 

199 case False, False: 

200 return cls.LL 

201 case False, True: 

202 return cls.LR 

203 case True, True: 

204 return cls.UR 

205 case True, False: 

206 return cls.UL 

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

208 

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

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

211 current = self.as_flips() 

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

213 

214 

215@final 

216class AmplifierRawGeometry(pydantic.BaseModel): 

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

218 

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

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

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

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

223 x_offset: int = pydantic.Field( 

224 0, 

225 description=( 

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

227 "assembled position of the amplifier." 

228 ), 

229 ) 

230 y_offset: int = pydantic.Field( 

231 0, 

232 description=( 

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

234 "assembled position of the amplifier." 

235 ), 

236 ) 

237 serial_overscan_bbox: Box = pydantic.Field( 

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

239 ) 

240 parallel_overscan_bbox: Box = pydantic.Field( 

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

242 ) 

243 prescan_bbox: Box = pydantic.Field( 

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

245 ) 

246 readout_corner: ReadoutCorner = pydantic.Field( 

247 description=( 

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

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

250 ) 

251 ) 

252 

253 @property 

254 def horizontal_overscan_bbox(self) -> Box: 

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

256 image (`.Box`). 

257 """ 

258 return self.serial_overscan_bbox 

259 

260 @horizontal_overscan_bbox.setter 

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

262 self.serial_overscan_bbox = value 

263 

264 @property 

265 def vertical_overscan_bbox(self) -> Box: 

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

267 image (`.Box`). 

268 """ 

269 return self.parallel_overscan_bbox 

270 

271 @vertical_overscan_bbox.setter 

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

273 self.parallel_overscan_bbox = value 

274 

275 @property 

276 def horizontal_prescan_bbox(self) -> Box: 

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

278 image (`.Box`). 

279 """ 

280 return self.prescan_bbox 

281 

282 @horizontal_prescan_bbox.setter 

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

284 self.prescan_bbox = value 

285 

286 @property 

287 def serial_prescan_bbox(self) -> Box: 

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

289 image (`.Box`). 

290 """ 

291 return self.prescan_bbox 

292 

293 @serial_prescan_bbox.setter 

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

295 self.prescan_bbox = value 

296 

297 @staticmethod 

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

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

300 

301 Parameters 

302 ---------- 

303 legacy_amplifier 

304 Legacy amplifier to convert. 

305 """ 

306 x_offset, y_offset = legacy_amplifier.getRawXYOffset() 

307 return AmplifierRawGeometry( 

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

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

310 flip_x=legacy_amplifier.getRawFlipX(), 

311 flip_y=legacy_amplifier.getRawFlipY(), 

312 x_offset=x_offset, 

313 y_offset=y_offset, 

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

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

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

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

318 ) 

319 

320 

321@final 

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

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

324 often superseded by separate calibration datasets. 

325 """ 

326 

327 gain: float 

328 read_noise: float 

329 saturation: float 

330 suspect_level: float 

331 linearity_coefficients: InlineArray 

332 linearity_type: str 

333 

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

335 if type(other) is not AmplifierCalibrations: 

336 return NotImplemented 

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

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

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

340 return ( 

341 self.gain == other.gain 

342 and self.read_noise == other.read_noise 

343 and self.saturation == other.saturation 

344 and ( 

345 self.suspect_level == other.suspect_level 

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

347 ) 

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

349 and self.linearity_type == other.linearity_type 

350 ) 

351 

352 @staticmethod 

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

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

355 

356 Parameters 

357 ---------- 

358 legacy_amplifier 

359 Legacy amplifier to convert. 

360 """ 

361 return AmplifierCalibrations( 

362 gain=legacy_amplifier.getGain(), 

363 read_noise=legacy_amplifier.getReadNoise(), 

364 saturation=legacy_amplifier.getSaturation(), 

365 suspect_level=legacy_amplifier.getSuspectLevel(), 

366 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(), 

367 linearity_type=legacy_amplifier.getLinearityType(), 

368 ) 

369 

370 

371@final 

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

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

374 

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

376 bbox: Box = pydantic.Field( 

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

378 ) 

379 readout_corner: ReadoutCorner = pydantic.Field( 

380 description=( 

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

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

383 ) 

384 ) 

385 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

386 None, 

387 description=( 

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

389 ), 

390 ) 

391 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

392 None, 

393 description=( 

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

395 ), 

396 ) 

397 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field( 

398 None, 

399 description=( 

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

401 ), 

402 ) 

403 

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

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

406 

407 Parameters 

408 ---------- 

409 is_raw_assembled 

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

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

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

413 a visit ID to detectors that have been assembled. 

414 """ 

415 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

416 from lsst.geom import Extent2I 

417 

418 builder = LegacyAmplifier.Builder() 

419 builder.setName(self.name) 

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

421 if is_raw_assembled: 

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

423 raise ValueError( 

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

425 ) 

426 else: 

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

428 raise ValueError( 

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

430 ) 

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

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

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

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

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

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

437 builder.setRawFlipX(raw_geom.flip_x) 

438 builder.setRawFlipY(raw_geom.flip_y) 

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

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

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

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

443 if self.nominal_calibrations is not None: 

444 builder.setGain(self.nominal_calibrations.gain) 

445 builder.setReadNoise(self.nominal_calibrations.read_noise) 

446 builder.setSaturation(self.nominal_calibrations.saturation) 

447 builder.setSuspectLevel(self.nominal_calibrations.suspect_level) 

448 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients) 

449 builder.setLinearityType(self.nominal_calibrations.linearity_type) 

450 return builder 

451 

452 @staticmethod 

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

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

455 

456 Parameters 

457 ---------- 

458 legacy_amplifier 

459 Legacy amplifier to convert. 

460 is_raw_assembled 

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

462 `Amplifier.unassembled_raw_geometry` (`False`). 

463 """ 

464 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier) 

465 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier) 

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

467 return Amplifier( 

468 name=legacy_amplifier.getName(), 

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

470 readout_corner=readout_corner, 

471 assembled_raw_geometry=raw_geometry if is_raw_assembled else None, 

472 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None, 

473 nominal_calibrations=nominal_calibrations, 

474 ) 

475 

476 

477@final 

478class Detector: 

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

480 

481 def __init__( 

482 self, 

483 attributes: DetectorAttributes, 

484 amplifiers: Iterable[Amplifier], 

485 frames: CameraFrameSet, 

486 visit: int | None = None, 

487 ): 

488 self._attributes = attributes 

489 self._amplifiers = list(amplifiers) 

490 self._frames = frames 

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

492 

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

494 if type(other) is not Detector: 

495 return NotImplemented 

496 return ( 

497 self._attributes == other._attributes 

498 and self._amplifiers == other._amplifiers 

499 and self._frames == other._frames 

500 and self.visit == other.visit 

501 ) 

502 

503 __hash__ = None # type: ignore[assignment] 

504 

505 @property 

506 def instrument(self) -> str: 

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

508 return self._frame.instrument 

509 

510 @property 

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

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

513 `None`). 

514 """ 

515 return self._frame.visit 

516 

517 @property 

518 def name(self) -> str: 

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

520 return self._attributes.name 

521 

522 @property 

523 def id(self) -> int: 

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

525 return self._attributes.id 

526 

527 @property 

528 def type(self) -> DetectorType: 

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

530 return self._attributes.type 

531 

532 @property 

533 def serial(self) -> str: 

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

535 return self._attributes.serial 

536 

537 @property 

538 def bbox(self) -> Box: 

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

540 assembly (`.Box`). 

541 """ 

542 return self._attributes.bbox 

543 

544 @property 

545 def orientation(self) -> Orientation: 

546 """Nominal position and rotation of the detector 

547 (`Orientation`). 

548 """ 

549 return self._attributes.orientation 

550 

551 @property 

552 def pixel_size(self) -> float: 

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

554 units (`float`). 

555 """ 

556 return self._attributes.pixel_size 

557 

558 @property 

559 def physical_type(self) -> str: 

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

561 

562 This may have a different interpretation for different cameras. 

563 """ 

564 return self._attributes.physical_type 

565 

566 @property 

567 def frame(self) -> DetectorFrame: 

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

569 grid (`.DetectorFrame`). 

570 """ 

571 return self._frame 

572 

573 @property 

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

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

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

577 """ 

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

579 

580 @property 

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

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

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

584 """ 

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

586 

587 @property 

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

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

590 return self._amplifiers 

591 

592 def copy(self) -> Detector: 

593 """Copy the detector. 

594 

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

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

597 immutable. 

598 """ 

599 return Detector( 

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

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

602 frames=self._frames, 

603 ) 

604 

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

606 """Serialize this detector to an archive. 

607 

608 Parameters 

609 ---------- 

610 archive 

611 Archive to save to. 

612 save_frames 

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

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

615 they are part of a multi-detector object. 

616 """ 

617 return DetectorSerializationModel( 

618 attributes=self._attributes, 

619 amplifiers=self._amplifiers, 

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

621 visit=self.visit, 

622 ) 

623 

624 @staticmethod 

625 def _get_archive_tree_type( 

626 pointer_type: builtins.type[Any], 

627 ) -> builtins.type[DetectorSerializationModel]: 

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

629 type that uses the given pointer type. 

630 """ 

631 return DetectorSerializationModel # type: ignore 

632 

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

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

635 

636 Parameters 

637 ---------- 

638 is_raw_assembled 

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

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

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

642 a visit ID to detectors that have been assembled. 

643 """ 

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

645 from lsst.geom import Extent2D, Point2D 

646 

647 if is_raw_assembled is None: 

648 is_raw_assembled = self.visit is not None 

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

650 # camera. 

651 camera_builder = Camera.Builder(self.name) 

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

653 legacy_fp_to_fa = fp_to_fa.to_legacy() 

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

655 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa) 

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

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

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

659 detector_builder.setSerial(self.serial) 

660 detector_builder.setPhysicalType(self.physical_type) 

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

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

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

664 for amp in self.amplifiers: 

665 try: 

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

667 except Exception as err: 

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

669 raise 

670 camera = camera_builder.finish() 

671 return camera[self.id] 

672 

673 @staticmethod 

674 def from_legacy( 

675 legacy_detector: LegacyDetector, 

676 *, 

677 instrument: str, 

678 visit: int | None = None, 

679 is_raw_assembled: bool | None = None, 

680 ) -> Detector: 

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

682 

683 Parameters 

684 ---------- 

685 legacy_detector 

686 Legacy detector to convert. 

687 instrument 

688 Name of the instrument this detector belongs to. 

689 visit 

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

691 particular visit. 

692 is_raw_assembled 

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

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

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

696 a visit ID to detectors that have been assembled. 

697 """ 

698 if is_raw_assembled is None: 

699 is_raw_assembled = visit is not None 

700 attributes = DetectorAttributes( 

701 name=legacy_detector.getName(), 

702 id=legacy_detector.getId(), 

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

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

705 serial=legacy_detector.getSerial(), 

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

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

708 physical_type=legacy_detector.getPhysicalType(), 

709 ) 

710 amplifiers = [ 

711 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled) 

712 for legacy_amp in legacy_detector.getAmplifiers() 

713 ] 

714 transform_map = legacy_detector.getTransformMap() 

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

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

717 

718 

719class DetectorSerializationModel(ArchiveTree): 

720 """Serialization model for `Detector`.""" 

721 

722 attributes: DetectorAttributes = pydantic.Field( 

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

724 ) 

725 

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

727 default_factory=list, 

728 description="Descriptions of the amplifiers.", 

729 ) 

730 

731 frames: CameraFrameSetSerializationModel | None = pydantic.Field( 

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

733 ) 

734 

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

736 

737 def deserialize( 

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

739 ) -> Detector: 

740 """Deserialize this detector from an archive. 

741 

742 Parameters 

743 ---------- 

744 model 

745 Serialization model instance for this detector. 

746 frames 

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

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

749 """ 

750 if kwargs: 

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

752 if frames is None: 

753 if self.frames is None: 

754 raise ArchiveReadError( 

755 "Serialized detector did not include coordinate transforms, " 

756 "and 'frames' was not provided." 

757 ) 

758 frames = self.frames.deserialize(archive) 

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