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

116 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. 

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 ..describe import DescribeOptions, FieldRole, Report, ReportField 

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

24from . import _ast as astshim 

25from . import _frames # use this import style to facilitate pattern matching 

26from ._frame_set import FrameLookupError, FrameSet 

27from ._transform import Transform 

28 

29 

30class CameraFrameSet(FrameSet): 

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

32 

33 The `CameraFrameSet` class constructor is considered a private 

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

35 loading them from storage (via 

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

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

38 

39 Parameters 

40 ---------- 

41 instrument 

42 Short (butler dimension) name of the instrument. 

43 ast 

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

45 """ 

46 

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

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

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

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

51 # lsst.afw.cameraGeom.TransformMap.makeFrameSet. 

52 # Should have frames with Ident values FOCAL_PLANE, FIELD_ANGLE 

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

54 # units. 

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

56 self._ast = ast 

57 self._focal_plane_frame_id: int = 0 

58 self._field_angle_frame_id: int = 0 

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

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

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

62 match ast_frame.ident: 

63 case "FOCAL_PLANE": 

64 self._focal_plane_frame_id = frame_id 

65 case "FIELD_ANGLE": 

66 self._field_angle_frame_id = frame_id 

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

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

69 self._detector_frame_ids[detector_id] = frame_id 

70 case _: 

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

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

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

74 self._focal_plane_frame = _frames.FocalPlaneFrame( 

75 instrument=instrument, 

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

77 ) 

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

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

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

81 

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

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

84 return NotImplemented 

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

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

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

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

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

90 # particular intermediate representation. 

91 return ( 

92 self.instrument == other.instrument 

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

94 ) 

95 

96 __hash__ = None # type: ignore[assignment] 

97 

98 @property 

99 def instrument(self) -> str: 

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

101 return self._focal_plane_frame.instrument 

102 

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

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

105 

106 Parameters 

107 ---------- 

108 options : `DescribeOptions`, optional 

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

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

111 """ 

112 detectors = sorted(self._detector_frame_ids) 

113 count = len(detectors) 

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

115 fields = [ 

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

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

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

119 ReportField( 

120 label="focal plane unit", 

121 value=self._focal_plane_frame.unit, 

122 role=FieldRole.DERIVED, 

123 ), 

124 ] 

125 if not options.brief: 

126 fields.append( 

127 ReportField( 

128 label="detectors", 

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

130 role=FieldRole.DERIVED, 

131 ) 

132 ) 

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

134 

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

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

137 

138 Parameters 

139 ---------- 

140 visit 

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

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

143 the sky via a `SkyProjection`. 

144 """ 

145 if visit is None: 

146 return self._focal_plane_frame 

147 else: 

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

149 

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

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

152 

153 Parameters 

154 ---------- 

155 visit 

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

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

158 the sky via a `SkyProjection`. 

159 """ 

160 if visit is None: 

161 return self._field_angle_frame 

162 else: 

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

164 

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

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

167 

168 Parameters 

169 ---------- 

170 detector 

171 ID of the detector. 

172 visit 

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

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

175 the sky via a `SkyProjection`. 

176 """ 

177 try: 

178 frame_id = self._detector_frame_ids[detector] 

179 except KeyError: 

180 raise FrameLookupError( 

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

182 ) from None 

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

184 bbox = Box.factory[ 

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

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

187 ] 

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

189 

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

191 try: 

192 self._parse_frame_arg(frame) 

193 return True 

194 except FrameLookupError: 

195 return False 

196 

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

198 in_frame, out_frame = key 

199 in_frame_id, in_bounds = self._parse_frame_arg(in_frame) 

200 out_frame_id, out_bounds = self._parse_frame_arg(out_frame) 

201 return Transform( 

202 in_frame, 

203 out_frame, 

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

205 in_bounds=in_bounds, 

206 out_bounds=out_bounds, 

207 ) 

208 

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

210 bounds: Bounds | None = None 

211 match frame: 

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

213 try: 

214 frame_id = self._detector_frame_ids[detector_id] 

215 except KeyError: 

216 raise FrameLookupError( 

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

218 ) from None 

219 bounds = frame.bbox 

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

221 frame_id = self._focal_plane_frame_id 

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

223 frame_id = self._field_angle_frame_id 

224 case _: 

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

226 return frame_id, bounds 

227 

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

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

230 

231 Parameters 

232 ---------- 

233 archive 

234 Archive to serialize to. 

235 

236 Returns 

237 ------- 

238 `CameraFrameSetSerializationModel` 

239 Serialized form of the frame set. 

240 """ 

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

242 

243 @staticmethod 

244 def _get_archive_tree_type( 

245 pointer_type: type[pydantic.BaseModel], 

246 ) -> type[CameraFrameSetSerializationModel]: 

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

248 type that uses the given pointer type. 

249 """ 

250 return CameraFrameSetSerializationModel 

251 

252 @classmethod 

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

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

255 

256 Parameters 

257 ---------- 

258 camera 

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

260 """ 

261 transform_map = camera.getTransformMap() 

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

263 return CameraFrameSet("HSC", ast_frame_set) 

264 

265 

266class CameraFrameSetSerializationModel(ArchiveTree): 

267 """Serialization model for `CameraFrameSet`.""" 

268 

269 SCHEMA_NAME: ClassVar[str] = "camera_frame_set" 

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

271 MIN_READ_VERSION: ClassVar[int] = 1 

272 PUBLIC_TYPE: ClassVar[type] = CameraFrameSet 

273 

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

275 ast: str = pydantic.Field( 

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

277 ) 

278 

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

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

281 

282 Parameters 

283 ---------- 

284 archive 

285 Archive to read from. 

286 **kwargs 

287 Unsupported keyword arguments are accepted only to provide better 

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

289 """ 

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

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

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