Coverage for python/lsst/images/_transforms/_sky_projection.py: 82%

198 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 15:24 -0700

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__ = ("SkyProjection", "SkyProjectionAstropyView", "SkyProjectionSerializationModel") 

15 

16import functools 

17from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, assert_type, cast, final, overload 

18 

19import astropy.units as u 

20import astropy.wcs 

21import numpy as np 

22import numpy.typing as npt 

23import pydantic 

24from astropy.coordinates import ICRS, Latitude, Longitude, SkyCoord 

25from astropy.wcs.wcsapi import BaseLowLevelWCS, HighLevelWCSMixin 

26 

27from .._geom import XY, YX, Bounds, Box 

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

29from ..utils import is_none 

30from . import _ast as astshim 

31from ._frames import Frame, SkyFrame 

32from ._transform import Transform, TransformSerializationModel, _ast_apply 

33 

34if TYPE_CHECKING: 

35 try: 

36 from lsst.afw.geom import SkyWcs as LegacySkyWcs 

37 except ImportError: 

38 type LegacySkyWcs = Any # type: ignore[no-redef] 

39 

40 

41# This pre-python-3.12 declaration is needed by Sphinx (probably the 

42# autodoc-typehints plugin. 

43F = TypeVar("F", bound=Frame) 

44P = TypeVar("P", bound=pydantic.BaseModel) 

45 

46 

47def _set_ast_skyframe_system(frame: astshim.SkyFrame, system: str) -> None: 

48 """Set an AST SkyFrame coordinate system across supported wrappers.""" 

49 if hasattr(frame, "_impl"): 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true

50 frame._impl.System = system 

51 else: 

52 setattr(frame, "system", system) 

53 

54 

55@final 

56class SkyProjection[F: Frame]: 

57 """A transform from pixel coordinates to sky coordinates. 

58 

59 Parameters 

60 ---------- 

61 pixel_to_sky 

62 A low-level transform that maps pixel coordinates to sky coordinates. 

63 fits_approximation 

64 An approximation to ``pixel_to_sky`` that is guaranteed to have a 

65 `~Transform.as_fits_wcs` method that does not return `None`. This 

66 should not be provided if ``pixel_to_sky`` is itself representable 

67 as a FITS WCS. 

68 

69 Notes 

70 ----- 

71 `Transform` is conceptually immutable (the internal AST Mapping should 

72 never be modified in-place after construction), and hence does not need to 

73 be copied when any object that holds it is copied. 

74 """ 

75 

76 def __init__( 

77 self, pixel_to_sky: Transform[F, SkyFrame], fits_approximation: Transform[F, SkyFrame] | None = None 

78 ) -> None: 

79 self._pixel_to_sky = pixel_to_sky 

80 if pixel_to_sky.in_frame.unit != u.pix: 

81 raise ValueError("Transform is not a mapping from pixel coordinates.") 

82 if pixel_to_sky.out_frame != SkyFrame.ICRS: 

83 raise ValueError("Transform is not a mapping to ICRS.") 

84 self._fits_approximation = fits_approximation 

85 

86 def __eq__(self, other: Any) -> bool: 

87 if self is other: 

88 return True 

89 if not isinstance(other, SkyProjection): 

90 return NotImplemented 

91 # Even though two approximations could be different and yet consistent 

92 # with the primary mapping (for example using different tolerances 

93 # on construction) we require them to be equal to declare that the 

94 # two objects are equal. 

95 if self._fits_approximation != other._fits_approximation: 

96 return False 

97 return self._pixel_to_sky == other._pixel_to_sky 

98 

99 @staticmethod 

100 def from_fits_wcs( 

101 fits_wcs: astropy.wcs.WCS, 

102 pixel_frame: F, 

103 pixel_bounds: Bounds | None = None, 

104 x0: int = 0, 

105 y0: int = 0, 

106 ) -> SkyProjection[F]: 

107 """Construct a transform from a FITS WCS. 

108 

109 Parameters 

110 ---------- 

111 fits_wcs 

112 FITS WCS to convert. 

113 pixel_frame 

114 Coordinate frame for the pixel grid. 

115 pixel_bounds 

116 The region that bounds valid pixels for this transform. 

117 x0 

118 Logical coordinate of the first column in the array this WCS 

119 relates to world coordinates. 

120 y0 

121 Logical coordinate of the first column in the array this WCS 

122 relates to world coordinates. 

123 

124 Notes 

125 ----- 

126 The ``x0`` and ``y0`` parameters reflect the fact that for FITS, the 

127 first row and column are always labeled ``(1, 1)``, while in Astropy 

128 and most other Python libraries, they are ``(0, 0)``. The `types` in 

129 this package (e.g. `Image`, `Mask`) allow them to be any pair of 

130 integers. 

131 

132 See Also 

133 -------- 

134 Transform.from_fits_wcs 

135 """ 

136 return SkyProjection( 

137 Transform.from_fits_wcs( 

138 fits_wcs, pixel_frame, SkyFrame.ICRS, in_bounds=pixel_bounds, x0=x0, y0=y0 

139 ) 

140 ) 

141 

142 @staticmethod 

143 def from_ast_frame_set( 

144 ast_frame_set: astshim.FrameSet, 

145 pixel_frame: F, 

146 pixel_bounds: Bounds | None = None, 

147 ) -> SkyProjection[F]: 

148 """Construct a sky projection from an AST FrameSet. 

149 

150 The current frame of the FrameSet must be an AST SkyFrame. Its 

151 coordinate system is forced to ICRS (AST adjusts the mapping 

152 automatically) so the resulting Projection is always in ICRS 

153 regardless of the original sky system. 

154 

155 Parameters 

156 ---------- 

157 ast_frame_set 

158 An AST FrameSet whose base frame is pixel coordinates and 

159 whose current frame is a SkyFrame (in any supported sky 

160 coordinate system). 

161 pixel_frame 

162 Coordinate frame for the pixel grid. 

163 pixel_bounds 

164 The region that bounds valid pixels for this transform. 

165 

166 Raises 

167 ------ 

168 ValueError 

169 If the current frame of the FrameSet is not a SkyFrame. 

170 """ 

171 current_frame = ast_frame_set.getFrame(ast_frame_set.current, copy=False) 

172 if not isinstance(current_frame, astshim.SkyFrame): 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true

173 raise ValueError( 

174 "The current frame of the AST FrameSet is not a SkyFrame " 

175 f"(got {type(current_frame).__name__})." 

176 ) 

177 _set_ast_skyframe_system(current_frame, "ICRS") 

178 return SkyProjection(Transform(pixel_frame, SkyFrame.ICRS, ast_frame_set, in_bounds=pixel_bounds)) 

179 

180 @property 

181 def pixel_frame(self) -> F: 

182 """Coordinate frame for the pixel grid.""" 

183 return self._pixel_to_sky.in_frame 

184 

185 @property 

186 def sky_frame(self) -> SkyFrame: 

187 """Coordinate frame for the sky.""" 

188 return self._pixel_to_sky.out_frame 

189 

190 @property 

191 def pixel_bounds(self) -> Bounds | None: 

192 """The region that bounds valid pixel points (`Bounds` | `None`).""" 

193 return self._pixel_to_sky.in_bounds 

194 

195 @property 

196 def pixel_to_sky_transform(self) -> Transform[F, SkyFrame]: 

197 """Low-level transform from pixel to sky coordinates (`Transform`).""" 

198 return self._pixel_to_sky 

199 

200 @property 

201 def sky_to_pixel_transform(self) -> Transform[SkyFrame, F]: 

202 """Low-level transform from sky to pixel coordinates (`Transform`).""" 

203 return self._pixel_to_sky.inverted() 

204 

205 @property 

206 def fits_approximation(self) -> SkyProjection[F] | None: 

207 """An approximation to this projection that is guaranteed to have an 

208 `as_fits_wcs` method that does not return `None`. 

209 """ 

210 return SkyProjection(self._fits_approximation) if self._fits_approximation is not None else None 

211 

212 def show(self, simplified: bool = False, comments: bool = False) -> str: 

213 """Return the AST native representation of the transform. 

214 

215 Parameters 

216 ---------- 

217 simplified 

218 Whether to ask AST to simplify the mapping before showing it. 

219 This will make it much more likely that two equivalent transforms 

220 have the same `show` result. 

221 comments 

222 Whether to include descriptive comments. 

223 """ 

224 return self._pixel_to_sky.show(simplified=simplified, comments=comments) 

225 

226 @overload 

227 def pixel_to_sky(self, point: XY[int | float] | YX[int | float], /) -> SkyCoord: ... 227 ↛ exitline 227 didn't return from function 'pixel_to_sky' because

228 

229 @overload 

230 def pixel_to_sky(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> SkyCoord: ... 230 ↛ exitline 230 didn't return from function 'pixel_to_sky' because

231 

232 @overload 

233 def pixel_to_sky( 233 ↛ exitline 233 didn't return from function 'pixel_to_sky' because

234 self, /, *, x: int | float | npt.ArrayLike, y: int | float | npt.ArrayLike 

235 ) -> SkyCoord: ... 

236 

237 def pixel_to_sky( 

238 self, 

239 point: XY[Any] | YX[Any] | None = None, 

240 /, 

241 *, 

242 x: Any = None, 

243 y: Any = None, 

244 ) -> SkyCoord: 

245 """Transform one or more pixel points to sky coordinates. 

246 

247 Parameters 

248 ---------- 

249 point 

250 An `XY` or `YX` coordinate pair of pixel positions to transform. 

251 Mutually exclusive with ``x`` and ``y``. 

252 x : `float` | array-like 

253 ``x`` values of the pixel points to transform, as a scalar or 

254 any array-like. Results are broadcast against ``y``. 

255 Mutually exclusive with ``point``. 

256 y : `float` | array-like 

257 ``y`` values of the pixel points to transform, as a scalar or 

258 any array-like. Results are broadcast against ``x``. 

259 Mutually exclusive with ``point``. 

260 

261 Returns 

262 ------- 

263 astropy.coordinates.SkyCoord 

264 Transformed sky coordinates, with the broadcast shape of ``x`` 

265 and ``y``. 

266 """ 

267 match point: 

268 case None: 

269 if x is None or y is None: 

270 raise TypeError("Pass either a point or both x= and y= to 'pixel_to_sky'.") 

271 case XY() | YX(): 271 ↛ 275line 271 didn't jump to line 275 because the pattern on line 271 always matched

272 if x is not None or y is not None: 

273 raise TypeError("'pixel_to_sky' point argument is mutually exclusive with x= and y=.") 

274 x, y = point.x, point.y 

275 case _: 

276 raise TypeError(f"Unexpected positional argument type: {type(point)!r}.") 

277 sky_rad = self._pixel_to_sky.apply_forward(x=x, y=y) 

278 return SkyCoord(ra=sky_rad.x, dec=sky_rad.y, unit=u.rad) 

279 

280 def sky_to_pixel(self, sky: SkyCoord) -> XY[Any]: 

281 """Transform one or more sky coordinates to pixels. 

282 

283 Parameters 

284 ---------- 

285 sky 

286 Sky coordinates to transform. Any shape is supported; the 

287 result has the same shape as ``sky``. 

288 

289 Returns 

290 ------- 

291 `XY` [`numpy.ndarray` | `float`] 

292 Transformed pixel coordinates with the same shape as ``sky``. 

293 """ 

294 if sky.frame.name != "icrs": 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true

295 sky = sky.transform_to("icrs") 

296 ra: Longitude = sky.ra 

297 dec: Latitude = sky.dec 

298 # cast works around a mypy false positive specific to generic 

299 # NamedTuple classes: returning XY[Any] from an overloaded function 

300 # when called with Any-typed arguments produces "Incompatible return 

301 # value type (got XY[Any], expected XY[Any])". 

302 return cast( 

303 XY[Any], 

304 self._pixel_to_sky.apply_inverse( 

305 x=ra.to_value(u.rad), 

306 y=dec.to_value(u.rad), 

307 ), 

308 ) 

309 

310 def as_astropy(self, bbox: Box | None = None) -> SkyProjectionAstropyView: 

311 """Return an `astropy.wcs` view of this `SkyProjection`. 

312 

313 Parameters 

314 ---------- 

315 bbox 

316 Bounding box of the array the view will describe. This 

317 projection object is assumed to work on the same coordinate system 

318 in which ``bbox`` is defined, while the Astropy view will consider 

319 the first row and column in that box to be ``(0, 0)``. 

320 

321 Notes 

322 ----- 

323 This returns an object that satisfies the 

324 `astropy.wcs.wcsapi.BaseHighLevelWCS` and 

325 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces while evaluating the 

326 underlying `SkyProjection` itself. It is *not* an `astropy.wcs.WCS` 

327 instance, which is a type that also satisfies those interfaces but 

328 only supports FITS WCS representations (see `as_fits_wcs`). 

329 """ 

330 return SkyProjectionAstropyView(self._pixel_to_sky._ast_mapping, bbox) 

331 

332 def as_fits_wcs(self, bbox: Box, allow_approximation: bool = False) -> astropy.wcs.WCS | None: 

333 """Return a FITS WCS representation of this projection, if possible. 

334 

335 Parameters 

336 ---------- 

337 bbox 

338 Bounding box of the array the FITS WCS will describe. This 

339 transform object is assumed to work on the same coordinate system 

340 in which ``bbox`` is defined, while the FITS WCS will consider the 

341 first row and column in that box to be ``(0, 0)`` (in Astropy 

342 interfaces) or ``(1, 1)`` (in the FITS representation itself). 

343 allow_approximation 

344 If `True` and this `SkyProjection` holds a FITS approximation to 

345 itself, return that approximation. 

346 """ 

347 if allow_approximation and self._fits_approximation: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true

348 return self._fits_approximation.as_fits_wcs(bbox) 

349 return self._pixel_to_sky.as_fits_wcs(bbox) 

350 

351 def serialize[P: pydantic.BaseModel]( 

352 self, archive: OutputArchive[P], *, use_frame_sets: bool = False 

353 ) -> SkyProjectionSerializationModel[P]: 

354 """Serialize a projection to an archive. 

355 

356 Parameters 

357 ---------- 

358 archive 

359 Archive to serialize to. 

360 use_frame_sets 

361 If `True`, decompose the underlying transform and try to reference 

362 component mappings that were already serialized into a `FrameSet` 

363 in the archive. The FITS approximation transform is never 

364 decomposed. 

365 

366 Returns 

367 ------- 

368 `SkyProjectionSerializationModel` 

369 Serialized form of the projection. 

370 """ 

371 pixel_to_sky = archive.serialize_direct( 

372 "pixel_to_sky", functools.partial(self._pixel_to_sky.serialize, use_frame_sets=use_frame_sets) 

373 ) 

374 fits_approximation = ( 

375 archive.serialize_direct("fits_approximation", self._fits_approximation.serialize) 

376 if self._fits_approximation is not None 

377 else None 

378 ) 

379 return SkyProjectionSerializationModel( 

380 pixel_to_sky=pixel_to_sky, fits_approximation=fits_approximation 

381 ) 

382 

383 @staticmethod 

384 def _get_archive_tree_type[P: pydantic.BaseModel]( 

385 pointer_type: type[P], 

386 ) -> type[SkyProjectionSerializationModel[P]]: 

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

388 type that uses the given pointer type. 

389 """ 

390 return SkyProjectionSerializationModel[pointer_type] # type: ignore 

391 

392 @staticmethod 

393 def from_legacy( 

394 sky_wcs: LegacySkyWcs, pixel_frame: F, pixel_bounds: Bounds | None = None 

395 ) -> SkyProjection[F]: 

396 """Construct a transform from a legacy `lsst.afw.geom.SkyWcs`. 

397 

398 Parameters 

399 ---------- 

400 sky_wcs : `lsst.afw.geom.SkyWcs` 

401 Legacy WCS object. 

402 pixel_frame 

403 Coordinate frame for the pixel grid. 

404 pixel_bounds 

405 The region that bounds valid pixels for this transform. 

406 """ 

407 fits_approximation: Transform[F, SkyFrame] | None = None 

408 if (legacy_fits_approximation := sky_wcs.getFitsApproximation()) is not None: 

409 fits_approximation = Transform( 

410 pixel_frame, 

411 SkyFrame.ICRS, 

412 legacy_fits_approximation.getFrameDict(), 

413 pixel_bounds, 

414 ) 

415 return SkyProjection( 

416 Transform(pixel_frame, SkyFrame.ICRS, sky_wcs.getFrameDict(), pixel_bounds), 

417 fits_approximation=fits_approximation, 

418 ) 

419 

420 def to_legacy(self) -> LegacySkyWcs: 

421 """Convert to a legacy `lsst.afw.geom.SkyWcs` instance.""" 

422 from lsst.afw.geom import SkyWcs as LegacySkyWcs 

423 

424 try: 

425 ast_mapping = astshim.FrameDict(self._pixel_to_sky._ast_mapping) 

426 except TypeError as err: 

427 err.add_note( 

428 "Only Projections created by from_legacy and from_fits_wcs " 

429 "are guaranteed to be convertible to SkyWcs." 

430 ) 

431 raise 

432 # SkyWcs requires the pixel frame's domain to be PIXELS, while AST 

433 # (and hence NDF and from_fits_wcs) uses PIXEL, so rename it in the 

434 # FrameDict (a deep copy, so this projection itself is unchanged). 

435 if ast_mapping.hasDomain("PIXEL") and not ast_mapping.hasDomain("PIXELS"): 435 ↛ 440line 435 didn't jump to line 440 because the condition on line 435 was always true

436 saved_current = ast_mapping.current 

437 ast_mapping.setCurrent("PIXEL") 

438 ast_mapping.setDomain("PIXELS") 

439 ast_mapping.current = saved_current 

440 legacy_wcs = LegacySkyWcs(ast_mapping) 

441 if self.fits_approximation is not None: 441 ↛ 442line 441 didn't jump to line 442 because the condition on line 441 was never true

442 legacy_wcs = legacy_wcs.copyWithFitsApproximation(self.fits_approximation.to_legacy()) 

443 return legacy_wcs 

444 

445 

446class SkyProjectionAstropyView(BaseLowLevelWCS, HighLevelWCSMixin): 

447 """An Astropy-interface view of a `SkyProjection`. 

448 

449 Parameters 

450 ---------- 

451 ast_pixel_to_sky 

452 AST mapping from pixel coordinates to sky coordinates. 

453 bbox 

454 Bounding box of the projection, or `None` if unbounded. 

455 

456 Notes 

457 ----- 

458 The constructor of this classe is considered a private implementation 

459 detail; use `SkyProjection.as_astropy` instead. 

460 

461 This object satisfies the `astropy.wcs.wcsapi.BaseHighLevelWCS` and 

462 `astropy.wcs.wcsapi.BaseLowLevelWCS` interfaces while evaluating the 

463 underlying `SkyProjection` itself. It is *not* an `astropy.wcs.WCS` 

464 subclass, which is a type that also satisfies those interfaces but 

465 only supports FITS WCS representations (see `SkyProjection.as_fits_wcs`). 

466 """ 

467 

468 def __init__(self, ast_pixel_to_sky: astshim.Mapping, bbox: Box | None) -> None: 

469 self._bbox = bbox 

470 if bbox is not None: 

471 ast_pixel_to_sky = astshim.ShiftMap(list(bbox.start.xy)).then(ast_pixel_to_sky) 

472 self._ast_pixel_to_sky = ast_pixel_to_sky 

473 

474 @property 

475 def low_level_wcs(self) -> Self: 

476 return self 

477 

478 @property 

479 def array_shape(self) -> YX[int] | None: 

480 return self._bbox.shape if self._bbox is not None else None 

481 

482 @property 

483 def axis_correlation_matrix(self) -> np.ndarray: 

484 return np.array([[True, True], [True, True]]) 

485 

486 @property 

487 def pixel_axis_names(self) -> XY[str]: 

488 return XY("x", "y") 

489 

490 @property 

491 def pixel_bounds(self) -> XY[tuple[int, int]] | None: 

492 if self._bbox is None: 

493 return None 

494 return XY((self._bbox.x.min, self._bbox.x.max), (self._bbox.y.min, self._bbox.y.max)) 

495 

496 @property 

497 def pixel_n_dim(self) -> int: 

498 return 2 

499 

500 @property 

501 def pixel_shape(self) -> XY[int] | None: 

502 array_shape = self.array_shape 

503 return array_shape.xy if array_shape is not None else None 

504 

505 @property 

506 def serialized_classes(self) -> bool: 

507 return False 

508 

509 @property 

510 def world_axis_names(self) -> tuple[str, str]: 

511 return ("ra", "dec") 

512 

513 @property 

514 def world_axis_object_classes(self) -> dict[str, tuple[type[SkyCoord], tuple[()], dict[str, Any]]]: 

515 return {"celestial": (SkyCoord, (), {"frame": ICRS, "unit": (u.rad, u.rad)})} 

516 

517 @property 

518 def world_axis_object_components(self) -> list[tuple[str, int, str]]: 

519 return [("celestial", 0, "spherical.lon.radian"), ("celestial", 1, "spherical.lat.radian")] 

520 

521 @property 

522 def world_axis_physical_types(self) -> tuple[str, str]: 

523 return ("pos.eq.ra", "pos.eq.dec") 

524 

525 @property 

526 def world_axis_units(self) -> tuple[str, str]: 

527 return ("rad", "rad") 

528 

529 @property 

530 def world_n_dim(self) -> int: 

531 return 2 

532 

533 def pixel_to_world_values(self, x: np.ndarray, y: np.ndarray) -> XY[Any]: 

534 return _ast_apply(self._ast_pixel_to_sky.applyForward, x=x, y=y) 

535 

536 def world_to_pixel_values(self, ra: np.ndarray, dec: np.ndarray) -> XY[Any]: 

537 return _ast_apply(self._ast_pixel_to_sky.applyInverse, x=ra, y=dec) 

538 

539 

540class SkyProjectionSerializationModel[P: pydantic.BaseModel](ArchiveTree): 

541 """Serialization model for projetions.""" 

542 

543 SCHEMA_NAME: ClassVar[str] = "sky_projection" 

544 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

545 MIN_READ_VERSION: ClassVar[int] = 1 

546 PUBLIC_TYPE: ClassVar[type] = SkyProjection 

547 

548 pixel_to_sky: TransformSerializationModel[P] = pydantic.Field( 

549 description="The transform that maps pixel coordinates to the sky." 

550 ) 

551 fits_approximation: TransformSerializationModel[P] | None = pydantic.Field( 

552 default=None, 

553 description=( 

554 "An approximation of the pixel-to-sky transform that is exactly representable as a FITS WCS." 

555 ), 

556 exclude_if=is_none, 

557 ) 

558 

559 def deserialize(self, archive: InputArchive[P], **kwargs: Any) -> SkyProjection[Any]: 

560 """Deserialize a projection from an archive. 

561 

562 Parameters 

563 ---------- 

564 archive 

565 Archive to read from. 

566 **kwargs 

567 Unsupported keyword arguments are accepted only to provide better 

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

569 """ 

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

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

572 pixel_to_sky = self.pixel_to_sky.deserialize(archive) 

573 fits_approximation = ( 

574 self.fits_approximation.deserialize(archive) if self.fits_approximation is not None else None 

575 ) 

576 return SkyProjection(pixel_to_sky, fits_approximation=fits_approximation) 

577 

578 

579if TYPE_CHECKING: 

580 

581 def _test_types() -> None: 

582 sp = cast(SkyProjection, None) 

583 sky = cast(SkyCoord, None) 

584 

585 # sky_to_pixel returns XY[Any] so that callers need no cast regardless 

586 # of whether sky is scalar or array-shaped. 

587 assert_type(sp.sky_to_pixel(sky), XY[Any])