Coverage for frank/utilities.py: 94%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

361 statements  

1# Frankenstein: 1D disc brightness profile reconstruction from Fourier data 

2# using non-parametric Gaussian Processes 

3# 

4# Copyright (C) 2019-2020 R. Booth, J. Jennings, M. Tazzari 

5# 

6# This program is free software: you can redistribute it and/or modify 

7# it under the terms of the GNU General Public License as published by 

8# the Free Software Foundation, either version 3 of the License, or 

9# (at your option) any later version. 

10# 

11# This program is distributed in the hope that it will be useful, 

12# but WITHOUT ANY WARRANTY; without even the implied warranty of 

13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

14# GNU General Public License for more details. 

15# 

16# You should have received a copy of the GNU General Public License 

17# along with this program. If not, see <https://www.gnu.org/licenses/> 

18# 

19"""This module has various functions useful for plotting and analyzing fit 

20results. 

21""" 

22import logging 

23import numpy as np 

24from scipy.interpolate import interp1d 

25 

26from frank.constants import deg_to_rad, sterad_to_arcsec, rad_to_arcsec 

27from frank.geometry import FixedGeometry 

28from frank.hankel import DiscreteHankelTransform 

29from frank.statistical_models import VisibilityMapping 

30 

31def arcsec_baseline(x): 

32 """ 

33 Provide x as a radial scale [arcsec] to return the corresponding baseline 

34 [lambda], or vice-versa 

35 

36 Parameters 

37 ---------- 

38 x : float 

39 Radial scale [arcsec] or baseline [lambda] 

40 

41 Returns 

42 ------- 

43 converted : float 

44 Baseline [lambda] or radial scale [arcsec] 

45 

46 """ 

47 

48 converted = 1 / (x / 60 / 60 * np.pi / 180) 

49 

50 return converted 

51 

52 

53def radius_convert(x, dist, conversion='arcsec_au'): 

54 """ 

55 Provide x as a radius/radii in [arcsec] to convert to [au] (or vice-versa), 

56 assuming a distance in [pc] 

57 

58 Parameters 

59 ---------- 

60 x : float or array 

61 Radius or radii ([arcsec] or [au]) 

62 dist : float 

63 Distance to source [pc] 

64 conversion : {'arcsec_au', 'au_arcsec'}, default = 'arcsec_au' 

65 The unit conversion to perform, e.g. 'arcsec_au' converts from [arcsec] 

66 to [au] 

67 

68 Returns 

69 ------- 

70 converted : float or array 

71 Radius or radii ([au] or [arcsec]) 

72 

73 """ 

74 

75 if conversion == 'arcsec_au': 

76 converted = x * dist 

77 elif conversion == 'au_arcsec': 

78 converted = x / dist 

79 else: 

80 raise AttributeError("conversion must be one of {}" 

81 "".format(['arcsec_au', 'au_arcsec'])) 

82 

83 return converted 

84 

85 

86def jy_convert(x, conversion, bmaj=None, bmin=None): 

87 """ 

88 Provide x as a brightness in one of the units [Jy / beam], [Jy / arcsec^2], 

89 [Jy / sterad] to convert x to another of these units 

90 

91 Parameters 

92 ---------- 

93 x : array 

94 Brightness in one of: [Jy / beam], [Jy / arcsec^2], [Jy / sterad] 

95 conversion : { 'beam_sterad', 'beam_arcsec2', 'arcsec2_beam', 

96 'arcsec2_sterad', 'sterad_beam', 'sterad_arcsec2'} 

97 The unit conversion to perform, e.g., 'beam_sterad' converts 

98 [Jy / beam] to [Jy / sterad] 

99 bmaj : float, optional 

100 Beam FWHM along the major axis [arcsec] 

101 bmin : float, optional 

102 Beam FWHM along the minor axis [arcsec] 

103 

104 Returns 

105 ------- 

106 converted : float 

107 Brightness in converted units 

108 

109 """ 

110 if (bmaj is None or bmin is None) and conversion in ['beam_sterad', 

111 'beam_arcsec2', 

112 'arcsec2_beam', 

113 'sterad_beam']: 

114 raise ValueError('bmaj and bmin must be specified to perform the' 

115 ' conversion {}'.format(conversion)) 

116 

117 if bmaj is not None and bmin is not None: 

118 beam_solid_angle = np.pi * bmaj * bmin / (4 * np.log(2)) 

119 

120 if conversion == 'beam_arcsec2': 

121 converted = x / beam_solid_angle 

122 elif conversion == 'arcsec2_beam': 

123 converted = x * beam_solid_angle 

124 elif conversion == 'arcsec2_sterad': 

125 converted = x * sterad_to_arcsec 

126 elif conversion == 'sterad_arcsec2': 

127 converted = x / sterad_to_arcsec 

128 elif conversion == 'beam_sterad': 

129 converted = x / beam_solid_angle * sterad_to_arcsec 

130 elif conversion == 'sterad_beam': 

131 converted = x * beam_solid_angle / sterad_to_arcsec 

132 else: 

133 raise AttributeError("conversion must be one of {}" 

134 "".format(['beam_sterad', 'beam_arcsec2', 

135 'arcsec2_beam', 'arcsec2_sterad', 

136 'sterad_beam', 'sterad_arcsec2'])) 

137 

138 return converted 

139 

140 

141def get_fit_stat_uncer(fit, return_linear=True): 

142 r""" 

143 Retrieve a model brightness profile's statistical uncertainty (that due to  

144 the uncertainty on the observed baselines). This is only a lower bound on  

145 the total uncertainty (which also has a systematic contribution due to  

146 sparse sampling of the (u, v) plane). 

147 

148 Parameters 

149 ---------- 

150 fit : FrankFitter result object 

151 Fitted profile to obtain statistical uncertainty of  

152 

153 Returns 

154 ------- 

155 sigma: array 

156 Statistical uncertainty on the fitted brightness, at the fitted radial 

157 points. 

158 return_linear : bool, default=True 

159 Whether to return linear uncertainty for a LogNormal fit  

160 (if False, the logarithmic uncertainty is instead returned).  

161 Has no effect for a Normal fit. 

162 """ 

163 

164 if 'method' not in fit._info.keys(): 

165 raise AttributeError("'fit' object lacks '_info.method' key. Should be one " 

166 "of ['linear', 'log']") 

167 

168 variance = np.diag(fit.covariance) 

169 

170 if fit._info["method"] == "LogNormal" and return_linear == True: 

171 # convert stored variance from var(log(brightness)) to var(brightness). 

172 # see Appendix A.2 in https://ui.adsabs.harvard.edu/abs/2016A%26A...586A..76J/abstract; 

173 # https://en.wikipedia.org/wiki/Log-normal_distribution 

174 variance = (np.exp(variance) - 1) * np.exp(2 * np.log(fit.I)) 

175 

176 sigma = np.sqrt(variance) 

177 return sigma 

178 

179 

180class UVDataBinner(object): 

181 r""" 

182 Average uv-data into bins of equal size. 

183 

184 Compute the weighted mean of the visibilities in each bin 

185 

186 Parameters 

187 ---------- 

188 uv : array, unit = :math:`\lambda` 

189 Baselines of the data to bin 

190 V : array, unit = Jy 

191 Observed visibility. If complex, both the real and imaginary 

192 components will be binned. Else only the real part will be binned. 

193 weights : array, unit = Jy^-2 

194 Weights on the visibility points 

195 bin_width : float, unit = :math:`\lambda` 

196 Width of the uv-bins 

197 

198 Notes 

199 ----- 

200 Uses numpy masked arrays to mask bins with no uv points. 

201 

202 """ 

203 

204 def __init__(self, uv, V, weights, bin_width): 

205 nbins = np.ceil(uv.max() / bin_width).astype('int') 

206 #Protect against rounding 

207 if nbins * bin_width < uv.max(): 

208 nbins += 1 

209 bins = np.arange(nbins+1, dtype='float64') * bin_width 

210 

211 self._bins = bins 

212 self._nbins = nbins 

213 self._norm = 1 / bin_width 

214 

215 bin_uv, bin_wgt, bin_vis, bin_n = \ 

216 self.bin_quantities(uv, weights, 

217 uv, np.ones_like(uv), V, 

218 bin_counts=True) 

219 

220 # Normalize 

221 idx = bin_n > 0 

222 w = bin_wgt[idx] 

223 bin_uv[idx] /= w 

224 bin_vis[idx] /= w 

225 

226 # Store the binned data, masking empty bins: 

227 mask = (bin_n == 0) 

228 self._uv = np.ma.masked_where(mask, bin_uv) 

229 self._V = np.ma.masked_where(mask, bin_vis) 

230 self._w = np.ma.masked_where(mask, bin_wgt) 

231 self._count = np.ma.masked_where(mask, bin_n) 

232 

233 self._uv_left = np.ma.masked_where(mask, bins[:-1]) 

234 self._uv_right = np.ma.masked_where(mask, bins[1:]) 

235 

236 # Compute the uncertainty on the means: 

237 bin_vis_err = np.full(nbins, np.nan, dtype=V.dtype) 

238 

239 # 1) Get the binned mean for each vis: 

240 mu = self._V[self.determine_uv_bin(uv)] 

241 

242 # 2) Compute weighted error for bins with n > 1 

243 quantities = (V-mu).real**2 

244 if np.iscomplexobj(V): 

245 quantities = quantities + 1j * (V-mu).imag**2 

246 err = self.bin_quantities(uv, weights**2, quantities) 

247 

248 idx2 = bin_n > 1 

249 err[idx2] /= bin_wgt[idx2]**2 * (1 - 1 / bin_n[idx2]) 

250 

251 temp_error = np.sqrt(err.real[idx2]) 

252 if np.iscomplexobj(V): 

253 temp_error = temp_error + 1.j * np.sqrt(err.imag[idx2]) 

254 bin_vis_err[idx2] = temp_error 

255 

256 # 3) Use a sensible error for bins with one baseline 

257 idx1 = bin_n == 1 

258 bin_vis_err[idx1].real = 1 / np.sqrt(bin_wgt[idx1]) 

259 if np.iscomplexobj(V): 

260 bin_vis_err[idx1].imag = bin_vis_err[idx1].real 

261 bin_vis_err[mask] = np.nan 

262 

263 # 4) Store the error 

264 self._Verr = np.ma.masked_where(mask, bin_vis_err) 

265 

266 

267 def determine_uv_bin(self, uv): 

268 r"""Determine the bin that the given uv points belong too. 

269 

270 Parameters 

271 ---------- 

272 uv : array, unit = :math:`\lambda` 

273 Baselines to determine the bins of 

274 

275 Returns 

276 ------- 

277 idx : array, 

278 Bins that the uv point belongs to. Will be -1 if the bin does not 

279 exist. 

280 

281 """ 

282 

283 bins = self._bins 

284 nbins = self._nbins 

285 

286 idx = np.floor(uv * self._norm).astype('int32') 

287 idx[uv < bins[idx]] -= 1 # Fix rounding error 

288 idx[uv == bins[nbins]] -= 1 # Handle point exaclty on outer boundary 

289 

290 too_high = idx >= nbins 

291 idx[too_high] = -1 

292 

293 # Increase points on the RH boundary, unless it is the outer one 

294 idx_tmp = idx[~too_high] 

295 increment = (uv[~too_high] >= bins[idx_tmp+1]) & (idx_tmp+1 < nbins) 

296 idx_tmp[increment] += 1 

297 

298 return idx 

299 

300 def bin_quantities(self, uv, w, *quantities, bin_counts=False): 

301 r"""Bin the given quantities according the to uv points and weights. 

302 

303 Parameters 

304 ---------- 

305 uv : array, unit = :math:`\lambda` 

306 Baselines of the data to bin 

307 weights : array, unit = Jy^-2 

308 Weights on the visibility points 

309 quantities : arrays, 

310 Quantities evaluated at the uv points to bin. 

311 bin_counts : bool, default = False 

312 Determines whether to count the number of uv points per bin. 

313 

314 Returns 

315 ------- 

316 results : arrays, same type as quantities 

317 Binned data 

318 bin_counts : array, int64, optional 

319 If bin_counts=True, then this array contains the number of uv 

320 points in each bin. Otherwise, it is not returned. 

321 

322 """ 

323 

324 bins = self._bins 

325 nbins = self._nbins 

326 norm = self._norm 

327 

328 results = [np.zeros(nbins, dtype=x.dtype) for x in quantities] 

329 

330 if bin_counts: 

331 counts = np.zeros(nbins, dtype='int64') 

332 

333 BLOCK = 65536 

334 for i in range(0, len(uv), BLOCK): 

335 tmp_uv = uv[i:i+BLOCK] 

336 tmp_wgt = w[i:i+BLOCK] 

337 

338 idx = np.floor(tmp_uv * norm).astype('int32') 

339 

340 # Fix rounding: 

341 idx[tmp_uv < bins[idx]] -= 1 

342 # Move points exactly on the outer boundary inwards: 

343 idx[idx == nbins] -= 1 

344 

345 # Only the last bin includes its edge 

346 increment = (tmp_uv >= bins[idx+1]) & (idx+1 != nbins) 

347 idx[increment] += 1 

348 

349 if bin_counts: 

350 counts += np.bincount(idx, minlength=nbins) 

351 

352 for qty, res in zip(quantities, results): 

353 tmp_qty = tmp_wgt*qty[i:i+BLOCK] 

354 

355 if np.iscomplexobj(qty): 

356 res.real += np.bincount(idx, weights=tmp_qty.real, 

357 minlength=nbins) 

358 res.imag += np.bincount(idx, weights=tmp_qty.imag, 

359 minlength=nbins) 

360 else: 

361 res += np.bincount(idx, weights=tmp_qty, minlength=nbins) 

362 

363 if bin_counts: 

364 return results + [counts] 

365 if len(results) == 1: 

366 return results[0] 

367 return results 

368 

369 def __len__(self): 

370 return len(self._uv) 

371 

372 @property 

373 def uv(self): 

374 r"""Binned uv points, unit = :math:`\lambda`""" 

375 return self._uv 

376 

377 @property 

378 def V(self): 

379 """Binned visibility, unit = Jy""" 

380 return self._V 

381 

382 @property 

383 def weights(self): 

384 """Binned weights, unit = Jy^-2""" 

385 return self._w 

386 

387 @property 

388 def error(self): 

389 """Uncertainty on the binned visibilities, unit = Jy""" 

390 return self._Verr 

391 

392 @property 

393 def bin_counts(self): 

394 """Number of points in each bin""" 

395 return self._count 

396 

397 @property 

398 def bin_edges(self): 

399 """Edges of the histogram bins""" 

400 return [self._uv_left, self._uv_right] 

401 

402 

403def check_uv(u, v, min_q=1e3, max_q=1e8): 

404 r""" 

405 Check if u,v distances are sensible for expected code unit of 

406 [lambda], or if instead they're being supplied in [m]. 

407 

408 Parameters 

409 ---------- 

410 u, v : array, unit = :math:`\lambda` 

411 u and v coordinates of observations 

412 min_q : float, unit = :math:`\lambda`, default=1e3 

413 Minimum baseline in code units expected for a dataset. The default 

414 value of 1e3 is a conservative value for ALMA, assuming a minimum 

415 antenna separation of ~12 m and maximum observing wavelength of 3.6 mm. 

416 max_q : float, unit = :math:`\lambda`, default=1e5 

417 Maximum baseline in code units expected for a dataset. The default 

418 value of 1e8 is a conservative value for ALMA, assuming a maximum 

419 antenna separation of ~16 km and minimum observing wavelength of 0.3 mm. 

420 """ 

421 q = np.hypot(u, v) 

422 

423 if min(q) < min_q: 

424 logging.warning("WARNING: " 

425 f"Minimum baseline {min(q):.1e} < expected minimum {min_q:.1e} [lambda]. " 

426 "'u' and 'v' distances must be in units of [lambda], " 

427 "but it looks like they're in [m]." 

428 ) 

429 

430 

431def normalize_uv(u, v, wle): 

432 r""" 

433 Normalize data u and v coordinates by the observing wavelength 

434 

435 Parameters 

436 ---------- 

437 u, v : array, unit = [m] 

438 u and v coordinates of observations 

439 wle : float or array, unit = [m] 

440 Observing wavelength of observations. If an array, it should be the 

441 pointwise wavelength for each (u,v) point 

442 

443 Returns 

444 ------- 

445 u_normed, v_normed : array, unit = :math:`\lambda` 

446 u and v coordinates normalized by observing wavelength 

447 

448 """ 

449 

450 logging.info(' Normalizing u and v coordinates by provided' 

451 ' observing wavelength of {} m'.format(wle)) 

452 

453 wle = np.atleast_1d(wle).astype('f8') 

454 if len(wle) != 1 and len(wle) != len(u): 

455 raise ValueError("len(wle) = {}. It should be equal to " 

456 "len(u) = {} (or 1 if all wavelengths are the same)".format(len(wle), len(u))) 

457 u_normed = u / wle 

458 v_normed = v / wle 

459 

460 return u_normed, v_normed 

461 

462 

463def cut_data_by_baseline(u, v, vis, weights, cut_range, geometry=None): 

464 r""" 

465 Truncate the data to be within a chosen baseline range. 

466 

467 The cut will be done in deprojected baseline space if the geometry is 

468 provided. 

469 

470 Parameters 

471 ---------- 

472 u, v : array, unit = :math:`\lambda` 

473 u and v coordinates of observations 

474 vis : array, unit = Jy 

475 Observed visibilities (complex: real + imag * 1j) 

476 weights : array, unit = Jy^-2 

477 Weights assigned to observed visibilities, of the form 

478 :math:`1 / \sigma^2` 

479 cut_range : list of float, length = 2, unit = [\lambda] 

480 Lower and upper baseline bounds outside of which visibilities are 

481 truncated 

482 geometry : SourceGeometry object, optional 

483 Fitted geometry (see frank.geometry.SourceGeometry). 

484 

485 

486 Returns 

487 ------- 

488 u_cut, v_cut : array, unit = :math:`\lambda` 

489 u and v coordinates in the chosen baseline range 

490 vis_cut : array, unit = Jy 

491 Visibilities in the chosen baseline range 

492 weights_cut : array, unit = Jy^-2 

493 Weights in the chosen baseline range 

494 

495 """ 

496 

497 logging.info(' Cutting data outside of the minimum and maximum baselines' 

498 ' of {} and {}' 

499 ' klambda'.format(cut_range[0] / 1e3, 

500 cut_range[1] / 1e3)) 

501 if geometry is not None: 

502 up, vp = geometry.deproject(u, v) 

503 else: 

504 up, vp = u, v 

505 

506 baselines = np.hypot(up, vp) 

507 above_lo = baselines >= cut_range[0] 

508 below_hi = baselines <= cut_range[1] 

509 in_range = above_lo & below_hi 

510 u_cut, v_cut, vis_cut, weights_cut = [x[in_range] for x in [u, v, vis, weights]] 

511 

512 return u_cut, v_cut, vis_cut, weights_cut 

513 

514 

515def estimate_weights(u, v=None, V=None, nbins=300, log=True, use_median=False, 

516 verbose=True): 

517 r""" 

518 Estimate the weights using the variance of the binned visibilities. 

519 

520 The estimation is done assuming that the variation in each bin is dominated 

521 by the noise. This will be true if: 

522 1) The source is axi-symmetric, 

523 2) The uv-points have been deprojected, 

524 3) The bins are not too wide, 

525 Otherwise the variance may be dominated by intrinsic variations in the 

526 visibilities. 

527 

528 Parameters 

529 ---------- 

530 u, v : array, unit = :math:`\lambda` 

531 u and v coordinates of observations (deprojected). Data will be binned 

532 by baseline. If v is not None, np.hpot(u,v) will be used instead. Note 

533 that if V is None the argument v will be intepreted as V instead 

534 V : array, unit = Jy, default = None 

535 Observed visibility. If complex, the weights will be computed from the 

536 average of the variance of the real and imaginary components, as in 

537 CASA's statwt. Otherwise the variance of the real part is used. 

538 nbins : int, default = 300 

539 Number of bins used. 

540 log : bool, default = True 

541 If True, the uv bins will be constructed in log space, otherwise linear 

542 spaced bins will be used. 

543 use_median : bool, default = False 

544 If True all of the weights will be set to the median of the variance 

545 estimated across the bins. Otherwise, the baseline dependent variance 

546 will be used. 

547 verbose : bool, default = True 

548 If true, the logger will record calls to this function, along with 

549 whether the median estimate was used. 

550 

551 Returns 

552 ------- 

553 weights : array 

554 Estimate of the weight for each uv point. 

555 

556 Notes 

557 ----- 

558 - This function does not use the original weights in the estimation. 

559 - Bins with only one uv point do not have a variance estimate. Thus 

560 the mean of the variance in the two adjacent bins is used instead. 

561 

562 Examples 

563 -------- 

564 All of the following calls will work as expected: 

565 `estimate_weights(u, v, V) ` 

566 `estimate_weights(u, V)` 

567 `estimate_weights(u, V=V)` 

568 In each case the variance of V in the uv-bins is used to estimate the 

569 weights. The first call will use q = np.hypot(u, v) in the uv-bins. The 

570 second and third calls are equivalent to the first with u=0. 

571 

572 """ 

573 

574 if verbose: 

575 logging.info(' Estimating visibility weights') 

576 

577 if V is None: 

578 if v is not None: 

579 V = v 

580 q = np.abs(u) 

581 else: 

582 raise ValueError("The visibilities, V, must be supplied") 

583 elif v is not None: 

584 q = np.hypot(u,v) 

585 else: 

586 q = np.abs(u) 

587 

588 if log: 

589 q = np.log(q) 

590 q -= q.min() 

591 

592 bin_width = (q.max()-q.min()) / nbins 

593 

594 uvBin = UVDataBinner(q, V, np.ones_like(q), bin_width) 

595 

596 if uvBin.bin_counts.max() == 1: 

597 raise ValueError("No bin contains more than one uv point, can't" 

598 " estimate the variance. Use fewer bins.") 

599 

600 if np.iscomplex(V.dtype): 

601 var = 0.5*(uvBin.error.real**2 + uvBin.error.imag**2) * uvBin.bin_counts 

602 else: 

603 var = uvBin.error.real**2 * uvBin.bin_counts 

604 

605 if use_median: 

606 if verbose: 

607 logging.info(' Setting all weights as median binned visibility ' 

608 'variance') 

609 return np.full(len(u), 1/np.ma.median(var[uvBin.bin_counts > 1])) 

610 else: 

611 if verbose: 

612 logging.info(' Setting weights according to baseline-dependent ' 

613 'binned visibility variance') 

614 # For bins with 1 uv point, use the average of the adjacent bins 

615 no_var = np.argwhere(uvBin.bin_counts == 1).reshape(-1) 

616 if len(no_var) > 0: 

617 # Find the location `loc` of the bad points in the array of good points 

618 good_var = np.argwhere(uvBin.bin_counts > 1).reshape(-1) 

619 loc = np.searchsorted(good_var, no_var, side='right') 

620 

621 # Set the variance to the average of the two adjacent bins 

622 im = good_var[np.maximum(loc-1, 0)] 

623 ip = good_var[np.minimum(loc, len(good_var)-1)] 

624 var[no_var] = 0.5*(var[im] + var[ip]) 

625 

626 bin_id = uvBin.determine_uv_bin(q) 

627 assert np.all(bin_id != -1), "Error in binning" # Should never occur 

628 

629 weights = 1/var[bin_id] 

630 

631 return weights 

632 

633 

634def draw_bootstrap_sample(u, v, vis, weights): 

635 r""" 

636 Obtain the sample for a bootstrap, drawing, with replacement, N samples from 

637 a length N dataset 

638 

639 Parameters 

640 ---------- 

641 u, v : array, unit = :math:`\lambda` 

642 u and v coordinates of observations 

643 vis : array, unit = Jy 

644 Observed visibilities (complex: real + imag * 1j) 

645 weights : array, unit = Jy^-2 

646 Weights on the visibilities, of the form 

647 :math:`1 / \sigma^2` 

648 

649 Returns 

650 ------- 

651 u_boot, v_boot : array, unit = :math:`\lambda` 

652 Bootstrap sampled u and v coordinates 

653 vis_boot : array, unit = Jy 

654 Bootstrap sampled visibilities 

655 weights_boot : array, unit = Jy^-2 

656 Boostrap sampled weights on the visibilities 

657 

658 """ 

659 idxs = np.random.randint(low=0, high=len(u), size=len(u)) 

660 

661 u_boot = u[idxs] 

662 v_boot = v[idxs] 

663 vis_boot = vis[idxs] 

664 weights_boot = weights[idxs] 

665 

666 return u_boot, v_boot, vis_boot, weights_boot 

667 

668 

669def sweep_profile(r, I, project=False, phase_shift=False, geom=None, axis=0, 

670 xmax=None, ymax=None, dr=None): 

671 r""" 

672 Sweep a 1D radial brightness profile over :math:`2 \pi` to yield a 2D 

673 brightness distribution. Optionally project this sweep by a supplied 

674 geometry. 

675 

676 Parameters 

677 ---------- 

678 r : array 

679 Radial coordinates at which the 1D brightness profile is defined 

680 I : array 

681 Brightness values at r 

682 project : bool, default = False 

683 Whether to project the swept profile by the supplied geom 

684 phase_shift : bool, default = False 

685 Whether to phase shift the projected profile by the supplied geom. 

686 If False, the source will be centered in the image 

687 geom : SourceGeometry object, default=None 

688 Fitted geometry (see frank.geometry.SourceGeometry). Here we use 

689 geom.inc [deg], geom.PA [deg], geom.dRA [arcsec], geom.dDec [arcsec] if 

690 project=True 

691 axis : int, default = 0 

692 Axis over which to interpolate the 1D profile 

693 xmax, ymax : float, optional, default = None 

694 Value setting the x- and y-bounds of the image (same units as r). The 

695 positive and negative bounds are both set to this value (modulo sign). 

696 If not provided, these will be set to r.max() 

697 dr : float, optional, default = None 

698 Pixel size (same units as r). If not provided, it will be set at the 

699 same spatial scale as r 

700 

701 Returns 

702 ------- 

703 I2D : array, shape = (len(r), len(r)) 

704 2D brightness distribution (projected if project=True) 

705 xmax : float 

706 Maximum x-value of the 2D grid 

707 ymax : float 

708 Maximum y-value of the 2D grid 

709 

710 Notes 

711 ----- 

712 Sign convention: a negative geom.dRA shifts the source to the right 

713 in the image 

714 

715 """ 

716 if project: 

717 inc, pa = geom.inc, geom.PA 

718 inc *= deg_to_rad 

719 pa *= deg_to_rad 

720 

721 cos_i = np.cos(inc) 

722 cos_pa, sin_pa = np.cos(pa), np.sin(pa) 

723 

724 if xmax is None: 

725 xmax = r.max() 

726 if ymax is None: 

727 ymax = r.max() 

728 

729 if dr is None: 

730 dr = np.mean(np.diff(r)) 

731 

732 x = np.linspace(-xmax, xmax, int(xmax/dr) + 1) 

733 y = np.linspace(-ymax, ymax, int(ymax/dr) + 1) 

734 

735 if phase_shift: 

736 xi, yi = np.meshgrid(x + geom.dRA, y - geom.dDec) 

737 else: 

738 xi, yi = np.meshgrid(x, y) 

739 

740 if project: 

741 xp = xi * cos_pa + yi * sin_pa 

742 yp = -xi * sin_pa + yi * cos_pa 

743 xp /= cos_i 

744 r1D = np.hypot(xp, yp) 

745 else: 

746 r1D = np.hypot(xi, yi) 

747 

748 im_shape = r1D.shape + I.shape[1:] 

749 

750 interp = interp1d(r, I, bounds_error=False, fill_value=0., axis=axis) 

751 I2D = interp(r1D.ravel()).reshape(*im_shape) 

752 

753 return I2D, xmax, ymax 

754 

755 

756def make_image(fit, Npix, xmax=None, ymax=None, project=True): 

757 """Make an image of a model fit. 

758  

759 Parameters 

760 ---------- 

761 fit : FrankFitter result object 

762 Fitted profile to make an image of 

763 Npix : int or list 

764 Number of pixels in the x-direction, or [x-,y-] direction 

765 xmax: float or None, unit=arcsec 

766 Size of the image is [-xmax, xmax]. By default this is twice 

767 fit.Rmax to avoid aliasing. 

768 ymax: float or None, unit=arcsec 

769 Size of the image is [-ymax,ymax]. Defaults to xmax if ymax=None 

770 project: bool, default=True 

771 Whether to produce a projected image. 

772 

773 Returns 

774 ------- 

775 x : array, 1D; unit=arcsec 

776 Locations of the x-points in the image. 

777 y : array, 1D; unit=arcsec 

778 Locations of the y-points in the image. 

779 I : array, 2D; unit=Jy/Sr 

780 Image of the surface brightness. 

781 """ 

782 if xmax is None: 

783 xmax = 2*fit.Rmax 

784 if ymax is None: 

785 ymax = xmax 

786 

787 try: 

788 Nx, Ny = Npix 

789 except TypeError: 

790 Nx = Npix 

791 Ny = int(Nx*(ymax/xmax)) 

792 

793 dx = 2*xmax/(Nx*rad_to_arcsec) 

794 dy = 2*ymax/(Ny*rad_to_arcsec) 

795 

796 

797 # The easiest way to produce an image is to predict the visibilities 

798 # on a regular grid in the Fourier plane and then transform it back. 

799 # All frank models must be able to compute the visibilities so this 

800 # method is completely general. 

801 u = np.fft.fftfreq(Nx)/dx 

802 v = np.fft.fftfreq(Ny)/dy 

803 u, v = np.meshgrid(u,v, indexing='ij') 

804 

805 # Get the visibilities: 

806 if project: 

807 Vis = fit.predict(u.reshape(-1), v.reshape(-1)).reshape(*u.shape) 

808 else: 

809 q = np.hypot(u,v) 

810 Vis = fit.predict_deprojected(q.reshape(-1)).reshape(*u.shape) 

811 

812 # Convert to the image plane 

813 I = np.fft.ifft(np.fft.ifft(Vis, axis=0), axis=1).real 

814 I /= dx*dy 

815 

816 # numpy's fft has zero in the corner. We want it in the middle so we need 

817 # to wrap: 

818 tmp = I.copy() 

819 tmp[:Nx//2,], tmp[Nx//2:] = I[Nx//2:], I[:Nx//2] 

820 I[:,:Ny//2], I[:,Ny//2:] = tmp[:,Ny//2:], tmp[:,:Ny//2] 

821 

822 xe = np.linspace(-xmax, xmax, Nx+1) 

823 x = 0.5*(xe[1:] + xe[:-1]) 

824 ye = np.linspace(-ymax, ymax, Ny+1) 

825 y = 0.5*(ye[1:] + ye[:-1]) 

826 

827 return x, y, I 

828 

829 

830def convolve_profile(r, I, disc_i, disc_pa, clean_beam, 

831 n_per_sigma=5, axis=0): 

832 r""" 

833 Convolve a 1D radial brightness profile with a 2D Gaussian beam, degrading 

834 the profile's resolution 

835 

836 Parameters 

837 ---------- 

838 r : array 

839 Radial coordinates at which the 1D brightness profile is defined 

840 I : array 

841 Brightness values at r 

842 disc_i : float, unit = deg 

843 Disc inclination 

844 disc_pa : float, unit = deg 

845 Disc position angle 

846 clean_beam : dict 

847 Dictionary with beam `bmaj` (FWHM of beam along its major axis) [arcsec], 

848 `bmin` (FWHM of beam along its minor axis) [arcsec], 

849 `pa` (beam position angle) [deg] 

850 n_per_sigma : int, default = 50 

851 Number of points per standard deviation of the Gaussian kernel (used 

852 for gridding) 

853 axis : int, default = 0 

854 Axis over which to interpolate the 1D profile 

855 

856 Returns 

857 ------- 

858 I_smooth : array, shape = (len(r), len(r)) 

859 Convolved brightness profile I at coordinates r 

860 

861 """ 

862 

863 from scipy.constants import c 

864 from scipy.interpolate import interp1d 

865 from scipy.ndimage import gaussian_filter 

866 

867 # Set up the geometry for the smoothing mesh. 

868 # We align the beam with the grid (major axis aligned) and rotate the 

869 # image accordingly 

870 

871 # Convert beam FWHM to sigma 

872 bmaj = clean_beam['bmaj'] / np.sqrt(8 * np.log(2)) 

873 bmin = clean_beam['bmin'] / np.sqrt(8 * np.log(2)) 

874 

875 PA = (disc_pa - clean_beam['beam_pa']) * np.pi / 180. 

876 

877 cos_i = np.cos(disc_i * np.pi/180.) 

878 cos_PA = np.cos(PA) 

879 sin_PA = np.sin(PA) 

880 

881 # Pixel size in terms of bmin 

882 rmax = r.max() 

883 dx = bmin / n_per_sigma 

884 

885 xmax = rmax * (np.abs(cos_i * cos_PA) + np.abs(sin_PA)) 

886 ymax = rmax * (np.abs(cos_i * sin_PA) + np.abs(cos_PA)) 

887 

888 xmax = int(xmax / dx + 1) * dx 

889 ymax = int(ymax / dx + 1) * dx 

890 

891 x = np.arange(-xmax, xmax + dx / 2, dx) 

892 y = np.arange(-ymax, ymax + dx / 2, dx) 

893 

894 xi, yi = np.meshgrid(x, y) 

895 

896 xp = xi * cos_PA + yi * sin_PA 

897 yp = -xi * sin_PA + yi * cos_PA 

898 xp /= cos_i 

899 

900 r1D = np.hypot(xi, yi) 

901 

902 im_shape = r1D.shape + I.shape[1:] 

903 

904 # Interpolate to grid and apply smoothing 

905 interp = interp1d(r, I, bounds_error=False, fill_value=0., axis=axis) 

906 

907 I2D = interp(r1D.ravel()).reshape(*im_shape) 

908 sigma = [float(n_per_sigma), (bmaj / bmin) * n_per_sigma] 

909 I2D = gaussian_filter(I2D, sigma, mode='nearest', cval=0.) 

910 

911 # Now convert back to a 1D profile 

912 edges = np.concatenate(([r[0] * r[0] / r[1]], r, [r[-1] * r[-1] / r[-2]])) 

913 edges = 0.5 * (edges[:-1] + edges[1:]) 

914 

915 I_smooth = np.empty_like(I) 

916 I_smooth = np.histogram(r1D.ravel(), weights=I2D.ravel(), bins=edges)[0] 

917 counts = np.maximum(np.histogram(r1D.ravel(), bins=edges)[0], 1) 

918 I_smooth /= counts 

919 

920 return I_smooth 

921 

922 

923def add_vis_noise(vis, weights, seed=None): 

924 r""" 

925 Add Gaussian noise to visibilities 

926 

927 Parameters 

928 ---------- 

929 vis : array, unit = [Jy] 

930 Visibilities to add noise to. 

931 Can be complex (real + imag * 1j) or purely real. 

932 weights : array, unit = [Jy^-2] 

933 Weights on the visibilities, of the form :math:`1 / \sigma^2`. 

934 Injected noise will be scaled proportional to `\sigma`. 

935 seed : int, default = None 

936 Number to initialize a pseudorandom number generator for the noise draws 

937 

938 Returns 

939 ------- 

940 vis_noisy : array, shape = vis.shape 

941 Visibilities with added noise 

942 

943 """ 

944 if seed is not None: 

945 np.random.seed(seed) 

946 

947 dim0 = 1 

948 if np.iscomplexobj(vis): 

949 dim0 = 2 

950 

951 vis = np.array(vis) 

952 noise = np.random.standard_normal((dim0,) + vis.shape) 

953 noise *= weights ** -0.5 

954 

955 vis_noisy = vis + noise[0] 

956 if np.iscomplexobj(vis): 

957 vis_noisy += 1j * noise[1] 

958 

959 return vis_noisy 

960 

961 

962def make_mock_data(r, I, Rmax, u, v, projection=None, geometry=None, N=500, 

963 add_noise=False, weights=None, seed=None): 

964 r""" 

965 Generate mock visibilities from a provided brightness profile and (u,v) 

966 distribution. 

967 

968 Parameters 

969 ---------- 

970 r : array, unit = [arcsec] 

971 Radial coordinates of I(r) 

972 I : array, unit = [Jy / sr] 

973 Brightness values at r 

974 Rmax : float, unit = [arcsec], default=2.0 

975 Maximum radius beyond which I(r) is zero. This should be larger than the 

976 disk size 

977 u, v : array, unit = :math:`\lambda` 

978 u and v coordinates of observations 

979 projection : str, default = None 

980 One of [None, 'deproject', 'reproject'] 

981 If None, the visibilities will be neither deprojected nor reprojected. 

982 If 'deproject' or 'reproject', the visibilites will be accordingly  

983 deprojected or reprojected by the supplied `geometry` and their total  

984 flux scaled by the inclination. 

985 geometry : SourceGeometry object, default=None 

986 Source geometry (see frank.geometry.SourceGeometry). Must be supplied 

987 if `projection` is 'deproject' or 'reproject'. 

988 N : integer, default=500 

989 Number of terms to use in the Fourier-Bessel series 

990 add_noise : bool, default = False 

991 Whether to add noise to the mock visibilities 

992 weights : array, unit = Jy^-2 

993 Visibility weights, of the form :math:`1 / \sigma^2`. 

994 If provided, injected noise will be scaled proportional to `\sigma`. 

995 seed : int, default = None 

996 Number to initialize a pseudorandom number generator for the noise draws 

997 

998 Returns 

999 ------- 

1000 baselines : array, unit = :math:`\lambda` 

1001 Baseline coordinates of the mock visibilities. These will be equal to 

1002 np.hypot(u, v) if 'geometry' is None (or if its keys are all equal to 0) 

1003 vis : array, unit = Jy 

1004 Mock visibility amplitudes, including noise if 'add_noise' is True 

1005 

1006 Notes 

1007 ----- 

1008 'r' and 'I' should be sufficiently sampled to ensure an interpolation will 

1009 be accurate, otherwise 'vis' may be a poor estimate of their transform. 

1010 """ 

1011 proj_valid = [None, 'deproject', 'reproject'] 

1012 if projection not in proj_valid: 

1013 raise AttributeError(f"projection is '{projection}'; must be one of {proj_valid}.") 

1014 

1015 if projection in ['deproject', 'reproject']: 

1016 if geometry is None: 

1017 raise AttributeError(f"geometry must be supplied to perform {projection}.") 

1018 

1019 if projection == 'deproject': 

1020 u, v = geometry.deproject(u, v) 

1021 else: 

1022 u, v = geometry.reproject(u, v) 

1023 

1024 else: 

1025 if geometry is not None: 

1026 raise AttributeError("projection is None; must be one of " 

1027 "['deproject', 'reproject'] " 

1028 "to perform projection.") 

1029 geometry = FixedGeometry(0, 0, 0, 0) 

1030 

1031 baselines = np.hypot(u, v) 

1032 

1033 _, vis = generic_dht(r, I, Rmax, N, grid=baselines, inc=geometry.inc) 

1034 

1035 if add_noise: 

1036 vis = add_vis_noise(vis, weights, seed) 

1037 

1038 return baselines, vis 

1039 

1040 

1041def get_collocation_points(Rmax=2.0, N=500, direction='forward'): 

1042 """ 

1043 Obtain the collocation points of a discrete Hankel transform for a given 

1044 'Rmax' and 'N' (see frank.hankel.DiscreteHankelTransform) 

1045 

1046 Parameters 

1047 ---------- 

1048 Rmax : float, unit = [arcsec], default=2.0 

1049 Maximum radius beyond which the real space function is zero 

1050 N : integer, default=500 

1051 Number of terms to use in the Fourier-Bessel series 

1052 direction : { 'forward', 'backward' }, default='forward' 

1053 Direction of the transform. 'forward' is real space -> Fourier space, 

1054 returning real space radial collocation points needed for the transform. 

1055 

1056 Returns 

1057 ------- 

1058 coll_pts : array, unit = [lambda] or [arcsec] 

1059 The DHT collocation points in either real or Fourier space. 

1060 

1061 """ 

1062 if direction not in ['forward', 'backward']: 

1063 raise AttributeError("direction must be one of ['forward', 'backward']") 

1064 

1065 r_pts, q_pts = DiscreteHankelTransform.get_collocation_points( 

1066 Rmax=Rmax / rad_to_arcsec, N=N, nu=0 

1067 ) 

1068 

1069 if direction == 'forward': 

1070 coll_pts = r_pts * rad_to_arcsec 

1071 else: 

1072 coll_pts = q_pts 

1073 

1074 return coll_pts 

1075 

1076 

1077def generic_dht(x, f, Rmax=2.0, N=500, direction='forward', grid=None, 

1078 inc=0.0): 

1079 """ 

1080 Compute the visibilities or brightness of a model by directly applying the 

1081 Discrete Hankel Transform.  

1082  

1083 The correction for inclination will also be applied, assuming an optically 

1084 thick disc. For an optically thin disc, setting inc=0 (the default) will 

1085 achieve the correct scaling. 

1086 

1087 Parameters 

1088 ---------- 

1089 x : array, unit = [arcsec] or [lambda] 

1090 Radial or spatial frequency coordinates of f(x) 

1091 f : array, unit = [Jy / sr] or [Jy] 

1092 Amplitude values of f(x) 

1093 Rmax : float, unit = [arcsec], default=2.0 

1094 Maximum radius beyond which the real space function is zero 

1095 N : integer, default=500 

1096 Number of terms to use in the Fourier-Bessel series 

1097 direction : { 'forward', 'backward' }, default='forward' 

1098 Direction of the transform. 'forward' is real space -> Fourier space. 

1099 grid : array, unit = [arcsec] or [lambda], default=None 

1100 The radial or spatial frequency points at which to sample the DHT. 

1101 If None, the DHT collocation points will be used. 

1102 inc : float, unit = [deg], default = 0.0 

1103 Source inclination. The total flux of the transform of f(x) 

1104 will be scaled by cos(inc); this has no effect if inc=0. 

1105 

1106 Returns 

1107 ------- 

1108 grid : array, size=N, unit = [arcsec] or [lambda] 

1109 Spatial frequency or radial coordinates of the Hankel transform of f(x) 

1110 f_transform : array, size=N, unit = [Jy / sr] or [Jy] 

1111 Hankel transform of f(x) 

1112 

1113 Notes 

1114 ----- 

1115 'x' and 'f' should be sufficiently sampled to ensure an interpolation will 

1116 be accurate, otherwise 'f_transform' may be a poor estimate of their 

1117 transform. 

1118 """ 

1119 

1120 if direction not in ['forward', 'backward']: 

1121 raise AttributeError("direction must be one of ['forward', 'backward']") 

1122 

1123 DHT = DiscreteHankelTransform(Rmax=Rmax / rad_to_arcsec, N=N, nu=0) 

1124 geom = FixedGeometry(inc, 0, 0, 0) 

1125 VM = VisibilityMapping(DHT, geom) 

1126 

1127 if direction == 'forward': 

1128 # map the profile f(x) onto the DHT collocation points. 

1129 # this is necessary for an accurate transform. 

1130 y = np.interp(VM.r, x, f) 

1131 

1132 if grid is None: 

1133 grid = VM.q 

1134 

1135 # perform the DHT 

1136 f_transform = VM.predict_visibilities(y, grid, geometry=geom) 

1137 

1138 else: 

1139 y = np.interp(VM.q, x, f) 

1140 

1141 if grid is None: 

1142 grid = VM.r 

1143 

1144 f_transform = VM.invert_visibilities(y, grid, geometry=geom) 

1145 

1146 return grid, f_transform