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

293 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 08:38 +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, ClassVar, 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 .describe import DescribableMixin, DescribeOptions, FieldRole, Report, ReportField 

44from .serialization import ( 

45 ArchiveReadError, 

46 ArchiveTree, 

47 InlineArray, 

48 InputArchive, 

49 InvalidParameterError, 

50 OutputArchive, 

51 Quantity, 

52) 

53 

54if TYPE_CHECKING: 

55 try: 

56 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

57 from lsst.afw.cameraGeom import Detector as LegacyDetector 

58 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType 

59 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

60 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

61 except ImportError: 

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

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

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

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

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

67 

68 

69class DetectorType(enum.StrEnum): 

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

71 

72 SCIENCE = "SCIENCE" 

73 FOCUS = "FOCUS" 

74 GUIDER = "GUIDER" 

75 WAVEFRONT = "WAVEFRONT" 

76 

77 def to_legacy(self) -> LegacyDetectorType: 

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

79 from lsst.afw.cameraGeom import DetectorType as LegacyDetectorType 

80 

81 return getattr(LegacyDetectorType, self.value) 

82 

83 @classmethod 

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

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

86 

87 Parameters 

88 ---------- 

89 legacy_detector_type 

90 Legacy detector type to convert. 

91 """ 

92 return getattr(cls, legacy_detector_type.name) 

93 

94 

95@final 

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

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

98 detector within a camera focal plane. 

99 """ 

100 

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

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

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

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

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

106 yaw: Quantity = pydantic.Field( 

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

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

109 ) 

110 pitch: Quantity = pydantic.Field( 

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

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

113 ) 

114 roll: Quantity = pydantic.Field( 

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

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

117 ) 

118 

119 def to_legacy(self) -> LegacyOrientation: 

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

121 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

122 from lsst.geom import Point2D, Point3D, radians 

123 

124 return LegacyOrientation( 

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

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

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

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

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

130 ) 

131 

132 @staticmethod 

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

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

135 

136 Parameters 

137 ---------- 

138 legacy_orientation 

139 Legacy orientation to convert. 

140 """ 

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

142 pixel_reference_x, pixel_reference_y = legacy_orientation.getReferencePoint() 

143 return Orientation( 

144 focal_plane_x=focal_plane_x, 

145 focal_plane_y=focal_plane_y, 

146 focal_plane_z=focal_plane_z, 

147 pixel_reference_x=pixel_reference_x, 

148 pixel_reference_y=pixel_reference_y, 

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

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

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

152 ) 

153 

154 

155@final 

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

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

158 

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

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

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

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

163 bbox: Box = pydantic.Field( 

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

165 ) 

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

167 pixel_size: float = pydantic.Field( 

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

169 ) 

170 physical_type: str = pydantic.Field( 

171 description=( 

172 "Vendor name or technology type for this detector " 

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

174 ) 

175 ) 

176 

177 

178class ReadoutCorner(enum.StrEnum): 

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

180 

181 LL = "LL" 

182 LR = "LR" 

183 UR = "UR" 

184 UL = "UL" 

185 

186 def to_legacy(self) -> LegacyReadoutCorner: 

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

188 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

189 

190 return getattr(LegacyReadoutCorner, self.value) 

191 

192 @classmethod 

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

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

195 

196 Parameters 

197 ---------- 

198 legacy_readout_corner 

199 Legacy readout corner to convert. 

200 """ 

201 return getattr(cls, legacy_readout_corner.name) 

202 

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

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

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

206 """ 

207 return YX( 

208 y=self is ReadoutCorner.UL or self is ReadoutCorner.UR, 

209 x=self is ReadoutCorner.LR or self is ReadoutCorner.UR, 

210 ) 

211 

212 @classmethod 

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

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

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

216 

217 Parameters 

218 ---------- 

219 y 

220 Whether the image is flipped in the y direction. 

221 x 

222 Whether the image is flipped in the x direction. 

223 """ 

224 match y, x: 

225 case False, False: 

226 return cls.LL 

227 case False, True: 

228 return cls.LR 

229 case True, True: 

230 return cls.UR 

231 case True, False: 231 ↛ 233line 231 didn't jump to line 233 because the pattern on line 231 always matched

232 return cls.UL 

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

234 

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

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

237 

238 Parameters 

239 ---------- 

240 y 

241 Whether to flip in the y direction. 

242 x 

243 Whether to flip in the x direction. 

244 """ 

245 current = self.as_flips() 

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

247 

248 

249@final 

250class AmplifierRawGeometry(pydantic.BaseModel): 

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

252 

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

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

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

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

257 x_offset: int = pydantic.Field( 

258 0, 

259 description=( 

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

261 "assembled position of the amplifier." 

262 ), 

263 ) 

264 y_offset: int = pydantic.Field( 

265 0, 

266 description=( 

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

268 "assembled position of the amplifier." 

269 ), 

270 ) 

271 serial_overscan_bbox: Box = pydantic.Field( 

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

273 ) 

274 parallel_overscan_bbox: Box = pydantic.Field( 

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

276 ) 

277 prescan_bbox: Box = pydantic.Field( 

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

279 ) 

280 readout_corner: ReadoutCorner = pydantic.Field( 

281 description=( 

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

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

284 ) 

285 ) 

286 

287 @property 

288 def horizontal_overscan_bbox(self) -> Box: 

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

290 image (`.Box`). 

291 """ 

292 return self.serial_overscan_bbox 

293 

294 @horizontal_overscan_bbox.setter 

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

296 self.serial_overscan_bbox = value 

297 

298 @property 

299 def vertical_overscan_bbox(self) -> Box: 

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

301 image (`.Box`). 

302 """ 

303 return self.parallel_overscan_bbox 

304 

305 @vertical_overscan_bbox.setter 

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

307 self.parallel_overscan_bbox = value 

308 

309 @property 

310 def horizontal_prescan_bbox(self) -> Box: 

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

312 image (`.Box`). 

313 """ 

314 return self.prescan_bbox 

315 

316 @horizontal_prescan_bbox.setter 

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

318 self.prescan_bbox = value 

319 

320 @property 

321 def serial_prescan_bbox(self) -> Box: 

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

323 image (`.Box`). 

324 """ 

325 return self.prescan_bbox 

326 

327 @serial_prescan_bbox.setter 

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

329 self.prescan_bbox = value 

330 

331 @staticmethod 

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

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

334 

335 Parameters 

336 ---------- 

337 legacy_amplifier 

338 Legacy amplifier to convert. 

339 """ 

340 x_offset, y_offset = legacy_amplifier.getRawXYOffset() 

341 return AmplifierRawGeometry( 

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

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

344 flip_x=legacy_amplifier.getRawFlipX(), 

345 flip_y=legacy_amplifier.getRawFlipY(), 

346 x_offset=x_offset, 

347 y_offset=y_offset, 

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

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

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

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

352 ) 

353 

354 

355@final 

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

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

358 often superseded by separate calibration datasets. 

359 """ 

360 

361 gain: float 

362 read_noise: float 

363 saturation: float 

364 suspect_level: float 

365 linearity_coefficients: InlineArray 

366 linearity_type: str 

367 

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

369 if type(other) is not AmplifierCalibrations: 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true

370 return NotImplemented 

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

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

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

374 return ( 

375 self.gain == other.gain 

376 and self.read_noise == other.read_noise 

377 and self.saturation == other.saturation 

378 and ( 

379 self.suspect_level == other.suspect_level 

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

381 ) 

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

383 and self.linearity_type == other.linearity_type 

384 ) 

385 

386 @staticmethod 

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

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

389 

390 Parameters 

391 ---------- 

392 legacy_amplifier 

393 Legacy amplifier to convert. 

394 """ 

395 return AmplifierCalibrations( 

396 gain=legacy_amplifier.getGain(), 

397 read_noise=legacy_amplifier.getReadNoise(), 

398 saturation=legacy_amplifier.getSaturation(), 

399 suspect_level=legacy_amplifier.getSuspectLevel(), 

400 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(), 

401 linearity_type=legacy_amplifier.getLinearityType(), 

402 ) 

403 

404 

405@final 

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

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

408 

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

410 bbox: Box = pydantic.Field( 

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

412 ) 

413 readout_corner: ReadoutCorner = pydantic.Field( 

414 description=( 

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

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

417 ) 

418 ) 

419 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

420 None, 

421 description=( 

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

423 ), 

424 ) 

425 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

426 None, 

427 description=( 

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

429 ), 

430 ) 

431 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field( 

432 None, 

433 description=( 

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

435 ), 

436 ) 

437 

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

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

440 

441 Parameters 

442 ---------- 

443 is_raw_assembled 

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

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

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

447 a visit ID to detectors that have been assembled. 

448 """ 

449 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

450 from lsst.geom import Extent2I 

451 

452 builder = LegacyAmplifier.Builder() 

453 builder.setName(self.name) 

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

455 if is_raw_assembled: 

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

457 raise ValueError( 

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

459 ) 

460 else: 

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

462 raise ValueError( 

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

464 ) 

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

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

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

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

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

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

471 builder.setRawFlipX(raw_geom.flip_x) 

472 builder.setRawFlipY(raw_geom.flip_y) 

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

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

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

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

477 if self.nominal_calibrations is not None: 

478 builder.setGain(self.nominal_calibrations.gain) 

479 builder.setReadNoise(self.nominal_calibrations.read_noise) 

480 builder.setSaturation(self.nominal_calibrations.saturation) 

481 builder.setSuspectLevel(self.nominal_calibrations.suspect_level) 

482 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients) 

483 builder.setLinearityType(self.nominal_calibrations.linearity_type) 

484 return builder 

485 

486 @staticmethod 

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

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

489 

490 Parameters 

491 ---------- 

492 legacy_amplifier 

493 Legacy amplifier to convert. 

494 is_raw_assembled 

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

496 `Amplifier.unassembled_raw_geometry` (`False`). 

497 """ 

498 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier) 

499 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier) 

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

501 return Amplifier( 

502 name=legacy_amplifier.getName(), 

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

504 readout_corner=readout_corner, 

505 assembled_raw_geometry=raw_geometry if is_raw_assembled else None, 

506 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None, 

507 nominal_calibrations=nominal_calibrations, 

508 ) 

509 

510 

511@final 

512class Detector(DescribableMixin): 

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

514 

515 Parameters 

516 ---------- 

517 attributes 

518 Identifying attributes and metadata for the detector. 

519 amplifiers 

520 Amplifiers that make up the detector. 

521 frames 

522 Coordinate systems and transforms for the camera. 

523 visit 

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

525 detector geometry. 

526 """ 

527 

528 def __init__( 

529 self, 

530 attributes: DetectorAttributes, 

531 amplifiers: Iterable[Amplifier], 

532 frames: CameraFrameSet, 

533 visit: int | None = None, 

534 ) -> None: 

535 self._attributes = attributes 

536 self._amplifiers = list(amplifiers) 

537 self._frames = frames 

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

539 

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

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

542 return NotImplemented 

543 return ( 

544 self._attributes == other._attributes 

545 and self._amplifiers == other._amplifiers 

546 and self._frames == other._frames 

547 and self.visit == other.visit 

548 ) 

549 

550 __hash__ = None # type: ignore[assignment] 

551 

552 @property 

553 def instrument(self) -> str: 

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

555 return self._frame.instrument 

556 

557 @property 

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

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

560 `None`). 

561 """ 

562 return self._frame.visit 

563 

564 @property 

565 def name(self) -> str: 

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

567 return self._attributes.name 

568 

569 @property 

570 def id(self) -> int: 

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

572 return self._attributes.id 

573 

574 @property 

575 def type(self) -> DetectorType: 

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

577 return self._attributes.type 

578 

579 @property 

580 def serial(self) -> str: 

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

582 return self._attributes.serial 

583 

584 @property 

585 def bbox(self) -> Box: 

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

587 assembly (`.Box`). 

588 """ 

589 return self._attributes.bbox 

590 

591 @property 

592 def orientation(self) -> Orientation: 

593 """Nominal position and rotation of the detector 

594 (`Orientation`). 

595 """ 

596 return self._attributes.orientation 

597 

598 @property 

599 def pixel_size(self) -> float: 

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

601 units (`float`). 

602 """ 

603 return self._attributes.pixel_size 

604 

605 @property 

606 def physical_type(self) -> str: 

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

608 

609 This may have a different interpretation for different cameras. 

610 """ 

611 return self._attributes.physical_type 

612 

613 @property 

614 def frame(self) -> DetectorFrame: 

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

616 grid (`.DetectorFrame`). 

617 """ 

618 return self._frame 

619 

620 @property 

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

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

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

624 """ 

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

626 

627 @property 

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

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

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

631 """ 

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

633 

634 @property 

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

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

637 return self._amplifiers 

638 

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

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

641 

642 Parameters 

643 ---------- 

644 options : `DescribeOptions`, optional 

645 Unused; accepted for interface compatibility. 

646 """ 

647 return Report( 

648 type_name="Detector", 

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

650 fields=[ 

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

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

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

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

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

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

657 ], 

658 ) 

659 

660 def copy(self) -> Detector: 

661 """Copy the detector. 

662 

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

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

665 immutable. 

666 """ 

667 return Detector( 

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

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

670 frames=self._frames, 

671 ) 

672 

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

674 """Serialize this detector to an archive. 

675 

676 Parameters 

677 ---------- 

678 archive 

679 Archive to save to. 

680 save_frames 

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

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

683 they are part of a multi-detector object. 

684 """ 

685 return DetectorSerializationModel( 

686 attributes=self._attributes, 

687 amplifiers=self._amplifiers, 

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

689 visit=self.visit, 

690 ) 

691 

692 @staticmethod 

693 def _get_archive_tree_type( 

694 pointer_type: builtins.type[Any], 

695 ) -> builtins.type[DetectorSerializationModel]: 

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

697 type that uses the given pointer type. 

698 """ 

699 return DetectorSerializationModel 

700 

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

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

703 

704 Parameters 

705 ---------- 

706 is_raw_assembled 

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

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

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

710 a visit ID to detectors that have been assembled. 

711 """ 

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

713 from lsst.geom import Extent2D, Point2D 

714 

715 if is_raw_assembled is None: 

716 is_raw_assembled = self.visit is not None 

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

718 # camera. 

719 camera_builder = Camera.Builder(self.name) 

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

721 legacy_fp_to_fa = fp_to_fa.to_legacy() 

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

723 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa) 

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

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

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

727 detector_builder.setSerial(self.serial) 

728 detector_builder.setPhysicalType(self.physical_type) 

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

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

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

732 for amp in self.amplifiers: 

733 try: 

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

735 except Exception as err: 

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

737 raise 

738 camera = camera_builder.finish() 

739 return camera[self.id] 

740 

741 @staticmethod 

742 def from_legacy( 

743 legacy_detector: LegacyDetector, 

744 *, 

745 instrument: str, 

746 visit: int | None = None, 

747 is_raw_assembled: bool | None = None, 

748 ) -> Detector: 

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

750 

751 Parameters 

752 ---------- 

753 legacy_detector 

754 Legacy detector to convert. 

755 instrument 

756 Name of the instrument this detector belongs to. 

757 visit 

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

759 particular visit. 

760 is_raw_assembled 

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

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

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

764 a visit ID to detectors that have been assembled. 

765 """ 

766 if is_raw_assembled is None: 

767 is_raw_assembled = visit is not None 

768 attributes = DetectorAttributes( 

769 name=legacy_detector.getName(), 

770 id=legacy_detector.getId(), 

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

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

773 serial=legacy_detector.getSerial(), 

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

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

776 physical_type=legacy_detector.getPhysicalType(), 

777 ) 

778 amplifiers = [ 

779 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled) 

780 for legacy_amp in legacy_detector.getAmplifiers() 

781 ] 

782 transform_map = legacy_detector.getTransformMap() 

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

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

785 

786 

787class DetectorSerializationModel(ArchiveTree): 

788 """Serialization model for `Detector`.""" 

789 

790 SCHEMA_NAME: ClassVar[str] = "detector" 

791 SCHEMA_VERSION: ClassVar[str] = "1.0.0.dev0" 

792 MIN_READ_VERSION: ClassVar[int] = 1 

793 PUBLIC_TYPE: ClassVar[type] = Detector 

794 

795 attributes: DetectorAttributes = pydantic.Field( 

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

797 ) 

798 

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

800 default_factory=list, 

801 description="Descriptions of the amplifiers.", 

802 ) 

803 

804 frames: CameraFrameSetSerializationModel | None = pydantic.Field( 

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

806 ) 

807 

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

809 

810 def deserialize( 

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

812 ) -> Detector: 

813 """Deserialize this detector from an archive. 

814 

815 Parameters 

816 ---------- 

817 archive 

818 Serialization model instance for this detector. 

819 frames 

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

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

822 **kwargs 

823 Unsupported keyword arguments are accepted only to provide 

824 better error messages (raising 

825 `.serialization.InvalidParameterError`). 

826 """ 

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

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

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

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

831 raise ArchiveReadError( 

832 "Serialized detector did not include coordinate transforms, " 

833 "and 'frames' was not provided." 

834 ) 

835 frames = self.frames.deserialize(archive) 

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