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

277 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-27 01:31 -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 "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 @staticmethod 

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

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

337 

338 Parameters 

339 ---------- 

340 legacy_amplifier 

341 Legacy amplifier to convert. 

342 """ 

343 return AmplifierCalibrations( 

344 gain=legacy_amplifier.getGain(), 

345 read_noise=legacy_amplifier.getReadNoise(), 

346 saturation=legacy_amplifier.getSaturation(), 

347 suspect_level=legacy_amplifier.getSuspectLevel(), 

348 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(), 

349 linearity_type=legacy_amplifier.getLinearityType(), 

350 ) 

351 

352 

353@final 

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

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

356 

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

358 bbox: Box = pydantic.Field( 

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

360 ) 

361 readout_corner: ReadoutCorner = pydantic.Field( 

362 description=( 

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

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

365 ) 

366 ) 

367 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

368 None, 

369 description=( 

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

371 ), 

372 ) 

373 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

374 None, 

375 description=( 

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

377 ), 

378 ) 

379 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field( 

380 None, 

381 description=( 

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

383 ), 

384 ) 

385 

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

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

388 

389 Parameters 

390 ---------- 

391 is_raw_assembled 

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

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

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

395 a visit ID to detectors that have been assembled. 

396 """ 

397 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

398 from lsst.geom import Extent2I 

399 

400 builder = LegacyAmplifier.Builder() 

401 builder.setName(self.name) 

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

403 if is_raw_assembled: 

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

405 raise ValueError( 

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

407 ) 

408 else: 

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

410 raise ValueError( 

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

412 ) 

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

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

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

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

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

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

419 builder.setRawFlipX(raw_geom.flip_x) 

420 builder.setRawFlipY(raw_geom.flip_y) 

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

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

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

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

425 if self.nominal_calibrations is not None: 

426 builder.setGain(self.nominal_calibrations.gain) 

427 builder.setReadNoise(self.nominal_calibrations.read_noise) 

428 builder.setSaturation(self.nominal_calibrations.saturation) 

429 builder.setSuspectLevel(self.nominal_calibrations.suspect_level) 

430 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients) 

431 builder.setLinearityType(self.nominal_calibrations.linearity_type) 

432 return builder 

433 

434 @staticmethod 

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

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

437 

438 Parameters 

439 ---------- 

440 legacy_amplifier 

441 Legacy amplifier to convert. 

442 is_raw_assembled 

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

444 `Amplifier.unassembled_raw_geometry` (`False`). 

445 """ 

446 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier) 

447 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier) 

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

449 return Amplifier( 

450 name=legacy_amplifier.getName(), 

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

452 readout_corner=readout_corner, 

453 assembled_raw_geometry=raw_geometry if is_raw_assembled else None, 

454 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None, 

455 nominal_calibrations=nominal_calibrations, 

456 ) 

457 

458 

459@final 

460class Detector: 

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

462 

463 def __init__( 

464 self, 

465 attributes: DetectorAttributes, 

466 amplifiers: Iterable[Amplifier], 

467 frames: CameraFrameSet, 

468 visit: int | None = None, 

469 ): 

470 self._attributes = attributes 

471 self._amplifiers = list(amplifiers) 

472 self._frames = frames 

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

474 

475 @property 

476 def instrument(self) -> str: 

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

478 return self._frame.instrument 

479 

480 @property 

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

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

483 `None`). 

484 """ 

485 return self._frame.visit 

486 

487 @property 

488 def name(self) -> str: 

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

490 return self._attributes.name 

491 

492 @property 

493 def id(self) -> int: 

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

495 return self._attributes.id 

496 

497 @property 

498 def type(self) -> DetectorType: 

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

500 return self._attributes.type 

501 

502 @property 

503 def serial(self) -> str: 

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

505 return self._attributes.serial 

506 

507 @property 

508 def bbox(self) -> Box: 

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

510 assembly (`.Box`). 

511 """ 

512 return self._attributes.bbox 

513 

514 @property 

515 def orientation(self) -> Orientation: 

516 """Nominal position and rotation of the detector 

517 (`Orientation`). 

518 """ 

519 return self._attributes.orientation 

520 

521 @property 

522 def pixel_size(self) -> float: 

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

524 units (`float`). 

525 """ 

526 return self._attributes.pixel_size 

527 

528 @property 

529 def physical_type(self) -> str: 

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

531 

532 This may have a different interpretation for different cameras. 

533 """ 

534 return self._attributes.physical_type 

535 

536 @property 

537 def frame(self) -> DetectorFrame: 

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

539 grid (`.DetectorFrame`). 

540 """ 

541 return self._frame 

542 

543 @property 

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

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

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

547 """ 

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

549 

550 @property 

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

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

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

554 """ 

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

556 

557 @property 

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

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

560 return self._amplifiers 

561 

562 def copy(self) -> Detector: 

563 """Copy the detector. 

564 

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

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

567 immutable. 

568 """ 

569 return Detector( 

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

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

572 frames=self._frames, 

573 ) 

574 

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

576 """Serialize this detector to an archive. 

577 

578 Parameters 

579 ---------- 

580 archive 

581 Archive to save to. 

582 save_frames 

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

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

585 they are part of a multi-detector object. 

586 """ 

587 return DetectorSerializationModel( 

588 attributes=self._attributes, 

589 amplifiers=self._amplifiers, 

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

591 visit=self.visit, 

592 ) 

593 

594 @staticmethod 

595 def _get_archive_tree_type( 

596 pointer_type: builtins.type[Any], 

597 ) -> builtins.type[DetectorSerializationModel]: 

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

599 type that uses the given pointer type. 

600 """ 

601 return DetectorSerializationModel # type: ignore 

602 

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

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

605 

606 Parameters 

607 ---------- 

608 is_raw_assembled 

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

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

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

612 a visit ID to detectors that have been assembled. 

613 """ 

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

615 from lsst.geom import Extent2D, Point2D 

616 

617 if is_raw_assembled is None: 

618 is_raw_assembled = self.visit is not None 

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

620 # camera. 

621 camera_builder = Camera.Builder(self.name) 

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

623 legacy_fp_to_fa = fp_to_fa.to_legacy() 

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

625 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa) 

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

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

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

629 detector_builder.setSerial(self.serial) 

630 detector_builder.setPhysicalType(self.physical_type) 

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

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

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

634 for amp in self.amplifiers: 

635 try: 

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

637 except Exception as err: 

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

639 raise 

640 camera = camera_builder.finish() 

641 return camera[self.id] 

642 

643 @staticmethod 

644 def from_legacy( 

645 legacy_detector: LegacyDetector, 

646 *, 

647 instrument: str, 

648 visit: int | None = None, 

649 is_raw_assembled: bool | None = None, 

650 ) -> Detector: 

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

652 

653 Parameters 

654 ---------- 

655 legacy_detector 

656 Legacy detector to convert. 

657 instrument 

658 Name of the instrument this detector belongs to. 

659 visit 

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

661 particular visit. 

662 is_raw_assembled 

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

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

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

666 a visit ID to detectors that have been assembled. 

667 """ 

668 if is_raw_assembled is None: 

669 is_raw_assembled = visit is not None 

670 attributes = DetectorAttributes( 

671 name=legacy_detector.getName(), 

672 id=legacy_detector.getId(), 

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

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

675 serial=legacy_detector.getSerial(), 

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

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

678 physical_type=legacy_detector.getPhysicalType(), 

679 ) 

680 amplifiers = [ 

681 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled) 

682 for legacy_amp in legacy_detector.getAmplifiers() 

683 ] 

684 transform_map = legacy_detector.getTransformMap() 

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

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

687 

688 

689class DetectorSerializationModel(ArchiveTree): 

690 """Serialization model for `Detector`.""" 

691 

692 attributes: DetectorAttributes = pydantic.Field( 

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

694 ) 

695 

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

697 default_factory=list, 

698 description="Descriptions of the amplifiers.", 

699 ) 

700 

701 frames: CameraFrameSetSerializationModel | None = pydantic.Field( 

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

703 ) 

704 

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

706 

707 def deserialize( 

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

709 ) -> Detector: 

710 """Deserialize this detector from an archive. 

711 

712 Parameters 

713 ---------- 

714 model 

715 Serialization model instance for this detector. 

716 frames 

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

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

719 """ 

720 if kwargs: 

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

722 if frames is None: 

723 if self.frames is None: 

724 raise ArchiveReadError( 

725 "Serialized detector did not include coordinate transforms, " 

726 "and 'frames' was not provided." 

727 ) 

728 frames = self.frames.deserialize(archive) 

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