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

250 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-30 04:13 -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 "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 SeparablePipelineExecutor 

46import lsst.afw.geom as afwGeom 

47import lsst.geom as geom 

48import lsst.daf.butler as dafButler 

49from lsst.daf.butler.registry import RegistryDefaults 

50 

51from astro_metadata_translator import ObservationInfo 

52import lsst.pex.config as pexConfig 

53from lsst.pipe.base import Pipeline 

54from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER 

55from lsst.utils.iteration import ensure_iterable 

56 

57import astropy 

58import astropy.units as u 

59from astropy.coordinates import SkyCoord, Distance 

60 

61 

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

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

64 

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

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

67 an image from ADU to electrons. 

68 

69 Parameters 

70 ---------- 

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

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

73 

74 gainDict : `dict` of `float` 

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

76 

77 invertGains : `bool` 

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

79 

80 Returns 

81 ------- 

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

83 The gain flat 

84 """ 

85 flat = exposure.clone() 

86 detector = flat.getDetector() 

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

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

89 

90 for amp in detector: 

91 bbox = amp.getBBox() 

92 if invertGains: 

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

94 else: 

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

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

97 flat.maskedImage.variance[:] = 0.0 

98 

99 return flat 

100 

101 

102def argMaxNd(array): 

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

104 

105 If there are multiple occurences of the maximum value 

106 just return the first. 

107 """ 

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

109 

110 

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

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

113 

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

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

116 rather than int-truncated ones. 

117 

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

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

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

121 

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

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

124 remaining nSamples-1 sections. 

125 

126 Visually, for a range: 

127 

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

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

130 

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

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

133 """ 

134 

135 if not includeEndpoints: 

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

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

138 else: 

139 if nSamples <= 1: 

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

141 if nSamples == 2: 

142 points = [start, stop] 

143 else: 

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

145 points = [start] 

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

147 

148 if integers: 

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

150 return points 

151 

152 

153def isExposureTrimmed(exp): 

154 det = exp.getDetector() 

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

156 return True 

157 return False 

158 

159 

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

161 """XXX doctring here 

162 

163 Trim identically in all direction for convenience""" 

164 if isExposureTrimmed(rawExp): 

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

166 

167 det = rawExp.getDetector() 

168 

169 amp = det[ampNum] 

170 if nOscanBorderPix == 0: 

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

172 else: 

173 b = nOscanBorderPix # line length limits :/ 

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

175 return noise 

176 

177 

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

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

180 

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

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

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

184 

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

186 

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

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

189 

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

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

192 the value supplied for correctionType. 

193 

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

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

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

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

198 solution to the resulting quadratic 

199 

200 Parameters 

201 ---------- 

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

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

204 

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

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

207 

208 correctionType : `str` or `None` 

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

210 

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

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

213 

214 overscanBorderSize : `int` 

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

216 

217 Returns 

218 ------- 

219 gainDict : `dict` 

220 Dictionary of the amplifier gains, keyed by ampName 

221 """ 

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

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

224 

225 if correctionType is not None and rawExpForNoiseCalc is None: 

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

227 

228 gains = {} 

229 det = flat1.getDetector() 

230 for ampNum, amp in enumerate(det): 

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

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

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

234 basicGain = 1. / const 

235 

236 if correctionType is None: 

237 gains[amp.getName()] = basicGain 

238 continue 

239 

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

241 sigma = getAmpReadNoiseFromRawExp(rawExpForNoiseCalc, ampNum, overscanBorderSize) 

242 

243 if correctionType == 'simple': 

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

245 gains[amp.getName()] = simpleGain 

246 

247 elif correctionType == 'full': 

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

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

250 

251 positiveSolution = (root + mu)/denom 

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

253 

254 gains[amp.getName()] = positiveSolution 

255 

256 return gains 

257 

258 

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

260 """Rotate an exposure by nDegrees clockwise. 

261 

262 Parameters 

263 ---------- 

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

265 The exposure to rotate 

266 nDegrees : `float` 

267 Number of degrees clockwise to rotate by 

268 kernelName : `str` 

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

270 logger : `logging.Logger` 

271 Logger for logging warnings 

272 

273 Returns 

274 ------- 

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

276 A copy of the input exposure, rotated by nDegrees 

277 """ 

278 nDegrees = nDegrees % 360 

279 

280 if not logger: 

281 logger = logging.getLogger(__name__) 

282 

283 wcs = exp.getWcs() 

284 if not wcs: 

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

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

287 

288 warper = afwMath.Warper(kernelName) 

289 if isinstance(exp, afwImage.ExposureU): 

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

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

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

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

294 

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

296 transformP2toP2 = afwGeom.makeTransform(affineRotTransform) 

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

298 

299 rotatedExp = warper.warpExposure(rotatedWcs, exp) 

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

301 return rotatedExp 

302 

303 

304def airMassFromRawMetadata(md): 

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

306 

307 Parameters 

308 ---------- 

309 md : `Mapping` 

310 The raw header. 

311 

312 Returns 

313 ------- 

314 airmass : `float` 

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

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

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

318 be used more easily in place. 

319 """ 

320 try: 

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

322 except Exception: 

323 return 0.0 

324 return obsInfo.boresight_airmass 

325 

326 

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

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

329 

330 Parameters 

331 ---------- 

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

333 Exposure with fitted WCS. 

334 

335 target : `str` 

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

337 

338 doMotionCorrection : `bool`, optional 

339 Correct for proper motion and parallax if possible. 

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

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

342 centroid is returned. 

343 

344 Returns 

345 ------- 

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

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

348 is not found. 

349 """ 

350 if logger is None: 

351 logger = logging.getLogger(__name__) 

352 

353 resultFrom = None 

354 targetLocation = None 

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

356 # many objects are found but have no Hipparcos entries 

357 try: 

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

359 resultFrom = 'vizier' 

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

361 

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

363 except ValueError: 

364 try: 

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

366 targetLocation = simbadLocationForTarget(target) 

367 resultFrom = 'simbad' 

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

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

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

371 return None 

372 

373 if not targetLocation: 

374 return None 

375 

376 if doMotionCorrection and resultFrom == 'simbad': 

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

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

379 

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

381 return pixCoord 

382 

383 

384def simbadLocationForTarget(target): 

385 """Get the target location from Simbad. 

386 

387 Parameters 

388 ---------- 

389 target : `str` 

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

391 

392 Returns 

393 ------- 

394 targetLocation : `lsst.geom.SpherePoint` 

395 Nominal location of the target object, uncorrected for 

396 proper motion and parallax. 

397 

398 Raises 

399 ------ 

400 ValueError 

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

402 """ 

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

404 # condition with directory creation 

405 from astroquery.simbad import Simbad 

406 

407 obj = Simbad.query_object(target) 

408 if not obj: 

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

410 if len(obj) != 1: 

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

412 

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

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

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

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

417 ra = geom.Angle(raRad) 

418 dec = geom.Angle(decRad) 

419 targetLocation = geom.SpherePoint(ra, dec) 

420 return targetLocation 

421 

422 

423def vizierLocationForTarget(exp, target, doMotionCorrection): 

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

425 

426 Parameters 

427 ---------- 

428 target : `str` 

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

430 

431 Returns 

432 ------- 

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

434 Location of the target object, optionally corrected for 

435 proper motion and parallax. 

436 

437 Raises 

438 ------ 

439 ValueError 

440 If object not found in Hipparcos2 via Vizier. 

441 This is quite common, even for bright objects. 

442 """ 

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

444 # condition with directory creation 

445 from astroquery.vizier import Vizier 

446 

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

448 try: 

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

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

451 raise ValueError 

452 

453 epoch = "J1991.25" 

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

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

456 obstime=epoch, 

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

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

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

460 

461 if doMotionCorrection: 

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

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

464 newCoord = coord.apply_space_motion(new_obstime=obsTime) 

465 else: 

466 newCoord = coord 

467 

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

469 ra = geom.Angle(raRad) 

470 dec = geom.Angle(decRad) 

471 targetLocation = geom.SpherePoint(ra, dec) 

472 return targetLocation 

473 

474 

475def isDispersedExp(exp): 

476 """Check if an exposure is dispersed. 

477 

478 Parameters 

479 ---------- 

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

481 The exposure. 

482 

483 Returns 

484 ------- 

485 isDispersed : `bool` 

486 Whether it is a dispersed image or not. 

487 """ 

488 filterFullName = exp.filter.physicalLabel 

489 if FILTER_DELIMITER not in filterFullName: 

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

491 filt, grating = filterFullName.split(FILTER_DELIMITER) 

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

493 return False 

494 return True 

495 

496 

497def isDispersedDataId(dataId, butler): 

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

499 

500 Parameters 

501 ---------- 

502 dataId : `dict` 

503 The dataId. 

504 butler : `lsst.daf.butler.Butler` 

505 The butler. 

506 

507 Returns 

508 ------- 

509 isDispersed : `bool` 

510 Whether it is a dispersed image or not. 

511 """ 

512 if isinstance(butler, dafButler.Butler): 

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

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

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

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

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

518 where = "exposure.day_obs=dayObs AND exposure.seq_num=seq_num" 

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

520 bind={'dayObs': day_obs, 

521 'seq_num': seq_num}) 

522 expRecords = set(expRecords) 

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

524 filterFullName = expRecords.pop().physical_filter 

525 else: 

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

527 if FILTER_DELIMITER not in filterFullName: 

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

529 filt, grating = filterFullName.split(FILTER_DELIMITER) 

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

531 return False 

532 return True 

533 

534 

535def getLinearStagePosition(exp): 

536 """Get the linear stage position. 

537 

538 Parameters 

539 ---------- 

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

541 The exposure. 

542 

543 Returns 

544 ------- 

545 position : `float` 

546 The position of the linear stage, in mm. 

547 """ 

548 md = exp.getMetadata() 

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

550 if 'LINSPOS' in md: 

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

552 if position is not None: 

553 linearStagePosition += position 

554 return linearStagePosition 

555 

556 

557def getFilterAndDisperserFromExp(exp): 

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

559 

560 Parameters 

561 ---------- 

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

563 The exposure. 

564 

565 Returns 

566 ------- 

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

568 The filter and the disperser names, as strings. 

569 """ 

570 filterFullName = exp.getFilter().physicalLabel 

571 if FILTER_DELIMITER not in filterFullName: 

572 filt = filterFullName 

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

574 else: 

575 filt, grating = filterFullName.split(FILTER_DELIMITER) 

576 return filt, grating 

577 

578 

579def runNotebook(dataId, 

580 outputCollection, 

581 *, 

582 extraInputCollections=None, 

583 taskConfigs={}, 

584 configOptions={}, 

585 embargo=False): 

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

587 specified output collection. 

588 

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

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

591 reductions. 

592 

593 Parameters 

594 ---------- 

595 dataId : `dict` 

596 The dataId to run. 

597 outputCollection : `str`, optional 

598 Output collection name. 

599 extraInputCollections : `list` of `str` 

600 Any extra input collections to use when processing. 

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

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

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

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

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

606 Dictionary of individual config options. The key of the 

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

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

609 key/value overrides to apply. 

610 embargo : `bool`, optional 

611 Use the embargo repo? 

612 

613 Returns 

614 ------- 

615 spectraction : `lsst.atmospec.spectraction.Spectraction` 

616 The extracted spectraction object. 

617 

618 Notes 

619 ----- 

620 Any ConfigurableInstances in supplied task config overrides will be 

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

622 """ 

623 def makeQuery(dataId): 

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

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

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

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

628 "instrument='LATISS'") 

629 

630 return queryString 

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

632 

633 # TODO: use LATISS_DEFAULT_COLLECTIONS here? 

634 inputs = ['LATISS/raw/all', 'refcats', 'LATISS/calib'] 

635 if extraInputCollections is not None: 

636 extraInputCollections = ensure_iterable(extraInputCollections) 

637 inputs.extend(extraInputCollections) 

638 

639 butler = dafButler.Butler(repo, writeable=True, collections=inputs) 

640 

641 butler.registry.registerCollection(outputCollection, dafButler.CollectionType.CHAINED) 

642 run = outputCollection + '/run' 

643 butler.registry.defaults = RegistryDefaults(collections=outputCollection, run=run) 

644 butler.registry.setCollectionChain(outputCollection, [run] + inputs) 

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

646 

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

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

649 # connections require special treatment 

650 if isinstance(value, configClass.ConnectionsConfigClass): 

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

652 pipeline.addConfigOverride(taskName, 

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

654 connectionValue) 

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

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

657 pipeline.addConfigOverride(taskName, option, value) 

658 

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

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

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

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

663 pipeline.addConfigOverride(taskName, option, value) 

664 

665 query = makeQuery(dataId) 

666 executor = SeparablePipelineExecutor(butler, clobber_output=True) 

667 

668 quantumGraph = executor.make_quantum_graph(pipeline, where=query) 

669 executor.pre_execute_qgraph(quantumGraph, save_versions=False) 

670 

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

672 executor.run_pipeline(quantumGraph, fail_fast=True) 

673 

674 butler.registry.refresh() 

675 result = butler.get('spectractorSpectrum', dataId) 

676 return result