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

275 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-15 08:44 +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 OutputArchive, 

49 Quantity, 

50) 

51 

52if TYPE_CHECKING: 

53 try: 

54 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

55 from lsst.afw.cameraGeom import Detector as LegacyDetector 

56 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType 

57 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

58 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

59 except ImportError: 

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

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

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

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

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

65 

66 

67class DetectorType(enum.StrEnum): 

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

69 

70 SCIENCE = "SCIENCE" 

71 FOCUS = "FOCUS" 

72 GUIDER = "GUIDER" 

73 WAVEFRONT = "WAVEFRONT" 

74 

75 def to_legacy(self) -> LegacyDetectorType: 

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

77 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType 

78 

79 return getattr(LegacyDetectorType, self.value) 

80 

81 @classmethod 

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

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

84 return getattr(cls, legacy_detector_type.name) 

85 

86 

87@final 

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

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

90 detector within a camera focal plane. 

91 """ 

92 

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

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

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

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

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

98 yaw: Quantity = pydantic.Field(0.0 * astropy.units.radian, description="Rotation about the Z axis.") 

99 pitch: Quantity = pydantic.Field( 

100 0.0 * astropy.units.radian, description="Rotation about the Y axis (as defined after applying 'yaw')." 

101 ) 

102 roll: Quantity = pydantic.Field( 

103 0.0 * astropy.units.radian, 

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

105 ) 

106 

107 def to_legacy(self) -> LegacyOrientation: 

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

109 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

110 from lsst.geom import Point2D, Point3D, radians 

111 

112 return LegacyOrientation( 

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

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

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

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

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

118 ) 

119 

120 @staticmethod 

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

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

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

124 pixel_reference_x, pixel_reference_y = legacy_orientation.getReferencePoint() 

125 return Orientation( 

126 focal_plane_x=focal_plane_x, 

127 focal_plane_y=focal_plane_y, 

128 focal_plane_z=focal_plane_z, 

129 pixel_reference_x=pixel_reference_x, 

130 pixel_reference_y=pixel_reference_y, 

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

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

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

134 ) 

135 

136 

137@final 

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

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

140 

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

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

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

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

145 bbox: Box = pydantic.Field( 

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

147 ) 

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

149 pixel_size: float = pydantic.Field( 

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

151 ) 

152 physical_type: str = pydantic.Field( 

153 description=( 

154 "Vendor name or technology type for this detector " 

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

156 ) 

157 ) 

158 

159 

160class ReadoutCorner(enum.StrEnum): 

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

162 

163 LL = "LL" 

164 LR = "LR" 

165 UR = "UR" 

166 UL = "UL" 

167 

168 def to_legacy(self) -> LegacyReadoutCorner: 

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

170 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

171 

172 return getattr(LegacyReadoutCorner, self.value) 

173 

174 @classmethod 

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

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

177 return getattr(cls, legacy_readout_corner.name) 

178 

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

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

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

182 """ 

183 return YX( 

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

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

186 ) 

187 

188 @classmethod 

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

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

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

192 """ 

193 match y, x: 

194 case False, False: 

195 return cls.LL 

196 case False, True: 

197 return cls.LR 

198 case True, True: 

199 return cls.UR 

200 case True, False: 

201 return cls.UL 

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

203 

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

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

206 current = self.as_flips() 

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

208 

209 

210@final 

211class AmplifierRawGeometry(pydantic.BaseModel): 

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

213 

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

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

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

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

218 x_offset: int = pydantic.Field( 

219 0, 

220 description=( 

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

222 "assembled position of the amplifier." 

223 ), 

224 ) 

225 y_offset: int = pydantic.Field( 

226 0, 

227 description=( 

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

229 "assembled position of the amplifier." 

230 ), 

231 ) 

232 serial_overscan_bbox: Box = pydantic.Field( 

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

234 ) 

235 parallel_overscan_bbox: Box = pydantic.Field( 

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

237 ) 

238 prescan_bbox: Box = pydantic.Field( 

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

240 ) 

241 readout_corner: ReadoutCorner = pydantic.Field( 

242 description=( 

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

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

245 ) 

246 ) 

247 

248 @property 

249 def horizontal_overscan_bbox(self) -> Box: 

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

251 image (`.Box`). 

252 """ 

253 return self.serial_overscan_bbox 

254 

255 @horizontal_overscan_bbox.setter 

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

257 self.serial_overscan_bbox = value 

258 

259 @property 

260 def vertical_overscan_bbox(self) -> Box: 

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

262 image (`.Box`). 

263 """ 

264 return self.parallel_overscan_bbox 

265 

266 @vertical_overscan_bbox.setter 

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

268 self.parallel_overscan_bbox = value 

269 

270 @property 

271 def horizontal_prescan_bbox(self) -> Box: 

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

273 image (`.Box`). 

274 """ 

275 return self.prescan_bbox 

276 

277 @horizontal_prescan_bbox.setter 

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

279 self.prescan_bbox = value 

280 

281 @property 

282 def serial_prescan_bbox(self) -> Box: 

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

284 image (`.Box`). 

285 """ 

286 return self.prescan_bbox 

287 

288 @serial_prescan_bbox.setter 

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

290 self.prescan_bbox = value 

291 

292 @staticmethod 

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

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

295 

296 Parameters 

297 ---------- 

298 legacy_amplifier 

299 Legacy amplifier to convert. 

300 """ 

301 x_offset, y_offset = legacy_amplifier.getRawXYOffset() 

302 return AmplifierRawGeometry( 

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

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

305 flip_x=legacy_amplifier.getRawFlipX(), 

306 flip_y=legacy_amplifier.getRawFlipY(), 

307 x_offset=x_offset, 

308 y_offset=y_offset, 

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

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

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

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

313 ) 

314 

315 

316@final 

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

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

319 often superseded by separate calibration datasets. 

320 """ 

321 

322 gain: float 

323 read_noise: float 

324 saturation: float 

325 suspect_level: float 

326 linearity_coefficients: InlineArray 

327 linearity_type: str 

328 

329 @staticmethod 

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

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

332 

333 Parameters 

334 ---------- 

335 legacy_amplifier 

336 Legacy amplifier to convert. 

337 """ 

338 return AmplifierCalibrations( 

339 gain=legacy_amplifier.getGain(), 

340 read_noise=legacy_amplifier.getReadNoise(), 

341 saturation=legacy_amplifier.getSaturation(), 

342 suspect_level=legacy_amplifier.getSuspectLevel(), 

343 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(), 

344 linearity_type=legacy_amplifier.getLinearityType(), 

345 ) 

346 

347 

348@final 

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

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

351 

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

353 bbox: Box = pydantic.Field( 

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

355 ) 

356 readout_corner: ReadoutCorner = pydantic.Field( 

357 description=( 

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

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

360 ) 

361 ) 

362 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

363 None, 

364 description=( 

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

366 ), 

367 ) 

368 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

369 None, 

370 description=( 

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

372 ), 

373 ) 

374 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field( 

375 None, 

376 description=( 

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

378 ), 

379 ) 

380 

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

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

383 

384 Parameters 

385 ---------- 

386 is_raw_assembled 

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

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

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

390 a visit ID to detectors that have been assembled. 

391 """ 

392 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

393 from lsst.geom import Extent2I 

394 

395 builder = LegacyAmplifier.Builder() 

396 builder.setName(self.name) 

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

398 if is_raw_assembled: 

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

400 raise ValueError( 

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

402 ) 

403 else: 

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

405 raise ValueError( 

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

407 ) 

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

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

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

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

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

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

414 builder.setRawFlipX(raw_geom.flip_x) 

415 builder.setRawFlipY(raw_geom.flip_y) 

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

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

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

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

420 if self.nominal_calibrations is not None: 

421 builder.setGain(self.nominal_calibrations.gain) 

422 builder.setReadNoise(self.nominal_calibrations.read_noise) 

423 builder.setSaturation(self.nominal_calibrations.saturation) 

424 builder.setSuspectLevel(self.nominal_calibrations.suspect_level) 

425 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients) 

426 builder.setLinearityType(self.nominal_calibrations.linearity_type) 

427 return builder 

428 

429 @staticmethod 

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

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

432 

433 Parameters 

434 ---------- 

435 legacy_amplifier 

436 Legacy amplifier to convert. 

437 is_raw_assembled 

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

439 `Amplifier.unassembled_raw_geometry` (`False`). 

440 """ 

441 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier) 

442 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier) 

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

444 return Amplifier( 

445 name=legacy_amplifier.getName(), 

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

447 readout_corner=readout_corner, 

448 assembled_raw_geometry=raw_geometry if is_raw_assembled else None, 

449 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None, 

450 nominal_calibrations=nominal_calibrations, 

451 ) 

452 

453 

454@final 

455class Detector: 

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

457 

458 def __init__( 

459 self, 

460 attributes: DetectorAttributes, 

461 amplifiers: Iterable[Amplifier], 

462 frames: CameraFrameSet, 

463 visit: int | None = None, 

464 ): 

465 self._attributes = attributes 

466 self._amplifiers = list(amplifiers) 

467 self._frames = frames 

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

469 

470 @property 

471 def instrument(self) -> str: 

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

473 return self._frame.instrument 

474 

475 @property 

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

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

478 `None`). 

479 """ 

480 return self._frame.visit 

481 

482 @property 

483 def name(self) -> str: 

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

485 return self._attributes.name 

486 

487 @property 

488 def id(self) -> int: 

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

490 return self._attributes.id 

491 

492 @property 

493 def type(self) -> DetectorType: 

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

495 return self._attributes.type 

496 

497 @property 

498 def serial(self) -> str: 

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

500 return self._attributes.serial 

501 

502 @property 

503 def bbox(self) -> Box: 

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

505 assembly (`.Box`). 

506 """ 

507 return self._attributes.bbox 

508 

509 @property 

510 def orientation(self) -> Orientation: 

511 """Nominal position and rotation of the detector 

512 (`Orientation`). 

513 """ 

514 return self._attributes.orientation 

515 

516 @property 

517 def pixel_size(self) -> float: 

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

519 units (`float`). 

520 """ 

521 return self._attributes.pixel_size 

522 

523 @property 

524 def physical_type(self) -> str: 

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

526 

527 This may have a different interpretation for different cameras. 

528 """ 

529 return self._attributes.physical_type 

530 

531 @property 

532 def frame(self) -> DetectorFrame: 

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

534 grid (`.DetectorFrame`). 

535 """ 

536 return self._frame 

537 

538 @property 

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

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

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

542 """ 

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

544 

545 @property 

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

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

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

549 """ 

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

551 

552 @property 

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

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

555 return self._amplifiers 

556 

557 def copy(self) -> Detector: 

558 """Copy the detector. 

559 

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

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

562 immutable. 

563 """ 

564 return Detector( 

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

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

567 frames=self._frames, 

568 ) 

569 

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

571 """Serialize this detector to an archive. 

572 

573 Parameters 

574 ---------- 

575 archive 

576 Archive to save to. 

577 save_frames 

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

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

580 they are part of a multi-detector object. 

581 """ 

582 return DetectorSerializationModel( 

583 attributes=self._attributes, 

584 amplifiers=self._amplifiers, 

585 frames=self._frames.serialize(archive) if save_frames else None, 

586 visit=self.visit, 

587 ) 

588 

589 @staticmethod 

590 def _get_archive_tree_type( 

591 pointer_type: builtins.type[Any], 

592 ) -> builtins.type[DetectorSerializationModel]: 

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

594 type that uses the given pointer type. 

595 """ 

596 return DetectorSerializationModel # type: ignore 

597 

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

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

600 

601 Parameters 

602 ---------- 

603 is_raw_assembled 

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

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

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

607 a visit ID to detectors that have been assembled. 

608 """ 

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

610 from lsst.geom import Extent2D, Point2D 

611 

612 if is_raw_assembled is None: 

613 is_raw_assembled = self.visit is not None 

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

615 # camera. 

616 camera_builder = Camera.Builder(self.name) 

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

618 legacy_fp_to_fa = fp_to_fa.to_legacy() 

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

620 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa) 

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

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

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

624 detector_builder.setSerial(self.serial) 

625 detector_builder.setPhysicalType(self.physical_type) 

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

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

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

629 for amp in self.amplifiers: 

630 try: 

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

632 except Exception as err: 

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

634 raise 

635 camera = camera_builder.finish() 

636 return camera[self.id] 

637 

638 @staticmethod 

639 def from_legacy( 

640 legacy_detector: LegacyDetector, 

641 *, 

642 instrument: str, 

643 visit: int | None = None, 

644 is_raw_assembled: bool | None = None, 

645 ) -> Detector: 

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

647 

648 Parameters 

649 ---------- 

650 legacy_detector 

651 Legacy detector to convert. 

652 instrument 

653 Name of the instrument this detector belongs to. 

654 visit 

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

656 particular visit. 

657 is_raw_assembled 

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

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

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

661 a visit ID to detectors that have been assembled. 

662 """ 

663 if is_raw_assembled is None: 

664 is_raw_assembled = visit is not None 

665 attributes = DetectorAttributes( 

666 name=legacy_detector.getName(), 

667 id=legacy_detector.getId(), 

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

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

670 serial=legacy_detector.getSerial(), 

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

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

673 physical_type=legacy_detector.getPhysicalType(), 

674 ) 

675 amplifiers = [ 

676 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled) 

677 for legacy_amp in legacy_detector.getAmplifiers() 

678 ] 

679 transform_map = legacy_detector.getTransformMap() 

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

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

682 

683 

684class DetectorSerializationModel(ArchiveTree): 

685 """Serialization model for `Detector`.""" 

686 

687 attributes: DetectorAttributes = pydantic.Field( 

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

689 ) 

690 

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

692 default_factory=list, 

693 description="Descriptions of the amplifiers.", 

694 ) 

695 

696 frames: CameraFrameSetSerializationModel | None = pydantic.Field( 

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

698 ) 

699 

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

701 

702 def deserialize(self, archive: InputArchive[Any], frames: CameraFrameSet | None = None) -> Detector: 

703 """Deserialize this detector from an archive. 

704 

705 Parameters 

706 ---------- 

707 model 

708 Serialization model instance for this detector. 

709 frames 

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

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

712 """ 

713 if frames is None: 

714 if self.frames is None: 

715 raise ArchiveReadError( 

716 "Serialized detector did not include coordinate transforms, " 

717 "and 'frames' was not provided." 

718 ) 

719 frames = self.frames.deserialize(archive) 

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