Coverage for tests/test_transforms.py: 83%

528 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 

14import dataclasses 

15import functools 

16import os 

17from typing import Any, ClassVar 

18 

19import astropy.units as u 

20import astropy.wcs 

21import numpy as np 

22import pydantic 

23import pytest 

24from astropy.coordinates import Longitude 

25 

26from lsst.images import ( 

27 ICRS, 

28 XY, 

29 YX, 

30 Box, 

31 CameraFrameSet, 

32 CameraFrameSetSerializationModel, 

33 DetectorFrame, 

34 FocalPlaneFrame, 

35 GeneralFrame, 

36 SkyProjection, 

37 Transform, 

38 TransformSerializationModel, 

39) 

40from lsst.images._transforms import _ast as astshim 

41from lsst.images.describe import FieldRole, Report 

42from lsst.images.fits import PointerModel 

43from lsst.images.serialization import ArchiveTree, InputArchive, JsonRef, OutputArchive 

44from lsst.images.tests import ( 

45 DP2_VISIT_DETECTOR_DATA_ID, 

46 RoundtripFits, 

47 RoundtripJson, 

48 check_transform, 

49 compare_sky_projection_to_legacy_wcs, 

50 legacy_points_to_xy_array, 

51 make_random_sky_projection, 

52) 

53 

54EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None) 

55 

56 

57@pytest.fixture(scope="session") 

58def legacy_camera() -> Any: 

59 """Return a legacy Camera loaded from camera.fits. 

60 

61 Skips if TESTDATA_IMAGES_DIR is unset or lsst.afw.cameraGeom is 

62 unavailable. 

63 """ 

64 if EXTERNAL_DATA_DIR is None: 64 ↛ 66line 64 didn't jump to line 66 because the condition on line 64 was always true

65 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.") 

66 try: 

67 from lsst.afw.cameraGeom import Camera 

68 except ImportError: 

69 pytest.skip("'lsst.afw.cameraGeom' could not be imported.") 

70 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "camera.fits") 

71 return Camera.readFits(filename) 

72 

73 

74@pytest.fixture(scope="session") 

75def legacy_detector_wcs() -> dict[str, Any]: 

76 """Return WCS-related objects read from visit_image.fits. 

77 

78 Skips if TESTDATA_IMAGES_DIR is unset or lsst.afw.image is unavailable. 

79 """ 

80 if EXTERNAL_DATA_DIR is None: 80 ↛ 82line 80 didn't jump to line 82 because the condition on line 80 was always true

81 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.") 

82 try: 

83 from lsst.afw.image import ExposureFitsReader 

84 except ImportError: 

85 pytest.skip("'lsst.afw.image' could not be imported.") 

86 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits") 

87 reader = ExposureFitsReader(filename) 

88 return { 

89 "legacy_wcs": reader.readWcs(), 

90 "wcs_bbox": Box.from_legacy(reader.readDetector().getBBox()), 

91 "subimage_bbox": Box.from_legacy(reader.readBBox()), 

92 } 

93 

94 

95def test_identity() -> None: 

96 """Test an identity transform.""" 

97 frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4]) 

98 xy = frame.bbox.meshgrid().map(np.ravel) 

99 identity = Transform.identity(frame) 

100 check_transform(identity, xy, xy, frame, frame) 

101 assert identity.decompose() == [] 

102 with RoundtripJson(identity) as roundtrip: 

103 pass 

104 check_transform(roundtrip.result, xy, xy, frame, frame) 

105 

106 

107def test_transform_equality() -> None: 

108 """Test Transform.__eq__ across all of its comparison branches.""" 

109 pixel_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4]) 

110 focal_plane = FocalPlaneFrame(instrument="LSSTCam", visit=1, unit=u.mm) 

111 # A distinct frame for the in-frame and out-frame branches. 

112 alt_frame = DetectorFrame(instrument="LSSTCam", visit=1, detector=12, bbox=Box.factory[:5, :4]) 

113 in_bounds = Box.factory[:5, :4] 

114 out_bounds = Box.factory[:10, :8] 

115 

116 def make( 

117 *, 

118 in_frame: Any = pixel_frame, 

119 out_frame: Any = focal_plane, 

120 ast_mapping: astshim.Mapping | None = None, 

121 in_bounds_: Box | None = in_bounds, 

122 out_bounds_: Box | None = out_bounds, 

123 components: Any = (), 

124 ) -> Transform[Any, Any]: 

125 return Transform( 

126 in_frame, 

127 out_frame, 

128 ast_mapping if ast_mapping is not None else astshim.UnitMap(2), 

129 in_bounds=in_bounds_, 

130 out_bounds=out_bounds_, 

131 components=components, 

132 ) 

133 

134 base = make() 

135 

136 # Identity short-circuit: an object is always equal to itself. 

137 assert base == base 

138 

139 # Two independently constructed but equivalent transforms are equal, 

140 # and equality is symmetric. 

141 assert base == make() 

142 assert make() == base 

143 

144 # Comparison against a non-Transform yields NotImplemented, so Python 

145 # falls back to identity: the objects are unequal and != is True. 

146 assert not (base == "not a transform") 

147 assert base != "not a transform" 

148 assert base != None # noqa: E711 

149 assert base != 42 

150 

151 # Each remaining branch differs from base in exactly one attribute. 

152 assert base != make(ast_mapping=astshim.ShiftMap([1.0, 2.0])) 

153 assert base != make(in_bounds_=out_bounds) 

154 assert base != make(out_bounds_=in_bounds) 

155 assert base != make(in_frame=alt_frame) 

156 assert base != make(out_frame=alt_frame) 

157 assert base != make(components=[Transform.identity(alt_frame)]) 

158 

159 

160def test_sky_projection_equality() -> None: 

161 """Test SkyProjection.__eq__ across all of its comparison branches.""" 

162 pixel_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4]) 

163 

164 # Check the two failure modes. 

165 with pytest.raises(ValueError): 

166 SkyProjection(Transform(ICRS, ICRS, astshim.UnitMap(2))) 

167 

168 with pytest.raises(ValueError): 

169 SkyProjection(Transform(pixel_frame, pixel_frame, astshim.UnitMap(2))) 

170 

171 def make_pixel_to_sky(ast_mapping: astshim.Mapping | None = None) -> Transform[Any, Any]: 

172 mapping = ast_mapping if ast_mapping is not None else astshim.UnitMap(2) 

173 return Transform(pixel_frame, ICRS, mapping) 

174 

175 base = SkyProjection(make_pixel_to_sky()) 

176 

177 # Identity short-circuit: an object is always equal to itself. 

178 assert base == base 

179 

180 # Two independently constructed but equivalent projections are equal. 

181 assert base == SkyProjection(make_pixel_to_sky()) 

182 

183 # Comparison against a non-SkyProjection yields NotImplemented. 

184 assert not (base == "not a projection") 

185 assert base != "not a projection" 

186 assert base != None # noqa: E711 

187 

188 # Differ only in the pixel-to-sky transform. 

189 assert base != SkyProjection(make_pixel_to_sky(astshim.ShiftMap([1.0, 2.0]))) 

190 

191 # The fits_approximation branch: absent on base but present here. 

192 with_approx = SkyProjection( 

193 make_pixel_to_sky(), fits_approximation=make_pixel_to_sky(astshim.ShiftMap([0.1, 0.2])) 

194 ) 

195 assert base != with_approx 

196 

197 # Equal pixel-to-sky and equal fits_approximations are equal. 

198 with_approx_again = SkyProjection( 

199 make_pixel_to_sky(), fits_approximation=make_pixel_to_sky(astshim.ShiftMap([0.1, 0.2])) 

200 ) 

201 assert with_approx == with_approx_again 

202 

203 # Same pixel-to-sky transform but a different fits_approximation. 

204 other_approx = SkyProjection( 

205 make_pixel_to_sky(), fits_approximation=make_pixel_to_sky(astshim.ShiftMap([0.3, 0.4])) 

206 ) 

207 assert with_approx != other_approx 

208 

209 

210def test_affine_2x2() -> None: 

211 """Test an affine transform constructed from a 2x2 matrix.""" 

212 in_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4]) 

213 out_frame = GeneralFrame(unit=u.pix) 

214 transform_matrix = np.array([[2.0, 0.25], [-0.75, 0.8]]) 

215 in_xy = in_frame.bbox.meshgrid().map(np.ravel) 

216 in_matrix = np.array([in_xy.x, in_xy.y]) 

217 out_matrix = np.dot(transform_matrix, in_matrix) 

218 check_transform( 

219 Transform.affine(in_frame, out_frame, transform_matrix), 

220 in_xy, 

221 XY(x=out_matrix[0, :], y=out_matrix[1, :]), 

222 in_frame, 

223 out_frame, 

224 in_atol=1e-15 * u.pix, 

225 out_atol=1e-15 * u.pix, 

226 ) 

227 

228 

229def test_affine_3x3() -> None: 

230 """Test an affine transform constructed from a 3x3 matrix.""" 

231 in_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4]) 

232 out_frame = GeneralFrame(unit=u.pix) 

233 transform_matrix = np.array([[2.0, 0.25, -0.5], [-0.75, 0.8, 0.4], [0.0, 0.0, 1.0]]) 

234 in_xy = in_frame.bbox.meshgrid().map(np.ravel) 

235 in_matrix = np.array([in_xy.x, in_xy.y, np.ones(in_xy.x.shape)]) 

236 out_matrix = np.dot(transform_matrix, in_matrix) 

237 check_transform( 

238 Transform.affine(in_frame, out_frame, transform_matrix), 

239 in_xy, 

240 XY(x=out_matrix[0, :], y=out_matrix[1, :]), 

241 in_frame, 

242 out_frame, 

243 in_atol=1e-15 * u.pix, 

244 out_atol=1e-15 * u.pix, 

245 ) 

246 

247 

248def compare_to_legacy_camera(legacy_camera: Any, frame_set: CameraFrameSet) -> None: 

249 """Assert that transforms extracted from a CameraFrameSet match the 

250 legacy afw implementations. 

251 """ 

252 from lsst.afw.cameraGeom import FIELD_ANGLE, FOCAL_PLANE, PIXELS 

253 from lsst.geom import Point2D 

254 

255 legacy_detector = legacy_camera[16] 

256 pixel_legacy_points = [Point2D(50.0, 60.0), Point2D(801.2, 322.8), Point2D(33.5, 22.1)] 

257 fp_legacy_points = [legacy_detector.transform(p, PIXELS, FOCAL_PLANE) for p in pixel_legacy_points] 

258 fa_legacy_points = [legacy_detector.transform(p, PIXELS, FIELD_ANGLE) for p in pixel_legacy_points] 

259 pixel_xy_array = legacy_points_to_xy_array(pixel_legacy_points) 

260 fp_xy_array = legacy_points_to_xy_array(fp_legacy_points) 

261 fa_xy_array = legacy_points_to_xy_array(fa_legacy_points) 

262 # Test transforms extracted directly from the frame set. 

263 pixel_to_fp = frame_set[frame_set.detector(16), frame_set.focal_plane()] 

264 check_transform(pixel_to_fp, pixel_xy_array, fp_xy_array, frame_set.detector(16), frame_set.focal_plane()) 

265 pixel_to_fa = frame_set[frame_set.detector(16), frame_set.field_angle()] 

266 check_transform(pixel_to_fa, pixel_xy_array, fa_xy_array, frame_set.detector(16), frame_set.field_angle()) 

267 fp_to_fa = frame_set[frame_set.focal_plane(), frame_set.field_angle()] 

268 check_transform(fp_to_fa, fp_xy_array, fa_xy_array, frame_set.focal_plane(), frame_set.field_angle()) 

269 # Test a composition. 

270 pixel_to_fa_indirect = pixel_to_fp.then(fp_to_fa) 

271 check_transform( 

272 pixel_to_fa_indirect, 

273 pixel_xy_array, 

274 fa_xy_array, 

275 frame_set.detector(16), 

276 frame_set.field_angle(), 

277 ) 

278 pixel_to_fp_d, fp_to_fa_d = pixel_to_fa_indirect.decompose() 

279 check_transform( 

280 pixel_to_fp_d, pixel_xy_array, fp_xy_array, frame_set.detector(16), frame_set.focal_plane() 

281 ) 

282 check_transform(fp_to_fa_d, fp_xy_array, fa_xy_array, frame_set.focal_plane(), frame_set.field_angle()) 

283 fa_to_fp_d, fp_to_pixel_d = pixel_to_fa_indirect.inverted().decompose() 

284 check_transform(fa_to_fp_d, fa_xy_array, fp_xy_array, frame_set.field_angle(), frame_set.focal_plane()) 

285 check_transform( 

286 fp_to_pixel_d, fp_xy_array, pixel_xy_array, frame_set.focal_plane(), frame_set.detector(16) 

287 ) 

288 

289 

290def test_camera(legacy_camera: Any) -> None: 

291 """Test CameraFrameSet construction, transforms, and FITS/JSON 

292 serialization round-trips. 

293 

294 Also verifies the archive system's pointer and frame-set reference 

295 machinery. 

296 """ 

297 legacy_camera = legacy_camera 

298 frame_set = CameraFrameSet.from_legacy(legacy_camera) 

299 detector_id: int = DP2_VISIT_DETECTOR_DATA_ID["detector"] 

300 compare_to_legacy_camera(legacy_camera, frame_set) 

301 test_holder = FrameSetTestHolder( 

302 frames=frame_set, 

303 pixels_to_fp=frame_set[frame_set.detector(detector_id), frame_set.focal_plane()], 

304 ) 

305 with RoundtripFits(test_holder) as roundtrip1: 

306 assert len(roundtrip1.serialized.pixels_to_fp.frames) == 2 

307 assert len(roundtrip1.serialized.pixels_to_fp.bounds) == 2 

308 assert len(roundtrip1.serialized.pixels_to_fp.mappings) == 1 

309 # Instead of storing the AST mapping directly, we should have 

310 # stored a reference to the frame set: 

311 assert isinstance(roundtrip1.serialized.pixels_to_fp.mappings[0], PointerModel) 

312 compare_to_legacy_camera(legacy_camera, roundtrip1.result.frames) 

313 assert roundtrip1.result.pixels_to_fp.in_frame == frame_set.detector(detector_id) 

314 assert roundtrip1.result.pixels_to_fp.out_frame == frame_set.focal_plane() 

315 assert ( 

316 roundtrip1.result.pixels_to_fp._ast_mapping.simplified().show() 

317 == test_holder.pixels_to_fp._ast_mapping.simplified().show() 

318 ) 

319 with RoundtripJson(test_holder) as roundtrip2: 

320 assert len(roundtrip2.serialized.pixels_to_fp.frames) == 2 

321 assert len(roundtrip2.serialized.pixels_to_fp.bounds) == 2 

322 assert len(roundtrip2.serialized.pixels_to_fp.mappings) == 1 

323 # Instead of storing the AST mapping directly, we should have 

324 # stored a reference to the frame set: 

325 assert isinstance(roundtrip2.serialized.pixels_to_fp.mappings[0], JsonRef) 

326 raw_data = roundtrip2.inspect() 

327 assert len(raw_data["indirect"]) == 1 

328 assert raw_data["frames"] == {"$ref": "#/indirect/0"} 

329 compare_to_legacy_camera(legacy_camera, roundtrip2.result.frames) 

330 assert roundtrip2.result.pixels_to_fp.in_frame == frame_set.detector(detector_id) 

331 assert roundtrip2.result.pixels_to_fp.out_frame == frame_set.focal_plane() 

332 assert ( 

333 roundtrip2.result.pixels_to_fp._ast_mapping.simplified().show() 

334 == test_holder.pixels_to_fp._ast_mapping.simplified().show() 

335 ) 

336 

337 

338def test_fits_wcs_projection_to_legacy() -> None: 

339 """Verify that a projection created by from_fits_wcs can be converted 

340 to a legacy SkyWcs. 

341 

342 The AST pixel frame uses the domain PIXEL, while lsst.afw.geom.SkyWcs 

343 requires PIXELS, so to_legacy has to rename it. 

344 """ 

345 pytest.importorskip("lsst.afw.geom") 

346 rng = np.random.default_rng(43) 

347 bbox = Box.factory[75:275, 25:225] 

348 pixel_frame = GeneralFrame(unit=u.pix) 

349 sky_projection = make_random_sky_projection(rng, pixel_frame, bbox) 

350 legacy_wcs = sky_projection.to_legacy() 

351 compare_sky_projection_to_legacy_wcs(sky_projection, legacy_wcs, pixel_frame, bbox, is_fits=True) 

352 # The conversion must not modify the projection in place: its own AST 

353 # mapping keeps the PIXEL domain, and the conversion is repeatable. 

354 frame_set = sky_projection.pixel_to_sky_transform._ast_mapping 

355 assert isinstance(frame_set, astshim.FrameSet) 

356 domains = {frame_set.getFrame(i, copy=False).domain for i in range(1, frame_set.nFrame + 1)} 

357 assert "PIXEL" in domains 

358 assert "PIXELS" not in domains 

359 compare_sky_projection_to_legacy_wcs( 

360 sky_projection, sky_projection.to_legacy(), pixel_frame, bbox, is_fits=True 

361 ) 

362 

363 

364def test_detector_wcs(legacy_detector_wcs: dict[str, Any]) -> None: 

365 """Test the Transform/SkyProjection representation of a detector WCS.""" 

366 legacy_wcs = legacy_detector_wcs["legacy_wcs"] 

367 wcs_bbox = legacy_detector_wcs["wcs_bbox"] 

368 subimage_bbox = legacy_detector_wcs["subimage_bbox"] 

369 detector_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=wcs_bbox) 

370 sky_projection = SkyProjection.from_legacy(legacy_wcs, detector_frame) 

371 assert sky_projection.fits_approximation is not None 

372 compare_sky_projection_to_legacy_wcs(sky_projection, legacy_wcs, detector_frame, subimage_bbox) 

373 # When we convert from a legacy SkyWcs, the internal AST Mapping needs 

374 # to really be an AST FrameSet in order to be able to convert back. 

375 assert "Begin FrameSet" in sky_projection.show() 

376 compare_sky_projection_to_legacy_wcs( 

377 sky_projection, sky_projection.to_legacy(), detector_frame, subimage_bbox 

378 ) 

379 assert "Begin FrameSet" in sky_projection.fits_approximation.show() 

380 compare_sky_projection_to_legacy_wcs( 

381 sky_projection.fits_approximation, 

382 sky_projection.fits_approximation.to_legacy(), 

383 detector_frame, 

384 subimage_bbox, 

385 is_fits=True, 

386 ) 

387 with RoundtripJson(sky_projection, "SkyProjection") as roundtrip: 

388 pass 

389 compare_sky_projection_to_legacy_wcs(roundtrip.result, legacy_wcs, detector_frame, subimage_bbox) 

390 # The AST FrameSet-ness needs to propagate through serialization. 

391 assert "Begin FrameSet" in roundtrip.result.show() 

392 compare_sky_projection_to_legacy_wcs( 

393 sky_projection, roundtrip.result.to_legacy(), detector_frame, subimage_bbox 

394 ) 

395 with RoundtripJson(sky_projection.fits_approximation, "SkyProjection") as roundtrip: 

396 pass 

397 compare_sky_projection_to_legacy_wcs( 

398 roundtrip.result, 

399 legacy_wcs.getFitsApproximation(), 

400 detector_frame, 

401 subimage_bbox, 

402 is_fits=True, 

403 ) 

404 assert "Begin FrameSet" in roundtrip.result.show() 

405 compare_sky_projection_to_legacy_wcs( 

406 sky_projection.fits_approximation, 

407 roundtrip.result.to_legacy(), 

408 detector_frame, 

409 subimage_bbox, 

410 is_fits=True, 

411 ) 

412 

413 

414@dataclasses.dataclass 

415class FrameSetTestHolder: 

416 """A top-level object that holds a CameraFrameSet and a transform 

417 extracted from it, for testing archive pointers and frame set references. 

418 """ 

419 

420 frames: CameraFrameSet 

421 pixels_to_fp: Transform[DetectorFrame, FocalPlaneFrame] 

422 

423 def serialize[P: pydantic.BaseModel](self, archive: OutputArchive[P]) -> FrameSetTestHolderModel[P]: 

424 frames_model = archive.serialize_frame_set( 

425 "frames", self.frames, self.frames.serialize, key=id(self.frames) 

426 ) 

427 pixels_to_fp_model = archive.serialize_direct( 

428 "pixels_to_fp", functools.partial(self.pixels_to_fp.serialize, use_frame_sets=True) 

429 ) 

430 return FrameSetTestHolderModel[P](frames=frames_model, pixels_to_fp=pixels_to_fp_model) 

431 

432 @staticmethod 

433 def _get_archive_tree_type[P: pydantic.BaseModel]( 

434 pointer_type: type[P], 

435 ) -> type[FrameSetTestHolderModel[P]]: 

436 return FrameSetTestHolderModel[pointer_type] # type: ignore 

437 

438 

439class FrameSetTestHolderModel[P: pydantic.BaseModel](ArchiveTree): 

440 """The serialization model for FrameSetTestHolder.""" 

441 

442 SCHEMA_NAME: ClassVar[str] = "_test_frame_set_holder" 

443 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

444 MIN_READ_VERSION: ClassVar[int] = 1 

445 PUBLIC_TYPE: ClassVar[type] = FrameSetTestHolder 

446 

447 frames: CameraFrameSetSerializationModel | P 

448 pixels_to_fp: TransformSerializationModel[P] 

449 

450 def deserialize(self, archive: InputArchive[Any]) -> FrameSetTestHolder: 

451 assert not isinstance(self.frames, CameraFrameSetSerializationModel), "Archive pointer expected." 

452 frames = archive.deserialize_pointer( 

453 self.frames, CameraFrameSetSerializationModel, CameraFrameSetSerializationModel.deserialize 

454 ) 

455 pixels_to_fp = self.pixels_to_fp.deserialize(archive) 

456 return FrameSetTestHolder(frames, pixels_to_fp) 

457 

458 

459@dataclasses.dataclass 

460class _BroadcastTestData: 

461 """Shared inputs for broadcasting/scalar tests on Transform and 

462 SkyProjection. 

463 """ 

464 

465 in_frame: DetectorFrame 

466 out_frame: GeneralFrame 

467 matrix: np.ndarray 

468 scalar_x: float 

469 scalar_y: float 

470 xv: list[int] 

471 yv: list[int] 

472 sky_proj: SkyProjection[DetectorFrame] 

473 

474 

475@pytest.fixture 

476def broadcast_test_data() -> _BroadcastTestData: 

477 """Return shared inputs for broadcasting/scalar tests.""" 

478 in_frame = DetectorFrame(instrument="Inst", visit=1, detector=1, bbox=Box.factory[0:20, 0:20]) 

479 return _BroadcastTestData( 

480 in_frame=in_frame, 

481 out_frame=GeneralFrame(unit=u.pix), 

482 matrix=np.array([[2.0, 0.5], [-0.3, 1.5]]), 

483 scalar_x=3.0, 

484 scalar_y=7.0, 

485 xv=[1, 2, 3], 

486 yv=[4, 5, 6], 

487 sky_proj=make_random_sky_projection(np.random.default_rng(42), in_frame, in_frame.bbox), 

488 ) 

489 

490 

491def test_apply_forward_scalar(broadcast_test_data: _BroadcastTestData) -> None: 

492 """Verify that apply_forward and apply_inverse accept scalar x/y and return 

493 scalar floats, and that the _q variants accept scalar Quantity inputs. 

494 """ 

495 t = broadcast_test_data.sky_proj.pixel_to_sky_transform 

496 # apply_forward with Python float scalars should return XY of floats. 

497 result_fwd = t.apply_forward(x=broadcast_test_data.scalar_x, y=broadcast_test_data.scalar_y) 

498 assert type(result_fwd.x) is float 

499 assert type(result_fwd.y) is float 

500 # Values must match the corresponding single-element array call. 

501 ref_fwd = t.apply_forward( 

502 x=np.array([broadcast_test_data.scalar_x]), y=np.array([broadcast_test_data.scalar_y]) 

503 ) 

504 assert result_fwd.x == ref_fwd.x[0] 

505 assert result_fwd.y == ref_fwd.y[0] 

506 # apply_inverse round-trips back to the original scalars. 

507 result_inv = t.apply_inverse(x=result_fwd.x, y=result_fwd.y) 

508 assert type(result_inv.x) is float 

509 assert type(result_inv.y) is float 

510 np.testing.assert_allclose(result_inv.x, broadcast_test_data.scalar_x, atol=1e-12) 

511 np.testing.assert_allclose(result_inv.y, broadcast_test_data.scalar_y, atol=1e-12) 

512 # apply_forward_q / apply_inverse_q with scalar Quantity inputs. 

513 x_q = broadcast_test_data.scalar_x * t.in_frame.unit 

514 y_q = broadcast_test_data.scalar_y * t.in_frame.unit 

515 result_fwd_q = t.apply_forward_q(x=x_q, y=y_q) 

516 assert result_fwd_q.x.shape == () 

517 assert result_fwd_q.y.shape == () 

518 np.testing.assert_allclose(result_fwd_q.x.to_value(t.out_frame.unit), result_fwd.x, atol=1e-12) 

519 result_inv_q = t.apply_inverse_q(x=result_fwd_q.x, y=result_fwd_q.y) 

520 assert result_inv_q.x.shape == () 

521 np.testing.assert_allclose( 

522 result_inv_q.x.to_value(t.in_frame.unit), broadcast_test_data.scalar_x, atol=1e-12 

523 ) 

524 

525 

526def test_apply_array_like_and_integer_input(broadcast_test_data: _BroadcastTestData) -> None: 

527 """Verify that apply_forward accepts Python lists and integer-dtype 

528 arrays, returning float64 ndarray results consistent with float64 

529 array input. 

530 """ 

531 t = broadcast_test_data.sky_proj.pixel_to_sky_transform 

532 # Python list input should return an ndarray. 

533 result_list = t.apply_forward(x=broadcast_test_data.xv, y=broadcast_test_data.yv) 

534 assert isinstance(result_list.x, np.ndarray) 

535 assert isinstance(result_list.y, np.ndarray) 

536 ref = t.apply_forward(x=np.array(broadcast_test_data.xv), y=np.array(broadcast_test_data.yv)) 

537 np.testing.assert_array_equal(result_list.x, ref.x) 

538 np.testing.assert_array_equal(result_list.y, ref.y) 

539 # Integer dtype arrays should not raise and should return float64. 

540 xi = np.array(broadcast_test_data.xv, dtype=np.int32) 

541 yi = np.array(broadcast_test_data.yv, dtype=np.int32) 

542 result_int = t.apply_forward(x=xi, y=yi) 

543 assert result_int.x.dtype == np.float64 

544 assert result_int.y.dtype == np.float64 

545 np.testing.assert_array_equal(result_int.x, ref.x) 

546 np.testing.assert_array_equal(result_int.y, ref.y) 

547 

548 

549def test_apply_broadcast(broadcast_test_data: _BroadcastTestData) -> None: 

550 """Verify that apply_forward and apply_inverse broadcast x and y like 

551 a NumPy ufunc, in both 1-D and 2-D cases. 

552 """ 

553 t = broadcast_test_data.sky_proj.pixel_to_sky_transform 

554 xv = np.array(broadcast_test_data.xv) 

555 yv = np.array(broadcast_test_data.yv + [7]) 

556 # 1-D broadcast: array x, scalar y. 

557 result_1d = t.apply_forward(x=xv, y=broadcast_test_data.scalar_y) 

558 assert isinstance(result_1d.x, np.ndarray) 

559 assert result_1d.x.shape == xv.shape 

560 ref_1d = t.apply_forward(x=xv, y=np.full_like(xv, broadcast_test_data.scalar_y)) 

561 np.testing.assert_array_equal(result_1d.x, ref_1d.x) 

562 np.testing.assert_array_equal(result_1d.y, ref_1d.y) 

563 # 2-D broadcast: column x (M,1) × row y (1,N) -> (M,N). 

564 x2d = xv[:, np.newaxis] # shape (3, 1) 

565 y2d = yv[np.newaxis, :] # shape (1, 4) 

566 result_2d = t.apply_forward(x=x2d, y=y2d) 

567 assert result_2d.x.shape == (3, 4) 

568 assert result_2d.y.shape == (3, 4) 

569 # Values must match the fully expanded meshgrid call. 

570 xmesh, ymesh = np.meshgrid(xv, yv, indexing="ij") 

571 ref_2d = t.apply_forward(x=xmesh, y=ymesh) 

572 np.testing.assert_array_equal(result_2d.x, ref_2d.x) 

573 np.testing.assert_array_equal(result_2d.y, ref_2d.y) 

574 # apply_inverse also broadcasts. 

575 result_inv_2d = t.apply_inverse(x=result_2d.x, y=result_2d.y) 

576 assert result_inv_2d.x.shape == (3, 4) 

577 np.testing.assert_allclose(result_inv_2d.x, xmesh, atol=1e-12) 

578 np.testing.assert_allclose(result_inv_2d.y, ymesh, atol=1e-12) 

579 

580 

581def test_sky_projection_broadcast(broadcast_test_data: _BroadcastTestData) -> None: 

582 """Verify that SkyProjection.pixel_to_sky, sky_to_pixel, and the 

583 Astropy view broadcast x and y like a NumPy ufunc. 

584 """ 

585 p = broadcast_test_data 

586 xv = np.array(p.xv) 

587 yv = np.array(p.yv + [7]) 

588 # 1-D broadcast: array x, scalar y. 

589 sc_1d = p.sky_proj.pixel_to_sky(x=xv, y=p.scalar_y) 

590 assert sc_1d.shape == xv.shape 

591 ref_1d = p.sky_proj.pixel_to_sky(x=xv, y=np.full_like(xv, p.scalar_y)) 

592 np.testing.assert_allclose(sc_1d.ra.rad, ref_1d.ra.rad, atol=1e-12) 

593 np.testing.assert_allclose(sc_1d.dec.rad, ref_1d.dec.rad, atol=1e-12) 

594 # 2-D broadcast: column x (M,1) × row y (1,N) -> (M,N). 

595 x2d = xv[:, np.newaxis] # shape (3, 1) 

596 y2d = yv[np.newaxis, :] # shape (1, 4) 

597 sc_2d = p.sky_proj.pixel_to_sky(x=x2d, y=y2d) 

598 assert sc_2d.shape == (3, 4) 

599 xmesh, ymesh = np.meshgrid(xv, yv, indexing="ij") 

600 ref_2d = p.sky_proj.pixel_to_sky(x=xmesh, y=ymesh) 

601 np.testing.assert_allclose(sc_2d.ra.rad, ref_2d.ra.rad, atol=1e-12) 

602 np.testing.assert_allclose(sc_2d.dec.rad, ref_2d.dec.rad, atol=1e-12) 

603 # sky_to_pixel round-trips back to the original grid. 

604 pix_2d = p.sky_proj.sky_to_pixel(sc_2d) 

605 assert pix_2d.x.shape == (3, 4) 

606 np.testing.assert_allclose(pix_2d.x, xmesh, atol=1e-9) 

607 np.testing.assert_allclose(pix_2d.y, ymesh, atol=1e-9) 

608 # SkyProjectionAstropyView.pixel_to_world_values also broadcasts. 

609 view = p.sky_proj.as_astropy() 

610 world_2d = view.pixel_to_world_values(x2d, y2d) 

611 assert world_2d[0].shape == (3, 4) 

612 assert world_2d[1].shape == (3, 4) 

613 np.testing.assert_allclose(world_2d[0], ref_2d.ra.rad, atol=1e-12) 

614 np.testing.assert_allclose(world_2d[1], ref_2d.dec.rad, atol=1e-12) 

615 # SkyProjectionAstropyView.world_to_pixel_values also broadcasts. 

616 ra_2d = ref_2d.ra.rad[:, np.newaxis, :] # (3, 1, 4) — over-broadcast to check 

617 dec_2d = ref_2d.dec.rad[np.newaxis, :, :] # (1, 3, 4) 

618 pix_world = view.world_to_pixel_values(ra_2d, dec_2d) 

619 assert pix_world[0].shape == (3, 3, 4) 

620 

621 

622def test_apply_xy_yx(broadcast_test_data: _BroadcastTestData) -> None: 

623 """Verify that apply_forward, apply_inverse, and the _q variants accept 

624 XY and YX positional arguments, producing results identical to the 

625 equivalent x=/y= keyword calls. 

626 """ 

627 p = broadcast_test_data 

628 t = p.sky_proj.pixel_to_sky_transform 

629 sx, sy = p.scalar_x, p.scalar_y 

630 xv, yv = np.array(p.xv, dtype=float), np.array(p.yv, dtype=float) 

631 

632 # --- apply_forward: scalar --- 

633 ref_fwd = t.apply_forward(x=sx, y=sy) 

634 assert t.apply_forward(XY(sx, sy)) == ref_fwd 

635 assert t.apply_forward(YX(sy, sx)) == ref_fwd 

636 

637 # --- apply_forward: array --- 

638 ref_fwd_arr = t.apply_forward(x=xv, y=yv) 

639 np.testing.assert_array_equal(t.apply_forward(XY(xv, yv)).x, ref_fwd_arr.x) 

640 np.testing.assert_array_equal(t.apply_forward(YX(yv, xv)).x, ref_fwd_arr.x) 

641 

642 # --- apply_inverse: scalar --- 

643 ref_inv = t.apply_inverse(x=ref_fwd.x, y=ref_fwd.y) 

644 assert t.apply_inverse(XY(ref_fwd.x, ref_fwd.y)) == ref_inv 

645 assert t.apply_inverse(YX(ref_fwd.y, ref_fwd.x)) == ref_inv 

646 

647 # --- apply_forward_q: scalar --- 

648 x_q = sx * t.in_frame.unit 

649 y_q = sy * t.in_frame.unit 

650 ref_fwd_q = t.apply_forward_q(x=x_q, y=y_q) 

651 result_q = t.apply_forward_q(XY(x_q, y_q)) 

652 np.testing.assert_allclose(result_q.x.value, ref_fwd_q.x.value, atol=1e-12) 

653 result_q_yx = t.apply_forward_q(YX(y_q, x_q)) 

654 np.testing.assert_allclose(result_q_yx.x.value, ref_fwd_q.x.value, atol=1e-12) 

655 

656 # --- apply_inverse_q: scalar --- 

657 ref_inv_q = t.apply_inverse_q(x=ref_fwd_q.x, y=ref_fwd_q.y) 

658 result_inv_q = t.apply_inverse_q(XY(ref_fwd_q.x, ref_fwd_q.y)) 

659 np.testing.assert_allclose(result_inv_q.x.value, ref_inv_q.x.value, atol=1e-12) 

660 

661 # --- TypeError on bad combinations --- 

662 with pytest.raises(TypeError): 

663 t.apply_forward(XY(sx, sy), x=sx) 

664 with pytest.raises(TypeError): 

665 t.apply_forward(YX(sy, sx), y=sy) 

666 with pytest.raises(TypeError): 

667 t.apply_forward() 

668 

669 

670def test_pixel_to_sky_xy_yx(broadcast_test_data: _BroadcastTestData) -> None: 

671 """Verify that SkyProjection.pixel_to_sky accepts XY and YX positional 

672 arguments, producing results identical to the x=/y= keyword form. 

673 """ 

674 p = broadcast_test_data 

675 sx, sy = p.scalar_x, p.scalar_y 

676 xv, yv = np.array(p.xv, dtype=float), np.array(p.yv, dtype=float) 

677 

678 # Scalar XY and YX. 

679 ref_scalar = p.sky_proj.pixel_to_sky(x=sx, y=sy) 

680 result_xy = p.sky_proj.pixel_to_sky(XY(sx, sy)) 

681 result_yx = p.sky_proj.pixel_to_sky(YX(sy, sx)) 

682 np.testing.assert_allclose(result_xy.ra.rad, ref_scalar.ra.rad, atol=1e-12) 

683 np.testing.assert_allclose(result_yx.ra.rad, ref_scalar.ra.rad, atol=1e-12) 

684 

685 # Array XY and YX. 

686 ref_array = p.sky_proj.pixel_to_sky(x=xv, y=yv) 

687 result_xy_arr = p.sky_proj.pixel_to_sky(XY(xv, yv)) 

688 result_yx_arr = p.sky_proj.pixel_to_sky(YX(yv, xv)) 

689 np.testing.assert_allclose(result_xy_arr.ra.rad, ref_array.ra.rad, atol=1e-12) 

690 np.testing.assert_allclose(result_yx_arr.ra.rad, ref_array.ra.rad, atol=1e-12) 

691 

692 # TypeError on bad combinations. 

693 with pytest.raises(TypeError): 

694 p.sky_proj.pixel_to_sky(XY(sx, sy), x=sx) 

695 with pytest.raises(TypeError): 

696 p.sky_proj.pixel_to_sky(YX(sy, sx), y=sy) 

697 with pytest.raises(TypeError): 

698 p.sky_proj.pixel_to_sky() 

699 

700 

701def _rotated_tan(rot_deg: float, *, crval2: float = 30.0, scale_y: float = 0.2) -> SkyProjection: 

702 """Return a rotated TAN projection with given pixel scales.""" 

703 cx = (0.2 * u.arcsec).to_value(u.deg) 

704 cy = (scale_y * u.arcsec).to_value(u.deg) 

705 t = np.deg2rad(rot_deg) 

706 header = { 

707 "CTYPE1": "RA---TAN", 

708 "CTYPE2": "DEC--TAN", 

709 "CRPIX1": 50, 

710 "CRPIX2": 100, 

711 "CRVAL1": 45.0, 

712 "CRVAL2": crval2, 

713 "CD1_1": -cx * np.cos(t), 

714 "CD1_2": cy * np.sin(t), 

715 "CD2_1": -cx * np.sin(t), 

716 "CD2_2": -cy * np.cos(t), 

717 } 

718 return SkyProjection.from_fits_wcs(astropy.wcs.WCS(header), GeneralFrame(unit=u.pix)) 

719 

720 

721def test_sky_projection_nominal_pixel_scale() -> None: 

722 """_nominal_pixel_scale reports per sky axis [longitude, latitude]. 

723 

724 Faithful KPG1_PXSCL port: the scale attaches to the sky axis, so a 90 deg 

725 rotation swaps the returned [RA, Dec] scales. Great-circle distances keep 

726 it correct near the poles. 

727 """ 

728 bbox = Box.factory[0:200, 0:100] 

729 

730 # Unrotated anisotropic WCS: RA scale 0.2, Dec scale 0.3. 

731 np.testing.assert_allclose( 

732 _rotated_tan(0.0, scale_y=0.3)._nominal_pixel_scale(bbox), [0.2, 0.3], rtol=1e-3 

733 ) 

734 # Rotated 90 deg: the sky-axis scales swap. 

735 np.testing.assert_allclose( 

736 _rotated_tan(90.0, scale_y=0.3)._nominal_pixel_scale(bbox), [0.3, 0.2], rtol=1e-3 

737 ) 

738 # Reference pixel ~2 arcsec from the north pole: great-circle scale holds. 

739 np.testing.assert_allclose( 

740 _rotated_tan(30.0, crval2=89.9995)._nominal_pixel_scale(bbox), [0.2, 0.2], rtol=1e-3 

741 ) 

742 

743 

744def test_sky_projection_describe() -> None: 

745 """SkyProjection._describe reports pixels, pixel scale, and a corners 

746 table. 

747 """ 

748 rng = np.random.default_rng(43) 

749 bbox = Box.factory[0:200, 0:100] 

750 pixel_frame = GeneralFrame(unit=u.pix) 

751 sky_projection = make_random_sky_projection(rng, pixel_frame, bbox) 

752 

753 # Without a bbox this projection still has pixel_bounds, so the report is 

754 # characterized over those rather than falling back to the pixel origin. 

755 assert sky_projection.pixel_bounds is not None 

756 report = sky_projection.describe() 

757 assert isinstance(report, Report) 

758 assert report.type_name == "SkyProjection" 

759 assert report.title == "ICRS coordinates" 

760 # The title carries the sky frame, so it is not repeated as a field. 

761 assert not any(f.label == "domain" for f in report.fields) 

762 # The uninformative pixel_to_sky field is gone, so repr has no ARG fields. 

763 assert not any(f.role is FieldRole.ARG for f in report.fields) 

764 assert not any(f.label == "origin pixel" for f in report.fields) 

765 assert any(f.label == "center pixel" for f in report.fields) 

766 assert any(t.title == "Corners" for t in report.tables) 

767 # Exactly one nominal-pixel-scale field is present (single or dual form). 

768 scale_fields = [f for f in report.fields if f.label.startswith("Nominal pixel scale")] 

769 assert len(scale_fields) == 1 

770 

771 # With a bbox: a Corners table and a center-pixel field for the box 

772 # center, and still no origin-pixel fallback. 

773 report = sky_projection.describe(bbox=bbox) 

774 corners = next(t for t in report.tables if t.title == "Corners") 

775 assert corners.columns == ["x", "y", "RA", "Dec", "RA (°)", "Dec (°)"] 

776 assert len(corners.rows) == 4 

777 # The first two columns carry the pixel coordinates of each corner. These 

778 # are the box corners expanded by half a pixel, so that they bound the full 

779 # area the image covers rather than the centers of the outermost pixels. 

780 assert [(row[0], row[1]) for row in corners.rows] == [ 

781 ("-0.5", "-0.5"), 

782 ("-0.5", "199.5"), 

783 ("99.5", "199.5"), 

784 ("99.5", "-0.5"), 

785 ] 

786 assert not any(f.label == "origin pixel" for f in report.fields) 

787 center = next(f for f in report.fields if f.label == "center pixel") 

788 assert center.role is FieldRole.DERIVED 

789 assert center.value.startswith("(x=") 

790 

791 # FITS-WCS availability is reported (projection is FITS-representable). 

792 fits_field = next(f for f in report.fields if f.label == "fits_wcs") 

793 assert fits_field.value == "available" 

794 

795 # A projection cannot be rebuilt from a string, so repr is descriptive 

796 # rather than an eval-ish call. It does not depend on a bbox and does not 

797 # evaluate the mapping. 

798 assert repr(sky_projection) == "<SkyProjection: GeneralFrame → ICRS>" 

799 

800 

801def test_sky_projection_describe_pixel_scale_forms() -> None: 

802 """The pixel-scale field collapses to one value when the axes agree.""" 

803 bbox = Box.factory[0:200, 0:100] 

804 

805 # Isotropic scale: a single "Nominal pixel scale" field to 0.01 arcsec. 

806 report = _rotated_tan(0.0).describe(bbox=bbox) 

807 scale = next(f for f in report.fields if f.label == "Nominal pixel scale") 

808 assert scale.value == "0.20 arcsec" 

809 assert not any(f.label == "Nominal pixel scales" for f in report.fields) 

810 

811 # Anisotropic scale: axes are named with the AST SkyFrame sky-axis labels. 

812 report = _rotated_tan(0.0, scale_y=0.3).describe(bbox=bbox) 

813 scales = next(f for f in report.fields if f.label == "Nominal pixel scales") 

814 assert scales.value == "Right ascension 0.20 arcsec; Declination 0.30 arcsec" 

815 assert not any(f.label == "Nominal pixel scale" for f in report.fields) 

816 

817 

818def test_sky_projection_describe_fits_wcs_availability() -> None: 

819 """fits_wcs is probed against a box, never blindly reported available.""" 

820 # No supplied bbox and no pixel bounds: representability cannot be checked. 

821 projection = _rotated_tan(0.0) 

822 assert projection.pixel_bounds is None 

823 report = projection.describe() 

824 fits_field = next(f for f in report.fields if f.label == "fits_wcs") 

825 assert fits_field.value == "unknown" 

826 

827 # A projection with pixel bounds probes those bounds when no bbox is given. 

828 bounded = SkyProjection.from_fits_wcs( 

829 _rotated_tan(0.0).pixel_to_sky_transform.as_fits_wcs(Box.factory[0:32, 0:32]), 

830 GeneralFrame(unit=u.pix), 

831 pixel_bounds=Box.factory[0:32, 0:32], 

832 ) 

833 report = bounded.describe() 

834 fits_field = next(f for f in report.fields if f.label == "fits_wcs") 

835 assert fits_field.value in ("available", "none") 

836 

837 

838def test_sky_projection_astropy_view_repr_str() -> None: 

839 """The Astropy view has informative str and repr, not the default object 

840 form. 

841 """ 

842 bbox = Box.factory[0:200, 0:100] 

843 sky_projection = _rotated_tan(0.0) 

844 

845 bounded = sky_projection.as_astropy(bbox) 

846 assert str(bounded) == "SkyProjectionAstropyView([y=0:200, x=0:100] → ICRS)" 

847 bounded_repr = repr(bounded) 

848 assert bounded_repr.startswith("SkyProjectionAstropyView\n") 

849 assert "ICRS (ra, dec)" in bounded_repr 

850 assert str(bbox.shape) in bounded_repr 

851 # The reported pixel (0, 0) sky position matches the view's own transform. 

852 ra, dec = bounded.pixel_to_world_values(0.0, 0.0) 

853 ra_hms = Longitude(float(ra) * u.rad).to_string(unit=u.hour, sep="hms", pad=True, precision=1) 

854 assert ra_hms in bounded_repr 

855 

856 unbounded = sky_projection.as_astropy() 

857 assert str(unbounded) == "SkyProjectionAstropyView(unbounded → ICRS)" 

858 # An unbounded view omits the array-shape line but keeps the reference. 

859 assert "array shape" not in repr(unbounded) 

860 assert "pixel (0, 0)" in repr(unbounded) 

861 

862 

863def test_sky_projection_describe_origin_pixel_is_a_last_resort() -> None: 

864 """The origin pixel appears only when no bounding box can be had. 

865 

866 Pixel (0, 0) is not a reference point of a projection, so it is reported 

867 only when neither the caller nor the projection itself supplies a box to 

868 characterize over. 

869 """ 

870 rng = np.random.default_rng(43) 

871 bbox = Box.factory[0:200, 0:100] 

872 bounded = make_random_sky_projection(rng, GeneralFrame(unit=u.pix), bbox) 

873 assert bounded.pixel_bounds is not None 

874 # A box from either source displaces the origin fallback. 

875 for report in (bounded.describe(), bounded.describe(bbox=bbox)): 

876 assert not any(f.label == "origin pixel" for f in report.fields) 

877 

878 # With neither, the origin is all that is left, and the corners table and 

879 # FITS-WCS probe drop out with it. 

880 unbounded = _rotated_tan(0.0) 

881 assert unbounded.pixel_bounds is None 

882 report = unbounded.describe() 

883 assert not any(f.label == "center pixel" for f in report.fields) 

884 assert not any(t.title == "Corners" for t in report.tables) 

885 assert next(f for f in report.fields if f.label == "fits_wcs").value == "unknown" 

886 origin = next(f for f in report.fields if f.label == "origin pixel") 

887 assert origin.role is FieldRole.DERIVED 

888 assert origin.value.startswith("(x=0, y=0) →") 

889 

890 

891def test_sky_projection_describe_origin_pixel_matches_transform() -> None: 

892 """The origin-pixel field reports pixel (0, 0)'s actual sky position.""" 

893 sky_projection = _rotated_tan(0.0) 

894 assert sky_projection.pixel_bounds is None 

895 

896 report = sky_projection.describe() 

897 ref = next(f for f in report.fields if f.label == "origin pixel") 

898 sky00 = sky_projection.pixel_to_sky(x=0, y=0) 

899 # Sexagesimal (hms/dms) and labeled decimal degrees both appear. 

900 assert sky00.ra.to_string(unit=u.hour, sep="hms", pad=True, precision=1) in ref.value 

901 assert sky00.dec.to_string(sep="dms", pad=True, alwayssign=True, precision=0) in ref.value 

902 assert f"RA {sky00.ra.deg:.6f}°" in ref.value 

903 assert f"Dec {sky00.dec.deg:+.6f}°" in ref.value 

904 

905 

906def test_frame_describe_preserves_pydantic_repr() -> None: 

907 """Frames expose describe() while retaining pydantic's repr.""" 

908 frame = GeneralFrame(unit=u.pix) 

909 report = frame.describe() 

910 assert report.type_name == "GeneralFrame" 

911 assert {f.label for f in report.fields} >= {"unit"} 

912 # The mixin must not shadow pydantic's repr. 

913 assert repr(frame).startswith("GeneralFrame(") 

914 

915 

916def test_transform_describe() -> None: 

917 """Transform._describe reports its frames and bounds.""" 

918 pixel_frame = DetectorFrame(**DP2_VISIT_DETECTOR_DATA_ID, bbox=Box.factory[:5, :4]) 

919 transform = Transform(pixel_frame, ICRS, astshim.UnitMap(2)) 

920 report = transform.describe() 

921 assert isinstance(report, Report) 

922 assert report.type_name == "Transform" 

923 labels = {f.label for f in report.fields} 

924 assert {"in_frame", "out_frame"} <= labels 

925 assert any(f.label == "mapping" for f in report.fields) 

926 

927 

928def test_sky_projection_describe_extent() -> None: 

929 """The extent gives the box's angular size and its orientation. 

930 

931 The size is the great-circle distance across the area the box covers, and 

932 the orientation is the position angle of the pixel y axis. 

933 """ 

934 projection = _rotated_tan(40.0) 

935 # 275 pixels at 0.2 arcsec/pixel spans 55 arcsec. 

936 report = projection.describe(bbox=Box.factory[0:275, 0:275]) 

937 extent = next(f for f in report.fields if f.label == "Image extent") 

938 assert extent.role is FieldRole.DERIVED 

939 assert extent.value == "55 x 55 arcsec @ 140 deg E of N" 

940 

941 # The unit follows the scale of the box. 

942 def size(pixels: int) -> str: 

943 report = projection.describe(bbox=Box.factory[0:pixels, 0:pixels]) 

944 return next(f.value for f in report.fields if f.label == "Image extent") 

945 

946 assert "arcsec" in size(275) 

947 assert "arcmin" in size(4000) 

948 assert "deg" in size(60000) 

949 

950 # A long, thin box has no unit that suits both sides, so each gets its 

951 # own; a square one names the shared unit once. 

952 def extent_of(x_pixels: int, y_pixels: int) -> str: 

953 report = projection.describe(bbox=Box.factory[0:y_pixels, 0:x_pixels]) 

954 return next(f.value for f in report.fields if f.label == "Image extent") 

955 

956 assert extent_of(10, 1000).startswith("2 arcsec x 3.33 arcmin ") 

957 assert extent_of(1000, 10).startswith("3.33 arcmin x 2 arcsec ") 

958 assert extent_of(275, 275).startswith("55 x 55 arcsec ") 

959 

960 # No box, so no extent to report. 

961 assert projection.pixel_bounds is None 

962 assert not any(f.label == "Image extent" for f in projection.describe().fields) 

963 

964 

965def test_sky_projection_describe_extent_position_angle() -> None: 

966 """The reported angle is the position angle of the pixel y axis. 

967 

968 ``_rotated_tan`` builds a CD matrix whose y axis lands at ``180 - rot`` 

969 degrees East of North, which pins both the axis and the direction of 

970 increasing angle. 

971 """ 

972 bbox = Box.factory[0:200, 0:100] 

973 for rot in (0.0, 30.0, 90.0, 270.0): 

974 report = _rotated_tan(rot).describe(bbox=bbox) 

975 value = next(f.value for f in report.fields if f.label == "Image extent") 

976 assert value.endswith(f"@ {(180 - rot) % 360:g} deg E of N"), (rot, value) 

977 

978 

979def test_sky_projection_describe_extent_is_measured_at_the_box() -> None: 

980 """The orientation is sampled at the box, not at the pixel origin. 

981 

982 Meridians converge near the pole, so the position angle at a pixel far 

983 outside the box says nothing about the box: here the origin differs from 

984 the box by nearly 200 degrees. 

985 """ 

986 projection = _rotated_tan(40.0, crval2=89.9995) 

987 bbox = Box.factory[0:200, 0:100] 

988 

989 def position_angle_at(x: float, y: float) -> float: 

990 here = projection.pixel_to_sky(x=x, y=y) 

991 up = projection.pixel_to_sky(x=x, y=y + 1.0) 

992 return here.position_angle(up).to_value(u.deg) % 360.0 

993 

994 at_origin = position_angle_at(0.0, 0.0) 

995 at_center = position_angle_at(bbox.x.center, bbox.y.center) 

996 assert abs(at_origin - at_center) > 100.0 

997 

998 value = next(f.value for f in projection.describe(bbox=bbox).fields if f.label == "Image extent") 

999 assert value.endswith(f"@ {round(at_center) % 360} deg E of N")