Coverage for python / lsst / summit / utils / plotRadialAnalysis.py: 0%

238 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-23 09:09 +0000

1# This file is part of summit_utils. 

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# This program is free software: you can redistribute it and/or modify 

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21 

22from __future__ import annotations 

23 

24from collections.abc import Iterable 

25 

26import matplotlib 

27import numpy as np 

28import pandas 

29from scipy.optimize import curve_fit # type: ignore 

30 

31from lsst.afw.cameraGeom import FIELD_ANGLE, Detector 

32from lsst.daf.butler import Butler, DatasetRef 

33from lsst.geom import Box2I, Extent2I, Point2I 

34from lsst.summit.utils.utils import getCameraFromInstrumentName 

35from lsst.utils.plotting.figures import make_figure 

36 

37 

38def gaussian2dFitFunction( 

39 xy: tuple[np.ndarray, np.ndarray], 

40 peak: float, 

41 fwhm: float, 

42 x0: float, 

43 y0: float, 

44 baseline: float = 0.0, 

45) -> np.ndarray: 

46 """Gaussian distribution with centroid. 

47 

48 Parameters 

49 ---------- 

50 xy: `tuple` of `np.ndarray` 

51 Points coordinates. 

52 peak: `float` 

53 Values of the intesity peak. 

54 fwhm: `float` 

55 Full Width at Half Maximum fo the distribution. 

56 x0: `float` 

57 The x position of the 2d Guassian function. 

58 y0: `float` 

59 The y position of the 2d Guassian function. 

60 baseline: `float` 

61 Offset to apply. Default 0. 

62 

63 Returns 

64 ------- 

65 pdf: `np.ndarray` 

66 Probability density function of the distribution. 

67 """ 

68 x, y = xy 

69 sigma = fwhm / (2 * np.sqrt(2 * np.log(2))) 

70 return baseline + peak * np.exp(-((x - x0) ** 2 + (y - y0) ** 2) / (2 * sigma**2)) 

71 

72 

73def moffat2dFitFunction( 

74 xy: tuple[np.ndarray, np.ndarray], 

75 peak: float, 

76 alpha: float, 

77 beta: float, 

78 x0: float, 

79 y0: float, 

80 baseline: float, 

81) -> np.ndarray: 

82 """Moffat distribution with centroid. 

83 

84 Parameters 

85 ---------- 

86 xy: `tuple` of `np.ndarray` 

87 Points coordinates. 

88 peak: `float` 

89 Values of the intesity peak. 

90 alpha: `float` 

91 The alpha parameter for the Moffat distribution. 

92 beta: `float` 

93 The beta parameter for the Moffat distribution. 

94 x0: `float` 

95 x coordinate of the distribution. 

96 y0: `float` 

97 y coordinate of the distribution. 

98 baseline: `float` 

99 Offset to apply. Default 0. 

100 

101 Returns 

102 ------- 

103 pdf: `np.ndarray` 

104 Probability density function of the distribution. 

105 """ 

106 x, y = xy 

107 return baseline + peak * (1 + (((x - x0) ** 2 + (y - y0) ** 2)) / alpha**2) ** (-beta) 

108 

109 

110def doRadialAnalysis(data: np.ndarray, fitModel: str): 

111 """Perform the radial analysis on a star cutout 

112 

113 Parameters 

114 ---------- 

115 data: `np.ndarray` 

116 2D array containing the star cutout. 

117 fitModel: `str` 

118 Model used for the fit ('moffat' or 'gauss'). 

119 

120 Returns 

121 ------- 

122 x: `np.ndarray` 

123 1d array with the radial from the centroid. 

124 y: `np.ndarray` 

125 1d array with the intensity value. 

126 yScatter: `np.ndarray` 

127 The flattened radial distribution of the start intensity. 

128 Usefull for plotting purpose. 

129 yFit: `np.ndarray` 

130 The fitted radial distribution. 

131 fwhmFit: `float` 

132 The Full Width at Half Maximum of the fitted distribution. 

133 eE50Diameter: `float` 

134 The Encircled Energy diameter at 50%. 

135 eE80Diameter: `float` 

136 The Encircled Energy diameter at 80%. 

137 """ 

138 # Create meshgrid for fitting with x and y positions 

139 xGrid, yGrid = np.meshgrid(np.arange(data.shape[1]), np.arange(data.shape[0])) 

140 xy = (xGrid.ravel(), yGrid.ravel()) 

141 radialValues = data.ravel() 

142 

143 match fitModel.lower(): 

144 case "moffat": 

145 # Initial guess for Moffat fitting 

146 initialGuess = [ 

147 np.max(radialValues), 

148 4, 

149 2, 

150 data.shape[1] / 2, 

151 data.shape[0] / 2, 

152 np.median(radialValues), 

153 ] 

154 params, _ = curve_fit(moffat2dFitFunction, xy, radialValues, p0=initialGuess, maxfev=10000) 

155 _, alphaFit, betaFit, x0Fit, y0Fit, baselineFit = params 

156 fwhmFit = np.abs(2.0 * alphaFit * np.sqrt(2 ** (1 / betaFit) - 1.0)) 

157 case "gauss": 

158 # Initial guess for Gaussian fitting 

159 initialGuess = [ 

160 np.max(radialValues), 

161 10, 

162 data.shape[1] / 2, 

163 data.shape[0] / 2, 

164 np.median(radialValues), 

165 ] 

166 params, _ = curve_fit(gaussian2dFitFunction, xy, radialValues, p0=initialGuess, maxfev=10000) 

167 _, fwhmFit, x0Fit, y0Fit, baselineFit = params 

168 case _: 

169 raise ValueError(f"The model {fitModel} is not among the available ones (gauss, moffat)") 

170 

171 # Compute the curve of growth (cumulative energy) 

172 radii = np.sqrt((xGrid - x0Fit) ** 2 + (yGrid - y0Fit) ** 2).ravel() 

173 

174 sortedIndices = np.argsort(radii) 

175 sortedradii = radii[sortedIndices] 

176 sortedValues = radialValues[sortedIndices] - baselineFit 

177 cumulativeEnergy = np.cumsum(sortedValues) 

178 

179 # Determine 50% and 80% encircled energy diameters 

180 eE50Diameter = 2 * sortedradii[np.searchsorted(cumulativeEnergy, 0.5 * cumulativeEnergy[-1])] 

181 eE80Diameter = 2 * sortedradii[np.searchsorted(cumulativeEnergy, 0.8 * cumulativeEnergy[-1])] 

182 

183 x = 0.2 * sortedradii 

184 yScatter = sortedValues + baselineFit # added back the backgroud. 

185 

186 match fitModel.lower(): 

187 case "moffat": 

188 yFit = moffat2dFitFunction(xy, *params)[sortedIndices] 

189 case "gauss": 

190 yFit = gaussian2dFitFunction(xy, *params)[sortedIndices] 

191 case _: 

192 raise ValueError(f"The model {fitModel} is not among the available ones (gauss, moffat)") 

193 

194 return ( 

195 x, 

196 yScatter, 

197 yFit, 

198 fwhmFit, 

199 eE50Diameter, 

200 eE80Diameter, 

201 ) 

202 

203 

204def makeLayerPlot( 

205 ax: matplotlib.axes.Axes, 

206 data: np.ndarray, 

207 e1: float, 

208 e2: float, 

209 e: float, 

210 fitModel: str, 

211 layers: list[str] | str = "all", 

212 levels: np.ndarray | Iterable[float] | None = None, 

213) -> tuple[float, float, float]: 

214 """Make per axes layer plot. 

215 

216 Create a plot with three possible layers: 

217 - Background image with the star cutout 

218 - The contour level of the star 

219 - The radial profile with Gaussian and Moffat fit 

220 The value of FWHM and Encircled Energy Radii (EE) 

221 at 50% and 80% are reported if the 'radial' layer has been chosen. 

222 

223 Parameters 

224 ---------- 

225 ax: `matplotlib.axes.Axes` 

226 The axes to use 

227 data: `np.ndarray` 

228 2D array containing the star cutout 

229 fitModel: `str` 

230 Model used for the fit ('moffat' or 'gauss') 

231 layers: list[str] | str = "all", 

232 List of layers to be displayed 

233 ('background', 'radial', 'contour', 'ellipticity'). 

234 levels: `np.ndarray` or `Iterable` of `float` or `None`, optional 

235 The levels value for the contour layer. 

236 If None, is set to `np.linspace(1.5*np.std(data), data.max(), 5)` 

237 

238 Returns 

239 ------- 

240 fwhmFit: `float` 

241 The FWHM of the fitted distribution. 

242 eE50Diameter: `float` 

243 The encircled energy diameter at 50%. 

244 eE80Diameter: `float` 

245 The encircled energy diameter at 80%. 

246 """ 

247 if layers == "all": 

248 layers = ["background", "radial", "contours", "ellipticity"] 

249 

250 ( 

251 x, 

252 yScatter, 

253 yFit, 

254 fwhmFit, 

255 eE50Diameter, 

256 eE80Diameter, 

257 ) = doRadialAnalysis(data, fitModel) 

258 

259 # get figure and axes position on figure 

260 # to create multiple axes on that position 

261 fig = ax.get_figure() 

262 bbox = ax.get_position() 

263 bboxArray: tuple[float, float, float, float] = bbox.bounds 

264 

265 assert fig is not None 

266 

267 # plot the background layer if present 

268 axBkg = None 

269 if "background" in layers: 

270 axBkg = fig.add_axes(bboxArray, frameon=False) 

271 axBkg.imshow(data, cmap="gray", origin="lower", zorder=1) 

272 axBkg.set(zorder=1, xticks=[], yticks=[]) 

273 

274 # plot the contour layer if present 

275 if "contour" in layers: 

276 axCtr = fig.add_axes(bboxArray, frameon=False) 

277 xGrid, yGrid = np.meshgrid(np.arange(data.shape[1]), np.arange(data.shape[0])) 

278 levels = levels if levels is not None else np.linspace(1.5 * np.std(data), data.max(), 5) 

279 axCtr.contour(xGrid, yGrid, data, cmap="spring", levels=levels, alpha=0.7) 

280 axCtr.set(zorder=2, facecolor=(1, 1, 1, 0), xticks=[], yticks=[]) 

281 

282 # plot the radial analysis layer if present 

283 if "radial" in layers: 

284 axRad = fig.add_axes(bboxArray, frameon=False) 

285 axRad.scatter( 

286 x, 

287 yScatter, 

288 marker="o", 

289 s=5, 

290 facecolor="none", 

291 edgecolor="cyan", 

292 label="Data", 

293 ) 

294 

295 axRad.plot( 

296 x, 

297 yFit, 

298 ls="-", 

299 color="y", 

300 label=f"{fitModel} Fit", 

301 ) 

302 

303 axRad.set(zorder=3, facecolor=(1, 1, 1, 0), xticks=[], yticks=[]) 

304 

305 if "ellipticity" in layers: 

306 axEll = fig.add_axes(bboxArray, frameon=False) 

307 axEll.set(zorder=4, facecolor=(1, 1, 1, 0), xticks=[], yticks=[]) 

308 

309 quiverScale = 1 

310 eU = e * np.cos(0.5 * np.arctan2(e2, e1)) 

311 eV = e * np.sin(0.5 * np.arctan2(e2, e1)) 

312 peak = np.argmax(data) 

313 centerY, centerX = np.unravel_index(peak, data.shape) 

314 

315 # better to use the background axes limits 

316 if "background" in layers: 

317 assert axBkg is not None, "Logic error: axBkg should have been defined above" 

318 axEll.set(xlim=axBkg.get_xlim(), ylim=axBkg.get_ylim()) 

319 else: # otherwise use the cutout limits 

320 axEll.set(xlim=(0, data.shape[1]), ylim=(0, data.shape[0])) 

321 

322 axEll.quiver( 

323 centerX, 

324 centerY, 

325 eU, 

326 eV, 

327 headlength=0, 

328 headaxislength=0, 

329 scale=quiverScale, 

330 pivot="mid", 

331 color="fuchsia", 

332 width=0.015, 

333 ) 

334 

335 return fwhmFit, eE50Diameter, eE80Diameter 

336 

337 

338def compactifyLayout( 

339 rectDict: dict[str, tuple[float, float, float, float]], 

340) -> dict[str, tuple[float, float, float, float]]: 

341 """Compact the layout of the rectangles to fit in a smaller area. 

342 This function rescales the rectangles to fit within a specified area 

343 while maintaining their aspect ratios. 

344 

345 Parameters 

346 ---------- 

347 rectDict: `dict` 

348 Dictionary with the detector boxes. 

349 

350 Returns 

351 ------- 

352 rectDict: `dict` 

353 Dictionary with the rescaled rectangles. 

354 """ 

355 

356 margin = 0.02 

357 

358 lefts = [r[0] for r in rectDict.values()] 

359 bottoms = [r[1] for r in rectDict.values()] 

360 

361 min_left = min(lefts) 

362 max_left = max(lefts) 

363 min_bottom = min(bottoms) 

364 max_bottom = max(bottoms) 

365 

366 range_x = max_left - min_left if max_left != min_left else 1 

367 range_y = max_bottom - min_bottom if max_bottom != min_bottom else 1 

368 

369 scale_x = (1 - 2 * margin) / range_x 

370 scale_y = (1 - 2 * margin) / range_y 

371 

372 newRectDict: dict[str, tuple[float, float, float, float]] = {} 

373 for name, (left, bottom, width, height) in rectDict.items(): 

374 new_left = (left - min_left) * scale_x + margin 

375 new_bottom = (bottom - min_bottom) * scale_y + margin 

376 newRectDict[name] = (new_left, new_bottom, width, height) 

377 

378 return newRectDict 

379 

380 

381def computeColorbarRect( 

382 rectDict: dict[str, tuple[float, float, float, float]], 

383) -> tuple[float, float, float, float]: 

384 """Compute the colorbar rectangle based on the detector rectangles 

385 

386 Parameters 

387 ---------- 

388 rectDict: `dict` 

389 Dictionary with the detector boxes. 

390 

391 Returns 

392 ------- 

393 rect: `tuple` 

394 The box in which the colorbar will be drawn. 

395 """ 

396 

397 width = 0.015 

398 padding = 0.01 

399 

400 rects = list(rectDict.values()) 

401 

402 min_bottom = min(r[1] for r in rects) 

403 max_top = max(r[1] + r[3] for r in rects) 

404 max_right = max(r[0] + r[2] for r in rects) 

405 

406 height = max_top - min_bottom 

407 left = max_right + padding 

408 bottom = min_bottom 

409 

410 return (left, bottom, width, height) 

411 

412 

413def createFigWithInstrumentLayout( 

414 fig: matplotlib.figure.Figure, 

415 instrument: str, 

416 onlyS11: bool = False, 

417) -> dict[str, matplotlib.axes.Axes]: 

418 """Create a figure with the requested instrument layout 

419 

420 Parameters 

421 ---------- 

422 fig: `matplotlib.figure.Figure` 

423 The figure to use 

424 instrument: `str` 

425 The instrument name. 

426 onlyS11: `bool`, optional 

427 If True, only S11 detectors are shown. Default False. 

428 

429 Returns 

430 ------- 

431 axsDict: `dict[str, matplotlib.axes.Axes]` 

432 A dictionary with the detector names as keys and the axes as values. 

433 """ 

434 

435 camera = getCameraFromInstrumentName(instrument) 

436 detectors = [detector.getId() for detector in camera] 

437 

438 rectDict: dict[str, tuple[float, float, float, float]] = {} 

439 for name in detectors: 

440 detector = camera.get(name) 

441 detName = detector.getName() 

442 

443 # Get the corners of the detector in FIELD_ANGLE 

444 corners = detector.getCorners(FIELD_ANGLE) 

445 corners_deg = np.rad2deg(corners) # Convert corners to degrees 

446 

447 # turn the cornsers coords in (letf, bottom, width, height) 

448 detRect = ( 

449 corners_deg[:, 0].min(), 

450 corners_deg[:, 1].min(), 

451 corners_deg[:, 0].max() - corners_deg[:, 0].min(), 

452 corners_deg[:, 1].max() - corners_deg[:, 1].min(), 

453 ) 

454 

455 if onlyS11 and "S11" not in detName: 

456 continue 

457 rectDict[detName] = detRect 

458 

459 if onlyS11: 

460 rectDict = compactifyLayout(rectDict) 

461 

462 axsDict = {} 

463 for detName, rect in rectDict.items(): 

464 # Create an axes for each detector 

465 ax = fig.add_axes(rect) 

466 ax.set(xticks=[], yticks=[]) 

467 axsDict[detName] = ax 

468 

469 cbraRect = computeColorbarRect(rectDict) 

470 cbarAx = fig.add_axes(cbraRect) 

471 axsDict["cbar"] = cbarAx 

472 

473 return axsDict 

474 

475 

476def makePsfPanel( 

477 cutouts: dict[str, tuple[np.ndarray, tuple[float, float, float]]], 

478 instrument: str = "LSSTComCam", 

479 onlyS11: bool = False, 

480 layers: list[str] | str = "all", 

481 fitModel: str = "moffat", 

482 levels: np.ndarray | Iterable[float] | None = None, 

483 **kwargs, 

484) -> matplotlib.figure.Figure: 

485 """Make a per-detector PSF radial analysis. 

486 

487 Each subplot shows for a detector a PSF cutout, a radial analysis and the 

488 morphology contour, a custom selection of this layer is possible. 

489 

490 Parameters 

491 ---------- 

492 cutouts: `dict[str, np.ndarray]` 

493 A detector's name key dictionary containing 

494 the 2D array of the star cutouts. 

495 instrument: `str`, optional 

496 Detector type. Default 'LSSTComCam'. 

497 onlyS11: `bool`, optional 

498 If True, only S11 detectors are shown. Default False. 

499 layers: `str` or `list` of `str`, optional 

500 List of layers to be displayed ('background', 'radial', 'contour'). 

501 It is possible to pass also the string 'all' as a shortcut for 

502 ['background', 'radial', 'contour']. Default 'all'. 

503 fitModel: `str`, optional 

504 Model used for the radial fit ('moffat' or 'gauss'). 

505 Default 'moffat'. 

506 levels: `np.ndarray` or `Iterable` of `float` or `None`, optional 

507 The levels value for the contour layer. 

508 If None, is set to `np.linspace(1.5*np.std(data), data.max(), 5)`. 

509 **kwargs: 

510 Additional keyword arguments passed to matplotlib Figure constructor. 

511 

512 Returns 

513 ------- 

514 fig: `matplotlib.figure.Figure` 

515 The figure. 

516 """ 

517 fig = make_figure(**kwargs) 

518 axsDict = createFigWithInstrumentLayout(fig, instrument, onlyS11=onlyS11) 

519 

520 if layers == "all": 

521 layers = ["background", "radial", "contours", "ellipticity"] 

522 

523 fwhmDict: dict[str, float] = {} 

524 ee50Dict: dict[str, float] = {} 

525 ee80Dict: dict[str, float] = {} 

526 for detName, (cutout, (e1, e2, e)) in cutouts.items(): 

527 fwhm, ee50, ee80 = makeLayerPlot(axsDict[detName], cutout, e1, e2, e, fitModel, layers, levels) 

528 fwhmDict[detName] = fwhm 

529 ee50Dict[detName] = ee50 

530 ee80Dict[detName] = ee80 

531 

532 cmap = matplotlib.colormaps["RdYlGn_r"] 

533 for detName, (_, (e1, e2, _)) in cutouts.items(): 

534 bbox = axsDict[detName].get_position() 

535 bboxArray = bbox.bounds 

536 

537 axText = fig.add_axes(bboxArray, frameon=True) 

538 axText.set(zorder=4, facecolor=(1, 1, 1, 0), xticks=[], yticks=[]) 

539 

540 if detName == "cbar": 

541 continue 

542 

543 val = (fwhmDict[detName] - min(fwhmDict.values())) / (max(fwhmDict.values()) - min(fwhmDict.values())) 

544 color = cmap(val) 

545 axText.patch.set(lw=7, ec=color) 

546 for _, spine in axText.spines.items(): 

547 spine.set_color(color) 

548 

549 # Text with FWHM and EE50|80 

550 text = ( 

551 f'FWHM: {fwhmDict[detName] * 0.2:.2f}" ' 

552 f'EE80: {ee80Dict[detName] * 0.2:.2f}"\n' 

553 f"E1|2: {e1:.2f}|{e2:.2f}" 

554 ) 

555 axText.text( 

556 0.97, 

557 0.95, 

558 text, 

559 color=color, 

560 fontsize=12, 

561 fontweight="bold", 

562 transform=axText.transAxes, 

563 ha="right", 

564 va="top", 

565 ) 

566 

567 axText.text( 

568 0.3, 

569 0.1, 

570 detName, 

571 color="silver", 

572 fontsize=11, 

573 fontweight="bold", 

574 transform=axText.transAxes, 

575 ha="right", 

576 va="top", 

577 ) 

578 

579 # set colorbar 

580 vmin = None 

581 vmax = None 

582 if fwhmDict: 

583 vmin = min(fwhmDict.values()) * 0.2 

584 vmax = max(fwhmDict.values()) * 0.2 

585 cbar = fig.colorbar( 

586 matplotlib.cm.ScalarMappable( 

587 cmap=cmap, 

588 norm=matplotlib.pyplot.Normalize(vmin=vmin, vmax=vmax), 

589 ), 

590 cax=axsDict["cbar"], 

591 ) 

592 cbar.ax.set_ylabel(ylabel='FWHM"', fontsize=30) 

593 cbar.ax.tick_params(labelsize=30) 

594 

595 return fig 

596 

597 

598def generateCutout( 

599 butler: Butler, 

600 imgRef: DatasetRef, 

601 detector: Detector, 

602 target: np.ndarray | list[float] | tuple[float, float], 

603) -> np.ndarray: 

604 """Generate the cutout around a target position 

605 

606 Parameters 

607 ---------- 

608 butler: `lsst.daf.butler.Butler` 

609 The butler to use to get the image 

610 imgRef: `lsst.daf.butler.DatasetRef` 

611 The dataset reference to use to get the image 

612 detector: `lsst.afw.cameraGeom.Detector` 

613 The detector to use to get calculate the region of interest 

614 target: `np.ndarray` or `list` of `float` or `tuple` of `float` 

615 The coordinates of the cutout center 

616 

617 Returns 

618 ------- 

619 cutout: `np.ndarray` 

620 The square cutout around the center position. 

621 """ 

622 

623 pad = 20 

624 detBbox = detector.getBBox() 

625 start = Point2I(target[0] - (pad // 2), target[1] - (pad // 2)) 

626 dim = Extent2I(pad, pad) 

627 roiBbox = detBbox.clippedTo(Box2I(start, dim)) 

628 cutout = butler.get(imgRef, parameters={"bbox": roiBbox}).image.array 

629 

630 return cutout 

631 

632 

633def findNearestStarToCenter( 

634 tab: pandas.DataFrame, 

635 detector: Detector, 

636 instrument: str, 

637) -> tuple[np.ndarray, tuple[float, float, float]]: 

638 """Find the nearest star w.r.t to the detector center 

639 N.B. The seacrh is done in PIXEL coordinates. 

640 

641 Parameters 

642 ---------- 

643 tab: `pandas.DataFrame` 

644 pandas.DataFrame with the in focus stars positions. 

645 detector: `lsst.afw.cameraGeom.Detector` 

646 The detector realted to the sourceTable's positions. 

647 instrument: `str` 

648 Instrument name. 

649 

650 Returns 

651 ------- 

652 `np.ndarray` 

653 The x and y coordinates of the nearest star. 

654 `tuple` 

655 The ellipticity parameters (e1, e2, e). 

656 """ 

657 

658 if instrument == "LSSTComCam": 

659 xCol = "slot_Centroid_x" 

660 yCol = "slot_Centroid_y" 

661 else: # for now just work with src file that has the same column. 

662 xCol = "slot_Centroid_x" # "x" 

663 yCol = "slot_Centroid_y" # "y" 

664 

665 target = (detector.getBBox().centerX, detector.getBBox().centerY) 

666 

667 tab["center_sep"] = np.sqrt((tab[xCol] - target[0]) ** 2 + (tab[yCol] - target[1]) ** 2) 

668 most_close = tab.sort_values(by=["center_sep"]).iloc[0].name 

669 nearest = tab.loc[most_close, [xCol, yCol]].values 

670 

671 # from makeTableFromSourceCatalogs on lsst.summit_extras.plotting 

672 iXX = tab.loc[most_close, "slot_Shape_xx"] * (0.2) ** 2 

673 iYY = tab.loc[most_close, "slot_Shape_yy"] * (0.2) ** 2 

674 iXY = tab.loc[most_close, "slot_Shape_xy"] * (0.2) ** 2 

675 T = iXX + iYY 

676 e1 = (iXX - iYY) / T 

677 e2 = 2 * iXY / T 

678 e = np.hypot(e1, e2) 

679 

680 return nearest, (e1, e2, e) 

681 

682 

683def makePanel( 

684 butler: Butler, 

685 visit: int, 

686 onlyS11: bool = False, 

687 **kwargs, 

688) -> matplotlib.figure.Figure: 

689 """Create the panel with the in focus stars. 

690 See the documentation of `makePsfPanel` for more information. 

691 

692 Parameters 

693 ---------- 

694 butler: `lsst.daf.butler.Butler` 

695 The butler to use to get the image and the source table 

696 imgRefs: `lsst.daf.butler.DatasetRef` 

697 The dataset reference to use to get the image 

698 srcRefs: `lsst.daf.butler.DatasetRef` 

699 The dataset reference to use to get the source table 

700 onlyS11: `bool`, optional 

701 If True, only S11 detectors are shown. Default False. 

702 **kwargs: 

703 Parameters for the `makePsfPanel` method. 

704 

705 Returns 

706 ------- 

707 fig: `matplotlib.figure.Figure` 

708 The figure. 

709 

710 Raises 

711 ------ 

712 ValueError 

713 If no image or source table datasets are found for the given visit. 

714 """ 

715 

716 # retrieve the image and source table dataset references 

717 imgRefs = butler.query_datasets("post_isr_image", where=f"exposure={visit}", explain=False) 

718 srcRefs = butler.query_datasets("single_visit_psf_star", where=f"exposure={visit}", explain=False) 

719 if not imgRefs or not srcRefs: 

720 raise ValueError(f"No image and source tables found for visit {visit}") 

721 

722 # grab the instrument name from one of the imgRefs 

723 instrument = imgRefs[0].dataId["instrument"] 

724 assert isinstance(instrument, str), f"Instrument name {instrument} is not a string" 

725 camera = getCameraFromInstrumentName(instrument) 

726 instrumentName = camera.getName() 

727 

728 # if only S11 then keep only the S11 detectors 

729 if onlyS11: 

730 imgRefs = [dr for dr in imgRefs if "S11" in camera[dr.dataId["detector"]].getName()] 

731 srcRefs = [dr for dr in srcRefs if "S11" in camera[dr.dataId["detector"]].getName()] 

732 

733 # retrieve the detector object for each detector 

734 detNameDict = {detector.getName(): detector for detector in camera} 

735 

736 # interesct the detNum for images and tables 

737 imgDetName = {camera[dr.dataId["detector"]].getName() for dr in imgRefs} 

738 srcDetName = {camera[dr.dataId["detector"]].getName() for dr in srcRefs} 

739 commonDetName = imgDetName.intersection(srcDetName) 

740 

741 # first retrieve the srcTable datasets from butler 

742 filterColumn = "calib_psf_used" 

743 sourceTableDict = { 

744 camera[dr.dataId["detector"]].getName(): butler.get(dr).to_pandas() 

745 for dr in srcRefs 

746 if camera[dr.dataId["detector"]].getName() in commonDetName 

747 } 

748 sourceTableDict = {detName: tab[tab[filterColumn]] for detName, tab in sourceTableDict.items()} 

749 

750 # filter commoDetName to keep only srcTable with non zero rows 

751 filterDetName = [] 

752 for detName in commonDetName: 

753 if sourceTableDict[detName].shape[0] > 0: 

754 filterDetName.append(detName) 

755 

756 # find the most center star in the srcTables 

757 candidates = { 

758 detName: findNearestStarToCenter(sourceTableDict[detName], detNameDict[detName], instrumentName) 

759 for detName in filterDetName 

760 } 

761 

762 # filter the imgRefs 

763 filterImgRefDict = {} 

764 for imgRef in imgRefs: 

765 detName = camera[imgRef.dataId["detector"]].getName() 

766 if detName in filterDetName: 

767 filterImgRefDict[detName] = imgRef 

768 

769 # generate the cutouts 

770 cutouts = { 

771 detName: ( 

772 generateCutout(butler, filterImgRefDict[detName], detNameDict[detName], candidates[detName][0]), 

773 candidates[detName][1], 

774 ) 

775 for detName in filterDetName 

776 } 

777 

778 fig = makePsfPanel(cutouts, instrumentName, onlyS11=onlyS11, **kwargs) 

779 return fig