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

277 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-20 08:26 +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(0.0 * astropy.units.radian, description="Rotation about the Z axis.") 

100 pitch: Quantity = pydantic.Field( 

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

102 ) 

103 roll: Quantity = pydantic.Field( 

104 0.0 * astropy.units.radian, 

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

106 ) 

107 

108 def to_legacy(self) -> LegacyOrientation: 

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

110 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

111 from lsst.geom import Point2D, Point3D, radians 

112 

113 return LegacyOrientation( 

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

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

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

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

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

119 ) 

120 

121 @staticmethod 

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

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

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

125 pixel_reference_x, pixel_reference_y = legacy_orientation.getReferencePoint() 

126 return Orientation( 

127 focal_plane_x=focal_plane_x, 

128 focal_plane_y=focal_plane_y, 

129 focal_plane_z=focal_plane_z, 

130 pixel_reference_x=pixel_reference_x, 

131 pixel_reference_y=pixel_reference_y, 

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

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

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

135 ) 

136 

137 

138@final 

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

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

141 

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

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

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

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

146 bbox: Box = pydantic.Field( 

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

148 ) 

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

150 pixel_size: float = pydantic.Field( 

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

152 ) 

153 physical_type: str = pydantic.Field( 

154 description=( 

155 "Vendor name or technology type for this detector " 

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

157 ) 

158 ) 

159 

160 

161class ReadoutCorner(enum.StrEnum): 

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

163 

164 LL = "LL" 

165 LR = "LR" 

166 UR = "UR" 

167 UL = "UL" 

168 

169 def to_legacy(self) -> LegacyReadoutCorner: 

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

171 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

172 

173 return getattr(LegacyReadoutCorner, self.value) 

174 

175 @classmethod 

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

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

178 return getattr(cls, legacy_readout_corner.name) 

179 

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

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

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

183 """ 

184 return YX( 

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

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

187 ) 

188 

189 @classmethod 

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

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

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

193 """ 

194 match y, x: 

195 case False, False: 

196 return cls.LL 

197 case False, True: 

198 return cls.LR 

199 case True, True: 

200 return cls.UR 

201 case True, False: 

202 return cls.UL 

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

204 

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

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

207 current = self.as_flips() 

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

209 

210 

211@final 

212class AmplifierRawGeometry(pydantic.BaseModel): 

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

214 

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

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

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

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

219 x_offset: int = pydantic.Field( 

220 0, 

221 description=( 

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

223 "assembled position of the amplifier." 

224 ), 

225 ) 

226 y_offset: int = pydantic.Field( 

227 0, 

228 description=( 

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

230 "assembled position of the amplifier." 

231 ), 

232 ) 

233 serial_overscan_bbox: Box = pydantic.Field( 

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

235 ) 

236 parallel_overscan_bbox: Box = pydantic.Field( 

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

238 ) 

239 prescan_bbox: Box = pydantic.Field( 

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

241 ) 

242 readout_corner: ReadoutCorner = pydantic.Field( 

243 description=( 

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

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

246 ) 

247 ) 

248 

249 @property 

250 def horizontal_overscan_bbox(self) -> Box: 

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

252 image (`.Box`). 

253 """ 

254 return self.serial_overscan_bbox 

255 

256 @horizontal_overscan_bbox.setter 

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

258 self.serial_overscan_bbox = value 

259 

260 @property 

261 def vertical_overscan_bbox(self) -> Box: 

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

263 image (`.Box`). 

264 """ 

265 return self.parallel_overscan_bbox 

266 

267 @vertical_overscan_bbox.setter 

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

269 self.parallel_overscan_bbox = value 

270 

271 @property 

272 def horizontal_prescan_bbox(self) -> Box: 

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

274 image (`.Box`). 

275 """ 

276 return self.prescan_bbox 

277 

278 @horizontal_prescan_bbox.setter 

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

280 self.prescan_bbox = value 

281 

282 @property 

283 def serial_prescan_bbox(self) -> Box: 

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

285 image (`.Box`). 

286 """ 

287 return self.prescan_bbox 

288 

289 @serial_prescan_bbox.setter 

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

291 self.prescan_bbox = value 

292 

293 @staticmethod 

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

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

296 

297 Parameters 

298 ---------- 

299 legacy_amplifier 

300 Legacy amplifier to convert. 

301 """ 

302 x_offset, y_offset = legacy_amplifier.getRawXYOffset() 

303 return AmplifierRawGeometry( 

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

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

306 flip_x=legacy_amplifier.getRawFlipX(), 

307 flip_y=legacy_amplifier.getRawFlipY(), 

308 x_offset=x_offset, 

309 y_offset=y_offset, 

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

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

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

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

314 ) 

315 

316 

317@final 

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

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

320 often superseded by separate calibration datasets. 

321 """ 

322 

323 gain: float 

324 read_noise: float 

325 saturation: float 

326 suspect_level: float 

327 linearity_coefficients: InlineArray 

328 linearity_type: str 

329 

330 @staticmethod 

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

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

333 

334 Parameters 

335 ---------- 

336 legacy_amplifier 

337 Legacy amplifier to convert. 

338 """ 

339 return AmplifierCalibrations( 

340 gain=legacy_amplifier.getGain(), 

341 read_noise=legacy_amplifier.getReadNoise(), 

342 saturation=legacy_amplifier.getSaturation(), 

343 suspect_level=legacy_amplifier.getSuspectLevel(), 

344 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(), 

345 linearity_type=legacy_amplifier.getLinearityType(), 

346 ) 

347 

348 

349@final 

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

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

352 

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

354 bbox: Box = pydantic.Field( 

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

356 ) 

357 readout_corner: ReadoutCorner = pydantic.Field( 

358 description=( 

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

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

361 ) 

362 ) 

363 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

364 None, 

365 description=( 

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

367 ), 

368 ) 

369 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

370 None, 

371 description=( 

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

373 ), 

374 ) 

375 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field( 

376 None, 

377 description=( 

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

379 ), 

380 ) 

381 

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

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

384 

385 Parameters 

386 ---------- 

387 is_raw_assembled 

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

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

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

391 a visit ID to detectors that have been assembled. 

392 """ 

393 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

394 from lsst.geom import Extent2I 

395 

396 builder = LegacyAmplifier.Builder() 

397 builder.setName(self.name) 

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

399 if is_raw_assembled: 

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

401 raise ValueError( 

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

403 ) 

404 else: 

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

406 raise ValueError( 

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

408 ) 

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

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

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

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

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

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

415 builder.setRawFlipX(raw_geom.flip_x) 

416 builder.setRawFlipY(raw_geom.flip_y) 

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

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

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

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

421 if self.nominal_calibrations is not None: 

422 builder.setGain(self.nominal_calibrations.gain) 

423 builder.setReadNoise(self.nominal_calibrations.read_noise) 

424 builder.setSaturation(self.nominal_calibrations.saturation) 

425 builder.setSuspectLevel(self.nominal_calibrations.suspect_level) 

426 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients) 

427 builder.setLinearityType(self.nominal_calibrations.linearity_type) 

428 return builder 

429 

430 @staticmethod 

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

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

433 

434 Parameters 

435 ---------- 

436 legacy_amplifier 

437 Legacy amplifier to convert. 

438 is_raw_assembled 

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

440 `Amplifier.unassembled_raw_geometry` (`False`). 

441 """ 

442 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier) 

443 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier) 

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

445 return Amplifier( 

446 name=legacy_amplifier.getName(), 

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

448 readout_corner=readout_corner, 

449 assembled_raw_geometry=raw_geometry if is_raw_assembled else None, 

450 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None, 

451 nominal_calibrations=nominal_calibrations, 

452 ) 

453 

454 

455@final 

456class Detector: 

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

458 

459 def __init__( 

460 self, 

461 attributes: DetectorAttributes, 

462 amplifiers: Iterable[Amplifier], 

463 frames: CameraFrameSet, 

464 visit: int | None = None, 

465 ): 

466 self._attributes = attributes 

467 self._amplifiers = list(amplifiers) 

468 self._frames = frames 

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

470 

471 @property 

472 def instrument(self) -> str: 

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

474 return self._frame.instrument 

475 

476 @property 

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

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

479 `None`). 

480 """ 

481 return self._frame.visit 

482 

483 @property 

484 def name(self) -> str: 

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

486 return self._attributes.name 

487 

488 @property 

489 def id(self) -> int: 

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

491 return self._attributes.id 

492 

493 @property 

494 def type(self) -> DetectorType: 

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

496 return self._attributes.type 

497 

498 @property 

499 def serial(self) -> str: 

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

501 return self._attributes.serial 

502 

503 @property 

504 def bbox(self) -> Box: 

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

506 assembly (`.Box`). 

507 """ 

508 return self._attributes.bbox 

509 

510 @property 

511 def orientation(self) -> Orientation: 

512 """Nominal position and rotation of the detector 

513 (`Orientation`). 

514 """ 

515 return self._attributes.orientation 

516 

517 @property 

518 def pixel_size(self) -> float: 

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

520 units (`float`). 

521 """ 

522 return self._attributes.pixel_size 

523 

524 @property 

525 def physical_type(self) -> str: 

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

527 

528 This may have a different interpretation for different cameras. 

529 """ 

530 return self._attributes.physical_type 

531 

532 @property 

533 def frame(self) -> DetectorFrame: 

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

535 grid (`.DetectorFrame`). 

536 """ 

537 return self._frame 

538 

539 @property 

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

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

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

543 """ 

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

545 

546 @property 

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

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

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

550 """ 

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

552 

553 @property 

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

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

556 return self._amplifiers 

557 

558 def copy(self) -> Detector: 

559 """Copy the detector. 

560 

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

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

563 immutable. 

564 """ 

565 return Detector( 

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

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

568 frames=self._frames, 

569 ) 

570 

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

572 """Serialize this detector to an archive. 

573 

574 Parameters 

575 ---------- 

576 archive 

577 Archive to save to. 

578 save_frames 

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

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

581 they are part of a multi-detector object. 

582 """ 

583 return DetectorSerializationModel( 

584 attributes=self._attributes, 

585 amplifiers=self._amplifiers, 

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

587 visit=self.visit, 

588 ) 

589 

590 @staticmethod 

591 def _get_archive_tree_type( 

592 pointer_type: builtins.type[Any], 

593 ) -> builtins.type[DetectorSerializationModel]: 

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

595 type that uses the given pointer type. 

596 """ 

597 return DetectorSerializationModel # type: ignore 

598 

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

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

601 

602 Parameters 

603 ---------- 

604 is_raw_assembled 

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

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

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

608 a visit ID to detectors that have been assembled. 

609 """ 

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

611 from lsst.geom import Extent2D, Point2D 

612 

613 if is_raw_assembled is None: 

614 is_raw_assembled = self.visit is not None 

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

616 # camera. 

617 camera_builder = Camera.Builder(self.name) 

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

619 legacy_fp_to_fa = fp_to_fa.to_legacy() 

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

621 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa) 

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

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

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

625 detector_builder.setSerial(self.serial) 

626 detector_builder.setPhysicalType(self.physical_type) 

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

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

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

630 for amp in self.amplifiers: 

631 try: 

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

633 except Exception as err: 

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

635 raise 

636 camera = camera_builder.finish() 

637 return camera[self.id] 

638 

639 @staticmethod 

640 def from_legacy( 

641 legacy_detector: LegacyDetector, 

642 *, 

643 instrument: str, 

644 visit: int | None = None, 

645 is_raw_assembled: bool | None = None, 

646 ) -> Detector: 

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

648 

649 Parameters 

650 ---------- 

651 legacy_detector 

652 Legacy detector to convert. 

653 instrument 

654 Name of the instrument this detector belongs to. 

655 visit 

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

657 particular visit. 

658 is_raw_assembled 

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

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

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

662 a visit ID to detectors that have been assembled. 

663 """ 

664 if is_raw_assembled is None: 

665 is_raw_assembled = visit is not None 

666 attributes = DetectorAttributes( 

667 name=legacy_detector.getName(), 

668 id=legacy_detector.getId(), 

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

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

671 serial=legacy_detector.getSerial(), 

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

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

674 physical_type=legacy_detector.getPhysicalType(), 

675 ) 

676 amplifiers = [ 

677 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled) 

678 for legacy_amp in legacy_detector.getAmplifiers() 

679 ] 

680 transform_map = legacy_detector.getTransformMap() 

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

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

683 

684 

685class DetectorSerializationModel(ArchiveTree): 

686 """Serialization model for `Detector`.""" 

687 

688 attributes: DetectorAttributes = pydantic.Field( 

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

690 ) 

691 

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

693 default_factory=list, 

694 description="Descriptions of the amplifiers.", 

695 ) 

696 

697 frames: CameraFrameSetSerializationModel | None = pydantic.Field( 

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

699 ) 

700 

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

702 

703 def deserialize( 

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

705 ) -> Detector: 

706 """Deserialize this detector from an archive. 

707 

708 Parameters 

709 ---------- 

710 model 

711 Serialization model instance for this detector. 

712 frames 

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

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

715 """ 

716 if kwargs: 

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

718 if frames is None: 

719 if self.frames is None: 

720 raise ArchiveReadError( 

721 "Serialized detector did not include coordinate transforms, " 

722 "and 'frames' was not provided." 

723 ) 

724 frames = self.frames.deserialize(archive) 

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