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

290 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 08:56 +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 .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 

86 Parameters 

87 ---------- 

88 legacy_detector_type 

89 Legacy detector type to convert. 

90 """ 

91 return getattr(cls, legacy_detector_type.name) 

92 

93 

94@final 

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

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

97 detector within a camera focal plane. 

98 """ 

99 

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

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

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

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

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

105 yaw: Quantity = pydantic.Field( 

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

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

108 ) 

109 pitch: Quantity = pydantic.Field( 

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

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

112 ) 

113 roll: Quantity = pydantic.Field( 

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

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

116 ) 

117 

118 def to_legacy(self) -> LegacyOrientation: 

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

120 from lsst.afw.cameraGeom import Orientation as LegacyOrientation 

121 from lsst.geom import Point2D, Point3D, radians 

122 

123 return LegacyOrientation( 

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

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

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

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

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

129 ) 

130 

131 @staticmethod 

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

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

134 

135 Parameters 

136 ---------- 

137 legacy_orientation 

138 Legacy orientation to convert. 

139 """ 

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

141 pixel_reference_x, pixel_reference_y = legacy_orientation.getReferencePoint() 

142 return Orientation( 

143 focal_plane_x=focal_plane_x, 

144 focal_plane_y=focal_plane_y, 

145 focal_plane_z=focal_plane_z, 

146 pixel_reference_x=pixel_reference_x, 

147 pixel_reference_y=pixel_reference_y, 

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

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

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

151 ) 

152 

153 

154@final 

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

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

157 

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

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

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

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

162 bbox: Box = pydantic.Field( 

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

164 ) 

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

166 pixel_size: float = pydantic.Field( 

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

168 ) 

169 physical_type: str = pydantic.Field( 

170 description=( 

171 "Vendor name or technology type for this detector " 

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

173 ) 

174 ) 

175 

176 

177class ReadoutCorner(enum.StrEnum): 

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

179 

180 LL = "LL" 

181 LR = "LR" 

182 UR = "UR" 

183 UL = "UL" 

184 

185 def to_legacy(self) -> LegacyReadoutCorner: 

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

187 from lsst.afw.cameraGeom import ReadoutCorner as LegacyReadoutCorner 

188 

189 return getattr(LegacyReadoutCorner, self.value) 

190 

191 @classmethod 

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

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

194 

195 Parameters 

196 ---------- 

197 legacy_readout_corner 

198 Legacy readout corner to convert. 

199 """ 

200 return getattr(cls, legacy_readout_corner.name) 

201 

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

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

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

205 """ 

206 return YX( 

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

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

209 ) 

210 

211 @classmethod 

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

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

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

215 

216 Parameters 

217 ---------- 

218 y 

219 Whether the image is flipped in the y direction. 

220 x 

221 Whether the image is flipped in the x direction. 

222 """ 

223 match y, x: 

224 case False, False: 

225 return cls.LL 

226 case False, True: 

227 return cls.LR 

228 case True, True: 

229 return cls.UR 

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

231 return cls.UL 

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

233 

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

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

236 

237 Parameters 

238 ---------- 

239 y 

240 Whether to flip in the y direction. 

241 x 

242 Whether to flip in the x direction. 

243 """ 

244 current = self.as_flips() 

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

246 

247 

248@final 

249class AmplifierRawGeometry(pydantic.BaseModel): 

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

251 

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

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

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

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

256 x_offset: int = pydantic.Field( 

257 0, 

258 description=( 

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

260 "assembled position of the amplifier." 

261 ), 

262 ) 

263 y_offset: int = pydantic.Field( 

264 0, 

265 description=( 

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

267 "assembled position of the amplifier." 

268 ), 

269 ) 

270 serial_overscan_bbox: Box = pydantic.Field( 

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

272 ) 

273 parallel_overscan_bbox: Box = pydantic.Field( 

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

275 ) 

276 prescan_bbox: Box = pydantic.Field( 

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

278 ) 

279 readout_corner: ReadoutCorner = pydantic.Field( 

280 description=( 

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

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

283 ) 

284 ) 

285 

286 @property 

287 def horizontal_overscan_bbox(self) -> Box: 

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

289 image (`.Box`). 

290 """ 

291 return self.serial_overscan_bbox 

292 

293 @horizontal_overscan_bbox.setter 

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

295 self.serial_overscan_bbox = value 

296 

297 @property 

298 def vertical_overscan_bbox(self) -> Box: 

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

300 image (`.Box`). 

301 """ 

302 return self.parallel_overscan_bbox 

303 

304 @vertical_overscan_bbox.setter 

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

306 self.parallel_overscan_bbox = value 

307 

308 @property 

309 def horizontal_prescan_bbox(self) -> Box: 

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

311 image (`.Box`). 

312 """ 

313 return self.prescan_bbox 

314 

315 @horizontal_prescan_bbox.setter 

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

317 self.prescan_bbox = value 

318 

319 @property 

320 def serial_prescan_bbox(self) -> Box: 

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

322 image (`.Box`). 

323 """ 

324 return self.prescan_bbox 

325 

326 @serial_prescan_bbox.setter 

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

328 self.prescan_bbox = value 

329 

330 @staticmethod 

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

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

333 

334 Parameters 

335 ---------- 

336 legacy_amplifier 

337 Legacy amplifier to convert. 

338 """ 

339 x_offset, y_offset = legacy_amplifier.getRawXYOffset() 

340 return AmplifierRawGeometry( 

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

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

343 flip_x=legacy_amplifier.getRawFlipX(), 

344 flip_y=legacy_amplifier.getRawFlipY(), 

345 x_offset=x_offset, 

346 y_offset=y_offset, 

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

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

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

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

351 ) 

352 

353 

354@final 

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

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

357 often superseded by separate calibration datasets. 

358 """ 

359 

360 gain: float 

361 read_noise: float 

362 saturation: float 

363 suspect_level: float 

364 linearity_coefficients: InlineArray 

365 linearity_type: str 

366 

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

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

369 return NotImplemented 

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

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

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

373 return ( 

374 self.gain == other.gain 

375 and self.read_noise == other.read_noise 

376 and self.saturation == other.saturation 

377 and ( 

378 self.suspect_level == other.suspect_level 

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

380 ) 

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

382 and self.linearity_type == other.linearity_type 

383 ) 

384 

385 @staticmethod 

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

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

388 

389 Parameters 

390 ---------- 

391 legacy_amplifier 

392 Legacy amplifier to convert. 

393 """ 

394 return AmplifierCalibrations( 

395 gain=legacy_amplifier.getGain(), 

396 read_noise=legacy_amplifier.getReadNoise(), 

397 saturation=legacy_amplifier.getSaturation(), 

398 suspect_level=legacy_amplifier.getSuspectLevel(), 

399 linearity_coefficients=legacy_amplifier.getLinearityCoeffs(), 

400 linearity_type=legacy_amplifier.getLinearityType(), 

401 ) 

402 

403 

404@final 

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

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

407 

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

409 bbox: Box = pydantic.Field( 

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

411 ) 

412 readout_corner: ReadoutCorner = pydantic.Field( 

413 description=( 

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

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

416 ) 

417 ) 

418 assembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

419 None, 

420 description=( 

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

422 ), 

423 ) 

424 unassembled_raw_geometry: AmplifierRawGeometry | None = pydantic.Field( 

425 None, 

426 description=( 

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

428 ), 

429 ) 

430 nominal_calibrations: AmplifierCalibrations | None = pydantic.Field( 

431 None, 

432 description=( 

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

434 ), 

435 ) 

436 

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

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

439 

440 Parameters 

441 ---------- 

442 is_raw_assembled 

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

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

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

446 a visit ID to detectors that have been assembled. 

447 """ 

448 from lsst.afw.cameraGeom import Amplifier as LegacyAmplifier 

449 from lsst.geom import Extent2I 

450 

451 builder = LegacyAmplifier.Builder() 

452 builder.setName(self.name) 

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

454 if is_raw_assembled: 

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

456 raise ValueError( 

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

458 ) 

459 else: 

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

461 raise ValueError( 

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

463 ) 

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

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

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

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

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

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

470 builder.setRawFlipX(raw_geom.flip_x) 

471 builder.setRawFlipY(raw_geom.flip_y) 

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

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

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

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

476 if self.nominal_calibrations is not None: 

477 builder.setGain(self.nominal_calibrations.gain) 

478 builder.setReadNoise(self.nominal_calibrations.read_noise) 

479 builder.setSaturation(self.nominal_calibrations.saturation) 

480 builder.setSuspectLevel(self.nominal_calibrations.suspect_level) 

481 builder.setLinearityCoeffs(self.nominal_calibrations.linearity_coefficients) 

482 builder.setLinearityType(self.nominal_calibrations.linearity_type) 

483 return builder 

484 

485 @staticmethod 

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

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

488 

489 Parameters 

490 ---------- 

491 legacy_amplifier 

492 Legacy amplifier to convert. 

493 is_raw_assembled 

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

495 `Amplifier.unassembled_raw_geometry` (`False`). 

496 """ 

497 raw_geometry = AmplifierRawGeometry.from_legacy_amplifier(legacy_amplifier) 

498 nominal_calibrations = AmplifierCalibrations.from_legacy_amplifier(legacy_amplifier) 

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

500 return Amplifier( 

501 name=legacy_amplifier.getName(), 

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

503 readout_corner=readout_corner, 

504 assembled_raw_geometry=raw_geometry if is_raw_assembled else None, 

505 unassembled_raw_geometry=raw_geometry if not is_raw_assembled else None, 

506 nominal_calibrations=nominal_calibrations, 

507 ) 

508 

509 

510@final 

511class Detector: 

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

513 

514 Parameters 

515 ---------- 

516 attributes 

517 Identifying attributes and metadata for the detector. 

518 amplifiers 

519 Amplifiers that make up the detector. 

520 frames 

521 Coordinate systems and transforms for the camera. 

522 visit 

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

524 detector geometry. 

525 """ 

526 

527 def __init__( 

528 self, 

529 attributes: DetectorAttributes, 

530 amplifiers: Iterable[Amplifier], 

531 frames: CameraFrameSet, 

532 visit: int | None = None, 

533 ) -> None: 

534 self._attributes = attributes 

535 self._amplifiers = list(amplifiers) 

536 self._frames = frames 

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

538 

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

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

541 return NotImplemented 

542 return ( 

543 self._attributes == other._attributes 

544 and self._amplifiers == other._amplifiers 

545 and self._frames == other._frames 

546 and self.visit == other.visit 

547 ) 

548 

549 __hash__ = None # type: ignore[assignment] 

550 

551 @property 

552 def instrument(self) -> str: 

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

554 return self._frame.instrument 

555 

556 @property 

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

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

559 `None`). 

560 """ 

561 return self._frame.visit 

562 

563 @property 

564 def name(self) -> str: 

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

566 return self._attributes.name 

567 

568 @property 

569 def id(self) -> int: 

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

571 return self._attributes.id 

572 

573 @property 

574 def type(self) -> DetectorType: 

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

576 return self._attributes.type 

577 

578 @property 

579 def serial(self) -> str: 

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

581 return self._attributes.serial 

582 

583 @property 

584 def bbox(self) -> Box: 

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

586 assembly (`.Box`). 

587 """ 

588 return self._attributes.bbox 

589 

590 @property 

591 def orientation(self) -> Orientation: 

592 """Nominal position and rotation of the detector 

593 (`Orientation`). 

594 """ 

595 return self._attributes.orientation 

596 

597 @property 

598 def pixel_size(self) -> float: 

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

600 units (`float`). 

601 """ 

602 return self._attributes.pixel_size 

603 

604 @property 

605 def physical_type(self) -> str: 

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

607 

608 This may have a different interpretation for different cameras. 

609 """ 

610 return self._attributes.physical_type 

611 

612 @property 

613 def frame(self) -> DetectorFrame: 

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

615 grid (`.DetectorFrame`). 

616 """ 

617 return self._frame 

618 

619 @property 

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

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

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

623 """ 

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

625 

626 @property 

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

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

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

630 """ 

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

632 

633 @property 

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

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

636 return self._amplifiers 

637 

638 def copy(self) -> Detector: 

639 """Copy the detector. 

640 

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

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

643 immutable. 

644 """ 

645 return Detector( 

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

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

648 frames=self._frames, 

649 ) 

650 

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

652 """Serialize this detector to an archive. 

653 

654 Parameters 

655 ---------- 

656 archive 

657 Archive to save to. 

658 save_frames 

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

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

661 they are part of a multi-detector object. 

662 """ 

663 return DetectorSerializationModel( 

664 attributes=self._attributes, 

665 amplifiers=self._amplifiers, 

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

667 visit=self.visit, 

668 ) 

669 

670 @staticmethod 

671 def _get_archive_tree_type( 

672 pointer_type: builtins.type[Any], 

673 ) -> builtins.type[DetectorSerializationModel]: 

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

675 type that uses the given pointer type. 

676 """ 

677 return DetectorSerializationModel 

678 

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

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

681 

682 Parameters 

683 ---------- 

684 is_raw_assembled 

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

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

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

688 a visit ID to detectors that have been assembled. 

689 """ 

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

691 from lsst.geom import Extent2D, Point2D 

692 

693 if is_raw_assembled is None: 

694 is_raw_assembled = self.visit is not None 

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

696 # camera. 

697 camera_builder = Camera.Builder(self.name) 

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

699 legacy_fp_to_fa = fp_to_fa.to_legacy() 

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

701 camera_builder.setTransformFromFocalPlaneTo(FIELD_ANGLE, legacy_fp_to_fa) 

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

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

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

705 detector_builder.setSerial(self.serial) 

706 detector_builder.setPhysicalType(self.physical_type) 

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

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

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

710 for amp in self.amplifiers: 

711 try: 

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

713 except Exception as err: 

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

715 raise 

716 camera = camera_builder.finish() 

717 return camera[self.id] 

718 

719 @staticmethod 

720 def from_legacy( 

721 legacy_detector: LegacyDetector, 

722 *, 

723 instrument: str, 

724 visit: int | None = None, 

725 is_raw_assembled: bool | None = None, 

726 ) -> Detector: 

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

728 

729 Parameters 

730 ---------- 

731 legacy_detector 

732 Legacy detector to convert. 

733 instrument 

734 Name of the instrument this detector belongs to. 

735 visit 

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

737 particular visit. 

738 is_raw_assembled 

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

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

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

742 a visit ID to detectors that have been assembled. 

743 """ 

744 if is_raw_assembled is None: 

745 is_raw_assembled = visit is not None 

746 attributes = DetectorAttributes( 

747 name=legacy_detector.getName(), 

748 id=legacy_detector.getId(), 

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

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

751 serial=legacy_detector.getSerial(), 

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

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

754 physical_type=legacy_detector.getPhysicalType(), 

755 ) 

756 amplifiers = [ 

757 Amplifier.from_legacy(legacy_amp, is_raw_assembled=is_raw_assembled) 

758 for legacy_amp in legacy_detector.getAmplifiers() 

759 ] 

760 transform_map = legacy_detector.getTransformMap() 

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

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

763 

764 

765class DetectorSerializationModel(ArchiveTree): 

766 """Serialization model for `Detector`.""" 

767 

768 SCHEMA_NAME: ClassVar[str] = "detector" 

769 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

770 MIN_READ_VERSION: ClassVar[int] = 1 

771 PUBLIC_TYPE: ClassVar[type] = Detector 

772 

773 attributes: DetectorAttributes = pydantic.Field( 

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

775 ) 

776 

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

778 default_factory=list, 

779 description="Descriptions of the amplifiers.", 

780 ) 

781 

782 frames: CameraFrameSetSerializationModel | None = pydantic.Field( 

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

784 ) 

785 

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

787 

788 def deserialize( 

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

790 ) -> Detector: 

791 """Deserialize this detector from an archive. 

792 

793 Parameters 

794 ---------- 

795 archive 

796 Serialization model instance for this detector. 

797 frames 

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

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

800 **kwargs 

801 Unsupported keyword arguments are accepted only to provide 

802 better error messages (raising 

803 `.serialization.InvalidParameterError`). 

804 """ 

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

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

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

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

809 raise ArchiveReadError( 

810 "Serialized detector did not include coordinate transforms, " 

811 "and 'frames' was not provided." 

812 ) 

813 frames = self.frames.deserialize(archive) 

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