Coverage for python/lsst/atmospec/utils.py: 10%

239 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-03-24 04:20 -0700

1# This file is part of atmospec. 

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 

22__all__ = [ 

23 "argMaxNd", 

24 "gainFromFlatPair", 

25 "getAmpReadNoiseFromRawExp", 

26 "getLinearStagePosition", 

27 "getFilterAndDisperserFromExp", 

28 "getSamplePoints", 

29 "getTargetCentroidFromWcs", 

30 "isDispersedDataId", 

31 "isDispersedExp", 

32 "isExposureTrimmed", 

33 "makeGainFlat", 

34 "rotateExposure", 

35 "simbadLocationForTarget", 

36 "vizierLocationForTarget", 

37] 

38 

39import logging 

40import numpy as np 

41import sys 

42import lsst.afw.math as afwMath 

43import lsst.afw.image as afwImage 

44from lsst.ctrl.mpexec import SimplePipelineExecutor 

45import lsst.afw.geom as afwGeom 

46import lsst.geom as geom 

47import lsst.daf.butler as dafButler 

48from astro_metadata_translator import ObservationInfo 

49import lsst.pex.config as pexConfig 

50from lsst.pipe.base import Pipeline 

51from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER 

52 

53import astropy 

54import astropy.units as u 

55from astropy.coordinates import SkyCoord, Distance 

56 

57 

58def makeGainFlat(exposure, gainDict, invertGains=False): 

59 """Given an exposure, make a flat from the gains. 

60 

61 Construct an exposure where the image array data contains the gain of the 

62 pixel, such that dividing by (or mutiplying by) the image will convert 

63 an image from ADU to electrons. 

64 

65 Parameters 

66 ---------- 

67 detectorExposure : `lsst.afw.image.exposure` 

68 The template exposure for which the flat is to be made. 

69 

70 gainDict : `dict` of `float` 

71 A dict of the amplifiers' gains, keyed by the amp names. 

72 

73 invertGains : `bool` 

74 Gains are specified in inverted units and should be applied as such. 

75 

76 Returns 

77 ------- 

78 gainFlat : `lsst.afw.image.exposure` 

79 The gain flat 

80 """ 

81 flat = exposure.clone() 

82 detector = flat.getDetector() 

83 ampNames = set(list(a.getName() for a in detector)) 

84 assert set(gainDict.keys()) == ampNames 

85 

86 for amp in detector: 

87 bbox = amp.getBBox() 

88 if invertGains: 

89 flat[bbox].maskedImage.image.array[:, :] = 1./gainDict[amp.getName()] 

90 else: 

91 flat[bbox].maskedImage.image.array[:, :] = gainDict[amp.getName()] 

92 flat.maskedImage.mask[:] = 0x0 

93 flat.maskedImage.variance[:] = 0.0 

94 

95 return flat 

96 

97 

98def argMaxNd(array): 

99 """Get the index of the max value of an array. 

100 

101 If there are multiple occurences of the maximum value 

102 just return the first. 

103 """ 

104 return np.unravel_index(np.argmax(array, axis=None), array.shape) 

105 

106 

107def getSamplePoints(start, stop, nSamples, includeEndpoints=False, integers=False): 

108 """Get the locations of the coordinates to use to sample a range evenly 

109 

110 Divide a range up and return the coordinated to use in order to evenly 

111 sample the range. If asking for integers, rounded values are returned, 

112 rather than int-truncated ones. 

113 

114 If not including the endpoints, divide the (stop-start) range into nSamples 

115 and return the midpoint of each section, thus leaving a sectionLength/2 gap 

116 between the first/last samples and the range start/end. 

117 

118 If including the endpoints, the first and last points will be 

119 start, stop respectively, and other points will be the endpoints of the 

120 remaining nSamples-1 sections. 

121 

122 Visually, for a range: 

123 

124 |--*--|--*--|--*--|--*--| return * if not including end points, n=4 

125 |-*-|-*-|-*-|-*-|-*-|-*-| return * if not including end points, n=6 

126 

127 *-----*-----*-----*-----* return * if we ARE including end points, n=4 

128 *---*---*---*---*---*---* return * if we ARE including end points, n=6 

129 """ 

130 

131 if not includeEndpoints: 

132 r = (stop-start)/(2*nSamples) 

133 points = [((2*pointNum+1)*r) for pointNum in range(nSamples)] 

134 else: 

135 if nSamples <= 1: 

136 raise RuntimeError('nSamples must be >= 2 if including endpoints') 

137 if nSamples == 2: 

138 points = [start, stop] 

139 else: 

140 r = (stop-start)/(nSamples-1) 

141 points = [start] 

142 points.extend([((pointNum)*r) for pointNum in range(1, nSamples)]) 

143 

144 if integers: 

145 return [int(x) for x in np.round(points)] 

146 return points 

147 

148 

149def isExposureTrimmed(exp): 

150 det = exp.getDetector() 

151 if exp.getDimensions() == det.getBBox().getDimensions(): 

152 return True 

153 return False 

154 

155 

156def getAmpReadNoiseFromRawExp(rawExp, ampNum, nOscanBorderPix=0): 

157 """XXX doctring here 

158 

159 Trim identically in all direction for convenience""" 

160 if isExposureTrimmed(rawExp): 

161 raise RuntimeError('Got an assembled exposure instead of a raw one') 

162 

163 det = rawExp.getDetector() 

164 

165 amp = det[ampNum] 

166 if nOscanBorderPix == 0: 

167 noise = np.std(rawExp[amp.getRawHorizontalOverscanBBox()].image.array) 

168 else: 

169 b = nOscanBorderPix # line length limits :/ 

170 noise = np.std(rawExp[amp.getRawHorizontalOverscanBBox()].image.array[b:-b, b:-b]) 

171 return noise 

172 

173 

174def gainFromFlatPair(flat1, flat2, correctionType=None, rawExpForNoiseCalc=None, overscanBorderSize=0): 

175 """Calculate the gain from a pair of flats. 

176 

177 The basic premise is 1/g = <(I1 - I2)^2/(I1 + I2)> 

178 Corrections for the variable QE and the read-noise are then made 

179 following the derivation in Robert's forthcoming book, which gets 

180 

181 1/g = <(I1 - I2)^2/(I1 + I2)> - 1/mu(sigma^2 - 1/2g^2) 

182 

183 If you are lazy, see below for the solution. 

184 https://www.wolframalpha.com/input/?i=solve+1%2Fy+%3D+c+-+(1%2Fm)*(s^2+-+1%2F(2y^2))+for+y 

185 

186 where mu is the average signal level, and sigma is the 

187 amplifier's readnoise. The way the correction is applied depends on 

188 the value supplied for correctionType. 

189 

190 correctionType is one of [None, 'simple' or 'full'] 

191 None : uses the 1/g = <(I1 - I2)^2/(I1 + I2)> formula 

192 'simple' : uses the gain from the None method for the 1/2g^2 term 

193 'full' : solves the full equation for g, discarding the non-physical 

194 solution to the resulting quadratic 

195 

196 Parameters 

197 ---------- 

198 flat1 : `lsst.afw.image.exposure` 

199 The first of the postISR assembled, overscan-subtracted flat pairs 

200 

201 flat2 : `lsst.afw.image.exposure` 

202 The second of the postISR assembled, overscan-subtracted flat pairs 

203 

204 correctionType : `str` or `None` 

205 The correction applied, one of [None, 'simple', 'full'] 

206 

207 rawExpForNoiseCalc : `lsst.afw.image.exposure` 

208 A raw (un-assembled) image from which to measure the noise 

209 

210 overscanBorderSize : `int` 

211 The number of pixels to crop from the overscan region in all directions 

212 

213 Returns 

214 ------- 

215 gainDict : `dict` 

216 Dictionary of the amplifier gains, keyed by ampName 

217 """ 

218 if correctionType not in [None, 'simple', 'full']: 

219 raise RuntimeError("Unknown correction type %s" % correctionType) 

220 

221 if correctionType is not None and rawExpForNoiseCalc is None: 

222 raise RuntimeError("Must supply rawFlat if performing correction") 

223 

224 gains = {} 

225 det = flat1.getDetector() 

226 for ampNum, amp in enumerate(det): 

227 i1 = flat1[amp.getBBox()].image.array 

228 i2 = flat2[amp.getBBox()].image.array 

229 const = np.mean((i1 - i2)**2 / (i1 + i2)) 

230 basicGain = 1. / const 

231 

232 if correctionType is None: 

233 gains[amp.getName()] = basicGain 

234 continue 

235 

236 mu = (np.mean(i1) + np.mean(i2)) / 2. 

237 sigma = getAmpReadNoiseFromRawExp(rawExpForNoiseCalc, ampNum, overscanBorderSize) 

238 

239 if correctionType == 'simple': 

240 simpleGain = 1/(const - (1/mu)*(sigma**2 - (1/2*basicGain**2))) 

241 gains[amp.getName()] = simpleGain 

242 

243 elif correctionType == 'full': 

244 root = np.sqrt(mu**2 - 2*mu*const + 2*sigma**2) 

245 denom = (2*const*mu - 2*sigma**2) 

246 

247 positiveSolution = (root + mu)/denom 

248 negativeSolution = (mu - root)/denom # noqa: F841 unused, but the other solution 

249 

250 gains[amp.getName()] = positiveSolution 

251 

252 return gains 

253 

254 

255def rotateExposure(exp, nDegrees, kernelName='lanczos4', logger=None): 

256 """Rotate an exposure by nDegrees clockwise. 

257 

258 Parameters 

259 ---------- 

260 exp : `lsst.afw.image.exposure.Exposure` 

261 The exposure to rotate 

262 nDegrees : `float` 

263 Number of degrees clockwise to rotate by 

264 kernelName : `str` 

265 Name of the warping kernel, used to instantiate the warper. 

266 logger : `logging.Logger` 

267 Logger for logging warnings 

268 

269 Returns 

270 ------- 

271 rotatedExp : `lsst.afw.image.exposure.Exposure` 

272 A copy of the input exposure, rotated by nDegrees 

273 """ 

274 nDegrees = nDegrees % 360 

275 

276 if not logger: 

277 logger = logging.getLogger(__name__) 

278 

279 wcs = exp.getWcs() 

280 if not wcs: 

281 logger.warning("Can't rotate exposure without a wcs - returning exp unrotated") 

282 return exp.clone() # return a clone so it's always returning a copy as this is what default does 

283 

284 warper = afwMath.Warper(kernelName) 

285 if isinstance(exp, afwImage.ExposureU): 

286 # TODO: remove once this bug is fixed - DM-20258 

287 logger.info('Converting ExposureU to ExposureF due to bug') 

288 logger.info('Remove this workaround after DM-20258') 

289 exp = afwImage.ExposureF(exp, deep=True) 

290 

291 affineRotTransform = geom.AffineTransform.makeRotation(nDegrees*geom.degrees) 

292 transformP2toP2 = afwGeom.makeTransform(affineRotTransform) 

293 rotatedWcs = afwGeom.makeModifiedWcs(transformP2toP2, wcs, False) 

294 

295 rotatedExp = warper.warpExposure(rotatedWcs, exp) 

296 # rotatedExp.setXY0(geom.Point2I(0, 0)) # TODO: check no longer required 

297 return rotatedExp 

298 

299 

300def airMassFromRawMetadata(md): 

301 """Calculate the visit's airmass from the raw header information. 

302 

303 Parameters 

304 ---------- 

305 md : `Mapping` 

306 The raw header. 

307 

308 Returns 

309 ------- 

310 airmass : `float` 

311 Returns the airmass, or 0.0 if the calculation fails. 

312 Zero was chosen as it is an obviously unphysical value, but means 

313 that calling code doesn't have to test if None, as numeric values can 

314 be used more easily in place. 

315 """ 

316 try: 

317 obsInfo = ObservationInfo(md, subset={"boresight_airmass"}) 

318 except Exception: 

319 return 0.0 

320 return obsInfo.boresight_airmass 

321 

322 

323def getTargetCentroidFromWcs(exp, target, doMotionCorrection=True, logger=None): 

324 """Get the target's centroid, given an exposure with fitted WCS. 

325 

326 Parameters 

327 ---------- 

328 exp : `lsst.afw.exposure.Exposure` 

329 Exposure with fitted WCS. 

330 

331 target : `str` 

332 The name of the target, e.g. 'HD 55852' 

333 

334 doMotionCorrection : `bool`, optional 

335 Correct for proper motion and parallax if possible. 

336 This requires the object is found in Vizier rather than Simbad. 

337 If that is not possible, a warning is logged, and the uncorrected 

338 centroid is returned. 

339 

340 Returns 

341 ------- 

342 pixCoord : `tuple` of `float`, or `None` 

343 The pixel (x, y) of the target's centroid, or None if the object 

344 is not found. 

345 """ 

346 if logger is None: 

347 logger = logging.getLogger(__name__) 

348 

349 resultFrom = None 

350 targetLocation = None 

351 # try vizier, but it is slow, unreliable, and 

352 # many objects are found but have no Hipparcos entries 

353 try: 

354 targetLocation = vizierLocationForTarget(exp, target, doMotionCorrection=doMotionCorrection) 

355 resultFrom = 'vizier' 

356 logger.info("Target location for %s retrieved from Vizier", target) 

357 

358 # fail over to simbad - it has ~every target, but no proper motions 

359 except ValueError: 

360 try: 

361 logger.warning("Target %s not found in Vizier, failing over to try Simbad", target) 

362 targetLocation = simbadLocationForTarget(target) 

363 resultFrom = 'simbad' 

364 logger.info("Target location for %s retrieved from Simbad", target) 

365 except ValueError as inst: # simbad found zero or several results for target 

366 logger.warning("%s", inst.args[0]) 

367 return None 

368 

369 if not targetLocation: 

370 return None 

371 

372 if doMotionCorrection and resultFrom == 'simbad': 

373 logger.warning("Failed to apply motion correction because %s was" 

374 " only found in Simbad, not Vizier/Hipparcos", target) 

375 

376 pixCoord = exp.getWcs().skyToPixel(targetLocation) 

377 return pixCoord 

378 

379 

380def simbadLocationForTarget(target): 

381 """Get the target location from Simbad. 

382 

383 Parameters 

384 ---------- 

385 target : `str` 

386 The name of the target, e.g. 'HD 55852' 

387 

388 Returns 

389 ------- 

390 targetLocation : `lsst.geom.SpherePoint` 

391 Nominal location of the target object, uncorrected for 

392 proper motion and parallax. 

393 

394 Raises 

395 ------ 

396 ValueError 

397 If object not found, or if multiple entries for the object are found. 

398 """ 

399 # do not import at the module level - tests crash due to a race 

400 # condition with directory creation 

401 from astroquery.simbad import Simbad 

402 

403 obj = Simbad.query_object(target) 

404 if not obj: 

405 raise ValueError(f"Failed to find {target} in simbad!") 

406 if len(obj) != 1: 

407 raise ValueError(f"Found {len(obj)} simbad entries for {target}!") 

408 

409 raStr = obj[0]['RA'] 

410 decStr = obj[0]['DEC'] 

411 skyLocation = SkyCoord(raStr, decStr, unit=(u.hourangle, u.degree), frame='icrs') 

412 raRad, decRad = skyLocation.ra.rad, skyLocation.dec.rad 

413 ra = geom.Angle(raRad) 

414 dec = geom.Angle(decRad) 

415 targetLocation = geom.SpherePoint(ra, dec) 

416 return targetLocation 

417 

418 

419def vizierLocationForTarget(exp, target, doMotionCorrection): 

420 """Get the target location from Vizier optionally correction motion. 

421 

422 Parameters 

423 ---------- 

424 target : `str` 

425 The name of the target, e.g. 'HD 55852' 

426 

427 Returns 

428 ------- 

429 targetLocation : `lsst.geom.SpherePoint` or `None` 

430 Location of the target object, optionally corrected for 

431 proper motion and parallax. 

432 

433 Raises 

434 ------ 

435 ValueError 

436 If object not found in Hipparcos2 via Vizier. 

437 This is quite common, even for bright objects. 

438 """ 

439 # do not import at the module level - tests crash due to a race 

440 # condition with directory creation 

441 from astroquery.vizier import Vizier 

442 

443 result = Vizier.query_object(target) # result is an empty table list for an unknown target 

444 try: 

445 star = result['I/311/hip2'] 

446 except TypeError: # if 'I/311/hip2' not in result (result doesn't allow easy checking without a try) 

447 raise ValueError 

448 

449 epoch = "J1991.25" 

450 coord = SkyCoord(ra=star[0]['RArad']*u.Unit(star['RArad'].unit), 

451 dec=star[0]['DErad']*u.Unit(star['DErad'].unit), 

452 obstime=epoch, 

453 pm_ra_cosdec=star[0]['pmRA']*u.Unit(star['pmRA'].unit), # NB contains cosdec already 

454 pm_dec=star[0]['pmDE']*u.Unit(star['pmDE'].unit), 

455 distance=Distance(parallax=star[0]['Plx']*u.Unit(star['Plx'].unit))) 

456 

457 if doMotionCorrection: 

458 expDate = exp.getInfo().getVisitInfo().getDate() 

459 obsTime = astropy.time.Time(expDate.get(expDate.EPOCH), format='jyear', scale='tai') 

460 newCoord = coord.apply_space_motion(new_obstime=obsTime) 

461 else: 

462 newCoord = coord 

463 

464 raRad, decRad = newCoord.ra.rad, newCoord.dec.rad 

465 ra = geom.Angle(raRad) 

466 dec = geom.Angle(decRad) 

467 targetLocation = geom.SpherePoint(ra, dec) 

468 return targetLocation 

469 

470 

471def isDispersedExp(exp): 

472 """Check if an exposure is dispersed. 

473 

474 Parameters 

475 ---------- 

476 exp : `lsst.afw.image.Exposure` 

477 The exposure. 

478 

479 Returns 

480 ------- 

481 isDispersed : `bool` 

482 Whether it is a dispersed image or not. 

483 """ 

484 filterFullName = exp.filter.physicalLabel 

485 if FILTER_DELIMITER not in filterFullName: 

486 raise RuntimeError(f"Error parsing filter name {filterFullName}") 

487 filt, grating = filterFullName.split(FILTER_DELIMITER) 

488 if grating.upper().startswith('EMPTY'): 

489 return False 

490 return True 

491 

492 

493def isDispersedDataId(dataId, butler): 

494 """Check if a dataId corresponds to a dispersed image. 

495 

496 Parameters 

497 ---------- 

498 dataId : `dict` 

499 The dataId. 

500 butler : `lsst.daf.butler.Butler` 

501 The butler. 

502 

503 Returns 

504 ------- 

505 isDispersed : `bool` 

506 Whether it is a dispersed image or not. 

507 """ 

508 if isinstance(butler, dafButler.Butler): 

509 # TODO: DM-38265 Need to make this work with DataCoords 

510 assert 'day_obs' in dataId or 'exposure.day_obs' in dataId, f'failed to find day_obs in {dataId}' 

511 assert 'seq_num' in dataId or 'exposure.seq_num' in dataId, f'failed to find seq_num in {dataId}' 

512 seq_num = dataId['seq_num'] if 'seq_num' in dataId else dataId['exposure.seq_num'] 

513 day_obs = dataId['day_obs'] if 'day_obs' in dataId else dataId['exposure.day_obs'] 

514 where = "exposure.day_obs=day_obs AND exposure.seq_num=seq_num" 

515 expRecords = butler.registry.queryDimensionRecords("exposure", where=where, 

516 bind={'day_obs': day_obs, 

517 'seq_num': seq_num}) 

518 expRecords = set(expRecords) 

519 assert len(expRecords) == 1, f'Found more than one exposure record for {dataId}' 

520 filterFullName = expRecords.pop().physical_filter 

521 else: 

522 raise RuntimeError(f'Expected a butler, got {type(butler)}') 

523 if FILTER_DELIMITER not in filterFullName: 

524 raise RuntimeError(f"Error parsing filter name {filterFullName}") 

525 filt, grating = filterFullName.split(FILTER_DELIMITER) 

526 if grating.upper().startswith('EMPTY'): 

527 return False 

528 return True 

529 

530 

531def getLinearStagePosition(exp): 

532 """Get the linear stage position. 

533 

534 Parameters 

535 ---------- 

536 exp : `lsst.afw.image.Exposure` 

537 The exposure. 

538 

539 Returns 

540 ------- 

541 position : `float` 

542 The position of the linear stage, in mm. 

543 """ 

544 md = exp.getMetadata() 

545 linearStagePosition = 115 # this seems to be the rough zero-point for some reason 

546 if 'LINSPOS' in md: 

547 position = md['LINSPOS'] # linear stage position in mm from CCD, larger->further from CCD 

548 if position is not None: 

549 linearStagePosition += position 

550 return linearStagePosition 

551 

552 

553def getFilterAndDisperserFromExp(exp): 

554 """Get the filter and disperser from an exposure. 

555 

556 Parameters 

557 ---------- 

558 exp : `lsst.afw.image.Exposure` 

559 The exposure. 

560 

561 Returns 

562 ------- 

563 filter, disperser : `tuple` of `str` 

564 The filter and the disperser names, as strings. 

565 """ 

566 filterFullName = exp.getFilter().physicalLabel 

567 if FILTER_DELIMITER not in filterFullName: 

568 filt = filterFullName 

569 grating = exp.getInfo().getMetadata()['GRATING'] 

570 else: 

571 filt, grating = filterFullName.split(FILTER_DELIMITER) 

572 return filt, grating 

573 

574 

575def runNotebook(dataId, outputCollection, taskConfigs={}, configOptions={}, embargo=False): 

576 """Run the ProcessStar pipeline for a single dataId, writing to the 

577 specified output collection. 

578 

579 This is a convenience function to allow running single dataIds in notebooks 

580 so that plots can be inspected easily. This is not designed for bulk data 

581 reductions. 

582 

583 Parameters 

584 ---------- 

585 dataId : `dict` 

586 The dataId to run. 

587 outputCollection : `str`, optional 

588 Output collection name. 

589 taskConfigs : `dict` [`lsst.pipe.base.PipelineTaskConfig`], optional 

590 Dictionary of config config classes. The key of the ``taskConfigs`` 

591 dict is the relevant task label. The value of ``taskConfigs`` 

592 is a task config object to apply. See notes for ignored items. 

593 configOptions : `dict` [`dict`], optional 

594 Dictionary of individual config options. The key of the 

595 ``configOptions`` dict is the relevant task label. The value 

596 of ``configOptions`` is another dict that contains config 

597 key/value overrides to apply. 

598 embargo : `bool`, optional 

599 Use the embargo repo? 

600 

601 Returns 

602 ------- 

603 spectraction : `lsst.atmospec.spectraction.Spectraction` 

604 The extracted spectraction object. 

605 

606 Notes 

607 ----- 

608 Any ConfigurableInstances in supplied task config overrides will be 

609 ignored. Currently (see DM-XXXXX) this causes a RecursionError. 

610 """ 

611 def makeQuery(dataId): 

612 dayObs = dataId['day_obs'] if 'day_obs' in dataId else dataId['exposure.day_obs'] 

613 seqNum = dataId['seq_num'] if 'seq_num' in dataId else dataId['exposure.seq_num'] 

614 queryString = (f"exposure.day_obs={dayObs} AND " 

615 f"exposure.seq_num={seqNum} AND " 

616 "instrument='LATISS'") 

617 

618 return queryString 

619 repo = "LATISS" if not embargo else "/repo/embargo" 

620 

621 # TODO: use LATISS_DEFAULT_COLLECTIONS here? 

622 butler = SimplePipelineExecutor.prep_butler(repo, 

623 inputs=['LATISS/raw/all', 

624 'refcats', 

625 'LATISS/calib', 

626 ], 

627 output=outputCollection) 

628 

629 pipeline = Pipeline.fromFile("${ATMOSPEC_DIR}/pipelines/processStar.yaml") 

630 

631 for taskName, configClass in taskConfigs.items(): 

632 for option, value in configClass.items(): 

633 # connections require special treatment 

634 if isinstance(value, configClass.ConnectionsConfigClass): 

635 for connectionOption, connectionValue in value.items(): 

636 pipeline.addConfigOverride(taskName, 

637 f'{option}.{connectionOption}', 

638 connectionValue) 

639 # ConfigurableInstance has to be done with .retarget() 

640 elif not isinstance(value, pexConfig.ConfigurableInstance): 

641 pipeline.addConfigOverride(taskName, option, value) 

642 

643 for taskName, configDict in configOptions.items(): 

644 for option, value in configDict.items(): 

645 # ConfigurableInstance has to be done with .retarget() 

646 if not isinstance(value, pexConfig.ConfigurableInstance): 

647 pipeline.addConfigOverride(taskName, option, value) 

648 

649 query = makeQuery(dataId) 

650 executor = SimplePipelineExecutor.from_pipeline(pipeline, 

651 where=query, 

652 root=repo, 

653 butler=butler) 

654 

655 logging.basicConfig(level=logging.INFO, stream=sys.stdout) 

656 quanta = executor.run() 

657 

658 # quanta is just a plain list, but the items know their names, so rather 

659 # than just taking the third item and relying on that being the position in 

660 # the pipeline we get the item by name 

661 processStarQuantum = [q for q in quanta if q.taskName == 'lsst.atmospec.processStar.ProcessStarTask'][0] 

662 dataRef = processStarQuantum.outputs['spectractorSpectrum'][0] 

663 result = butler.get(dataRef) 

664 return result