Coverage for python/lsst/summit/utils/guiders/detection.py: 19%

229 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-14 03:12 -0700

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/>. 

21from __future__ import annotations 

22 

23import logging 

24from dataclasses import asdict, dataclass, field 

25 

26__all__ = [ 

27 "runSourceDetection", 

28 "buildReferenceCatalog", 

29 "trackStarAcrossStamp", 

30 "makeBlankCatalog", 

31 "runGalSim", 

32] 

33 

34import galsim 

35import numpy as np 

36import pandas as pd 

37from astropy.nddata import Cutout2D 

38from astropy.stats import sigma_clipped_stats 

39 

40from lsst.afw.image import ExposureF, ImageF, MaskedImageF 

41from lsst.summit.utils.utils import detectObjectsInExp 

42 

43from .reading import GuiderData 

44 

45_DEFAULT_COLUMNS: str = ( 

46 "trackid detector expid elapsed_time dalt daz dtheta dx dy " 

47 "fwhm xroi yroi xccd yccd xroi_ref yroi_ref xccd_ref yccd_ref " 

48 "dxfp dyfp xfp yfp alt az xfp_ref yfp_ref alt_ref az_ref " 

49 "xerr yerr theta theta_err theta_ref flux flux_err magoffset snr " 

50 "ixx iyy ixy e1 e2 e1_altaz e2_altaz " 

51 "ampname timestamp stamp detid filter exptime " 

52) 

53DEFAULT_COLUMNS: tuple[str, ...] = tuple(_DEFAULT_COLUMNS.split()) 

54 

55 

56def makeBlankCatalog() -> pd.DataFrame: 

57 """ 

58 Create a blank DataFrame with the default columns for a star catalog. 

59 

60 Returns 

61 ------- 

62 catalog : `pd.DataFrame` 

63 Empty catalog with the default schema. 

64 """ 

65 return pd.DataFrame(columns=DEFAULT_COLUMNS) 

66 

67 

68@dataclass(frozen=True, slots=True) 

69class GuiderStarTrackerConfig: 

70 """Configuration for the GuiderStarTracker. 

71 

72 Parameters 

73 ---------- 

74 minSnr : `float` 

75 Minimum signal-to-noise ratio for star detection. 

76 minStampDetections : `int` 

77 Minimum number of detections across all stamps for a star to be 

78 considered valid. 

79 edgeMargin : `int` 

80 Margin in pixels to avoid edge effects in the image. 

81 maxEllipticity : `float` 

82 Maximum allowed ellipticity for a star to be considered valid. 

83 cutOutSize : `int` 

84 Size of the cutout around the star for tracking. 

85 aperSizeArcsec : `float` 

86 Aperture size in arcseconds for star detection. 

87 gain : `float` 

88 Gain factor for the guider data, used in flux calculations. 

89 """ 

90 

91 minSnr: float = 10.0 

92 minStampDetections: int = 30 

93 edgeMargin: int = 25 

94 maxEllipticity: float = 0.3 

95 cutOutSize: int = 50 

96 aperSizeArcsec: float = 3.0 

97 gain: float = 1.0 

98 

99 

100def trackStarAcrossStamp( 

101 refCenter: tuple[float, float], 

102 guiderData: GuiderData, 

103 guiderName: str, 

104 config: GuiderStarTrackerConfig = GuiderStarTrackerConfig(), 

105) -> pd.DataFrame: 

106 """ 

107 Track a star across all guider stamps and compute centroid, shape, and 

108 flux. 

109 

110 GalSim is used for centroid and shape measurements. Flux is measured with 

111 aperture photometry. 

112 

113 Parameters 

114 ---------- 

115 refCenter : `tuple[float, float]` 

116 Reference position (x, y) in pixel coordinates for the star. 

117 guiderData : `GuiderData` 

118 Guider data containing image stamps and metadata. 

119 guiderName : `str` 

120 Name of the guider to process. 

121 config : `GuiderStarTrackerConfig` 

122 Configuration parameters for the star tracker. 

123 

124 Returns 

125 ------- 

126 stars : `pd.DataFrame` 

127 DataFrame containing the tracked star measurements across all stamps. 

128 """ 

129 gd = guiderData 

130 expid = gd.expid 

131 wcs = gd.getWcs(guiderName) 

132 pixelScale = wcs.getPixelScale().asArcseconds() 

133 

134 # Initialize parameters from config 

135 apertureRadius = config.aperSizeArcsec / pixelScale 

136 cutOutSize = config.cutOutSize 

137 gain = config.gain 

138 

139 # check if the ref center is within the image bounds 

140 stampShape = gd[guiderName, 0].shape 

141 if not (0 <= refCenter[0] < stampShape[1]) or not (0 <= refCenter[1] < stampShape[0]): 

142 return makeBlankCatalog() 

143 

144 # loop over stamps 

145 results = [] 

146 for i in range(len(gd)): 

147 data = gd[guiderName, i] 

148 star = measureStarOnStamp(data, refCenter, cutOutSize, apertureRadius, gain=gain).toDataFrame() 

149 

150 # Add stamp index 

151 if not star.empty: 

152 star["stamp"] = i 

153 results.append(star) 

154 

155 # 3) Concatenate 

156 if not results: 

157 return makeBlankCatalog() 

158 stars = pd.concat(results, ignore_index=True) 

159 

160 # 4) Add metadata 

161 stars["detector"] = guiderName 

162 stars["expid"] = expid 

163 stars["ampname"] = gd.getGuiderAmpName(guiderName) 

164 stars["detid"] = gd.getGuiderDetNum(guiderName) 

165 stars["filter"] = gd.header.get("filter", "UNKNOWN") 

166 stars["exptime"] = gd.guiderDurationSec 

167 return stars 

168 

169 

170def annulusBackgroundSubtraction(data: np.ndarray, annulus: tuple[float, float]) -> tuple[np.ndarray, float]: 

171 """ 

172 Subtract background from the data using an annulus. 

173 

174 Parameters 

175 ---------- 

176 data : `np.ndarray` 

177 Image cutout data. 

178 annulus : `tuple[float, float]` 

179 Inner and outer radii (pixels) defining the background annulus. 

180 

181 Returns 

182 ------- 

183 dataBkgSub : `np.ndarray` 

184 Background-subtracted data. 

185 bkgStd : `float` 

186 Standard deviation of the background estimation. 

187 """ 

188 rin, rout = annulus 

189 x0, y0 = data.shape[1] // 2, data.shape[0] // 2 

190 x, y = np.indices(data.shape) 

191 annMask = ((x - x0) ** 2 + (y - y0) ** 2 >= rin**2) & ((x - x0) ** 2 + (y - y0) ** 2 <= rout**2) 

192 annMask &= np.isfinite(data) 

193 _, bkgSub, bkgStd = sigma_clipped_stats(data[annMask], sigma=3.0) 

194 dataBkgSub = data - bkgSub 

195 return dataBkgSub, bkgStd 

196 

197 

198@dataclass 

199class StarMeasurement: 

200 xroi: float = field(default=np.nan) 

201 yroi: float = field(default=np.nan) 

202 xerr: float = field(default=0.0) 

203 yerr: float = field(default=0.0) 

204 e1: float = field(default=np.nan) 

205 e2: float = field(default=np.nan) 

206 ixx: float = field(default=np.nan) 

207 iyy: float = field(default=np.nan) 

208 ixy: float = field(default=np.nan) 

209 fwhm: float = field(default=np.nan) 

210 flux: float = field(default=np.nan) 

211 flux_err: float = field(default=0.0) 

212 snr: float = field(default=0.0) 

213 

214 def toDataFrame(self) -> pd.DataFrame: 

215 """ 

216 Convert this measurement to a single-row DataFrame. 

217 

218 Returns 

219 ------- 

220 row : `pd.DataFrame` 

221 Single-row DataFrame with measurement fields, or empty if invalid. 

222 """ 

223 d = asdict(self) 

224 # Only drop the column if xroi is NaN (i.e., measurement failed) 

225 if not np.isfinite(d.get("xroi", np.nan)): 

226 # Return an empty DataFrame with all the keys as columns, 

227 return pd.DataFrame(columns=list(d.keys())) 

228 # Otherwise, return all columns, even if some are NaN 

229 return pd.DataFrame([d]) 

230 

231 def runAperturePhotometry( 

232 self, cutout: np.ndarray, radius: float, bkgStd: float = 1.0, gain: float = 1.0 

233 ) -> None: 

234 """ 

235 Perform aperture photometry on a cutout image. 

236 

237 Updates the flux, flux_err, and snr attributes of the StarMeasurement. 

238 

239 Parameters 

240 ---------- 

241 cutout : `np.ndarray` 

242 2D cutout image (background-subtracted). 

243 radius : `float` 

244 Aperture radius in pixels. 

245 bkgStd : `float` 

246 Background RMS per pixel. 

247 gain : `float` 

248 Detector gain (e-/ADU). 

249 """ 

250 x0, y0 = self.xroi, self.yroi 

251 if np.isfinite(x0) and np.isfinite(y0): 

252 ny, nx = cutout.shape 

253 y, x = np.indices((ny, nx)) 

254 x0, y0 = self.xroi, self.yroi 

255 

256 # Background mask 

257 aperMask = (x - x0) ** 2 + (y - y0) ** 2 <= radius**2 

258 

259 # Aperture sum 

260 fluxNet = np.nansum(cutout[aperMask]) 

261 fluxNet = np.clip(fluxNet, 0, None) # Ensure non-negative flux 

262 npix = aperMask.sum() 

263 

264 # Flux error 

265 fluxErr = np.sqrt(fluxNet / gain + npix * bkgStd**2) 

266 snr = fluxNet / (fluxErr + 1e-9) if fluxErr > 0 else 0.0 

267 

268 # Update the measurement 

269 self.flux = fluxNet 

270 self.flux_err = fluxErr 

271 self.snr = snr 

272 

273 

274def runSourceDetection( 

275 image: np.ndarray, 

276 threshold: float = 10, 

277 cutOutSize: int = 25, 

278 apertureRadius: int = 5, 

279 gain: float = 1.0, 

280) -> pd.DataFrame: 

281 """ 

282 Detect sources in an image and measure their properties. 

283 

284 Parameters 

285 ---------- 

286 image : `np.ndarray` 

287 2D image array. 

288 threshold : `float` 

289 Detection threshold in sigma units. 

290 cutOutSize : `int` 

291 Size of the cutout around each detected source (pixels). 

292 apertureRadius : `int` 

293 Aperture radius in pixels for photometry. 

294 gain : `float` 

295 Detector gain (e-/ADU). 

296 

297 Returns 

298 ------- 

299 sources : `pd.DataFrame` 

300 DataFrame with detected source properties. 

301 """ 

302 # Step 1: Convert numpy image to MaskedImage and Exposure 

303 exposure = ExposureF(MaskedImageF(ImageF(image))) 

304 

305 # Step 2: Detect sources 

306 footprints = detectObjectsInExp(exposure, nSigma=threshold) 

307 

308 if not footprints: 

309 return pd.DataFrame(columns=DEFAULT_COLUMNS) 

310 

311 results = [] 

312 for fp in footprints.getFootprints(): 

313 # only single peaked stars 

314 if len(fp.getPeaks()) > 1: 

315 continue 

316 

317 # Create a cutout of the image around the footprint 

318 refCenter = tuple(fp.getCentroid()) 

319 star = measureStarOnStamp(image, refCenter, cutOutSize, apertureRadius, gain).toDataFrame() 

320 if not star.empty: 

321 results.append(star) 

322 if not results: 

323 return pd.DataFrame(columns=DEFAULT_COLUMNS) 

324 df = pd.concat([sf for sf in results], ignore_index=True) 

325 return df 

326 

327 

328def measureStarOnStamp( 

329 stamp: np.ndarray, 

330 refCenter: tuple[float, float], 

331 cutOutSize: int, 

332 apertureRadius: int, 

333 gain: float = 1.0, 

334) -> StarMeasurement: 

335 """ 

336 Measure a star on a single stamp: background subtraction, shape, centroid, 

337 photometry. 

338 

339 Parameters 

340 ---------- 

341 stamp : `np.ndarray` 

342 Full stamp array. 

343 refCenter : `tuple[float, float]` 

344 Reference (x, y) pixel position for the cutout center. 

345 cutOutSize : `int` 

346 Size of the cutout in pixels. 

347 apertureRadius : `int` 

348 Aperture radius in pixels for photometry. 

349 gain : `float` 

350 Detector gain (e-/ADU). 

351 

352 Returns 

353 ------- 

354 measurement : `StarMeasurement` 

355 StarMeasurement object with populated fields (may be empty on failure). 

356 """ 

357 cutout = getCutouts(stamp, refCenter, cutoutSize=cutOutSize) 

358 data = cutout.data 

359 

360 if np.all(data == 0) | (not np.isfinite(data).all()): 

361 return StarMeasurement() 

362 

363 # 1) Subtract the background 

364 annulus = (apertureRadius * 1.0, apertureRadius * 2) 

365 dataBkgSub, bkgStd = annulusBackgroundSubtraction(data, annulus) 

366 

367 # 2) Track the star across all stamps for this guider 

368 star = runGalSim(dataBkgSub, gain=gain, bkgStd=bkgStd) 

369 

370 # 3) Make aperture photometry measurements 

371 # Galsim flux is the normalization of the Gaussian, not w/ fixed aper. 

372 star.runAperturePhotometry(dataBkgSub, apertureRadius, gain=gain, bkgStd=bkgStd) 

373 

374 # 4) Add centroid and shape in amplifier roi coordinates 

375 star.xroi += cutout.xmin_original 

376 star.yroi += cutout.ymin_original 

377 return star 

378 

379 

380def runGalSim( 

381 imageArray: np.ndarray, 

382 gain: float = 1.0, 

383 bkgStd: float = 0.0, 

384) -> StarMeasurement: 

385 """ 

386 Measure star properties with GalSim adaptive moments. 

387 

388 Parameters 

389 ---------- 

390 imageArray : `np.ndarray` 

391 Background-subtracted image cutout. 

392 gain : `float` 

393 Detector gain (e-/ADU). 

394 bkgStd : `float` 

395 Background RMS per pixel. 

396 

397 Returns 

398 ------- 

399 result : `StarMeasurement` 

400 Resulting measurement (empty if measurement failed). 

401 """ 

402 gsImg = galsim.Image(imageArray) 

403 hsmRes = galsim.hsm.FindAdaptiveMom(gsImg, strict=False) 

404 success = hsmRes.error_message == "" 

405 

406 if not success: 

407 result = StarMeasurement() 

408 else: 

409 xCentroid = hsmRes.moments_centroid.x 

410 yCentroid = hsmRes.moments_centroid.y 

411 flux = hsmRes.moments_amp 

412 sigma = hsmRes.moments_sigma 

413 e1 = hsmRes.observed_shape.e1 

414 e2 = hsmRes.observed_shape.e2 

415 fwhm = 2.355 * sigma 

416 

417 # Calculate errors using GalSim's error estimation 

418 xErr, yErr = calcGalsimError(imageArray, hsmRes, gain=gain, bkgStd=bkgStd, correctForGain=True) 

419 

420 # Calculate SNR and flux error 

421 ellipticity = np.sqrt(e1**2 + e2**2) 

422 nEff = 2 * np.pi * sigma**2 * np.sqrt(1 - ellipticity**2) 

423 shotNoise = np.sqrt(nEff * bkgStd**2) 

424 fluxErr = np.sqrt(flux / gain + shotNoise**2) 

425 snr = flux / (shotNoise + 1e-9) if shotNoise > 0 else 0.0 

426 

427 # Calculate second moments 

428 ixx = sigma**2 * (1 + e1) 

429 iyy = sigma**2 * (1 - e1) 

430 ixy = sigma**2 * e2 

431 

432 result = StarMeasurement( 

433 xroi=xCentroid, 

434 yroi=yCentroid, 

435 xerr=xErr, 

436 yerr=yErr, 

437 e1=e1, 

438 e2=e2, 

439 ixx=ixx, 

440 iyy=iyy, 

441 ixy=ixy, 

442 fwhm=fwhm, 

443 flux=flux, 

444 flux_err=fluxErr, 

445 snr=snr, 

446 ) 

447 return result 

448 

449 

450def calcGalsimError( 

451 imageArray: np.ndarray, 

452 shape: galsim.hsm.ShapeData, 

453 gain: float = 1.0, 

454 bkgStd: float = 0.0, 

455 correctForGain: bool = False, 

456) -> tuple[float, float]: 

457 """ 

458 Estimate centroid errors from GalSim HSMShapeData. 

459 

460 Parameters 

461 ---------- 

462 imageArray : `np.ndarray` 

463 Image cutout used for measurement. 

464 shape : `galsim.hsm` 

465 GalSim HSM shape data result object. 

466 gain : `float` 

467 Detector gain (e-/ADU), ignored if `correctForGain` is False. 

468 bkgStd : `float` 

469 Background RMS per pixel. 

470 correctForGain : `bool` 

471 Whether to include gain-dependent weighting. 

472 

473 Returns 

474 ------- 

475 xerr : `float` 

476 Estimated x centroid uncertainty (pixels). 

477 yerr : `float` 

478 Estimated y centroid uncertainty (pixels). 

479 """ 

480 if not shape or shape.error_message != "": 

481 return 0.0, 0.0 

482 

483 x0 = shape.moments_centroid.x 

484 y0 = shape.moments_centroid.y 

485 sigma = shape.moments_sigma 

486 e1 = shape.observed_shape.e1 

487 e2 = shape.observed_shape.e2 

488 flux = shape.moments_amp 

489 

490 kernel = makeEllipticalGaussianStar( 

491 shape=(imageArray.shape[0], imageArray.shape[1]), 

492 e1=e1, 

493 e2=e2, 

494 flux=1, 

495 sigma=sigma, 

496 center=(x0, y0), 

497 ) 

498 

499 weight = np.ones_like(imageArray) / (bkgStd**2 + 1e-9) 

500 if correctForGain: 

501 weight = np.ones_like(imageArray) / (bkgStd**2 + np.abs(flux * kernel / gain)) 

502 

503 mask = weight == 0.0 

504 data = imageArray.copy() 

505 if np.any(mask): 

506 kernelMasked = kernel.copy() 

507 data[mask] = kernelMasked[mask] * np.sum(data[~mask]) / np.sum(kernelMasked[~mask]) 

508 

509 u, v = np.meshgrid(np.arange(imageArray.shape[1]) - x0, np.arange(imageArray.shape[0]) - y0) 

510 usq = u**2 

511 vsq = v**2 

512 WI = kernel * data 

513 M00 = np.nansum(WI) 

514 WV = (kernel**2).astype(float) 

515 WV[~mask] /= weight[~mask] 

516 WV[mask] /= np.median(weight[~mask]) 

517 WV = WV / float(M00**2) 

518 

519 varM10 = 4 * np.sum(WV * usq) 

520 varM01 = 4 * np.sum(WV * vsq) 

521 xerr = np.sqrt(varM10) 

522 yerr = np.sqrt(varM01) 

523 return xerr, yerr 

524 

525 

526def makeEllipticalGaussianStar( 

527 shape: tuple[int, int], 

528 flux: float, 

529 sigma: float, 

530 e1: float, 

531 e2: float, 

532 center: tuple[float, float], 

533) -> np.ndarray: 

534 """ 

535 Create an elliptical 2D Gaussian star with specified parameters. 

536 

537 Parameters 

538 ---------- 

539 shape : `tuple[int, int]` 

540 (ny, nx) output array shape. 

541 flux : `float` 

542 Total flux (normalization). 

543 sigma : `float` 

544 Gaussian sigma (pixels). 

545 e1 : `float` 

546 Ellipticity component e1. 

547 e2 : `float` 

548 Ellipticity component e2. 

549 center : `tuple[float, float]` 

550 (x0, y0) centroid position in pixels. 

551 

552 Returns 

553 ------- 

554 image : `np.ndarray` 

555 Generated model image. 

556 """ 

557 y, x = np.indices(shape) 

558 x0, y0 = center 

559 u = x - x0 

560 v = y - y0 

561 

562 # Second-moment matrix elements 

563 ixx = sigma**2 * (1 + e1) 

564 iyy = sigma**2 * (1 - e1) 

565 ixy = sigma**2 * e2 

566 

567 # Inverse covariance matrix 

568 det = ixx * iyy - ixy**2 

569 invIxx = iyy / det 

570 invIyy = ixx / det 

571 invIxy = -ixy / det 

572 

573 # Quadratic form: u^2 * invIxx + v^2 * invIyy + 2uv * invIxy 

574 r2 = invIxx * u**2 + invIyy * v**2 + 2 * invIxy * u * v 

575 

576 e = np.sqrt(e1**2 + e2**2) 

577 norm = flux / (2 * np.pi * sigma**2 * np.sqrt(1 - e**2)) 

578 image = norm * np.exp(-0.5 * r2) 

579 return image 

580 

581 

582def buildReferenceCatalog( 

583 guiderData: GuiderData, 

584 log: logging.Logger, 

585 config: GuiderStarTrackerConfig = GuiderStarTrackerConfig(), 

586) -> pd.DataFrame: 

587 """ 

588 Build a reference star catalog from each guider's coadded stamp. 

589 

590 Parameters 

591 ---------- 

592 guiderData : `GuiderData` 

593 Guider dataset containing stamps and metadata. 

594 log : `logging.Logger` 

595 Logger for warnings and diagnostics. 

596 config : `GuiderStarTrackerConfig` 

597 Star tracker configuration. 

598 

599 Returns 

600 ------- 

601 refCatalog : `pd.DataFrame` 

602 Concatenated reference catalog of brightest stars per guider. 

603 """ 

604 expId = guiderData.expid 

605 minSnr = config.minSnr 

606 gain = config.gain 

607 cutOutSize = config.cutOutSize 

608 

609 tableList = [] 

610 for guiderName in guiderData.guiderNames: 

611 pixelScale = guiderData.getWcs(guiderName).getPixelScale().asArcseconds() 

612 apertureRadius = int(config.aperSizeArcsec / pixelScale) 

613 

614 array = guiderData.getStampArrayCoadd(guiderName) 

615 # array = np.where(array < 0, 0, array) # Ensure no negative values 

616 array = array - np.nanmin(array) # Ensure no negative values 

617 sources = runSourceDetection( 

618 array, 

619 threshold=minSnr, 

620 apertureRadius=apertureRadius, 

621 cutOutSize=cutOutSize, 

622 gain=gain, 

623 ) 

624 if sources.empty: 

625 log.warning(f"No sources detected in `buildReferenceCatalog`" f"for {guiderName} in {expId}. ") 

626 continue 

627 

628 sources.sort_values(by=["snr"], ascending=False, inplace=True) 

629 sources.reset_index(drop=True, inplace=True) 

630 

631 detNum = guiderData.getGuiderDetNum(guiderName) 

632 sources["detector"] = guiderName 

633 sources["detid"] = detNum 

634 sources["starid"] = detNum * 100 

635 tableList.append(sources) 

636 

637 if len(tableList) == 0: 

638 log.warning(f"buildReferenceCatalog failed - no stars detected in any guider for {expId}.") 

639 return makeBlankCatalog() 

640 

641 refCatalog = pd.concat(tableList, ignore_index=True) 

642 return refCatalog 

643 

644 

645def getCutouts(imageArray: np.ndarray, refCenter: tuple[float, float], cutoutSize: int = 25) -> Cutout2D: 

646 """ 

647 Get a cutout at the reference position from an image array. 

648 

649 Parameters 

650 ---------- 

651 imageArray : `np.ndarray` 

652 Full image array. 

653 refCenter : `tuple[float, float]` 

654 (x, y) center for the cutout in pixels. 

655 cutoutSize : `int` 

656 Size (pixels) of the square cutout. 

657 

658 Returns 

659 ------- 

660 cutout : `Cutout2D` 

661 Astropy Cutout2D object. 

662 """ 

663 refX, refY = refCenter 

664 return Cutout2D(imageArray, (refX, refY), size=cutoutSize, mode="partial", fill_value=np.nan)