Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# -*- coding: utf-8 -*- 

2from . import config 

3from ._constants import * 

4from .maps import MapBase, RVBase, ReflectedBase 

5from ._core import OpsSystem, math, linalg 

6import numpy as np 

7from astropy import units 

8from inspect import getmro 

9import matplotlib.pyplot as plt 

10from matplotlib.animation import FuncAnimation 

11from IPython.display import HTML 

12import os 

13import logging 

14 

15logger = logging.getLogger("starry.maps") 

16 

17 

18__all__ = ["Primary", "Secondary", "System"] 

19 

20 

21class Body(object): 

22 """A generic body. Must be subclassed.""" 

23 

24 def __init__( 

25 self, 

26 map, 

27 r=1.0, 

28 m=1.0, 

29 prot=1.0, 

30 t0=0.0, 

31 theta0=0.0, 

32 length_unit=units.Rsun, 

33 mass_unit=units.Msun, 

34 time_unit=units.day, 

35 angle_unit=units.degree, 

36 **kwargs, 

37 ): 

38 # Surface map 

39 self.map = map 

40 

41 # Units 

42 self.length_unit = length_unit 

43 self.mass_unit = mass_unit 

44 self.time_unit = time_unit 

45 self.angle_unit = angle_unit 

46 

47 # Attributes 

48 self.r = r 

49 self.m = m 

50 self.prot = prot 

51 self.t0 = t0 

52 self.theta0 = theta0 

53 

54 @property 

55 def length_unit(self): 

56 """An ``astropy.units`` unit defining the length metric for this body.""" 

57 return self._length_unit 

58 

59 @length_unit.setter 

60 def length_unit(self, value): 

61 assert value.physical_type == "length" 

62 self._length_unit = value 

63 self._length_factor = value.in_units(units.Rsun) 

64 

65 @property 

66 def mass_unit(self): 

67 """An ``astropy.units`` unit defining the mass metric for this body.""" 

68 return self._mass_unit 

69 

70 @mass_unit.setter 

71 def mass_unit(self, value): 

72 assert value.physical_type == "mass" 

73 self._mass_unit = value 

74 self._mass_factor = value.in_units(units.Msun) 

75 

76 @property 

77 def time_unit(self): 

78 """An ``astropy.units`` unit defining the time metric for this body.""" 

79 return self._time_unit 

80 

81 @time_unit.setter 

82 def time_unit(self, value): 

83 assert value.physical_type == "time" 

84 self._time_unit = value 

85 self._time_factor = value.in_units(units.day) 

86 

87 @property 

88 def angle_unit(self): 

89 """An ``astropy.units`` unit defining the angle metric for this body.""" 

90 return self._angle_unit 

91 

92 @angle_unit.setter 

93 def angle_unit(self, value): 

94 assert value.physical_type == "angle" 

95 self._angle_unit = value 

96 self._angle_factor = value.in_units(units.radian) 

97 

98 @property 

99 def _angle_unit(self): 

100 return self._map._angle_unit 

101 

102 @_angle_unit.setter 

103 def _angle_unit(self, value): 

104 self._map._angle_unit = value 

105 

106 @property 

107 def _angle_factor(self): 

108 return self._map._angle_factor 

109 

110 @_angle_factor.setter 

111 def _angle_factor(self, value): 

112 self._map._angle_factor = value 

113 

114 @property 

115 def map(self): 

116 """The surface map for this body.""" 

117 return self._map 

118 

119 @map.setter 

120 def map(self, value): 

121 assert MapBase in getmro( 

122 type(value) 

123 ), "The `map` attribute must be a `starry` map instance." 

124 self._map = value 

125 

126 @property 

127 def r(self): 

128 """The radius in units of :py:attr:`length_unit`.""" 

129 return self._r / self._length_factor 

130 

131 @r.setter 

132 def r(self, value): 

133 self._r = math.cast(value * self._length_factor) 

134 

135 @property 

136 def m(self): 

137 """The mass in units of :py:attr:`mass_unit`.""" 

138 return self._m / self._mass_factor 

139 

140 @m.setter 

141 def m(self, value): 

142 self._m = math.cast(value * self._mass_factor) 

143 

144 @property 

145 def prot(self): 

146 """The rotation period in units of :py:attr:`time_unit`.""" 

147 return self._prot / self._time_factor 

148 

149 @prot.setter 

150 def prot(self, value): 

151 self._prot = math.cast(value * self._time_factor) 

152 

153 @property 

154 def t0(self): 

155 """A reference time in units of :py:attr:`time_unit`.""" 

156 return self._t0 / self._time_factor 

157 

158 @t0.setter 

159 def t0(self, value): 

160 self._t0 = math.cast(value * self._time_factor) 

161 

162 @property 

163 def theta0(self): 

164 """The map rotational phase at time :py:attr:`t0`.""" 

165 return self._theta0 / self._angle_factor 

166 

167 @theta0.setter 

168 def theta0(self, value): 

169 self._theta0 = math.cast(value * self._angle_factor) 

170 

171 def _check_kwargs(self, method, kwargs): 

172 if not config.quiet: 

173 for key in kwargs.keys(): 

174 message = "Invalid keyword `{0}` in call to `{1}()`. Ignoring." 

175 message = message.format(key, method) 

176 logger.warning(message) 

177 

178 

179class Primary(Body): 

180 """A primary (central) body. 

181 

182 Args: 

183 map: The surface map of this body. This should be an instance 

184 returned by :py:func:`starry.Map`. 

185 r (scalar, optional): The radius of the body in units of 

186 :py:attr:`length_unit`. Defaults to 1.0. 

187 m (scalar, optional): The mass of the body in units of 

188 :py:attr:`mass_unit`. Defaults to 1.0. 

189 prot (scalar, optional): The rotation period of the body in units of 

190 :py:attr:`time_unit`. Defaults to 1.0. 

191 t0 (scalar, optional): A reference time in units of 

192 :py:attr:`time_unit`. Defaults to 0.0. 

193 theta0 (scalar, optional): The rotational phase of the map at time 

194 :py:attr:`t0` in units of :py:attr:`angle_unit`. Defaults to 0.0. 

195 length_unit (optional): An ``astropy.units`` unit defining the 

196 distance metric for this object. Defaults to 

197 :py:attr:`astropy.units.Rsun.` 

198 mass_unit (optional): An ``astropy.units`` unit defining the 

199 mass metric for this object. Defaults to 

200 :py:attr:`astropy.units.Msun.` 

201 time_unit (optional): An ``astropy.units`` unit defining the 

202 time metric for this object. Defaults to 

203 :py:attr:`astropy.units.day.` 

204 angle_unit (optional): An ``astropy.units`` unit defining the 

205 angular metric for this object. Defaults to 

206 :py:attr:`astropy.units.degree.` 

207 """ 

208 

209 def __init__(self, map, **kwargs): 

210 # Initialize `Body` 

211 super(Primary, self).__init__(map, **kwargs) 

212 for kw in [ 

213 "r", 

214 "m", 

215 "prot", 

216 "t0", 

217 "theta0", 

218 "length_unit", 

219 "mass_unit", 

220 "time_unit", 

221 "angle_unit", 

222 ]: 

223 kwargs.pop(kw, None) 

224 self._check_kwargs("Primary", kwargs) 

225 

226 

227class Secondary(Body): 

228 """A secondary (orbiting) body. 

229 

230 Args: 

231 map: The surface map of this body. This should be an instance 

232 returned by :py:func:`starry.Map`. 

233 r (scalar, optional): The radius of the body in units of 

234 :py:attr:`length_unit`. Defaults to 1.0. 

235 m (scalar, optional): The mass of the body in units of 

236 :py:attr:`mass_unit`. Defaults to 1.0. 

237 a (scalar, optional): The semi-major axis of the body in units of 

238 :py:attr:`time_unit`. Defaults to 1.0. If :py:attr:`porb` is 

239 also provided, this value is ignored. 

240 porb (scalar, optional): The orbital period of the body in units of 

241 :py:attr:`time_unit`. Defaults to 1.0. Setting this value 

242 overrides :py:attr:`a`. 

243 prot (scalar, optional): The rotation period of the body in units of 

244 :py:attr:`time_unit`. Defaults to 1.0. 

245 t0 (scalar, optional): A reference time in units of 

246 :py:attr:`time_unit`. This is taken to be the time of a reference 

247 transit. Defaults to 0.0. 

248 ecc (scalar, optional): The orbital eccentricity of the body. 

249 Defaults to 0. 

250 w, omega (scalar, optional): The argument of pericenter of the body 

251 in units of :py:attr:`angle_unit`. Defaults to 90 degrees. 

252 Omega (scalar, optional): The longitude of ascending node of the 

253 body in units of :py:attr:`angle_unit`. Defaults to 0 degrees. 

254 inc (scalar, optional): The orbital inclination of the body in 

255 units of :py:attr:`angle_unit`. Defaults to 90 degrees. 

256 theta0 (scalar, optional): The rotational phase of the map at time 

257 :py:attr:`t0` in units of :py:attr:`angle_unit`. Defaults to 

258 0.0. 

259 length_unit (optional): An ``astropy.units`` unit defining the 

260 distance metric for this object. Defaults to 

261 :py:attr:`astropy.units.Rsun.` 

262 mass_unit (optional): An ``astropy.units`` unit defining the 

263 mass metric for this object. Defaults to 

264 :py:attr:`astropy.units.Msun.` 

265 time_unit (optional): An ``astropy.units`` unit defining the 

266 time metric for this object. Defaults to 

267 :py:attr:`astropy.units.day.` 

268 angle_unit (optional): An ``astropy.units`` unit defining the 

269 angular metric for this object. Defaults to 

270 :py:attr:`astropy.units.degree.` 

271 """ 

272 

273 def __init__(self, map, **kwargs): 

274 # Initialize `Body` 

275 super(Secondary, self).__init__(map, **kwargs) 

276 for kw in [ 

277 "r", 

278 "m", 

279 "prot", 

280 "t0", 

281 "theta0", 

282 "length_unit", 

283 "mass_unit", 

284 "time_unit", 

285 "angle_unit", 

286 ]: 

287 kwargs.pop(kw, None) 

288 

289 # Attributes 

290 if kwargs.get("porb", None) is not None: 

291 self.porb = kwargs.pop("porb", None) 

292 elif kwargs.get("a", None) is not None: 

293 self.a = kwargs.pop("a", None) 

294 else: 

295 raise ValueError("Must provide a value for either `porb` or `a`.") 

296 self.ecc = kwargs.pop("ecc", 0.0) 

297 self.w = kwargs.pop( 

298 "w", kwargs.pop("omega", 0.5 * np.pi / self._angle_factor) 

299 ) 

300 self.Omega = kwargs.pop("Omega", 0.0) 

301 self.inc = kwargs.pop("inc", 0.5 * np.pi / self._angle_factor) 

302 self._check_kwargs("Secondary", kwargs) 

303 

304 @property 

305 def porb(self): 

306 """The orbital period in units of :py:attr:`time_unit`. 

307 

308 .. note:: 

309 Setting this value overrides the value of :py:attr:`a`. 

310 """ 

311 if self._porb == 0.0: 

312 return None 

313 else: 

314 return self._porb / self._time_factor 

315 

316 @porb.setter 

317 def porb(self, value): 

318 self._porb = math.cast(value * self._time_factor) 

319 self._a = 0.0 

320 

321 @property 

322 def a(self): 

323 """The semi-major axis in units of :py:attr:`length_unit`. 

324 

325 .. note:: 

326 Setting this value overrides the value of :py:attr:`porb`. 

327 """ 

328 if self._a == 0.0: 

329 return None 

330 else: 

331 return self._a / self._length_factor 

332 

333 @a.setter 

334 def a(self, value): 

335 self._a = math.cast(value * self._length_factor) 

336 self._porb = 0.0 

337 

338 @property 

339 def ecc(self): 

340 """The orbital eccentricity.""" 

341 return self._ecc 

342 

343 @ecc.setter 

344 def ecc(self, value): 

345 self._ecc = value 

346 

347 @property 

348 def w(self): 

349 """The longitude of pericenter in units of :py:attr:`angle_unit`.""" 

350 return self._w / self._angle_factor 

351 

352 @w.setter 

353 def w(self, value): 

354 self._w = math.cast(value * self._angle_factor) 

355 

356 @property 

357 def omega(self): 

358 """Alias for the longitude of pericenter :py:attr:`w`.""" 

359 return self.w 

360 

361 @omega.setter 

362 def omega(self, value): 

363 self.w = value 

364 

365 @property 

366 def Omega(self): 

367 """The longitude of ascending node in units of :py:attr:`angle_unit`.""" 

368 return self._Omega / self._angle_factor 

369 

370 @Omega.setter 

371 def Omega(self, value): 

372 self._Omega = math.cast(value * self._angle_factor) 

373 

374 @property 

375 def inc(self): 

376 """The orbital inclination in units of :py:attr:`angle_unit`.""" 

377 return self._inc / self._angle_factor 

378 

379 @inc.setter 

380 def inc(self, value): 

381 self._inc = math.cast(value * self._angle_factor) 

382 

383 

384class System(object): 

385 """A system of bodies in Keplerian orbits about a central primary body. 

386 

387 Args: 

388 primary (:py:class:`Primary`): The central body. 

389 secondaries (:py:class:`Secondary`): One or more secondary bodies 

390 in orbit about the primary. 

391 time_unit (optional): An ``astropy.units`` unit defining the 

392 time metric for this object. Defaults to 

393 :py:attr:`astropy.units.day.` 

394 light_delay (bool, optional): Account for the light travel time 

395 delay to the barycenter of the system? Default is False. 

396 texp (scalar): The exposure time of each observation. This can be a 

397 scalar or a tensor with the same shape as ``t``. If ``texp`` is 

398 provided, ``t`` is assumed to indicate the timestamp at the middle 

399 of an exposure of length ``texp``. 

400 oversample (int): The number of function evaluations to use when 

401 numerically integrating the exposure time. 

402 order (int): The order of the numerical integration scheme. This must 

403 be one of the following: ``0`` for a centered Riemann sum 

404 (equivalent to the "resampling" procedure suggested by Kipping 2010), 

405 ``1`` for the trapezoid rule, or ``2`` for Simpson’s rule. 

406 """ 

407 

408 def _no_spectral(self): 

409 if self._primary._map.nw is not None: # pragma: no cover 

410 raise NotImplementedError( 

411 "Method not yet implemented for spectral maps." 

412 ) 

413 

414 def __init__( 

415 self, 

416 primary, 

417 *secondaries, 

418 time_unit=units.day, 

419 light_delay=False, 

420 texp=None, 

421 oversample=7, 

422 order=0, 

423 ): 

424 # Units 

425 self.time_unit = time_unit 

426 self._light_delay = bool(light_delay) 

427 if texp is None: 

428 self._texp = 0.0 

429 else: 

430 self._texp = texp 

431 assert self._texp >= 0.0, "Parameter `texp` must be >= 0." 

432 self._oversample = int(oversample) 

433 assert self._oversample > 0, "Parameter `oversample` must be > 0." 

434 self._order = int(order) 

435 assert self._order in [0, 1, 2], "Invalid value for parameter `order`." 

436 

437 # Primary body 

438 assert ( 

439 type(primary) is Primary 

440 ), "Argument `primary` must be an instance of `Primary`." 

441 assert ( 

442 primary._map.__props__["reflected"] == False 

443 ), "Reflected light map not allowed for the primary body." 

444 self._primary = primary 

445 self._rv = primary._map.__props__["rv"] 

446 

447 # Secondary bodies 

448 assert len(secondaries) > 0, "There must be at least one secondary." 

449 for sec in secondaries: 

450 assert type(sec) is Secondary, ( 

451 "Argument `*secondaries` must be a sequence of " 

452 "`Secondary` instances." 

453 ) 

454 assert ( 

455 sec._map.nw == self._primary._map.nw 

456 ), "All bodies must have the same number of wavelength bins `nw`." 

457 assert sec._map.__props__["rv"] == self._rv, ( 

458 "Radial velocity must be enabled " 

459 "for either all or none of the bodies." 

460 ) 

461 

462 reflected = [sec._map.__props__["reflected"] for sec in secondaries] 

463 if np.all(reflected): 

464 self._reflected = True 

465 elif np.any(reflected): 

466 raise ValueError( 

467 "Reflected light must be enabled " 

468 "for either all or none of the secondaries." 

469 ) 

470 else: 

471 self._reflected = False 

472 self._secondaries = secondaries 

473 

474 # All bodies 

475 self._bodies = [self._primary] + list(self._secondaries) 

476 

477 # Indices of each of the bodies in the design matrix 

478 Ny = [self._primary._map.Ny] + [ 

479 sec._map.Ny for sec in self._secondaries 

480 ] 

481 self._inds = [] 

482 cur = 0 

483 for N in Ny: 

484 self._inds.append(cur + np.arange(N)) 

485 cur += N 

486 

487 # Theano ops class 

488 self.ops = OpsSystem( 

489 self._primary, 

490 self._secondaries, 

491 reflected=self._reflected, 

492 rv=self._rv, 

493 light_delay=self._light_delay, 

494 texp=self._texp, 

495 oversample=self._oversample, 

496 order=self._order, 

497 ) 

498 

499 # Solve stuff 

500 self._flux = None 

501 self._C = None 

502 self._solution = None 

503 self._solved_bodies = [] 

504 

505 @property 

506 def light_delay(self): 

507 """Account for the light travel time delay? *Read-only*""" 

508 return self._light_delay 

509 

510 @property 

511 def texp(self): 

512 """The exposure time in units of :py:attr:`time_unit`. *Read-only*""" 

513 

514 @property 

515 def oversample(self): 

516 """Oversample factor when integrating over exposure time. *Read-only*""" 

517 return self._oversample 

518 

519 @property 

520 def order(self): 

521 """The order of the numerical integration scheme. *Read-only* 

522 

523 - ``0``: a centered Riemann sum 

524 - ``1``: trapezoid rule 

525 - ``2``: Simpson’s rule 

526 """ 

527 return self._order 

528 

529 @property 

530 def time_unit(self): 

531 """An ``astropy.units`` unit defining the time metric for the system.""" 

532 return self._time_unit 

533 

534 @time_unit.setter 

535 def time_unit(self, value): 

536 assert value.physical_type == "time" 

537 self._time_unit = value 

538 self._time_factor = value.in_units(units.day) 

539 

540 @property 

541 def primary(self): 

542 """The primary (central) object in the Keplerian system.""" 

543 return self._primary 

544 

545 @property 

546 def secondaries(self): 

547 """A list of the secondary (orbiting) object(s) in the Keplerian system.""" 

548 return self._secondaries 

549 

550 @property 

551 def bodies(self): 

552 """A list of all objects in the Keplerian system.""" 

553 return self._bodies 

554 

555 @property 

556 def map_indices(self): 

557 """A list of the indices corresponding to each body in the design matrix.""" 

558 return self._inds 

559 

560 def show( 

561 self, 

562 t, 

563 cmap="plasma", 

564 res=300, 

565 interval=75, 

566 file=None, 

567 figsize=(3, 3), 

568 html5_video=True, 

569 window_pad=1.0, 

570 ): 

571 """Visualize the Keplerian system. 

572 

573 Args: 

574 t (scalar or vector): The time(s) at which to evaluate the orbit and 

575 the map in units of :py:attr:`time_unit`. 

576 cmap (string or colormap instance, optional): The matplotlib colormap 

577 to use. Defaults to ``plasma``. 

578 res (int, optional): The resolution of the map in pixels on a 

579 side. Defaults to 300. 

580 figsize (tuple, optional): Figure size in inches. Default is 

581 (3, 3) for orthographic maps and (7, 3.5) for rectangular 

582 maps. 

583 interval (int, optional): Interval between frames in milliseconds 

584 (animated maps only). Defaults to 75. 

585 file (string, optional): The file name (including the extension) 

586 to save the animation to (animated maps only). Defaults to None. 

587 html5_video (bool, optional): If rendering in a Jupyter notebook, 

588 display as an HTML5 video? Default is True. If False, displays 

589 the animation using Javascript (file size will be larger.) 

590 window_pad (float, optional): Padding around the primary in units 

591 of the primary radius. Bodies outside of this window will be 

592 cropped. Default is 1.0. 

593 """ 

594 # Not yet implemented 

595 if self._primary._map.nw is not None: # pragma: no cover 

596 raise NotImplementedError( 

597 "Method not implemented for spectral maps." 

598 ) 

599 

600 # Render the maps & get the orbital positions 

601 if self._rv: 

602 self._primary.map._set_RV_filter() 

603 for sec in self._secondaries: 

604 sec.map._set_RV_filter() 

605 img_pri, img_sec, x, y, z = self.ops.render( 

606 math.reshape(math.to_array_or_tensor(t), [-1]) * self._time_factor, 

607 res, 

608 self._primary._r, 

609 self._primary._m, 

610 self._primary._prot, 

611 self._primary._t0, 

612 self._primary._theta0, 

613 self._primary._map._amp, 

614 self._primary._map._inc, 

615 self._primary._map._obl, 

616 self._primary._map._y, 

617 self._primary._map._u, 

618 self._primary._map._f, 

619 self._primary._map._alpha, 

620 math.to_array_or_tensor([sec._r for sec in self._secondaries]), 

621 math.to_array_or_tensor([sec._m for sec in self._secondaries]), 

622 math.to_array_or_tensor([sec._prot for sec in self._secondaries]), 

623 math.to_array_or_tensor([sec._t0 for sec in self._secondaries]), 

624 math.to_array_or_tensor( 

625 [sec._theta0 for sec in self._secondaries] 

626 ), 

627 self._get_periods(), 

628 math.to_array_or_tensor([sec._ecc for sec in self._secondaries]), 

629 math.to_array_or_tensor([sec._w for sec in self._secondaries]), 

630 math.to_array_or_tensor([sec._Omega for sec in self._secondaries]), 

631 math.to_array_or_tensor([sec._inc for sec in self._secondaries]), 

632 math.to_array_or_tensor( 

633 [sec._map._amp for sec in self._secondaries] 

634 ), 

635 math.to_array_or_tensor( 

636 [sec._map._inc for sec in self._secondaries] 

637 ), 

638 math.to_array_or_tensor( 

639 [sec._map._obl for sec in self._secondaries] 

640 ), 

641 math.to_array_or_tensor( 

642 [sec._map._y for sec in self._secondaries] 

643 ), 

644 math.to_array_or_tensor( 

645 [sec._map._u for sec in self._secondaries] 

646 ), 

647 math.to_array_or_tensor( 

648 [sec._map._f for sec in self._secondaries] 

649 ), 

650 math.to_array_or_tensor( 

651 [sec._map._alpha for sec in self._secondaries] 

652 ), 

653 ) 

654 

655 # Convert to units of the primary radius 

656 fac = np.reshape( 

657 [sec._length_factor for sec in self._secondaries], [-1, 1] 

658 ) 

659 fac = fac * self._primary._r 

660 x, y, z = x / fac, y / fac, z / fac 

661 r = math.to_array_or_tensor([sec._r for sec in self._secondaries]) 

662 r = r / self._primary._r 

663 

664 # Evaluate if needed 

665 if config.lazy: 

666 img_pri = img_pri.eval() 

667 img_sec = img_sec.eval() 

668 x = x.eval() 

669 y = y.eval() 

670 z = z.eval() 

671 r = r.eval() 

672 

673 # We need this to be of shape (nplanet, nframe) 

674 x = x.T 

675 y = y.T 

676 z = z.T 

677 

678 # Ensure we have an array of frames 

679 if len(img_pri.shape) == 3: 

680 nframes = img_pri.shape[0] 

681 else: # pragma: no cover 

682 nframes = 1 

683 img_pri = np.reshape(img_pri, (1,) + img_pri.shape) 

684 img_sec = np.reshape(img_sec, (1,) + img_sec.shape) 

685 animated = nframes > 1 

686 

687 # Set up the plot 

688 fig, ax = plt.subplots(1, figsize=figsize) 

689 ax.axis("off") 

690 ax.set_xlim(-1.0 - window_pad, 1.0 + window_pad) 

691 ax.set_ylim(-1.0 - window_pad, 1.0 + window_pad) 

692 

693 # Render the first frame 

694 img = [None for n in range(1 + len(self._secondaries))] 

695 circ = [None for n in range(1 + len(self._secondaries))] 

696 extent = np.array([-1.0, 1.0, -1.0, 1.0]) 

697 img[0] = ax.imshow( 

698 img_pri[0], 

699 origin="lower", 

700 extent=extent, 

701 cmap=cmap, 

702 interpolation="none", 

703 vmin=np.nanmin(img_pri), 

704 vmax=np.nanmax(img_pri), 

705 animated=animated, 

706 zorder=0.0, 

707 ) 

708 circ[0] = plt.Circle( 

709 (0, 0), 1, color="k", fill=False, zorder=1e-3, lw=2 

710 ) 

711 ax.add_artist(circ[0]) 

712 for i, _ in enumerate(self._secondaries): 

713 extent = np.array([x[i, 0], x[i, 0], y[i, 0], y[i, 0]]) + ( 

714 r[i] * np.array([-1.0, 1.0, -1.0, 1.0]) 

715 ) 

716 img[i + 1] = ax.imshow( 

717 img_sec[i, 0], 

718 origin="lower", 

719 extent=extent, 

720 cmap=cmap, 

721 interpolation="none", 

722 vmin=np.nanmin(img_sec), 

723 vmax=np.nanmax(img_sec), 

724 animated=animated, 

725 zorder=z[i, 0], 

726 ) 

727 circ[i] = plt.Circle( 

728 (x[i, 0], y[i, 0]), 

729 r[i], 

730 color="k", 

731 fill=False, 

732 zorder=z[i, 0] + 1e-3, 

733 lw=2, 

734 ) 

735 ax.add_artist(circ[i]) 

736 

737 # Animation 

738 if animated: 

739 

740 def updatefig(k): 

741 

742 # Update Primary map 

743 img[0].set_array(img_pri[k]) 

744 

745 # Update Secondary maps & positions 

746 for i, _ in enumerate(self._secondaries): 

747 extent = np.array([x[i, k], x[i, k], y[i, k], y[i, k]]) + ( 

748 r[i] * np.array([-1.0, 1.0, -1.0, 1.0]) 

749 ) 

750 if np.any(np.abs(extent) < 1.0 + window_pad): 

751 img[i + 1].set_array(img_sec[i, k]) 

752 img[i + 1].set_extent(extent) 

753 img[i + 1].set_zorder(z[i, k]) 

754 circ[i].center = (x[i, k], y[i, k]) 

755 circ[i].set_zorder(z[i, k] + 1e-3) 

756 

757 return img + circ 

758 

759 ani = FuncAnimation( 

760 fig, updatefig, interval=interval, blit=False, frames=nframes 

761 ) 

762 

763 # Business as usual 

764 if (file is not None) and (file != ""): 

765 if file.endswith(".mp4"): 

766 ani.save(file, writer="ffmpeg") 

767 elif file.endswith(".gif"): 

768 ani.save(file, writer="imagemagick") 

769 else: # pragma: no cover 

770 # Try and see what happens! 

771 ani.save(file) 

772 plt.close() 

773 else: # pragma: no cover 

774 try: 

775 if "zmqshell" in str(type(get_ipython())): 

776 plt.close() 

777 if html5_video: 

778 display(HTML(ani.to_html5_video())) 

779 else: 

780 display(HTML(ani.to_jshtml())) 

781 else: 

782 raise NameError("") 

783 except NameError: 

784 plt.show() 

785 plt.close() 

786 

787 # Matplotlib generates an annoying empty 

788 # file when producing an animation. Delete it. 

789 try: 

790 os.remove("None0000000.png") 

791 except FileNotFoundError: 

792 pass 

793 

794 else: 

795 

796 if (file is not None) and (file != ""): 

797 fig.savefig(file) 

798 plt.close() 

799 else: # pragma: no cover 

800 plt.show() 

801 

802 if self._rv: 

803 self._primary.map._unset_RV_filter() 

804 for sec in self._secondaries: 

805 sec.map._unset_RV_filter() 

806 

807 def design_matrix(self, t): 

808 """Compute the system flux design matrix at times ``t``. 

809 

810 .. note:: 

811 

812 This is the *unweighted* design matrix, i.e., it does not 

813 include the scaling by the amplitude of each body's map. 

814 To perform this weighting, do 

815 

816 .. code-block:: python 

817 

818 X = sys.design_matrix(**kwargs) 

819 for i, body in zip(sys.map_indices, sys.bodies): 

820 X[:, i] *= body.map.amp 

821 

822 Args: 

823 t (scalar or vector): An array of times at which to evaluate 

824 the design matrix in units of :py:attr:`time_unit`. 

825 """ 

826 return self.ops.X( 

827 math.reshape(math.to_array_or_tensor(t), [-1]) * self._time_factor, 

828 self._primary._r, 

829 self._primary._m, 

830 self._primary._prot, 

831 self._primary._t0, 

832 self._primary._theta0, 

833 math.ones_like(self._primary._map._amp), 

834 self._primary._map._inc, 

835 self._primary._map._obl, 

836 self._primary._map._u, 

837 self._primary._map._f, 

838 self._primary._map._alpha, 

839 math.to_array_or_tensor([sec._r for sec in self._secondaries]), 

840 math.to_array_or_tensor([sec._m for sec in self._secondaries]), 

841 math.to_array_or_tensor([sec._prot for sec in self._secondaries]), 

842 math.to_array_or_tensor([sec._t0 for sec in self._secondaries]), 

843 math.to_array_or_tensor( 

844 [sec._theta0 for sec in self._secondaries] 

845 ), 

846 self._get_periods(), 

847 math.to_array_or_tensor([sec._ecc for sec in self._secondaries]), 

848 math.to_array_or_tensor([sec._w for sec in self._secondaries]), 

849 math.to_array_or_tensor([sec._Omega for sec in self._secondaries]), 

850 math.to_array_or_tensor([sec._inc for sec in self._secondaries]), 

851 math.to_array_or_tensor( 

852 [math.ones_like(sec._map._amp) for sec in self._secondaries] 

853 ), 

854 math.to_array_or_tensor( 

855 [sec._map._inc for sec in self._secondaries] 

856 ), 

857 math.to_array_or_tensor( 

858 [sec._map._obl for sec in self._secondaries] 

859 ), 

860 math.to_array_or_tensor( 

861 [sec._map._u for sec in self._secondaries] 

862 ), 

863 math.to_array_or_tensor( 

864 [sec._map._f for sec in self._secondaries] 

865 ), 

866 math.to_array_or_tensor( 

867 [sec._map._alpha for sec in self._secondaries] 

868 ), 

869 ) 

870 

871 def flux(self, t, total=True): 

872 """Compute the system flux at times ``t``. 

873 

874 Args: 

875 t (scalar or vector): An array of times at which to evaluate 

876 the flux in units of :py:attr:`time_unit`. 

877 total (bool, optional): Return the total system flux? Defaults to 

878 True. If False, returns arrays corresponding to the flux 

879 from each body. 

880 """ 

881 X = self.design_matrix(t) 

882 

883 # Weight the ylms by amplitude 

884 if self._reflected: 

885 # If we're doing reflected light, scale the amplitude of 

886 # each of the secondaries by the amplitude of the primary 

887 # (the illumination source). 

888 ay = [self._primary.map.amp * self._primary._map._y] + [ 

889 self._primary.map.amp * body.map.amp * body._map._y 

890 for body in self._secondaries 

891 ] 

892 else: 

893 ay = [body.map.amp * body._map._y for body in self._bodies] 

894 

895 if total: 

896 return math.dot(X, math.concatenate(ay)) 

897 else: 

898 return [ 

899 math.dot(X[:, idx], ay[i]) for i, idx in enumerate(self._inds) 

900 ] 

901 

902 def rv(self, t, keplerian=True, total=True): 

903 """Compute the observed radial velocity of the system at times ``t``. 

904 

905 Args: 

906 t (scalar or vector): An array of times at which to evaluate 

907 the radial velocity in units of :py:attr:`time_unit`. 

908 keplerian (bool): Include the Keplerian component of the radial 

909 velocity of the primary? Default is True. If False, this 

910 method returns a model for only the radial velocity anomaly 

911 due to transits (the Rossiter-McLaughlin effect) and 

912 time-variable surface features (Doppler tomography) for all 

913 bodies in the system. 

914 total (bool, optional): Return the total system RV? Defaults to 

915 True. If False, returns arrays corresponding to the RV 

916 contribution from each body. 

917 

918 """ 

919 rv = self.ops.rv( 

920 math.reshape(math.to_array_or_tensor(t), [-1]) * self._time_factor, 

921 self._primary._r, 

922 self._primary._m, 

923 self._primary._prot, 

924 self._primary._t0, 

925 self._primary._theta0, 

926 self._primary._map._amp, 

927 self._primary._map._inc, 

928 self._primary._map._obl, 

929 self._primary._map._y, 

930 self._primary._map._u, 

931 self._primary._map._alpha, 

932 self._primary._map._veq, 

933 math.to_array_or_tensor([sec._r for sec in self._secondaries]), 

934 math.to_array_or_tensor([sec._m for sec in self._secondaries]), 

935 math.to_array_or_tensor([sec._prot for sec in self._secondaries]), 

936 math.to_array_or_tensor([sec._t0 for sec in self._secondaries]), 

937 math.to_array_or_tensor( 

938 [sec._theta0 for sec in self._secondaries] 

939 ), 

940 self._get_periods(), 

941 math.to_array_or_tensor([sec._ecc for sec in self._secondaries]), 

942 math.to_array_or_tensor([sec._w for sec in self._secondaries]), 

943 math.to_array_or_tensor([sec._Omega for sec in self._secondaries]), 

944 math.to_array_or_tensor([sec._inc for sec in self._secondaries]), 

945 math.to_array_or_tensor( 

946 [sec._map._amp for sec in self._secondaries] 

947 ), 

948 math.to_array_or_tensor( 

949 [sec._map._inc for sec in self._secondaries] 

950 ), 

951 math.to_array_or_tensor( 

952 [sec._map._obl for sec in self._secondaries] 

953 ), 

954 math.to_array_or_tensor( 

955 [sec._map._y for sec in self._secondaries] 

956 ), 

957 math.to_array_or_tensor( 

958 [sec._map._u for sec in self._secondaries] 

959 ), 

960 math.to_array_or_tensor( 

961 [sec._map._alpha for sec in self._secondaries] 

962 ), 

963 math.to_array_or_tensor( 

964 [sec._map._veq for sec in self._secondaries] 

965 ), 

966 np.array(keplerian), 

967 ) 

968 if total: 

969 return math.sum(rv, axis=0) 

970 else: 

971 return rv 

972 

973 def position(self, t): 

974 """Compute the Cartesian positions of all bodies at times ``t``. 

975 

976 Args: 

977 t (scalar or vector): An array of times at which to evaluate 

978 the position in units of :py:attr:`time_unit`. 

979 """ 

980 x, y, z = self.ops.position( 

981 math.reshape(math.to_array_or_tensor(t), [-1]) * self._time_factor, 

982 self._primary._m, 

983 self._primary._t0, 

984 math.to_array_or_tensor([sec._m for sec in self._secondaries]), 

985 math.to_array_or_tensor([sec._t0 for sec in self._secondaries]), 

986 self._get_periods(), 

987 math.to_array_or_tensor([sec._ecc for sec in self._secondaries]), 

988 math.to_array_or_tensor([sec._w for sec in self._secondaries]), 

989 math.to_array_or_tensor([sec._Omega for sec in self._secondaries]), 

990 math.to_array_or_tensor([sec._inc for sec in self._secondaries]), 

991 ) 

992 fac = np.reshape( 

993 [self._primary._length_factor] 

994 + [sec._length_factor for sec in self._secondaries], 

995 [-1, 1], 

996 ) 

997 return (x / fac, y / fac, z / fac) 

998 

999 def _get_periods(self): 

1000 periods = [None for sec in self._secondaries] 

1001 for i, sec in enumerate(self._secondaries): 

1002 if sec.porb: 

1003 periods[i] = sec.porb 

1004 else: 

1005 periods[i] = ( 

1006 (2 * np.pi) 

1007 * sec._a ** (3 / 2) 

1008 / (math.sqrt(G_grav * (self._primary._m + sec._m))) 

1009 ) 

1010 return math.to_array_or_tensor(periods) 

1011 

1012 def set_data(self, flux, C=None, cho_C=None): 

1013 """Set the data vector and covariance matrix. 

1014 

1015 This method is required by the :py:meth:`solve` method, which 

1016 analytically computes the posterior over surface maps for all bodies 

1017 in the system given a dataset and a prior, provided both are described 

1018 as multivariate Gaussians. 

1019 

1020 Args: 

1021 flux (vector): The observed system light curve. 

1022 C (scalar, vector, or matrix): The data covariance. This may be 

1023 a scalar, in which case the noise is assumed to be 

1024 homoscedastic, a vector, in which case the covariance 

1025 is assumed to be diagonal, or a matrix specifying the full 

1026 covariance of the dataset. Default is None. Either `C` or 

1027 `cho_C` must be provided. 

1028 cho_C (matrix): The lower Cholesky factorization of the data 

1029 covariance matrix. Defaults to None. Either `C` or 

1030 `cho_C` must be provided. 

1031 """ 

1032 self._flux = math.cast(flux) 

1033 self._C = linalg.Covariance(C=C, cho_C=cho_C, N=self._flux.shape[0]) 

1034 

1035 def solve(self, *, design_matrix=None, t=None): 

1036 """Solve the least-squares problem for the posterior over maps for all bodies. 

1037 

1038 This method solves the generalized least squares problem given a system 

1039 light curve and its covariance (set via the :py:meth:`set_data` method) 

1040 and a Gaussian prior on the spherical harmonic coefficients 

1041 (set via the :py:meth:`set_prior` method). The map amplitudes and 

1042 coefficients of each of the bodies in the system are then set to the 

1043 maximum a posteriori (MAP) solution. 

1044 

1045 Args: 

1046 design_matrix (matrix, optional): The flux design matrix, the 

1047 quantity returned by :py:meth:`design_matrix`. Default is 

1048 None, in which case this is computed based on ``kwargs``. 

1049 t (vector, optional): The vector of times at which to evaluate 

1050 :py:meth:`design_matrix`, if a design matrix is not provided. 

1051 Default is None. 

1052 

1053 Returns: 

1054 The posterior mean for the spherical harmonic \ 

1055 coefficients `l > 0` and the Cholesky factorization of the \ 

1056 posterior covariance of all of the bodies in the system, \ 

1057 stacked in order (primary, followed by each of the secondaries \ 

1058 in the order they were provided.) 

1059 

1060 .. note:: 

1061 Users may call the :py:meth:`draw` method of this class to draw 

1062 from the posterior after calling :py:meth:`solve`. 

1063 """ 

1064 # TODO: Implement for spectral maps? 

1065 self._no_spectral() 

1066 

1067 # Check that the data is set 

1068 if self._flux is None or self._C is None: 

1069 raise ValueError("Please provide a dataset with `set_data()`.") 

1070 

1071 # Get the full design matrix 

1072 if design_matrix is None: 

1073 assert t is not None, "Please provide a time vector `t`." 

1074 design_matrix = self.design_matrix(t) 

1075 X = math.cast(design_matrix) 

1076 

1077 # Get the data vector 

1078 f = math.cast(self._flux) 

1079 

1080 # Check for bodies whose priors are set 

1081 self._solved_bodies = [] 

1082 inds = [] 

1083 dense_L = False 

1084 for k, body in enumerate(self._bodies): 

1085 

1086 if body.map._mu is None or body.map._L is None: 

1087 

1088 # Subtract out this term from the data vector, 

1089 # since it is fixed 

1090 f -= body.map.amp * math.dot(X[:, self._inds[k]], body.map.y) 

1091 

1092 else: 

1093 

1094 # Add to our list of indices/bodies to solve for 

1095 inds.extend(self._inds[k]) 

1096 self._solved_bodies.append(body) 

1097 if body.map._L.kind in ["matrix", "cholesky"]: 

1098 dense_L = True 

1099 

1100 # Do we have at least one body? 

1101 if len(self._solved_bodies) == 0: 

1102 raise ValueError("Please provide a prior for at least one body.") 

1103 

1104 # Keep only the terms we'll solve for 

1105 X = X[:, inds] 

1106 

1107 # Stack our priors 

1108 mu = math.concatenate([body.map._mu for body in self._solved_bodies]) 

1109 

1110 if not dense_L: 

1111 # We can just concatenate vectors 

1112 LInv = math.concatenate( 

1113 [ 

1114 body.map._L.inverse * math.ones(body.map.Ny) 

1115 for body in self._solved_bodies 

1116 ] 

1117 ) 

1118 else: 

1119 # FACT: The inverse of a block diagonal matrix 

1120 # is the block diagonal matrix of the inverses. 

1121 LInv = math.block_diag( 

1122 *[ 

1123 body.map._L.inverse * math.eye(body.map.Ny) 

1124 for body in self._solved_bodies 

1125 ] 

1126 ) 

1127 

1128 # Compute the MAP solution 

1129 self._solution = linalg.solve(X, f, self._C.cholesky, mu, LInv) 

1130 

1131 # Set all the map vectors 

1132 x, _ = self._solution 

1133 n = 0 

1134 for body in self._solved_bodies: 

1135 inds = slice(n, n + body.map.Ny) 

1136 body.map.amp = x[inds][0] 

1137 body.map[1:, :] = x[inds][1:] / body.map.amp 

1138 n += body.map.Ny 

1139 

1140 # Return the mean and covariance 

1141 return self._solution 

1142 

1143 @property 

1144 def solution(self): 

1145 r"""The posterior probability distribution for the maps in the system. 

1146 

1147 This is a tuple containing the mean and lower Cholesky factorization of the 

1148 covariance of the amplitude-weighted spherical harmonic coefficient vectors, 

1149 obtained by solving the regularized least-squares problem 

1150 via the :py:meth:`solve` method. 

1151 

1152 Note that to obtain the actual covariance matrix from the lower Cholesky 

1153 factorization :math:`L`, simply compute :math:`L L^\top`. 

1154 

1155 Note also that this is the posterior for the **amplitude-weighted** 

1156 map vectors. Under this convention, the map amplitude is equal to the 

1157 first term of the vector of each body and the spherical harmonic coefficients are 

1158 equal to the vector normalized by the first term. 

1159 """ 

1160 if self._solution is None: 

1161 raise ValueError("Please call `solve()` first.") 

1162 return self._solution 

1163 

1164 def draw(self): 

1165 """ 

1166 Draw a map from the posterior distribution and set 

1167 the :py:attr:`y` map vector of each body. 

1168 

1169 Users should call :py:meth:`solve` to enable this attribute. 

1170 """ 

1171 if self._solution is None: 

1172 raise ValueError("Please call `solve()` first.") 

1173 

1174 # Number of coefficients 

1175 N = np.sum([body.map.Ny for body in self._solved_bodies]) 

1176 

1177 # Fast multivariate sampling using the Cholesky factorization 

1178 yhat, cho_ycov = self._solution 

1179 u = math.cast(np.random.randn(N)) 

1180 x = yhat + math.dot(cho_ycov, u) 

1181 

1182 # Set all the map vectors 

1183 n = 0 

1184 for body in self._solved_bodies: 

1185 inds = slice(n, n + body.map.Ny) 

1186 body.map.amp = x[inds][0] 

1187 body.map[1:, :] = x[inds][1:] / body.map.amp 

1188 n += body.map.Ny 

1189 

1190 def lnlike(self, *, design_matrix=None, t=None, woodbury=True): 

1191 """Returns the log marginal likelihood of the data given a design matrix. 

1192 

1193 This method computes the marginal likelihood (marginalized over the 

1194 spherical harmonic coefficients of all bodies) given a system 

1195 light curve and its covariance (set via the :py:meth:`set_data` method) 

1196 and a Gaussian prior on the spherical harmonic coefficients 

1197 (set via the :py:meth:`set_prior` method). 

1198 

1199 Args: 

1200 design_matrix (matrix, optional): The flux design matrix, the 

1201 quantity returned by :py:meth:`design_matrix`. Default is 

1202 None, in which case this is computed based on ``kwargs``. 

1203 t (vector, optional): The vector of times at which to evaluate 

1204 :py:meth:`design_matrix`, if a design matrix is not provided. 

1205 Default is None. 

1206 woodbury (bool, optional): Solve the linear problem using the 

1207 Woodbury identity? Default is True. The 

1208 `Woodbury identity <https://en.wikipedia.org/wiki/Woodbury_matrix_identity>`_ 

1209 is used to speed up matrix operations in the case that the 

1210 number of data points is much larger than the number of 

1211 spherical harmonic coefficients. In this limit, it can 

1212 speed up the code by more than an order of magnitude. Keep 

1213 in mind that the numerical stability of the Woodbury identity 

1214 is not great, so if you're getting strange results try 

1215 disabling this. It's also a good idea to disable this in the 

1216 limit of few data points and large spherical harmonic degree. 

1217 

1218 Returns: 

1219 lnlike: The log marginal likelihood. 

1220 """ 

1221 # TODO: Implement for spectral maps? 

1222 self._no_spectral() 

1223 

1224 # Check that the data is set 

1225 if self._flux is None or self._C is None: 

1226 raise ValueError("Please provide a dataset with `set_data()`.") 

1227 

1228 # Get the full design matrix 

1229 if design_matrix is None: 

1230 assert t is not None, "Please provide a time vector `t`." 

1231 design_matrix = self.design_matrix(t) 

1232 X = math.cast(design_matrix) 

1233 

1234 # Get the data vector 

1235 f = math.cast(self._flux) 

1236 

1237 # Check for bodies whose priors are set 

1238 self._solved_bodies = [] 

1239 inds = [] 

1240 dense_L = False 

1241 for k, body in enumerate(self._bodies): 

1242 

1243 if body.map._mu is None or body.map._L is None: 

1244 

1245 # Subtract out this term from the data vector, 

1246 # since it is fixed 

1247 f -= body.map.amp * math.dot(X[:, self._inds[k]], body.map.y) 

1248 

1249 else: 

1250 

1251 # Add to our list of indices/bodies to solve for 

1252 inds.extend(self._inds[k]) 

1253 self._solved_bodies.append(body) 

1254 if body.map._L.kind in ["matrix", "cholesky"]: 

1255 dense_L = True 

1256 

1257 # Do we have at least one body? 

1258 if len(self._solved_bodies) == 0: 

1259 raise ValueError("Please provide a prior for at least one body.") 

1260 

1261 # Keep only the terms we'll solve for 

1262 X = X[:, inds] 

1263 

1264 # Stack our priors 

1265 mu = math.concatenate([body.map._mu for body in self._solved_bodies]) 

1266 

1267 # Compute the likelihood 

1268 if woodbury: 

1269 if not dense_L: 

1270 # We can just concatenate vectors 

1271 LInv = math.concatenate( 

1272 [ 

1273 body.map._L.inverse * math.ones(body.map.Ny) 

1274 for body in self._solved_bodies 

1275 ] 

1276 ) 

1277 else: 

1278 LInv = math.block_diag( 

1279 *[ 

1280 body.map._L.inverse * math.eye(body.map.Ny) 

1281 for body in self._solved_bodies 

1282 ] 

1283 ) 

1284 lndetL = math.cast( 

1285 [body.map._L.lndet for body in self._solved_bodies] 

1286 ) 

1287 return linalg.lnlike_woodbury( 

1288 X, f, self._C.inverse, mu, LInv, self._C.lndet, lndetL 

1289 ) 

1290 else: 

1291 if not dense_L: 

1292 # We can just concatenate vectors 

1293 L = math.concatenate( 

1294 [ 

1295 body.map._L.value * math.ones(body.map.Ny) 

1296 for body in self._solved_bodies 

1297 ] 

1298 ) 

1299 else: 

1300 L = math.block_diag( 

1301 *[ 

1302 body.map._L.value * math.eye(body.map.Ny) 

1303 for body in self._solved_bodies 

1304 ] 

1305 ) 

1306 return linalg.lnlike(X, f, self._C.value, mu, L)