Coverage for python/lsst/ap/association/transformDiaSourceCatalog.py: 87%

183 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-09 18:03 +0000

1# This file is part of ap_association 

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__ = ("TransformDiaSourceCatalogConnections", 

23 "TransformDiaSourceCatalogConfig", 

24 "TransformDiaSourceCatalogTask", 

25 "UnpackApdbFlags") 

26 

27import dataclasses 

28import os 

29import yaml 

30 

31import numpy as np 

32 

33from lsst.resources import ResourcePath 

34from lsst.daf.base import DateTime 

35import lsst.pex.config as pexConfig 

36import lsst.pipe.base as pipeBase 

37import lsst.pipe.base.connectionTypes as connTypes 

38from lsst.pipe.tasks.postprocess import TransformCatalogBaseTask, TransformCatalogBaseConfig 

39from lsst.pipe.tasks.functors import Column 

40from lsst.utils.timer import timeMethod 

41 

42from lsst.pipe.tasks.schemaUtils import convertDataFrameToSdmSchema, readSdmSchemaFile 

43 

44_UNKNOWN_INSTRUMENT = "UNKNOWN" 

45"""Name of the template instrument when it is not known, as recorded by 

46`lsst.images.DifferenceImageTemplateInfo`. 

47""" 

48 

49 

50class TransformDiaSourceCatalogConnections(pipeBase.PipelineTaskConnections, 

51 dimensions=("instrument", "visit", "detector"), 

52 defaultTemplates={"coaddName": "deep", "fakesType": ""}): 

53 diaSourceSchema = connTypes.InitInput( 

54 doc="Schema for DIASource catalog output by ImageDifference.", 

55 storageClass="SourceCatalog", 

56 name="{fakesType}{coaddName}Diff_diaSrc_schema", 

57 ) 

58 diaSourceCat = connTypes.Input( 

59 doc="Catalog of DiaSources produced during image differencing.", 

60 name="{fakesType}{coaddName}Diff_candidateDiaSrc", 

61 storageClass="SourceCatalog", 

62 dimensions=("instrument", "visit", "detector"), 

63 ) 

64 diffIm = connTypes.Input( 

65 doc="Difference image on which the DiaSources were detected.", 

66 name="{fakesType}{coaddName}Diff_differenceExp", 

67 storageClass="ExposureF", 

68 dimensions=("instrument", "visit", "detector"), 

69 ) 

70 diaSourceTable = connTypes.Output( 

71 doc=".", 

72 name="{fakesType}{coaddName}Diff_diaSrcTable", 

73 storageClass="ArrowAstropy", 

74 dimensions=("instrument", "visit", "detector"), 

75 ) 

76 

77 def __init__(self, *, config=None): 

78 super().__init__(config=config) 

79 if config.input_image_type == "future": 

80 self.diffIm = dataclasses.replace(self.diffIm, storageClass="DifferenceImage") 

81 

82 

83class TransformDiaSourceCatalogConfig(TransformCatalogBaseConfig, 

84 pipelineConnections=TransformDiaSourceCatalogConnections): 

85 input_image_type = pexConfig.ChoiceField[str]( 

86 doc="Type of the input difference image. run() takes an" 

87 " lsst.afw.image.Exposure either way.", 

88 allowed={ 

89 "legacy": "Read an `lsst.afw.image.Exposure`.", 

90 "future": "Read an `lsst.images.DifferenceImage`.", 

91 }, 

92 optional=False, 

93 default="legacy", 

94 ) 

95 flagMap = pexConfig.Field( 

96 dtype=str, 

97 doc="Yaml file specifying SciencePipelines flag fields to bit packs.", 

98 default=os.path.join("${AP_ASSOCIATION_DIR}", 

99 "data", 

100 "association-flag-map.yaml"), 

101 ) 

102 flagRenameMap = pexConfig.Field( 

103 dtype=str, 

104 doc="Yaml file specifying specifying rules to rename flag names", 

105 default=os.path.join("${AP_ASSOCIATION_DIR}", 

106 "data", 

107 "flag-rename-rules.yaml"), 

108 ) 

109 doRemoveSkySources = pexConfig.Field( 

110 dtype=bool, 

111 default=False, 

112 doc="Input DiaSource catalog contains SkySources that should be " 

113 "removed before storing the output DiaSource catalog.", 

114 ) 

115 # TODO: remove on DM-41532 

116 doPackFlags = pexConfig.Field( 

117 dtype=bool, 

118 default=False, 

119 doc="Do pack the flags into one integer column named 'flags'." 

120 "If False, instead produce one boolean column per flag.", 

121 deprecated="This field is no longer used. Will be removed after v28." 

122 ) 

123 doUseApdbSchema = pexConfig.Field( 

124 dtype=bool, 

125 default=False, 

126 doc="Use the APDB schema to coerce the data types of the output columns.", 

127 deprecated="This field has been renamed to doUseSchema, and will be " 

128 "removed after v30." 

129 ) 

130 doUseSchema = pexConfig.Field( 

131 dtype=bool, 

132 default=False, 

133 doc="Use an existing schema to coerce the data types of the output columns." 

134 ) 

135 schemaDir = pexConfig.Field( 

136 dtype=str, 

137 doc="Path to the directory containing schema definitions.", 

138 default=os.path.join("${SDM_SCHEMAS_DIR}", 

139 "yml"), 

140 ) 

141 schemaFile = pexConfig.Field( 

142 dtype=str, 

143 doc="Yaml file specifying the schema of the output catalog.", 

144 default="apdb.yaml", 

145 ) 

146 schemaName = pexConfig.Field( 

147 dtype=str, 

148 doc="Name of the table in the schema file to read.", 

149 default="ApdbSchema", 

150 deprecated="This config is no longer used, and will be removed after v30" 

151 ) 

152 

153 def setDefaults(self): 

154 super().setDefaults() 

155 self.functorFile = os.path.join("${AP_ASSOCIATION_DIR}", 

156 "data", 

157 "DiaSource.yaml") 

158 

159 

160class TransformDiaSourceCatalogTask(TransformCatalogBaseTask): 

161 """Transform a DiaSource catalog by calibrating and renaming columns to 

162 produce a table ready to insert into the Apdb. 

163 

164 Parameters 

165 ---------- 

166 initInputs : `dict` 

167 Must contain ``diaSourceSchema`` as the schema for the input catalog. 

168 """ 

169 ConfigClass = TransformDiaSourceCatalogConfig 

170 _DefaultName = "transformDiaSourceCatalog" 

171 # Needed to create a valid TransformCatalogBaseTask, but unused 

172 inputDataset = "deepDiff_diaSrc" 

173 outputDataset = "deepDiff_diaSrcTable" 

174 

175 def __init__(self, initInputs, **kwargs): 

176 super().__init__(**kwargs) 

177 self.funcs = self.getFunctors() 

178 self.inputSchema = initInputs['diaSourceSchema'].schema 

179 self._create_bit_pack_mappings() 

180 if self.config.doUseSchema: 

181 schemaFile = os.path.join(self.config.schemaDir, self.config.schemaFile) 

182 self.schema = readSdmSchemaFile(schemaFile) 

183 else: 

184 self.schema = None 

185 

186 if not self.config.doPackFlags: 186 ↛ exitline 186 didn't return from function '__init__' because the condition on line 186 was always true

187 # get the flag rename rules 

188 with open(os.path.expandvars(self.config.flagRenameMap)) as yaml_stream: 

189 self.rename_rules = list(yaml.safe_load_all(yaml_stream)) 

190 

191 def _create_bit_pack_mappings(self): 

192 """Setup all flag bit packings. 

193 """ 

194 self.bit_pack_columns = [] 

195 flag_map_file = os.path.expandvars(self.config.flagMap) 

196 with open(flag_map_file) as yaml_stream: 

197 table_list = list(yaml.safe_load_all(yaml_stream)) 

198 for table in table_list: 198 ↛ 206line 198 didn't jump to line 206

199 if table['tableName'] == 'DiaSource': 199 ↛ 198line 199 didn't jump to line 198 because the condition on line 199 was always true

200 self.bit_pack_columns = table['columns'] 

201 break 

202 

203 # Test that all flags requested are present in the input schemas. 

204 # Output schemas are flexible, however if names are not specified in 

205 # the Apdb schema, flag columns will not be persisted. 

206 for outputFlag in self.bit_pack_columns: 

207 bitList = outputFlag['bitList'] 

208 for bit in bitList: 

209 try: 

210 self.inputSchema.find(bit['name']) 

211 except KeyError: 

212 raise KeyError( 

213 "Requested column %s not found in input DiaSource " 

214 "schema. Please check that the requested input " 

215 "column exists." % bit['name']) 

216 

217 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

218 inputs = butlerQC.get(inputRefs) 

219 inputs["band"] = butlerQC.quantum.dataId["band"] 

220 inputs["instrument"] = butlerQC.quantum.dataId["instrument"] 

221 if self.config.input_image_type == "future": 221 ↛ 228line 221 didn't jump to line 228 because the condition on line 221 was always true

222 # Templates are required to all use the same instrument. 

223 templates = getattr(inputs["diffIm"], "templates", None) 

224 if templates: 

225 inputs["templateInstrument"] = templates[0].instrument 

226 inputs["diffIm"] = inputs["diffIm"].to_legacy() 

227 

228 outputs = self.run(**inputs) 

229 

230 butlerQC.put(outputs, outputRefs) 

231 

232 @timeMethod 

233 def run(self, 

234 diaSourceCat, 

235 diffIm, 

236 band, 

237 reliability=None, 

238 instrument=None, 

239 templateInstrument=None): 

240 """Convert input catalog to ParquetTable/Pandas and run functors. 

241 

242 Additionally, add new columns for stripping information from the 

243 exposure and into the DiaSource catalog. 

244 

245 Parameters 

246 ---------- 

247 diaSourceCat : `lsst.afw.table.SourceCatalog` 

248 Catalog of sources measured on the difference image. 

249 diffIm : `lsst.afw.image.Exposure` 

250 Result of subtracting template and science images. 

251 band : `str` 

252 Filter band of the science image. 

253 reliability : `lsst.afw.table.SourceCatalog` 

254 Reliability (e.g. real/bogus) scores, row-matched to 

255 ``diaSourceCat``. 

256 instrument : `str`, optional 

257 Name of the instrument that took the science image. 

258 templateInstrument : `str`, optional 

259 Name of the instrument recorded for the template, or `None` if it 

260 is not known. 

261 

262 Returns 

263 ------- 

264 results : `lsst.pipe.base.Struct` 

265 Results struct with components. 

266 

267 - ``diaSourceTable`` : Catalog of DiaSources with calibrated values 

268 and renamed columns. 

269 (`lsst.pipe.tasks.ParquetTable` or `pandas.DataFrame`) 

270 """ 

271 self.log.info( 

272 "Transforming/standardizing the DiaSource table for visit,detector: %i, %i", 

273 diffIm.visitInfo.id, diffIm.detector.getId()) 

274 

275 diaSourceDf = diaSourceCat.asAstropy().to_pandas() 

276 if self.config.doRemoveSkySources: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 diaSourceDf = diaSourceDf[~diaSourceDf["sky_source"]] 

278 diaSourceCat = diaSourceCat[~diaSourceCat["sky_source"]] 

279 

280 # Need UTC time but without a timezone because pandas requires a 

281 # naive datetime. 

282 diaSourceDf["timeProcessedMjdTai"] = DateTime.now().get(system=DateTime.MJD, scale=DateTime.TAI) 

283 diaSourceDf["snr"] = getSignificance(diaSourceCat) 

284 diaSourceDf["bboxSize"] = self.computeBBoxSizes(diaSourceCat) 

285 diaSourceDf["visit"] = diffIm.visitInfo.id 

286 # int16 instead of uint8 because databases don't like unsigned bytes. 

287 diaSourceDf["detector"] = np.int16(diffIm.detector.getId()) 

288 diaSourceDf["band"] = band 

289 diaSourceDf["midpointMjdTai"] = diffIm.visitInfo.date.get(system=DateTime.MJD) 

290 diaSourceDf["exposureTime"] = diffIm.visitInfo.exposureTime 

291 diaSourceDf["diaObjectId"] = 0 

292 diaSourceDf["ssObjectId"] = 0 

293 templateInstrument = templateInstrument or _UNKNOWN_INSTRUMENT 

294 mixedInstruments = (instrument is not None 

295 and templateInstrument not in (_UNKNOWN_INSTRUMENT, instrument)) 

296 diaSourceDf["templateInstrument"] = templateInstrument 

297 diaSourceDf["mixedInstruments"] = mixedInstruments 

298 # Only templates from another instrument have a nonzero version for now. 

299 diaSourceDf["templateVersion"] = 1 if mixedInstruments else 0 

300 

301 # TODO: this has been formally deprecated and should be removed too 

302 if self.config.doPackFlags: 302 ↛ 304line 302 didn't jump to line 304 because the condition on line 302 was never true

303 # either bitpack the flags 

304 self.bitPackFlags(diaSourceDf) 

305 else: 

306 # or add the individual flag functors 

307 self.addUnpackedFlagFunctors() 

308 # and remove the packed flag functor 

309 if 'flags' in self.funcs.funcDict: 

310 del self.funcs.funcDict['flags'] 

311 

312 df = self.transform(band, 

313 diaSourceDf, 

314 self.funcs, 

315 dataId=None).df 

316 if self.config.doUseSchema: 

317 df = convertDataFrameToSdmSchema(self.schema, df, tableName="DiaSource") 

318 

319 return pipeBase.Struct( 

320 diaSourceTable=df, 

321 ) 

322 

323 def addUnpackedFlagFunctors(self): 

324 """Add Column functor for each of the flags to the internal functor 

325 dictionary. 

326 """ 

327 for flag in self.bit_pack_columns[0]['bitList']: 

328 flagName = flag['name'] 

329 targetName = self.funcs.renameCol(flagName, self.rename_rules[0]['flag_rename_rules']) 

330 self.funcs.update({targetName: Column(flagName)}) 

331 

332 def computeBBoxSizes(self, inputCatalog): 

333 """Compute the size of a square bbox that fully contains the detection 

334 footprint. 

335 

336 Parameters 

337 ---------- 

338 inputCatalog : `lsst.afw.table.SourceCatalog` 

339 Catalog containing detected footprints. 

340 

341 Returns 

342 ------- 

343 outputBBoxSizes : `np.ndarray`, (N,) 

344 Array of bbox sizes. 

345 """ 

346 # Schema validation requires that this field is int. 

347 outputBBoxSizes = np.empty(len(inputCatalog), dtype=int) 

348 for i, record in enumerate(inputCatalog): 

349 footprintBBox = record.getFootprint().getBBox() 

350 # Compute twice the size of the largest dimension of the footprint 

351 # bounding box. This is the largest footprint we should need to cover 

352 # the complete DiaSource assuming the centroid is within the bounding 

353 # box. 

354 maxSize = 2 * np.max([footprintBBox.getWidth(), 

355 footprintBBox.getHeight()]) 

356 recX = record.getCentroid().x 

357 recY = record.getCentroid().y 

358 bboxSize = int( 

359 np.ceil(2 * np.max(np.fabs([footprintBBox.maxX - recX, 

360 footprintBBox.minX - recX, 

361 footprintBBox.maxY - recY, 

362 footprintBBox.minY - recY])))) 

363 if bboxSize > maxSize: 363 ↛ 364line 363 didn't jump to line 364 because the condition on line 363 was never true

364 bboxSize = maxSize 

365 outputBBoxSizes[i] = bboxSize 

366 

367 return outputBBoxSizes 

368 

369 def bitPackFlags(self, df): 

370 """Pack requested flag columns in inputRecord into single columns in 

371 outputRecord. 

372 

373 Parameters 

374 ---------- 

375 df : `pandas.DataFrame` 

376 DataFrame to read bits from and pack them into. 

377 """ 

378 for outputFlag in self.bit_pack_columns: 

379 bitList = outputFlag['bitList'] 

380 value = np.zeros(len(df), dtype=np.uint64) 

381 for bit in bitList: 

382 # Hard type the bit arrays. 

383 value += (df[bit['name']]*2**bit['bit']).to_numpy().astype(np.uint64) 

384 df[outputFlag['columnName']] = value 

385 

386 

387class UnpackApdbFlags: 

388 """Class for unpacking bits from integer flag fields stored in the Apdb. 

389 

390 Attributes 

391 ---------- 

392 flag_map_file : `lsst.resources.ResourcePathExpression` 

393 Absolute or relative URI to a yaml file specifiying mappings of flags 

394 to integer bits. 

395 table_name : `str` 

396 Name of the Apdb table the integer bit data are coming from. 

397 """ 

398 

399 def __init__(self, flag_map_file, table_name): 

400 self.bit_pack_columns = [] 

401 flag_map_file = os.path.expandvars(flag_map_file) 

402 with ResourcePath(flag_map_file, forceDirectory=False).open("r") as yaml_stream: 

403 table_list = list(yaml.safe_load_all(yaml_stream)) 

404 for table in table_list: 404 ↛ 409line 404 didn't jump to line 409

405 if table['tableName'] == table_name: 405 ↛ 404line 405 didn't jump to line 404 because the condition on line 405 was always true

406 self.bit_pack_columns = table['columns'] 

407 break 

408 

409 self.output_flag_columns = {} 

410 

411 for column in self.bit_pack_columns: 

412 names = {} 

413 for bit in column["bitList"]: 

414 names[bit["name"]] = bit["bit"] 

415 self.output_flag_columns[column["columnName"]] = names 

416 

417 def unpack(self, input_flag_values, flag_name): 

418 """Determine individual boolean flags from an input array of unsigned 

419 ints. 

420 

421 Parameters 

422 ---------- 

423 input_flag_values : array-like of type uint 

424 Array of integer packed bit flags to unpack. 

425 flag_name : `str` 

426 Apdb column name from the loaded file, e.g. "flags". 

427 

428 Returns 

429 ------- 

430 output_flags : `numpy.ndarray` 

431 Numpy structured array of booleans, one column per flag in the 

432 loaded file. 

433 """ 

434 output_flags = np.zeros(len(input_flag_values), 

435 dtype=[(name, bool) for name in self.output_flag_columns[flag_name]]) 

436 

437 for name in self.output_flag_columns[flag_name]: 

438 masked_bits = np.bitwise_and(input_flag_values, 

439 2**self.output_flag_columns[flag_name][name]) 

440 output_flags[name] = masked_bits 

441 

442 return output_flags 

443 

444 def flagExists(self, flagName, columnName='flags'): 

445 """Check if named flag is in the bitpacked flag set. 

446 

447 Parameters: 

448 ---------- 

449 flagName : `str` 

450 Flag name to search for. 

451 columnName : `str`, optional 

452 Name of bitpacked flag column to search in. 

453 

454 Returns 

455 ------- 

456 flagExists : `bool` 

457 `True` if `flagName` is present in `columnName`. 

458 

459 Raises 

460 ------ 

461 ValueError 

462 Raised if `columnName` is not defined. 

463 """ 

464 if columnName not in self.output_flag_columns: 

465 raise ValueError(f'column {columnName} not in flag map: {self.output_flag_columns}') 

466 

467 return flagName in [c for c in self.output_flag_columns[columnName]] 

468 

469 def makeFlagBitMask(self, flagNames, columnName='flags'): 

470 """Return a bitmask corresponding to the supplied flag names. 

471 

472 Parameters: 

473 ---------- 

474 flagNames : `list` [`str`] 

475 Flag names to include in the bitmask. 

476 columnName : `str`, optional 

477 Name of bitpacked flag column. 

478 

479 Returns 

480 ------- 

481 bitmask : `np.unit64` 

482 Bitmask corresponding to the supplied flag names given the loaded configuration. 

483 

484 Raises 

485 ------ 

486 ValueError 

487 Raised if a flag in `flagName` is not included in `columnName`. 

488 """ 

489 bitmask = np.uint64(0) 

490 

491 for flag in flagNames: 

492 if not self.flagExists(flag, columnName=columnName): 

493 raise ValueError(f"flag '{flag}' not included in '{columnName}' flag column") 

494 

495 for outputFlag in self.bit_pack_columns: 

496 if outputFlag['columnName'] == columnName: 496 ↛ 495line 496 didn't jump to line 495 because the condition on line 496 was always true

497 bitList = outputFlag['bitList'] 

498 for bit in bitList: 

499 if bit['name'] in flagNames: 

500 bitmask += np.uint64(2**bit['bit']) 

501 

502 return bitmask 

503 

504 

505def getSignificance(catalog): 

506 """Return the significance value of the first peak in each source 

507 footprint, or NaN for peaks without a significance field. 

508 

509 Parameters 

510 ---------- 

511 catalog : `lsst.afw.table.SourceCatalog` 

512 Catalog to process. 

513 

514 Returns 

515 ------- 

516 significance : `np.ndarray`, (N,) 

517 Signficance of the first peak in each source footprint. 

518 """ 

519 result = np.full(len(catalog), np.nan) 

520 for i, record in enumerate(catalog): 

521 peaks = record.getFootprint().peaks 

522 if "significance" in peaks.schema: 

523 result[i] = peaks[0]["significance"] 

524 return result