Coverage for python/lsst/images/cameras/_camera_frame_set.py: 28%

114 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-19 10:11 +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. 

11 

12from __future__ import annotations 

13 

14__all__ = ("CameraFrameSet", "CameraFrameSetSerializationModel") 

15 

16from typing import Any, ClassVar 

17 

18import astropy.units as u 

19import pydantic 

20 

21from .._geom import Bounds, Box 

22from .._transforms import ( 

23 FrameLookupError, 

24 FrameSet, 

25 Transform, 

26 _frames, # use this import style to facilitate pattern matching 

27) 

28from .._transforms import _ast as astshim 

29from ..describe import DescribeOptions, FieldRole, Report, ReportField 

30from ..serialization import ArchiveTree, InputArchive, InvalidParameterError, OutputArchive 

31 

32 

33class CameraFrameSet(FrameSet): 

34 """A `.FrameSet` that manages the coordinate systems of a camera. 

35 

36 The `CameraFrameSet` class constructor is considered a private 

37 implementation detail. At present, instances can only be obtained by 

38 loading them from storage (via 

39 `~CameraFrameSetSerializationModel.deserialize`) or converting a legacy 

40 `lsst.afw.cameraGeom` object (`from_legacy`). 

41 

42 Parameters 

43 ---------- 

44 instrument 

45 Short (butler dimension) name of the instrument. 

46 ast 

47 AST frame set describing the camera's coordinate systems. 

48 """ 

49 

50 # This constructor is kept private while we support both the astshim 

51 # and starlink-pyast AST wrappers. For now: 

52 # 'instrument': the short (butler dimension) name. 

53 # 'ast': an astshim.FrameSet as returned by 

54 # lsst.afw.cameraGeom.TransformMap.makeFrameSet. 

55 # Should have frames with Ident values FOCAL_PLANE, FIELD_ANGLE 

56 # and DETECTOR_${ID}, and the focal plane frame must know its 

57 # units. 

58 def __init__(self, instrument: str, ast: astshim.FrameSet) -> None: 

59 self._ast = ast 

60 self._focal_plane_frame_id: int = 0 

61 self._field_angle_frame_id: int = 0 

62 self._detector_frame_ids: dict[int, int] = {} 

63 for frame_id in range(1, self._ast.nFrame + 1): 

64 ast_frame = self._ast.getFrame(frame_id, copy=False) 

65 match ast_frame.ident: 

66 case "FOCAL_PLANE": 

67 self._focal_plane_frame_id = frame_id 

68 case "FIELD_ANGLE": 

69 self._field_angle_frame_id = frame_id 

70 case str(s) if s.startswith("DETECTOR_"): 70 ↛ 73line 70 didn't jump to line 73 because the pattern on line 70 always matched

71 detector_id = int(s.removeprefix("DETECTOR_")) 

72 self._detector_frame_ids[detector_id] = frame_id 

73 case _: 

74 raise ValueError(f"Unexpected frame in camera AST FrameSet:\n{ast_frame.show()}.") 

75 if self._focal_plane_frame_id == 0: 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 raise ValueError("No FOCAL_PLANE frame in camera AST FrameSet.") 

77 self._focal_plane_frame = _frames.FocalPlaneFrame( 

78 instrument=instrument, 

79 unit=u.Unit(self._ast.getFrame(self._focal_plane_frame_id, copy=False).getUnit(1)), 

80 ) 

81 self._field_angle_frame = _frames.FieldAngleFrame(instrument=instrument) 

82 if self._field_angle_frame_id == 0: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true

83 raise ValueError("No FIELD_ANGLE frame in camera AST FrameSet.") 

84 

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

86 if type(other) is not CameraFrameSet: 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true

87 return NotImplemented 

88 # Every cached attribute on this class is derived from ``_ast`` and 

89 # the instrument name. Compare the *simplified* AST serialisations 

90 # explicitly rather than relying on ``Object.__eq__``: what we care 

91 # about is that the two frame sets describe the same transforms, and 

92 # simplifying first makes the check about behaviour rather than the 

93 # particular intermediate representation. 

94 return ( 

95 self.instrument == other.instrument 

96 and self._ast.simplified().show() == other._ast.simplified().show() 

97 ) 

98 

99 __hash__ = None # type: ignore[assignment] 

100 

101 @property 

102 def instrument(self) -> str: 

103 """Name of the instrument (`str`).""" 

104 return self._focal_plane_frame.instrument 

105 

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

107 """Return a `Report` describing this camera frame set. 

108 

109 Parameters 

110 ---------- 

111 options : `DescribeOptions`, optional 

112 Rendering options. `DescribeOptions.brief` omits the detector 

113 list, which is one entry per detector in the camera. 

114 """ 

115 detectors = sorted(self._detector_frame_ids) 

116 count = len(detectors) 

117 summary = f"CameraFrameSet({self.instrument!r}, {count} detector{'' if count == 1 else 's'})" 

118 fields = [ 

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

120 # The focal plane and field angle frames are required by the 

121 # constructor, so only their configuration is worth reporting. 

122 ReportField( 

123 label="focal plane unit", 

124 value=self._focal_plane_frame.unit, 

125 role=FieldRole.DERIVED, 

126 ), 

127 ] 

128 if not options.brief: 

129 fields.append( 

130 ReportField( 

131 label="detectors", 

132 value=", ".join(str(detector) for detector in detectors), 

133 role=FieldRole.DERIVED, 

134 ) 

135 ) 

136 return Report(type_name="CameraFrameSet", summary=summary, fields=fields) 

137 

138 def focal_plane(self, visit: int | None = None) -> _frames.FocalPlaneFrame: 

139 """Return a focal plane frame for this instrument. 

140 

141 Parameters 

142 ---------- 

143 visit 

144 ID for the visit this frame will correspond to. This only needs 

145 to be provided in contexts where camera frames will be related to 

146 the sky via a `.SkyProjection`. 

147 """ 

148 if visit is None: 

149 return self._focal_plane_frame 

150 else: 

151 return self._focal_plane_frame.model_copy(update={"visit": visit}) 

152 

153 def field_angle(self, visit: int | None = None) -> _frames.FieldAngleFrame: 

154 """Return a field angle frame for this instrument. 

155 

156 Parameters 

157 ---------- 

158 visit 

159 ID for the visit this frame will correspond to. This only needs 

160 to be provided in contexts where camera frames will be related to 

161 the sky via a `.SkyProjection`. 

162 """ 

163 if visit is None: 

164 return self._field_angle_frame 

165 else: 

166 return self._field_angle_frame.model_copy(update={"visit": visit}) 

167 

168 def detector(self, detector: int, *, visit: int | None = None) -> _frames.DetectorFrame: 

169 """Return a detector pixel-coordinate frame for this instrument. 

170 

171 Parameters 

172 ---------- 

173 detector 

174 ID of the detector. 

175 visit 

176 ID for the visit this frame will correspond to. This only needs 

177 to be provided in contexts where camera frames will be related to 

178 the sky via a `.SkyProjection`. 

179 """ 

180 try: 

181 frame_id = self._detector_frame_ids[detector] 

182 except KeyError: 

183 raise FrameLookupError( 

184 f"No frame for detector {detector!r} in camera for {self.instrument!r}." 

185 ) from None 

186 ast_frame = self._ast.getFrame(frame_id, copy=False) 

187 bbox = Box.factory[ 

188 int(ast_frame.getBottom(2)) : int(ast_frame.getTop(2)), 

189 int(ast_frame.getBottom(1)) : int(ast_frame.getTop(1)), 

190 ] 

191 return _frames.DetectorFrame(instrument=self.instrument, detector=detector, visit=visit, bbox=bbox) 

192 

193 def __contains__(self, frame: _frames.Frame) -> bool: 

194 try: 

195 self._parse_frame_arg(frame) 

196 return True 

197 except FrameLookupError: 

198 return False 

199 

200 def __getitem__[I: _frames.Frame, O: _frames.Frame](self, key: tuple[I, O]) -> Transform[I, O]: 

201 in_frame, out_frame = key 

202 in_frame_id, in_bounds = self._parse_frame_arg(in_frame) 

203 out_frame_id, out_bounds = self._parse_frame_arg(out_frame) 

204 return Transform( 

205 in_frame, 

206 out_frame, 

207 self._ast.getMapping(in_frame_id, out_frame_id), 

208 in_bounds=in_bounds, 

209 out_bounds=out_bounds, 

210 ) 

211 

212 def _parse_frame_arg(self, frame: _frames.Frame) -> tuple[int, Bounds | None]: 

213 bounds: Bounds | None = None 

214 match frame: 

215 case _frames.DetectorFrame(instrument=self.instrument, detector=detector_id): 

216 try: 

217 frame_id = self._detector_frame_ids[detector_id] 

218 except KeyError: 

219 raise FrameLookupError( 

220 f"No frame for detector {detector_id!r} in camera for {self.instrument!r}." 

221 ) from None 

222 bounds = frame.bbox 

223 case _frames.FocalPlaneFrame(instrument=self.instrument): 

224 frame_id = self._focal_plane_frame_id 

225 case _frames.FieldAngleFrame(instrument=self.instrument): 

226 frame_id = self._field_angle_frame_id 

227 case _: 

228 raise FrameLookupError(f"Invalid frame for camera {self.instrument}: {frame!r}.") 

229 return frame_id, bounds 

230 

231 def serialize(self, archive: OutputArchive[Any]) -> CameraFrameSetSerializationModel: 

232 """Serialize the frame set to an archive. 

233 

234 Parameters 

235 ---------- 

236 archive 

237 Archive to serialize to. 

238 

239 Returns 

240 ------- 

241 `CameraFrameSetSerializationModel` 

242 Serialized form of the frame set. 

243 """ 

244 return CameraFrameSetSerializationModel(instrument=self.instrument, ast=self._ast.show()) 

245 

246 @staticmethod 

247 def _get_archive_tree_type( 

248 pointer_type: type[pydantic.BaseModel], 

249 ) -> type[CameraFrameSetSerializationModel]: 

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

251 type that uses the given pointer type. 

252 """ 

253 return CameraFrameSetSerializationModel 

254 

255 @classmethod 

256 def from_legacy(cls, camera: Any) -> CameraFrameSet: 

257 """Construct a transform from a legacy `lsst.afw.cameraGeom.Camera`. 

258 

259 Parameters 

260 ---------- 

261 camera 

262 An `lsst.afw.cameraGeom.Camera` instance to convert. 

263 """ 

264 transform_map = camera.getTransformMap() 

265 ast_frame_set = transform_map.makeFrameSet(list(camera)) 

266 return CameraFrameSet("HSC", ast_frame_set) 

267 

268 

269class CameraFrameSetSerializationModel(ArchiveTree): 

270 """Serialization model for `CameraFrameSet`.""" 

271 

272 SCHEMA_NAME: ClassVar[str] = "camera_frame_set" 

273 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

274 MIN_READ_VERSION: ClassVar[int] = 1 

275 PUBLIC_TYPE: ClassVar[type] = CameraFrameSet 

276 

277 instrument: str = pydantic.Field(description="Name of the instrument.") 

278 ast: str = pydantic.Field( 

279 description="A serialized Starlink AST FrameSet, using the AST native encoding." 

280 ) 

281 

282 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> CameraFrameSet: 

283 """Deserialize a frame set from an archive. 

284 

285 Parameters 

286 ---------- 

287 archive 

288 Archive to read from. 

289 **kwargs 

290 Unsupported keyword arguments are accepted only to provide better 

291 error messages (raising `.serialization.InvalidParameterError`). 

292 """ 

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

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

295 return CameraFrameSet(self.instrument, astshim.FrameSet.fromString(self.ast))