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

239 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-12 10:42 +0000

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 "runNotebook", 

38] 

39 

40import logging 

41import numpy as np 

42import sys 

43import lsst.afw.math as afwMath 

44import lsst.afw.image as afwImage 

45from lsst.ctrl.mpexec import SimplePipelineExecutor 

46import lsst.afw.geom as afwGeom 

47import lsst.geom as geom 

48import lsst.daf.butler as dafButler 

49from astro_metadata_translator import ObservationInfo 

50import lsst.pex.config as pexConfig 

51from lsst.pipe.base import Pipeline 

52from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER 

53 

54import astropy 

55import astropy.units as u 

56from astropy.coordinates import SkyCoord, Distance 

57 

58 

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

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

61 

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

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

64 an image from ADU to electrons. 

65 

66 Parameters 

67 ---------- 

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

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

70 

71 gainDict : `dict` of `float` 

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

73 

74 invertGains : `bool` 

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

76 

77 Returns 

78 ------- 

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

80 The gain flat 

81 """ 

82 flat = exposure.clone() 

83 detector = flat.getDetector() 

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

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

86 

87 for amp in detector: 

88 bbox = amp.getBBox() 

89 if invertGains: 

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

91 else: 

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

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

94 flat.maskedImage.variance[:] = 0.0 

95 

96 return flat 

97 

98 

99def argMaxNd(array): 

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

101 

102 If there are multiple occurences of the maximum value 

103 just return the first. 

104 """ 

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

106 

107 

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

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

110 

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

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

113 rather than int-truncated ones. 

114 

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

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

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

118 

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

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

121 remaining nSamples-1 sections. 

122 

123 Visually, for a range: 

124 

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

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

127 

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

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

130 """ 

131 

132 if not includeEndpoints: 

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

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

135 else: 

136 if nSamples <= 1: 

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

138 if nSamples == 2: 

139 points = [start, stop] 

140 else: 

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

142 points = [start] 

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

144 

145 if integers: 

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

147 return points 

148 

149 

150def isExposureTrimmed(exp): 

151 det = exp.getDetector() 

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

153 return True 

154 return False 

155 

156 

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

158 """XXX doctring here 

159 

160 Trim identically in all direction for convenience""" 

161 if isExposureTrimmed(rawExp): 

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

163 

164 det = rawExp.getDetector() 

165 

166 amp = det[ampNum] 

167 if nOscanBorderPix == 0: 

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

169 else: 

170 b = nOscanBorderPix # line length limits :/ 

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

172 return noise 

173 

174 

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

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

177 

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

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

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

181 

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

183 

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

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

186 

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

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

189 the value supplied for correctionType. 

190 

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

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

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

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

195 solution to the resulting quadratic 

196 

197 Parameters 

198 ---------- 

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

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

201 

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

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

204 

205 correctionType : `str` or `None` 

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

207 

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

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

210 

211 overscanBorderSize : `int` 

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

213 

214 Returns 

215 ------- 

216 gainDict : `dict` 

217 Dictionary of the amplifier gains, keyed by ampName 

218 """ 

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

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

221 

222 if correctionType is not None and rawExpForNoiseCalc is None: 

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

224 

225 gains = {} 

226 det = flat1.getDetector() 

227 for ampNum, amp in enumerate(det): 

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

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

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

231 basicGain = 1. / const 

232 

233 if correctionType is None: 

234 gains[amp.getName()] = basicGain 

235 continue 

236 

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

238 sigma = getAmpReadNoiseFromRawExp(rawExpForNoiseCalc, ampNum, overscanBorderSize) 

239 

240 if correctionType == 'simple': 

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

242 gains[amp.getName()] = simpleGain 

243 

244 elif correctionType == 'full': 

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

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

247 

248 positiveSolution = (root + mu)/denom 

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

250 

251 gains[amp.getName()] = positiveSolution 

252 

253 return gains 

254 

255 

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

257 """Rotate an exposure by nDegrees clockwise. 

258 

259 Parameters 

260 ---------- 

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

262 The exposure to rotate 

263 nDegrees : `float` 

264 Number of degrees clockwise to rotate by 

265 kernelName : `str` 

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

267 logger : `logging.Logger` 

268 Logger for logging warnings 

269 

270 Returns 

271 ------- 

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

273 A copy of the input exposure, rotated by nDegrees 

274 """ 

275 nDegrees = nDegrees % 360 

276 

277 if not logger: 

278 logger = logging.getLogger(__name__) 

279 

280 wcs = exp.getWcs() 

281 if not wcs: 

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

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

284 

285 warper = afwMath.Warper(kernelName) 

286 if isinstance(exp, afwImage.ExposureU): 

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

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

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

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

291 

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

293 transformP2toP2 = afwGeom.makeTransform(affineRotTransform) 

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

295 

296 rotatedExp = warper.warpExposure(rotatedWcs, exp) 

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

298 return rotatedExp 

299 

300 

301def airMassFromRawMetadata(md): 

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

303 

304 Parameters 

305 ---------- 

306 md : `Mapping` 

307 The raw header. 

308 

309 Returns 

310 ------- 

311 airmass : `float` 

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

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

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

315 be used more easily in place. 

316 """ 

317 try: 

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

319 except Exception: 

320 return 0.0 

321 return obsInfo.boresight_airmass 

322 

323 

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

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

326 

327 Parameters 

328 ---------- 

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

330 Exposure with fitted WCS. 

331 

332 target : `str` 

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

334 

335 doMotionCorrection : `bool`, optional 

336 Correct for proper motion and parallax if possible. 

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

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

339 centroid is returned. 

340 

341 Returns 

342 ------- 

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

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

345 is not found. 

346 """ 

347 if logger is None: 

348 logger = logging.getLogger(__name__) 

349 

350 resultFrom = None 

351 targetLocation = None 

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

353 # many objects are found but have no Hipparcos entries 

354 try: 

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

356 resultFrom = 'vizier' 

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

358 

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

360 except ValueError: 

361 try: 

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

363 targetLocation = simbadLocationForTarget(target) 

364 resultFrom = 'simbad' 

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

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

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

368 return None 

369 

370 if not targetLocation: 

371 return None 

372 

373 if doMotionCorrection and resultFrom == 'simbad': 

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

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

376 

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

378 return pixCoord 

379 

380 

381def simbadLocationForTarget(target): 

382 """Get the target location from Simbad. 

383 

384 Parameters 

385 ---------- 

386 target : `str` 

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

388 

389 Returns 

390 ------- 

391 targetLocation : `lsst.geom.SpherePoint` 

392 Nominal location of the target object, uncorrected for 

393 proper motion and parallax. 

394 

395 Raises 

396 ------ 

397 ValueError 

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

399 """ 

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

401 # condition with directory creation 

402 from astroquery.simbad import Simbad 

403 

404 obj = Simbad.query_object(target) 

405 if not obj: 

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

407 if len(obj) != 1: 

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

409 

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

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

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

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

414 ra = geom.Angle(raRad) 

415 dec = geom.Angle(decRad) 

416 targetLocation = geom.SpherePoint(ra, dec) 

417 return targetLocation 

418 

419 

420def vizierLocationForTarget(exp, target, doMotionCorrection): 

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

422 

423 Parameters 

424 ---------- 

425 target : `str` 

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

427 

428 Returns 

429 ------- 

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

431 Location of the target object, optionally corrected for 

432 proper motion and parallax. 

433 

434 Raises 

435 ------ 

436 ValueError 

437 If object not found in Hipparcos2 via Vizier. 

438 This is quite common, even for bright objects. 

439 """ 

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

441 # condition with directory creation 

442 from astroquery.vizier import Vizier 

443 

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

445 try: 

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

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

448 raise ValueError 

449 

450 epoch = "J1991.25" 

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

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

453 obstime=epoch, 

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

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

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

457 

458 if doMotionCorrection: 

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

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

461 newCoord = coord.apply_space_motion(new_obstime=obsTime) 

462 else: 

463 newCoord = coord 

464 

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

466 ra = geom.Angle(raRad) 

467 dec = geom.Angle(decRad) 

468 targetLocation = geom.SpherePoint(ra, dec) 

469 return targetLocation 

470 

471 

472def isDispersedExp(exp): 

473 """Check if an exposure is dispersed. 

474 

475 Parameters 

476 ---------- 

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

478 The exposure. 

479 

480 Returns 

481 ------- 

482 isDispersed : `bool` 

483 Whether it is a dispersed image or not. 

484 """ 

485 filterFullName = exp.filter.physicalLabel 

486 if FILTER_DELIMITER not in filterFullName: 

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

488 filt, grating = filterFullName.split(FILTER_DELIMITER) 

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

490 return False 

491 return True 

492 

493 

494def isDispersedDataId(dataId, butler): 

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

496 

497 Parameters 

498 ---------- 

499 dataId : `dict` 

500 The dataId. 

501 butler : `lsst.daf.butler.Butler` 

502 The butler. 

503 

504 Returns 

505 ------- 

506 isDispersed : `bool` 

507 Whether it is a dispersed image or not. 

508 """ 

509 if isinstance(butler, dafButler.Butler): 

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

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

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

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

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

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

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

517 bind={'day_obs': day_obs, 

518 'seq_num': seq_num}) 

519 expRecords = set(expRecords) 

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

521 filterFullName = expRecords.pop().physical_filter 

522 else: 

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

524 if FILTER_DELIMITER not in filterFullName: 

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

526 filt, grating = filterFullName.split(FILTER_DELIMITER) 

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

528 return False 

529 return True 

530 

531 

532def getLinearStagePosition(exp): 

533 """Get the linear stage position. 

534 

535 Parameters 

536 ---------- 

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

538 The exposure. 

539 

540 Returns 

541 ------- 

542 position : `float` 

543 The position of the linear stage, in mm. 

544 """ 

545 md = exp.getMetadata() 

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

547 if 'LINSPOS' in md: 

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

549 if position is not None: 

550 linearStagePosition += position 

551 return linearStagePosition 

552 

553 

554def getFilterAndDisperserFromExp(exp): 

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

556 

557 Parameters 

558 ---------- 

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

560 The exposure. 

561 

562 Returns 

563 ------- 

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

565 The filter and the disperser names, as strings. 

566 """ 

567 filterFullName = exp.getFilter().physicalLabel 

568 if FILTER_DELIMITER not in filterFullName: 

569 filt = filterFullName 

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

571 else: 

572 filt, grating = filterFullName.split(FILTER_DELIMITER) 

573 return filt, grating 

574 

575 

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

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

578 specified output collection. 

579 

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

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

582 reductions. 

583 

584 Parameters 

585 ---------- 

586 dataId : `dict` 

587 The dataId to run. 

588 outputCollection : `str`, optional 

589 Output collection name. 

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

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

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

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

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

595 Dictionary of individual config options. The key of the 

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

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

598 key/value overrides to apply. 

599 embargo : `bool`, optional 

600 Use the embargo repo? 

601 

602 Returns 

603 ------- 

604 spectraction : `lsst.atmospec.spectraction.Spectraction` 

605 The extracted spectraction object. 

606 

607 Notes 

608 ----- 

609 Any ConfigurableInstances in supplied task config overrides will be 

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

611 """ 

612 def makeQuery(dataId): 

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

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

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

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

617 "instrument='LATISS'") 

618 

619 return queryString 

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

621 

622 # TODO: use LATISS_DEFAULT_COLLECTIONS here? 

623 butler = SimplePipelineExecutor.prep_butler(repo, 

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

625 'refcats', 

626 'LATISS/calib', 

627 ], 

628 output=outputCollection) 

629 

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

631 

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

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

634 # connections require special treatment 

635 if isinstance(value, configClass.ConnectionsConfigClass): 

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

637 pipeline.addConfigOverride(taskName, 

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

639 connectionValue) 

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

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

642 pipeline.addConfigOverride(taskName, option, value) 

643 

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

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

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

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

648 pipeline.addConfigOverride(taskName, option, value) 

649 

650 query = makeQuery(dataId) 

651 executor = SimplePipelineExecutor.from_pipeline(pipeline, 

652 where=query, 

653 root=repo, 

654 butler=butler) 

655 

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

657 quanta = executor.run() 

658 

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

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

661 # the pipeline we get the item by name 

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

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

664 result = butler.get(dataRef) 

665 return result