Coverage for python/lsst/images/_transforms/_transform.py: 77%

278 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__ = ( 

15 "Transform", 

16 "TransformCompositionError", 

17 "TransformSerializationModel", 

18) 

19 

20import textwrap 

21from collections.abc import Iterable 

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

23 

24import astropy.io.fits.header 

25import astropy.units as u 

26import numpy as np 

27import numpy.typing as npt 

28import pydantic 

29 

30from .._concrete_bounds import BoundsSerializationModel 

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

32from ..serialization import ArchiveReadError, ArchiveTree, InputArchive, InvalidParameterError, OutputArchive 

33from . import _ast as astshim 

34from ._frames import Frame, SerializableFrame, SkyFrame 

35 

36if TYPE_CHECKING: 

37 try: 

38 from lsst.afw.geom import TransformPoint2ToPoint2 as LegacyTransform 

39 except ImportError: 

40 type LegacyTransform = Any # type: ignore[no-redef] 

41 

42# These pre-python-3.12 declaration are needed by Sphinx (probably the 

43# autodoc-typehints plugin. 

44I = TypeVar("I", bound=Frame) # noqa: E741 

45O = TypeVar("O", bound=Frame) # noqa: E741 

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

47 

48 

49class TransformCompositionError(RuntimeError): 

50 """Exception raised when two transforms cannot be composed.""" 

51 

52 

53@final 

54class Transform[I: Frame, O: Frame]: 

55 """A transform that maps two coordinate frames. 

56 

57 Parameters 

58 ---------- 

59 in_frame 

60 Input coordinate frame. 

61 out_frame 

62 Output coordinate frame. 

63 ast_mapping 

64 AST mapping that implements the transform. 

65 in_bounds 

66 Bounds of the input frame, defaulting to the input frame's 

67 bounding box. 

68 out_bounds 

69 Bounds of the output frame, defaulting to the output frame's 

70 bounding box. 

71 components 

72 Component transforms that this transform was composed from. 

73 

74 Notes 

75 ----- 

76 The `Transform` class constructor is considered a private implementation 

77 detail. Instead of using this, various factory methods are available: 

78 

79 - `from_fits_wcs` constructs a transform from a FITS WCS, as represented 

80 `astropy.wcs.WCS`; 

81 - `then` composes two transforms; 

82 - `identity` constructs a trivial transform that does nothing; 

83 - `affine` contructs an affine transform from a 2x2 or 3x3 matrix; 

84 - `inverted` returns the inverse of a transform; 

85 - `from_legacy` converts an `lsst.afw.geom.Transform` instance. 

86 

87 When applied to celestial coordinate systems, ``x=ra`` and ``y=dec``. 

88 `SkyProjection` provides a more natural interface for pixel-to-sky 

89 transforms. 

90 

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

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

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

94 """ 

95 

96 def __init__( 

97 self, 

98 in_frame: I, 

99 out_frame: O, 

100 ast_mapping: astshim.Mapping, 

101 in_bounds: Bounds | None = None, 

102 out_bounds: Bounds | None = None, 

103 components: Iterable[Transform[Any, Any]] = (), 

104 ) -> None: 

105 self._in_frame = in_frame 

106 self._out_frame = out_frame 

107 self._ast_mapping = ast_mapping 

108 self._in_bounds = in_bounds or getattr(in_frame, "bbox", None) 

109 self._out_bounds = out_bounds or getattr(out_frame, "bbox", None) 

110 self._components = list(components) 

111 

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

113 if self is other: 

114 # Short circuit for case where you are quickly checking 

115 # that the image WCS and variance WCS are the same object. 

116 return True 

117 if not isinstance(other, Transform): 

118 return NotImplemented 

119 if self._ast_mapping != other._ast_mapping: 

120 return False 

121 if self._in_bounds != other._in_bounds: 

122 return False 

123 if self._out_bounds != other._out_bounds: 

124 return False 

125 if self._in_frame != other._in_frame: 

126 return False 

127 if self._out_frame != other._out_frame: 

128 return False 

129 if self._components != other._components: 

130 return False 

131 return True 

132 

133 @staticmethod 

134 def from_fits_wcs( 

135 fits_wcs: astropy.wcs.WCS, 

136 in_frame: I, 

137 out_frame: O, 

138 in_bounds: Bounds | None = None, 

139 out_bounds: Bounds | None = None, 

140 x0: int = 0, 

141 y0: int = 0, 

142 ) -> Transform[I, O]: 

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

144 

145 Parameters 

146 ---------- 

147 fits_wcs 

148 FITS WCS to convert. 

149 in_frame 

150 Coordinate frame for input points to the forward transform. 

151 out_frame 

152 Coordinate frame for output points from the forward transform. 

153 in_bounds 

154 The region that bounds valid input points. 

155 out_bounds 

156 The region that bounds valid output points. 

157 x0 

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

159 relates to world coordinates. 

160 y0 

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

162 relates to world coordinates. 

163 

164 Notes 

165 ----- 

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

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

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

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

170 integers. 

171 

172 See Also 

173 -------- 

174 SkyProjection.from_fits_wcs 

175 """ 

176 ast_stream = astshim.StringStream(fits_wcs.to_header_string(relax=True)) 

177 ast_fits_chan = astshim.FitsChan(ast_stream, "Encoding=FITS-WCS, SipReplace=0, IWC=1") 

178 ast_frame_set = ast_fits_chan.read() 

179 _prepend_ast_shift(ast_frame_set, x=x0 - 1.0, y=y0 - 1.0, ast_domain="PIXEL") 

180 return Transform( 

181 in_frame, 

182 out_frame, 

183 ast_frame_set, 

184 in_bounds=in_bounds, 

185 out_bounds=out_bounds, 

186 ) 

187 

188 @staticmethod 

189 def identity(frame: I) -> Transform[I, I]: 

190 """Construct a trivial transform that maps a frame to itelf. 

191 

192 Parameters 

193 ---------- 

194 frame 

195 Frame used for both input and output points. 

196 """ 

197 return Transform(frame, frame, astshim.UnitMap(2)) 

198 

199 @staticmethod 

200 def affine(in_frame: I, out_frame: O, matrix: np.ndarray) -> Transform[I, O]: 

201 """Construct an affine transform from a matrix. 

202 

203 Parameters 

204 ---------- 

205 in_frame 

206 Coordinate frame for input points to the forward transform. 

207 out_frame 

208 Coordinate frame for output points from the forward transform. 

209 matrix 

210 Matrix of coefficients, either a 2x2 linear transform or a 3x3 

211 augmented affine transform, with a shift embedded in the third 

212 column and ``[0, 0, 1]`` the third row. 

213 """ 

214 if matrix.shape == (2, 2): 

215 return Transform(in_frame, out_frame, astshim.MatrixMap(matrix.copy())) 

216 elif matrix.shape == (3, 3): 216 ↛ 223line 216 didn't jump to line 223 because the condition on line 216 was always true

217 linear = astshim.MatrixMap(matrix[:2, :2].copy()) 

218 shift = astshim.ShiftMap(matrix[:2, 2]) 

219 if not np.array_equal(matrix[2, :], np.array([0.0, 0.0, 1.0])): 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 raise ValueError("3x3 affine transform array must have [0, 0, 1] in its last row.") 

221 return Transform(in_frame, out_frame, linear.then(shift)) 

222 else: 

223 raise ValueError("Affine transform array must be 2x2 or 3x3.") 

224 

225 @property 

226 def in_frame(self) -> I: 

227 """Coordinate frame for input points.""" 

228 return self._in_frame 

229 

230 @property 

231 def out_frame(self) -> O: 

232 """Coordinate frame for output points.""" 

233 return self._out_frame 

234 

235 @property 

236 def in_bounds(self) -> Bounds | None: 

237 """The region that bounds valid input points (`Bounds` | `None`).""" 

238 return self._in_bounds 

239 

240 @property 

241 def out_bounds(self) -> Bounds | None: 

242 """The region that bounds valid output points (`Bounds` | `None`).""" 

243 return self._out_bounds 

244 

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

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

247 

248 Parameters 

249 ---------- 

250 simplified 

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

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

253 have the same `show` result. If the internal mapping is actually 

254 a frame set (as needed to round-trip legacy 

255 `lsst.afw.geom.SkyWcs` objects), this will also just show the 

256 mapping with no frame set information. 

257 comments 

258 Whether to include descriptive comments. 

259 """ 

260 ast_mapping = self._ast_mapping 

261 if simplified: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

262 if isinstance(ast_mapping, astshim.FrameSet): 

263 ast_mapping = ast_mapping.getMapping() 

264 ast_mapping = ast_mapping.simplified() 

265 return ast_mapping.show(comments) 

266 

267 @overload 

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

269 

270 @overload 

271 def apply_forward(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> XY[np.ndarray]: ... 271 ↛ exitline 271 didn't return from function 'apply_forward' because

272 

273 @overload 

274 def apply_forward(self, /, *, x: int | float, y: int | float) -> XY[float]: ... 274 ↛ exitline 274 didn't return from function 'apply_forward' because

275 

276 @overload 

277 def apply_forward(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> XY[np.ndarray]: ... 277 ↛ exitline 277 didn't return from function 'apply_forward' because

278 

279 def apply_forward( 

280 self, point: XY[Any] | YX[Any] | None = None, /, *, x: Any = None, y: Any = None 

281 ) -> XY[float] | XY[np.ndarray]: 

282 """Apply the forward transform to one or more points. 

283 

284 Parameters 

285 ---------- 

286 point 

287 An `XY` or `YX` coordinate pair to transform. Mutually exclusive 

288 with ``x`` and ``y``. 

289 x : `float` | array-like 

290 ``x`` values of the points to transform, as a scalar or any 

291 array-like. Results are broadcast against ``y``. 

292 Mutually exclusive with ``point``. 

293 y : `float` | array-like 

294 ``y`` values of the points to transform, as a scalar or any 

295 array-like. Results are broadcast against ``x``. 

296 Mutually exclusive with ``point``. 

297 

298 Returns 

299 ------- 

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

301 The transformed point or points. A scalar input pair returns 

302 `XY` of `float`; array-like inputs return `XY` of 

303 `numpy.ndarray` with the broadcast shape of ``x`` and ``y``. 

304 """ 

305 match point: 

306 case None: 

307 if x is None or y is None: 

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

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

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

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

312 x, y = point.x, point.y 

313 case _: 

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

315 return _standardize_xy( 

316 _ast_apply( 

317 self._ast_mapping.applyForward, 

318 x=self._in_frame.standardize_x(x), 

319 y=self._in_frame.standardize_y(y), 

320 ), 

321 self._out_frame, 

322 ) 

323 

324 @overload 

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

326 

327 @overload 

328 def apply_inverse(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> XY[np.ndarray]: ... 328 ↛ exitline 328 didn't return from function 'apply_inverse' because

329 

330 @overload 

331 def apply_inverse(self, /, *, x: int | float, y: int | float) -> XY[float]: ... 331 ↛ exitline 331 didn't return from function 'apply_inverse' because

332 

333 @overload 

334 def apply_inverse(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> XY[np.ndarray]: ... 334 ↛ exitline 334 didn't return from function 'apply_inverse' because

335 

336 def apply_inverse( 

337 self, point: XY[Any] | YX[Any] | None = None, /, *, x: Any = None, y: Any = None 

338 ) -> XY[float] | XY[np.ndarray]: 

339 """Apply the inverse transform to one or more points. 

340 

341 Parameters 

342 ---------- 

343 point 

344 An `XY` or `YX` coordinate pair to transform. Mutually exclusive 

345 with ``x`` and ``y``. 

346 x : `float` | array-like 

347 ``x`` values of the points to transform, as a scalar or any 

348 array-like. Results are broadcast against ``y``. 

349 Mutually exclusive with ``point``. 

350 y : `float` | array-like 

351 ``y`` values of the points to transform, as a scalar or any 

352 array-like. Results are broadcast against ``x``. 

353 Mutually exclusive with ``point``. 

354 

355 Returns 

356 ------- 

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

358 The transformed point or points. A scalar input pair returns 

359 `XY` of `float`; array-like inputs return `XY` of 

360 `numpy.ndarray` with the broadcast shape of ``x`` and ``y``. 

361 """ 

362 match point: 

363 case None: 

364 if x is None or y is None: 364 ↛ 365line 364 didn't jump to line 365 because the condition on line 364 was never true

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

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

367 if x is not None or y is not None: 367 ↛ 368line 367 didn't jump to line 368 because the condition on line 367 was never true

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

369 x, y = point.x, point.y 

370 case _: 

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

372 return _standardize_xy( 

373 _ast_apply( 

374 self._ast_mapping.applyInverse, 

375 x=self._out_frame.standardize_x(x), 

376 y=self._out_frame.standardize_y(y), 

377 ), 

378 self._in_frame, 

379 ) 

380 

381 @overload 

382 def apply_forward_q(self, point: XY[u.Quantity] | YX[u.Quantity], /) -> XY[u.Quantity]: ... 382 ↛ exitline 382 didn't return from function 'apply_forward_q' because

383 

384 @overload 

385 def apply_forward_q(self, /, *, x: u.Quantity, y: u.Quantity) -> XY[u.Quantity]: ... 385 ↛ exitline 385 didn't return from function 'apply_forward_q' because

386 

387 def apply_forward_q( 

388 self, point: XY[u.Quantity] | YX[u.Quantity] | None = None, /, *, x: Any = None, y: Any = None 

389 ) -> XY[u.Quantity]: 

390 """Apply the forward transform to one or more unit-aware points. 

391 

392 Parameters 

393 ---------- 

394 point 

395 An `XY` or `YX` coordinate pair of `~astropy.units.Quantity` to 

396 transform. Mutually exclusive with ``x`` and ``y``. 

397 x 

398 ``x`` values of the points to transform. 

399 Mutually exclusive with ``point``. 

400 y 

401 ``y`` values of the points to transform. 

402 Mutually exclusive with ``point``. 

403 

404 Returns 

405 ------- 

406 `XY` [`astropy.units.Quantity`] 

407 The transformed point or points. 

408 """ 

409 match point: 

410 case None: 

411 if x is None or y is None: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

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

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

414 if x is not None or y is not None: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true

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

416 x, y = point.x, point.y 

417 case _: 

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

419 xy = self.apply_forward(x=x.to_value(self._in_frame.unit), y=y.to_value(self._in_frame.unit)) 

420 return XY(xy.x * self._out_frame.unit, xy.y * self._out_frame.unit) 

421 

422 @overload 

423 def apply_inverse_q(self, point: XY[u.Quantity] | YX[u.Quantity], /) -> XY[u.Quantity]: ... 423 ↛ exitline 423 didn't return from function 'apply_inverse_q' because

424 

425 @overload 

426 def apply_inverse_q(self, /, *, x: u.Quantity, y: u.Quantity) -> XY[u.Quantity]: ... 426 ↛ exitline 426 didn't return from function 'apply_inverse_q' because

427 

428 def apply_inverse_q( 

429 self, point: XY[u.Quantity] | YX[u.Quantity] | None = None, /, *, x: Any = None, y: Any = None 

430 ) -> XY[u.Quantity]: 

431 """Apply the inverse transform to one or more unit-aware points. 

432 

433 Parameters 

434 ---------- 

435 point 

436 An `XY` or `YX` coordinate pair of `~astropy.units.Quantity` to 

437 transform. Mutually exclusive with ``x`` and ``y``. 

438 x 

439 ``x`` values of the points to transform. 

440 Mutually exclusive with ``point``. 

441 y 

442 ``y`` values of the points to transform. 

443 Mutually exclusive with ``point``. 

444 

445 Returns 

446 ------- 

447 `XY` [`astropy.units.Quantity`] 

448 The transformed point or points. 

449 """ 

450 match point: 

451 case None: 

452 if x is None or y is None: 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true

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

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

455 if x is not None or y is not None: 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true

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

457 x, y = point.x, point.y 

458 case _: 

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

460 xy = self.apply_inverse(x=x.to_value(self._out_frame.unit), y=y.to_value(self._out_frame.unit)) 

461 return XY(xy.x * self._in_frame.unit, xy.y * self._in_frame.unit) 

462 

463 def decompose(self) -> list[Transform[Any, Any]]: 

464 """Deconstruct a composed transform into its constituent parts. 

465 

466 Notes 

467 ----- 

468 Most transforms will just return a single-element list holding 

469 ``self``. Identity transform will return an empty list, and 

470 transforms composed with `then` will return the original transforms. 

471 Transforms constructed by `FrameSet` may or may not be decomposable. 

472 """ 

473 if not self._components: 473 ↛ 479line 473 didn't jump to line 479 because the condition on line 473 was always true

474 if self.in_frame == self._out_frame: 474 ↛ 477line 474 didn't jump to line 477 because the condition on line 474 was always true

475 return [] 

476 else: 

477 return [self] 

478 else: 

479 return list(self._components) 

480 

481 def inverted(self) -> Transform[O, I]: 

482 """Return the inverse of this transform.""" 

483 return Transform[O, I]( 

484 self._out_frame, 

485 self._in_frame, 

486 self._ast_mapping.inverted(), 

487 in_bounds=self.out_bounds, 

488 out_bounds=self.in_bounds, 

489 components=[t.inverted() for t in reversed(self._components)], 

490 ) 

491 

492 def then[F: Frame](self, next: Transform[O, F], remember_components: bool = True) -> Transform[I, F]: 

493 """Compose two transforms into another. 

494 

495 Parameters 

496 ---------- 

497 next 

498 Another transform to apply after ``self``. 

499 remember_components 

500 If `True`, the returned composed transform will remember ``self`` 

501 and ``other`` so they can be returned by `decompose`. 

502 """ 

503 if self._out_frame != next._in_frame: 

504 raise TransformCompositionError( 

505 "Cannot compose transforms that do not share a common intermediate frame: " 

506 f"{self._out_frame} != {next._in_frame}." 

507 ) 

508 components = self.decompose() + next.decompose() if remember_components else () 

509 return Transform( 

510 self._in_frame, 

511 next._out_frame, 

512 self._ast_mapping.then(next._ast_mapping), 

513 in_bounds=self.in_bounds, 

514 out_bounds=next.out_bounds, 

515 components=components, 

516 ) 

517 

518 def as_fits_wcs(self, bbox: Box) -> astropy.wcs.WCS | None: 

519 """Return a FITS WCS representation of this transform, if possible. 

520 

521 Parameters 

522 ---------- 

523 bbox 

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

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

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

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

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

529 

530 Notes 

531 ----- 

532 This method assumes the transform maps pixel coordinates to world 

533 coordinates. 

534 

535 Not all transforms can be represented exactly; when a FITS 

536 represention is not possible, `None` is returned. When the returned 

537 WCS is not `None`, it will have the same functional form, but it may 

538 not evaluate identically due to small implementation differences in 

539 the order of floating-point operations. 

540 """ 

541 ast_frame_set = self._get_ast_frame_set() 

542 _prepend_ast_shift(ast_frame_set, x=1.0 - bbox.x.start, y=1.0 - bbox.y.start, ast_domain="GRID") 

543 ast_stream = astshim.StringStream() 

544 ast_fits_chan = astshim.FitsChan( 

545 ast_stream, "Encoding=FITS-WCS, CDMatrix=1, FitsAxisOrder=<copy>, FitsTol=0.0001" 

546 ) 

547 ast_fits_chan.setFitsI("NAXIS1", bbox.x.size) 

548 ast_fits_chan.setFitsI("NAXIS2", bbox.y.size) 

549 n_writes = ast_fits_chan.write(ast_frame_set) 

550 if not n_writes: 

551 return None 

552 header = astropy.io.fits.Header(astropy.io.fits.Card.fromstring(c) for c in ast_fits_chan) 

553 return astropy.wcs.WCS(header) 

554 

555 def serialize[P: pydantic.BaseModel]( 

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

557 ) -> TransformSerializationModel[P]: 

558 """Serialize a transform to an archive. 

559 

560 Parameters 

561 ---------- 

562 archive 

563 Archive to serialize to. 

564 use_frame_sets 

565 If `True`, decompose the transform and try to reference component 

566 mappings that were already serialized into a `FrameSet` in the 

567 archive. Note that if multiple transforms exist between a pair of 

568 frames (e.g. a `SkyProjection` and its FITS approximation), this 

569 may cause the wrong one to be saved. When this option is used, the 

570 frame set must be saved before the transform, and it must be 

571 deserialized before the transform as well. 

572 

573 Returns 

574 ------- 

575 `TransformSerializationModel` 

576 Serialized form of the transform. 

577 """ 

578 model = TransformSerializationModel[P]() 

579 if use_frame_sets: 579 ↛ 580line 579 didn't jump to line 580 because the condition on line 579 was never true

580 for link in self.decompose(): 

581 model.frames.append(link.in_frame.serialize()) 

582 model.bounds.append(link.in_bounds.serialize() if link.in_bounds is not None else None) 

583 for frame_set, pointer in archive.iter_frame_sets(): 

584 if link.in_frame in frame_set and link.out_frame in frame_set: 

585 model.mappings.append(pointer) 

586 break 

587 else: 

588 model.mappings.append(MappingSerializationModel(ast=link._ast_mapping.show())) 

589 else: 

590 model.frames.append(self.in_frame.serialize()) 

591 model.bounds.append(self.in_bounds.serialize() if self.in_bounds is not None else None) 

592 model.mappings.append(MappingSerializationModel(ast=self._ast_mapping.show())) 

593 model.frames.append(self.out_frame.serialize()) 

594 model.bounds.append(self.out_bounds.serialize() if self.out_bounds is not None else None) 

595 return model 

596 

597 @staticmethod 

598 def _get_archive_tree_type[P: pydantic.BaseModel]( 

599 pointer_type: type[P], 

600 ) -> type[TransformSerializationModel[P]]: 

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

602 type that uses the given pointer type. 

603 """ 

604 return TransformSerializationModel[pointer_type] # type: ignore 

605 

606 @staticmethod 

607 def from_legacy( 

608 legacy: LegacyTransform, 

609 in_frame: I, 

610 out_frame: O, 

611 in_bounds: Bounds | None = None, 

612 out_bounds: Bounds | None = None, 

613 ) -> Transform[I, O]: 

614 """Construct a transform from a legacy `lsst.afw.geom.Transform`. 

615 

616 Parameters 

617 ---------- 

618 legacy : `lsst.afw.geom.Transform` 

619 Legacy transform object. 

620 in_frame 

621 Coordinate frame for input points to the forward transform. 

622 out_frame 

623 Coordinate frame for output points from the forward transform. 

624 in_bounds 

625 The region that bounds valid input points. 

626 out_bounds 

627 The region that bounds valid output points. 

628 """ 

629 return Transform( 

630 in_frame, 

631 out_frame, 

632 legacy.getMapping(), 

633 in_bounds=in_bounds, 

634 out_bounds=out_bounds, 

635 ) 

636 

637 def to_legacy(self) -> LegacyTransform: 

638 """Convert to a legacy `lsst.afw.geom.TransformPoint2ToPoint2` 

639 instance. 

640 """ 

641 from lsst.afw.geom import TransformPoint2ToPoint2 as LegacyTransform 

642 

643 return LegacyTransform(self._ast_mapping, False) 

644 

645 def _get_ast_frame_set(self) -> Any: 

646 ast_frame_set = astshim.FrameSet(_make_ast_frame(self._in_frame)) 

647 ast_frame_set.addFrame(astshim.FrameSet.BASE, self._ast_mapping, _make_ast_frame(self._out_frame)) 

648 return ast_frame_set 

649 

650 

651def _ast_apply(method: Any, *, x: Any, y: Any) -> XY[float] | XY[np.ndarray]: 

652 # TODO: add bounds argument and check inputs 

653 xa = np.asarray(x) 

654 ya = np.asarray(y) 

655 broadcast_shape = np.broadcast(xa, ya).shape 

656 scalar = not broadcast_shape 

657 xb, yb = np.broadcast_arrays(xa, ya) 

658 xy_in = np.vstack([xb.ravel(), yb.ravel()]).astype(np.float64) 

659 xy_out = method(xy_in) 

660 if scalar: 

661 return XY(float(xy_out[0, 0]), float(xy_out[1, 0])) 

662 return XY(xy_out[0].reshape(broadcast_shape), xy_out[1].reshape(broadcast_shape)) 

663 

664 

665def _prepend_ast_shift(ast_frame_set: Any, x: float, y: float, ast_domain: str) -> None: 

666 ast_output_frame_id = ast_frame_set.current 

667 ast_frame_set.addFrame( 

668 astshim.FrameSet.BASE, 

669 astshim.ShiftMap([x, y]), 

670 astshim.Frame(2, f"Domain={ast_domain}"), 

671 ) 

672 ast_frame_set.base = ast_frame_set.current 

673 ast_frame_set.current = ast_output_frame_id 

674 

675 

676def _make_ast_frame(frame: Frame) -> Any: 

677 if frame is SkyFrame.ICRS: 

678 return astshim.SkyFrame("") 

679 ast_frame = astshim.Frame(2, f"Ident={frame._ast_ident}") 

680 if frame.unit is not None: 680 ↛ 684line 680 didn't jump to line 684 because the condition on line 680 was always true

681 fits_unit = frame.unit.to_string(format="fits") 

682 ast_frame.setUnit(1, fits_unit) 

683 ast_frame.setUnit(2, fits_unit) 

684 ast_frame.setLabel(1, "x") 

685 ast_frame.setLabel(2, "y") 

686 return ast_frame 

687 

688 

689def _standardize_xy(xy: XY[Any], frame: Frame) -> XY[Any]: 

690 return XY(x=frame.standardize_x(xy.x), y=frame.standardize_y(xy.y)) 

691 

692 

693class MappingSerializationModel(pydantic.BaseModel): 

694 """Serialization model for an AST Mapping.""" 

695 

696 ast: str = pydantic.Field(description="A serialized Starlink AST Mapping, using the AST native encoding.") 

697 

698 

699class TransformSerializationModel[P: pydantic.BaseModel](ArchiveTree): 

700 """Serialization model for coordinate transforms.""" 

701 

702 SCHEMA_NAME: ClassVar[str] = "transform" 

703 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

704 MIN_READ_VERSION: ClassVar[int] = 1 

705 PUBLIC_TYPE: ClassVar[type] = Transform 

706 

707 frames: list[SerializableFrame] = pydantic.Field( 

708 default_factory=list, 

709 description=textwrap.dedent( 

710 """ 

711 List of frames that this transform passes through. 

712 

713 All transforms include at least two frames (the endpoints). Others 

714 intermediate frames may be included to facilitate data-sharing 

715 between transforms. 

716 """ 

717 ), 

718 ) 

719 

720 bounds: list[BoundsSerializationModel | None] = pydantic.Field( 

721 default_factory=list, 

722 description=textwrap.dedent( 

723 """ 

724 List of the bounds of the ``frames`` for this transform. 

725 

726 This always has the same number of elements as ``frames``. 

727 """ 

728 ), 

729 ) 

730 

731 mappings: list[P | MappingSerializationModel] = pydantic.Field( 

732 default_factory=list, 

733 description=textwrap.dedent( 

734 """ 

735 The actual mappings between frames, or archive pointers to 

736 serialized FrameSet objects from which they can be obtained. 

737 

738 This always has one fewer element than ``frames``. 

739 """ 

740 ), 

741 ) 

742 

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

744 """Deserialize a transform from an archive. 

745 

746 Parameters 

747 ---------- 

748 archive 

749 Archive to read from. 

750 **kwargs 

751 Unsupported keyword arguments are accepted only to provide better 

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

753 """ 

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

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

756 if len(self.frames) != len(self.bounds): 756 ↛ 757line 756 didn't jump to line 757 because the condition on line 756 was never true

757 raise ArchiveReadError( 

758 f"Inconsistent lengths for 'frames' ({len(self.frames)}) and 'bounds' ({len(self.bounds)})." 

759 ) 

760 if len(self.frames) != len(self.mappings) + 1: 760 ↛ 761line 760 didn't jump to line 761 because the condition on line 760 was never true

761 raise ArchiveReadError( 

762 f"Inconsistent lengths for 'frames' ({len(self.frames)}) and " 

763 f"'mappings' ({len(self.mappings)}; should be one less)." 

764 ) 

765 # We can't just compose onto an identity Transform if we want to 

766 # preserve the FrameSet-ness of any of these mappings. 

767 transform: Transform | None = None 

768 for n, mapping in enumerate(self.mappings): 

769 match mapping: 

770 case MappingSerializationModel(ast=serialized_mapping): 770 ↛ 781line 770 didn't jump to line 781 because the pattern on line 770 always matched

771 ast_mapping = astshim.Mapping.fromString(serialized_mapping) 

772 in_bounds = self.bounds[n] 

773 out_bounds = self.bounds[n + 1] 

774 new_transform = Transform( 

775 self.frames[n].deserialize(), 

776 self.frames[n + 1].deserialize(), 

777 ast_mapping, 

778 in_bounds.deserialize() if in_bounds is not None else None, 

779 out_bounds.deserialize() if out_bounds is not None else None, 

780 ) 

781 case reference: 

782 frame_set = archive.get_frame_set(reference) 

783 new_transform = frame_set[self.frames[n].deserialize(), self.frames[n + 1].deserialize()] 

784 if transform is None: 784 ↛ 787line 784 didn't jump to line 787 because the condition on line 784 was always true

785 transform = new_transform 

786 else: 

787 transform = transform.then(new_transform) 

788 if transform is None: 788 ↛ 789line 788 didn't jump to line 789 because the condition on line 788 was never true

789 transform = Transform.identity(self.frames[0].deserialize()) 

790 return transform 

791 

792 

793if TYPE_CHECKING: 

794 

795 def _test_types() -> None: 

796 t = cast(Transform, None) 

797 arr = np.zeros(3) 

798 

799 # Scalar inputs → XY[float] 

800 assert_type(t.apply_forward(x=1.0, y=2.0), XY[float]) 

801 assert_type(t.apply_inverse(x=1.0, y=2.0), XY[float]) 

802 

803 # Array inputs → XY[np.ndarray] 

804 assert_type(t.apply_forward(x=arr, y=arr), XY[np.ndarray]) 

805 assert_type(t.apply_inverse(x=arr, y=arr), XY[np.ndarray]) 

806 

807 # Array-like (list) inputs → XY[np.ndarray] 

808 assert_type(t.apply_forward(x=[1.0, 2.0], y=[3.0, 4.0]), XY[np.ndarray]) 

809 assert_type(t.apply_inverse(x=[1.0, 2.0], y=[3.0, 4.0]), XY[np.ndarray])