Coverage for python/lsst/ap/association/packageAlerts.py: 78%

305 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-08 01:50 -0800

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__ = ("PackageAlertsConfig", "PackageAlertsTask") 

23 

24import io 

25import os 

26import sys 

27import time 

28 

29from astropy import wcs 

30import astropy.units as u 

31from astropy.nddata import CCDData, VarianceUncertainty 

32import numpy as np 

33import pandas as pd 

34import struct 

35import fastavro 

36from confluent_kafka.cimpl import KafkaError 

37 

38# confluent_kafka is not in the standard Rubin environment as it is a third 

39# party package and is only needed when producing alerts. 

40try: 

41 import confluent_kafka 

42 from confluent_kafka import KafkaException 

43 from confluent_kafka.admin import AdminClient 

44except ImportError: 

45 confluent_kafka = None 

46 

47import lsst.alert.packet as alertPack 

48from lsst.afw.detection import InvalidPsfError 

49import lsst.afw.geom as afwGeom 

50import lsst.geom as geom 

51import lsst.pex.config as pexConfig 

52from lsst.pex.exceptions import InvalidParameterError 

53import lsst.pipe.base as pipeBase 

54from lsst.pipe.tasks.associationUtils import ss_object_id_to_obj_id 

55import lsst.utils.logging 

56from lsst.utils.timer import timeMethod 

57 

58 

59class PackageAlertsConfig(pexConfig.Config): 

60 """Config class for AssociationTask. 

61 """ 

62 schemaFile = pexConfig.Field( 

63 dtype=str, 

64 doc="Schema definition file URI for the avro alerts.", 

65 default=str(alertPack.get_uri_to_latest_schema()) 

66 ) 

67 minCutoutSize = pexConfig.RangeField( 

68 dtype=int, 

69 min=0, 

70 max=1000, 

71 default=30, 

72 doc="Dimension of the square image cutouts to package in the alert." 

73 ) 

74 maxCutoutSize = pexConfig.RangeField( 

75 dtype=int, 

76 min=0, 

77 max=1000, 

78 default=102, 

79 doc="Dimension of the square image cutouts to package in the alert. The" 

80 "default size comes from the max trail length in arcseconds " 

81 "(10 deg/day) for a 30 second observation divided by the" 

82 "arcseconds per pixel (0.2), which is 62.5 pixels. The effective" 

83 "size of the psf (40 pixels) is then added for a total of 102 pixels. " 

84 ) 

85 alertWriteLocation = pexConfig.Field( 

86 dtype=str, 

87 doc="Location to write alerts to.", 

88 default=os.path.join(os.getcwd(), "alerts"), 

89 ) 

90 

91 doProduceAlerts = pexConfig.Field( 

92 dtype=bool, 

93 doc="Turn on alert production to kafka if true and if confluent_kafka is in the environment.", 

94 default=False, 

95 ) 

96 

97 doWriteAlerts = pexConfig.Field( 

98 dtype=bool, 

99 doc="Write alerts to disk if true.", 

100 default=False, 

101 ) 

102 

103 doWriteFailedAlerts = pexConfig.Field( 

104 dtype=bool, 

105 doc="If an alert cannot be sent when doProduceAlerts is set, " 

106 "write it to disk for debugging purposes.", 

107 default=False, 

108 ) 

109 

110 maxTimeout = pexConfig.Field( 

111 dtype=float, 

112 doc="Sets the maximum time in seconds to wait for the alert stream " 

113 "broker to respond to a query before timing out.", 

114 default=15.0, 

115 ) 

116 

117 deliveryTimeout = pexConfig.Field( 

118 dtype=float, 

119 doc="Sets the time to wait for the producer to wait to deliver an " 

120 "alert in milliseconds.", 

121 default=1200.0, 

122 ) 

123 

124 useAveragePsf = pexConfig.Field( 

125 dtype=bool, 

126 doc="Use the average PSF for the image, instead of the PSF for each cutout. " 

127 "This option is much less accurate, but much faster.", 

128 default=False, 

129 ) 

130 

131 

132class PackageAlertsTask(pipeBase.Task): 

133 """Tasks for packaging Dia and Pipelines data into Avro alert packages. 

134 """ 

135 ConfigClass = PackageAlertsConfig 

136 _DefaultName = "packageAlerts" 

137 

138 _scale = (1.0 * geom.arcseconds).asDegrees() 

139 

140 def __init__(self, **kwargs): 

141 super().__init__(**kwargs) 

142 self.alertSchema = alertPack.Schema.from_uri(self.config.schemaFile) 

143 os.makedirs(self.config.alertWriteLocation, exist_ok=True) 

144 

145 if self.config.doProduceAlerts: 

146 if confluent_kafka is not None: 146 ↛ 204line 146 didn't jump to line 204 because the condition on line 146 was always true

147 self.password = os.getenv("AP_KAFKA_PRODUCER_PASSWORD") 

148 if not self.password: 

149 raise ValueError("Kafka password environment variable was not set.") 

150 self.username = os.getenv("AP_KAFKA_PRODUCER_USERNAME") 

151 if not self.username: 

152 raise ValueError("Kafka username environment variable was not set.") 

153 self.server = os.getenv("AP_KAFKA_SERVER") 

154 if not self.server: 

155 raise ValueError("Kafka server environment variable was not set.") 

156 self.kafkaTopic = os.getenv("AP_KAFKA_TOPIC") 

157 if not self.kafkaTopic: 

158 raise ValueError("Kafka topic environment variable was not set.") 

159 

160 # confluent_kafka configures all of its classes with dictionaries. This one 

161 # sets up the bare minimum that is needed. 

162 self.kafkaConfig = { 

163 # This is the URL to use to connect to the Kafka cluster. 

164 "bootstrap.servers": self.server, 

165 # These next two properties tell the Kafka client about the specific 

166 # authentication and authorization protocols that should be used when 

167 # connecting. 

168 "security.protocol": "SASL_PLAINTEXT", 

169 "sasl.mechanisms": "SCRAM-SHA-512", 

170 # The sasl.username and sasl.password are passed through over 

171 # SCRAM-SHA-512 auth to connect to the cluster. The username is not 

172 # sensitive, but the password is (of course) a secret value which 

173 # should never be committed to source code. 

174 "sasl.username": self.username, 

175 "sasl.password": self.password, 

176 # Batch size limits the largest size of a kafka alert that can be sent. 

177 # We set the batch size to 2 Mb. 

178 "batch.size": 2097152, 

179 "linger.ms": 5, 

180 "delivery.timeout.ms": self.config.deliveryTimeout, 

181 # Compression options are snappy, lz4, zstd, and gzip. 

182 "compression.type": 'snappy' 

183 } 

184 self.kafkaAdminConfig = { 

185 # This is the URL to use to connect to the Kafka cluster. 

186 "bootstrap.servers": self.server, 

187 # These next two properties tell the Kafka client about the specific 

188 # authentication and authorization protocols that should be used when 

189 # connecting. 

190 "security.protocol": "SASL_PLAINTEXT", 

191 "sasl.mechanisms": "SCRAM-SHA-512", 

192 # The sasl.username and sasl.password are passed through over 

193 # SCRAM-SHA-512 auth to connect to the cluster. The username is not 

194 # sensitive, but the password is (of course) a secret value which 

195 # should never be committed to source code. 

196 "sasl.username": self.username, 

197 "sasl.password": self.password, 

198 } 

199 

200 self._server_check() 

201 self.producer = confluent_kafka.Producer(**self.kafkaConfig) 

202 

203 else: 

204 raise RuntimeError("Produce alerts is set but confluent_kafka is not present in " 

205 "the environment. Alerts will not be sent to the alert stream.") 

206 

207 @timeMethod 

208 def run(self, 

209 diaSourceCat, 

210 diaObjectCat, 

211 diaSrcHistory, 

212 diaForcedSources, 

213 diffIm, 

214 calexp, 

215 template, 

216 ssSrc=None, 

217 doRunForcedMeasurement=True, 

218 forcedSourceHistoryThreshold=0, 

219 ): 

220 """Package DiaSources/Object and exposure data into Avro alerts. 

221 

222 Alerts can be sent to the alert stream if ``doProduceAlerts`` is set 

223 and written to disk if ``doWriteAlerts`` is set. Both can be set at the 

224 same time, and are independent of one another. 

225 

226 Writes Avro alerts to a location determined by the 

227 ``alertWriteLocation`` configurable. 

228 

229 Parameters 

230 ---------- 

231 diaSourceCat : `pandas.DataFrame` 

232 New DiaSources to package. DataFrame should be indexed on 

233 ``["diaObjectId", "band", "diaSourceId"]`` 

234 diaObjectCat : `pandas.DataFrame` 

235 New and updated DiaObjects matched to the new DiaSources. DataFrame 

236 is indexed on ``["diaObjectId"]`` 

237 diaSrcHistory : `pandas.DataFrame` 

238 12 month history of DiaSources matched to the DiaObjects. Excludes 

239 the newest DiaSource and is indexed on 

240 ``["diaObjectId", "band", "diaSourceId"]`` 

241 diaForcedSources : `pandas.DataFrame` 

242 12 month history of DiaForcedSources matched to the DiaObjects. 

243 ``["diaObjectId"]`` 

244 diffIm : `lsst.afw.image.ExposureF` 

245 Difference image the sources in ``diaSourceCat`` were detected in. 

246 calexp : `lsst.afw.image.ExposureF` 

247 Calexp used to create the ``diffIm``. 

248 template : `lsst.afw.image.ExposureF` or `None` 

249 Template image used to create the ``diffIm``. 

250 ssSrc : `astropy.table.Table`, optional 

251 Solar system specific information for diaSources associated to ssObjects. 

252 doRunForcedMeasurement : `bool`, optional 

253 Flag to indicate whether forced measurement was run. 

254 This should only be turned off for debugging purposes. 

255 Added to allow disabling forced sources for performance 

256 reasons during the ops rehearsal. 

257 forcedSourceHistoryThreshold : `int`, optional 

258 Minimum number of detections of a diaObject required 

259 to run forced photometry. Set to 1 to include all diaObjects. 

260 """ 

261 

262 alerts = [] 

263 self._patchDiaSources(diaSourceCat) 

264 self._patchDiaSources(diaSrcHistory) 

265 detector = diffIm.detector.getId() 

266 visit = diffIm.visitInfo.id 

267 observation_reason = diffIm.visitInfo.getObservationReason() 

268 target_name = diffIm.visitInfo.getObject() 

269 midpoint_unix = diffIm.visitInfo.date.toAstropy().tai.unix 

270 exposure_time = diffIm.visitInfo.exposureTime 

271 diffImPhotoCalib = diffIm.getPhotoCalib() 

272 calexpPhotoCalib = calexp.getPhotoCalib() 

273 templatePhotoCalib = template.getPhotoCalib() 

274 diffImPsf = self._computePsf(diffIm, diffIm.psf.getAveragePosition()) 

275 sciencePsf = self._computePsf(calexp, calexp.psf.getAveragePosition()) 

276 templatePsf = self._computePsf(template, template.psf.getAveragePosition()) 

277 

278 # cast to object; works around pandas coercing Series to nullable 

279 # Float64 type when selecting single rows below 

280 diaSourceCat = diaSourceCat.astype('object') 

281 diaObjectCat = diaObjectCat.astype('object') 

282 diaSrcHistory = diaSrcHistory.astype('object') 

283 diaForcedSources = diaForcedSources.astype('object') 

284 

285 if ssSrc is not None: 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 ssSrc = ssSrc.set_index('diaSourceId') 

287 ssSrc = ssSrc.astype('object') 

288 

289 n_sources = len(diaSourceCat) 

290 self.log.info("Packaging alerts for %d DiaSources.", n_sources) 

291 # Log every 10 seconds as proof of liveness. 

292 loop_logger = lsst.utils.logging.PeriodicLogger(self.log, interval=10.0) 

293 

294 for srcIndex, diaSource in diaSourceCat.iterrows(): 

295 loop_logger.log("%s/%s sources have been packaged.", len(alerts), n_sources) 

296 

297 alertId = diaSource["diaSourceId"] 

298 if srcIndex[0] == 0: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true

299 diaObject = None 

300 objSourceHistory = None 

301 objDiaForcedSources = None 

302 ssSource = ssSrc.loc[[alertId]].to_dict("records")[0] 

303 else: 

304 ssSource = None 

305 diaObject = diaObjectCat.loc[srcIndex[0]] 

306 if diaObject["nDiaSources"] > 1: 306 ↛ 309line 306 didn't jump to line 309 because the condition on line 306 was always true

307 objSourceHistory = diaSrcHistory.loc[srcIndex[0]] 

308 else: 

309 objSourceHistory = None 

310 if doRunForcedMeasurement and diaObject["nDiaSources"] >= forcedSourceHistoryThreshold: 310 ↛ 318line 310 didn't jump to line 318 because the condition on line 310 was always true

311 if srcIndex[0] in diaForcedSources.diaObjectId: 311 ↛ 315line 311 didn't jump to line 315 because the condition on line 311 was always true

312 objDiaForcedSources = diaForcedSources.loc[srcIndex[0]] 

313 else: 

314 # Send empty table with correct columns 

315 objDiaForcedSources = diaForcedSources.loc[[]] 

316 else: 

317 # Send empty table with correct columns 

318 objDiaForcedSources = diaForcedSources.loc[[]] 

319 

320 sphPoint = geom.SpherePoint(diaSource["ra"], 

321 diaSource["dec"], 

322 geom.degrees) 

323 pixelPoint = geom.Point2D(diaSource["x"], 

324 diaSource["y"]) 

325 

326 cutoutExtent = self.createDiaSourceExtent(diaSource["bboxSize"]) 

327 diffImCutout = self.createCcdDataCutout( 

328 diffIm, 

329 sphPoint, 

330 pixelPoint, 

331 cutoutExtent, 

332 diffImPhotoCalib, 

333 diaSource["diaSourceId"], 

334 averagePsf=diffImPsf, 

335 rotPa=diffIm.visitInfo.boresightRotAngle.asDegrees()) 

336 calexpCutout = self.createCcdDataCutout( 

337 calexp, 

338 sphPoint, 

339 pixelPoint, 

340 cutoutExtent, 

341 calexpPhotoCalib, 

342 diaSource["diaSourceId"], 

343 averagePsf=sciencePsf, 

344 rotPa=calexp.visitInfo.boresightRotAngle.asDegrees()) 

345 templateCutout = self.createCcdDataCutout( 

346 template, 

347 sphPoint, 

348 pixelPoint, 

349 cutoutExtent, 

350 templatePhotoCalib, 

351 diaSource["diaSourceId"], 

352 averagePsf=templatePsf, 

353 rotPa=calexp.visitInfo.boresightRotAngle.asDegrees()) 

354 

355 alerts.append( 

356 self.makeAlertDict(diaSource["diaSourceId"], 

357 observation_reason, 

358 target_name, 

359 diaSource, 

360 diaObject, 

361 objSourceHistory, 

362 objDiaForcedSources, 

363 diffImCutout, 

364 calexpCutout, 

365 templateCutout, 

366 ssSource)) 

367 

368 if self.config.doProduceAlerts: 

369 self.log.info("Producing alerts to %s.", self.kafkaTopic) 

370 self.produceAlerts(alerts, visit, detector, midpoint_unix, exposure_time) 

371 else: 

372 # Fill values for the metadata so that downstream metrics don't crash 

373 self.metadata['visit_midpoint'] = midpoint_unix 

374 self.metadata['produce_end_timestamp'] = -1 

375 self.metadata['produce_start_timestamp'] = -1 

376 self.metadata['alert_timing_since_shutter_close'] = -1 

377 self.metadata['total_alerts'] = -1 

378 

379 if self.config.doWriteAlerts: 

380 avro_path = os.path.join(self.config.alertWriteLocation, f"{visit}_{detector}.avro") 

381 self.log.info("Writing alerts to %s.", avro_path) 

382 with open(avro_path, "wb") as f: 

383 self.alertSchema.store_alerts(f, alerts) 

384 

385 def _patchDiaSources(self, diaSources): 

386 """Add the ``programId`` column to the data. 

387 

388 Parameters 

389 ---------- 

390 diaSources : `pandas.DataFrame` 

391 DataFrame of DiaSources to patch. 

392 """ 

393 diaSources["programId"] = 0 

394 

395 def findMissingDiaSourceColumns(self, diaSources): 

396 """Check that a DiaSource catalog has the correct schema for alerts. 

397 

398 Parameters 

399 ---------- 

400 diaSources : `pandas.DataFrame` 

401 DiaSources to include in alerts. 

402 

403 Returns 

404 ------- 

405 missing : `list` [`str`] 

406 Names of the DiaSource fields in the alert schema that have no 

407 default, do not accept null, and are not columns of 

408 ``diaSources``, in schema order. 

409 """ 

410 diaSourceSchema = next(field["type"] for field in self.alertSchema.definition["fields"] 

411 if field["name"] == "diaSource") 

412 return [field["name"] for field in diaSourceSchema["fields"] 

413 if "default" not in field 

414 and not (isinstance(field["type"], list) and "null" in field["type"]) 

415 and field["name"] not in diaSources.columns] 

416 

417 def createDiaSourceExtent(self, bboxSize): 

418 """Create an extent for a box for the cutouts given the size of the 

419 square BBox that covers the source footprint. 

420 

421 Parameters 

422 ---------- 

423 bboxSize : `int` 

424 Size of a side of the square bounding box in pixels. 

425 

426 Returns 

427 ------- 

428 extent : `lsst.geom.Extent2I` 

429 Geom object representing the size of the bounding box. 

430 """ 

431 if bboxSize < self.config.minCutoutSize: 

432 extent = geom.Extent2I(self.config.minCutoutSize, 

433 self.config.minCutoutSize) 

434 elif bboxSize > self.config.maxCutoutSize: 

435 extent = geom.Extent2I(self.config.maxCutoutSize, 

436 self.config.maxCutoutSize) 

437 else: 

438 extent = geom.Extent2I(bboxSize, bboxSize) 

439 return extent 

440 

441 @timeMethod 

442 def produceAlerts(self, alerts, visit, detector, midpoint_unix, exposure_time): 

443 """Serialize alerts and send them to the alert stream using 

444 confluent_kafka's producer. 

445 

446 Parameters 

447 ---------- 

448 alerts : `dict` 

449 Dictionary of alerts to be sent to the alert stream. 

450 visit, detector : `int` 

451 Visit and detector ids of these alerts. Used to write out alerts 

452 which fail to be sent to the alert stream. 

453 """ 

454 schema_id = self.alertSchema.get_schema_id() 

455 # Serialize and send alerts with the producer timestamp. 

456 total_alerts = 0 

457 produce_start_timestamp = time.time() 

458 try: 

459 for alert in alerts: 

460 alertBytes = self._serializeAlert(alert, 

461 schema=self.alertSchema.definition, schema_id=schema_id) 

462 try: 

463 timestamp = time.time()*1000 # Current time in milliseconds 

464 headers = [("producer_timestamp", str(timestamp).encode('utf-8'))] 

465 self.producer.produce(self.kafkaTopic, alertBytes, callback=self._delivery_callback, 

466 headers=headers) 

467 self.producer.flush() 

468 total_alerts += 1 

469 

470 except KafkaException as e: 

471 self.log.warning('Kafka error: %s, message was %s bytes', 

472 e, sys.getsizeof(alertBytes)) 

473 

474 if self.config.doWriteFailedAlerts: 474 ↛ 459line 474 didn't jump to line 459 because the condition on line 474 was always true

475 with open(os.path.join(self.config.alertWriteLocation, 

476 f"{visit}_{detector}_{alert['diaSourceId']}.avro"), "wb") as f: 

477 f.write(alertBytes) 

478 

479 self.producer.flush() 

480 finally: 

481 produce_end_timestamp = time.time() # Current time in seconds 

482 total_time = produce_end_timestamp - (midpoint_unix + exposure_time/2.0) 

483 self.metadata['visit_midpoint'] = midpoint_unix 

484 self.metadata['produce_end_timestamp'] = produce_end_timestamp 

485 self.metadata['produce_start_timestamp'] = produce_start_timestamp 

486 self.metadata['alert_timing_since_shutter_close'] = total_time 

487 self.metadata['total_alerts'] = total_alerts 

488 # A single log message is easier for Loki to parse than timeMethod's start+end pairs. 

489 self.log.verbose("Producing alerts: Took %.4f seconds", 

490 produce_end_timestamp - produce_start_timestamp) 

491 

492 self.log.info(f"Total time since shutter close to produce alerts for" 

493 f" visit {visit} detector {detector}: {total_time} seconds") 

494 

495 def createCcdDataCutout(self, image, skyCenter, pixelCenter, extent, photoCalib, srcId, averagePsf=None, 

496 rotPa=None): 

497 """Grab an image as a cutout and return a calibrated CCDData image. 

498 

499 Parameters 

500 ---------- 

501 image : `lsst.afw.image.ExposureF` 

502 Image to pull cutout from. 

503 skyCenter : `lsst.geom.SpherePoint` 

504 Center point of DiaSource on the sky. 

505 pixelCenter : `lsst.geom.Point2D` 

506 Pixel center of DiaSource on the sky. 

507 extent : `lsst.geom.Extent2I` 

508 Bounding box to cutout from the image. 

509 photoCalib : `lsst.afw.image.PhotoCalib` 

510 Calibrate object of the image the cutout is cut from. 

511 srcId : `int` 

512 Unique id of DiaSource. Used for when an error occurs extracting 

513 a cutout. 

514 averagePsf : `numpy.array`, optional 

515 Average PSF to attach to the cutout. 

516 Used if ``self.config.useAveragePsf`` is set. 

517 cutoutType : str, optional 

518 Type of cutout being created ('difference', 'template', or 'science') 

519 

520 Returns 

521 ------- 

522 ccdData : `astropy.nddata.CCDData` or `None` 

523 CCDData object storing the calibrate information from the input 

524 difference or template image. 

525 """ 

526 imBBox = image.getBBox() 

527 if not geom.Box2D(image.getBBox()).contains(pixelCenter): 

528 self.log.warning( 

529 "DiaSource id=%i centroid lies at pixel (%.2f, %.2f) " 

530 "which is outside the Exposure with bounding box " 

531 "((%i, %i), (%i, %i)). Returning None for cutout...", 

532 srcId, pixelCenter.x, pixelCenter.y, 

533 imBBox.minX, imBBox.maxX, imBBox.minY, imBBox.maxY) 

534 return None 

535 # Catch errors in retrieving the cutout. 

536 try: 

537 cutout = image.getCutout(pixelCenter, extent) 

538 except InvalidParameterError: 

539 self.log.warning( 

540 "Failed to retrieve cutout from image for DiaSource with " 

541 "id=%i. InvalidParameterError thrown during cutout " 

542 "creation. Returning None for cutout..." 

543 % srcId) 

544 if self.config.useAveragePsf: 

545 if averagePsf is None: 

546 self.log.info("Using source id=%i to compute the average PSF.", srcId) 

547 averagePsf = self._computePsf(image, pixelCenter, srcId=srcId) 

548 cutoutPsf = averagePsf 

549 else: 

550 cutoutPsf = self._computePsf(image, pixelCenter, srcId=srcId) 

551 

552 # Find the value of the bottom corner of our cutout's BBox and 

553 # subtract 1 so that the CCDData cutout position value will be 

554 # [1, 1]. 

555 cutOutMinX = cutout.getBBox().minX - 1 

556 cutOutMinY = cutout.getBBox().minY - 1 

557 center = cutout.getWcs().skyToPixel(skyCenter) 

558 calibCutout = photoCalib.calibrateImage(cutout.getMaskedImage()) 

559 

560 cutoutWcs = wcs.WCS(naxis=2) 

561 cutoutWcs.array_shape = (cutout.getBBox().getWidth(), 

562 cutout.getBBox().getWidth()) 

563 cutoutWcs.wcs.crpix = [center.x - cutOutMinX, center.y - cutOutMinY] 

564 cutoutWcs.wcs.crval = [skyCenter.getRa().asDegrees(), 

565 skyCenter.getDec().asDegrees()] 

566 cutoutWcs.wcs.cd = self.makeLocalTransformMatrix(cutout.getWcs(), 

567 center, 

568 skyCenter) 

569 ccdData = CCDData( 

570 data=calibCutout.getImage().array, 

571 uncertainty=VarianceUncertainty(calibCutout.getVariance().array), 

572 flags=calibCutout.getMask().array, 

573 wcs=cutoutWcs, 

574 psf=cutoutPsf, 

575 meta={"cutMinX": cutOutMinX, 

576 "cutMinY": cutOutMinY}, 

577 unit=u.nJy) 

578 

579 ccdData.header['ROTPA'] = (rotPa, 'Pos angle in deg of focal plane +Y wrt North') 

580 

581 return ccdData 

582 

583 def makeLocalTransformMatrix(self, wcs, center, skyCenter): 

584 """Create a local, linear approximation of the wcs transformation 

585 matrix. 

586 

587 The approximation is created as if the center is at RA=0, DEC=0. All 

588 comparing x,y coordinate are relative to the position of center. Matrix 

589 is initially calculated with units arcseconds and then converted to 

590 degrees. This yields higher precision results due to quirks in AST. 

591 

592 Parameters 

593 ---------- 

594 wcs : `lsst.afw.geom.SkyWcs` 

595 Wcs to approximate 

596 center : `lsst.geom.Point2D` 

597 Point at which to evaluate the LocalWcs. 

598 skyCenter : `lsst.geom.SpherePoint` 

599 Point on sky to approximate the Wcs. 

600 

601 Returns 

602 ------- 

603 localMatrix : `numpy.ndarray` 

604 Matrix representation the local wcs approximation with units 

605 degrees. 

606 """ 

607 blankCDMatrix = [[self._scale, 0], [0, self._scale]] 

608 localGnomonicWcs = afwGeom.makeSkyWcs( 

609 center, skyCenter, blankCDMatrix) 

610 measurementToLocalGnomonic = wcs.getTransform().then( 

611 localGnomonicWcs.getTransform().inverted() 

612 ) 

613 localMatrix = measurementToLocalGnomonic.getJacobian(center) 

614 return localMatrix / 3600 

615 

616 def makeAlertDict(self, 

617 diaSourceId, 

618 observationReason, 

619 targetName, 

620 diaSource, 

621 diaObject, 

622 objDiaSrcHistory, 

623 objDiaForcedSources, 

624 diffImCutout, 

625 calexpCutout, 

626 templateCutout, 

627 ssSource=None): 

628 """Convert data and package into a dictionary alert. 

629 

630 Parameters 

631 ---------- 

632 diaSourceId : `int` 

633 Unique identifier of the triggering diaSource 

634 diaSource : `pandas.DataFrame` 

635 New single DiaSource to package. 

636 observationReason : `str` 

637 Scheduler reason for the image containing this diaSource. 

638 targetName : `str` 

639 Scheduler target for the image containing this diaSource. 

640 diaObject : `pandas.DataFrame` 

641 DiaObject that ``diaSource`` is matched to. 

642 objDiaSrcHistory : `pandas.DataFrame` 

643 12 month history of ``diaObject`` excluding the latest DiaSource. 

644 objDiaForcedSources : `pandas.DataFrame` 

645 12 month history of ``diaObject`` forced measurements. 

646 diffImCutout : `astropy.nddata.CCDData` or `None` 

647 Cutout of the difference image around the location of ``diaSource`` 

648 with a min size set by the ``cutoutSize`` configurable. 

649 calexpCutout : `astropy.nddata.CCDData` or `None` 

650 Cutout of the calexp around the location of ``diaSource`` 

651 with a min size set by the ``cutoutSize`` configurable. 

652 templateCutout : `astropy.nddata.CCDData` or `None` 

653 Cutout of the template image around the location of ``diaSource`` 

654 with a min size set by the ``cutoutSize`` configurable. 

655 """ 

656 alert = dict() 

657 alert['diaSourceId'] = diaSourceId 

658 # alerts should have NULL if scheduler fields are not populated 

659 if not len(observationReason): 

660 observationReason = None 

661 if not len(targetName): 

662 targetName = None 

663 alert['observation_reason'] = observationReason 

664 alert['target_name'] = targetName 

665 alert['diaSource'] = diaSource.to_dict() 

666 

667 if objDiaSrcHistory is None: 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true

668 alert['prvDiaSources'] = None 

669 else: 

670 alert['prvDiaSources'] = objDiaSrcHistory.to_dict("records") 

671 

672 if isinstance(objDiaForcedSources, pd.Series): 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true

673 alert['prvDiaForcedSources'] = [objDiaForcedSources.to_dict()] 

674 elif objDiaForcedSources is None: 674 ↛ 675line 674 didn't jump to line 675 because the condition on line 674 was never true

675 alert['prvDiaForcedSources'] = None 

676 else: 

677 alert['prvDiaForcedSources'] = objDiaForcedSources.to_dict("records") 

678 

679 alert['prvDiaNondetectionLimits'] = None 

680 

681 if diaObject is None: 681 ↛ 682line 681 didn't jump to line 682 because the condition on line 681 was never true

682 alert['diaObject'] = None 

683 else: 

684 alert['diaObject'] = diaObject.to_dict() 

685 

686 if ssSource is None: 686 ↛ 690line 686 didn't jump to line 690 because the condition on line 686 was always true

687 alert['ssSource'] = None 

688 alert['mpc_orbits'] = None 

689 else: 

690 mpcorbColumns = [col for col in ssSource if col[:7] == 'MPCORB_'] 

691 mpcOrbit = {key[7:]: ssSource[key] for key in mpcorbColumns} 

692 

693 fields_to_cast = [ 

694 "arc_length_total", "arc_length_sel", "a", "mean_anomaly", "period", "mean_motion", "a_unc", 

695 "mean_anomaly_unc", "period_unc", "mean_motion_unc", "yarkovsky", "srp", "a1", "a2", "a3", 

696 "dt", "yarkovsky_unc", "srp_unc", "a1_unc", "a2_unc", "a3_unc", "dt_unc", 

697 "not_normalized_rms", "earth_moid" 

698 ] 

699 for key in fields_to_cast: 

700 if key in mpcOrbit and mpcOrbit[key] is not None: 

701 mpcOrbit[key] = float(mpcOrbit[key]) 

702 

703 if 'packed_primary_provisional_designation' not in mpcOrbit: 

704 unpacked_desig = ssSource['designation'] 

705 packed_desig = ss_object_id_to_obj_id(ssSource['ssObjectId'], packed=True) 

706 mpcOrbit['designation'] = unpacked_desig 

707 mpcOrbit['packed_primary_provisional_designation'] = packed_desig 

708 mpcOrbit['unpacked_primary_provisional_designation'] = unpacked_desig 

709 

710 mpcOrbit['id'] = 0 

711 ssSource = {key: ssSource[key] for key in ssSource if key not in mpcorbColumns} 

712 ssSource['diaSourceId'] = diaSourceId 

713 alert['ssSource'] = ssSource 

714 alert['mpc_orbits'] = mpcOrbit 

715 

716 if diffImCutout is None: 

717 alert['cutoutDifference'] = None 

718 else: 

719 alert['cutoutDifference'] = self.streamCcdDataToBytes(diffImCutout) 

720 

721 if calexpCutout is None: 

722 alert['cutoutScience'] = None 

723 else: 

724 alert['cutoutScience'] = self.streamCcdDataToBytes(calexpCutout) 

725 

726 if templateCutout is None: 

727 alert["cutoutTemplate"] = None 

728 else: 

729 alert["cutoutTemplate"] = self.streamCcdDataToBytes(templateCutout) 

730 

731 return alert 

732 

733 def streamCcdDataToBytes(self, cutout): 

734 """Serialize a cutout into bytes. 

735 

736 Parameters 

737 ---------- 

738 cutout : `astropy.nddata.CCDData` 

739 Cutout to serialize. 

740 

741 Returns 

742 ------- 

743 coutputBytes : `bytes` 

744 Input cutout serialized into byte data. 

745 """ 

746 with io.BytesIO() as streamer: 

747 cutout.write(streamer, format="fits", hdu_flags='MASKPLANE') 

748 cutoutBytes = streamer.getvalue() 

749 return cutoutBytes 

750 

751 def _serializeAlert(self, alert, schema=None, schema_id=0): 

752 """Serialize an alert to a byte sequence for sending to Kafka. 

753 

754 Parameters 

755 ---------- 

756 alert : `dict` 

757 An alert payload to be serialized. 

758 schema : `dict`, optional 

759 An Avro schema definition describing how to encode `alert`. By default, 

760 the schema is None, which sets it to the latest schema available. 

761 schema_id : `int`, optional 

762 The Confluent Schema Registry ID of the schema. By default, 0 (an 

763 invalid ID) is used, indicating that the schema is not registered. 

764 

765 Returns 

766 ------- 

767 serialized : `bytes` 

768 The byte sequence describing the alert, including the Confluent Wire 

769 Format prefix. 

770 """ 

771 if schema is None: 

772 schema = self.alertSchema.definition 

773 

774 buf = io.BytesIO() 

775 buf.write(self._serializeConfluentWireHeader(schema_id)) 

776 fastavro.schemaless_writer(buf, schema, alert) 

777 return buf.getvalue() 

778 

779 @staticmethod 

780 def _serializeConfluentWireHeader(schema_version): 

781 """Returns the byte prefix for Confluent Wire Format-style Kafka messages. 

782 

783 Parameters 

784 ---------- 

785 schema_version : `int` 

786 A version number which indicates the Confluent Schema Registry ID 

787 number of the Avro schema used to encode the message that follows this 

788 header. 

789 

790 Returns 

791 ------- 

792 header : `bytes` 

793 The 5-byte encoded message prefix. 

794 

795 Notes 

796 ----- 

797 The Confluent Wire Format is described more fully here: 

798 https://docs.confluent.io/current/schema-registry/serdes-develop/index.html#wire-format 

799 """ 

800 ConfluentWireFormatHeader = struct.Struct(">bi") 

801 return ConfluentWireFormatHeader.pack(0, schema_version) 

802 

803 def _delivery_callback(self, err, msg): 

804 if err: 

805 if err.code() == KafkaError.UNKNOWN_TOPIC_OR_PART: 

806 topic = msg.topic() if msg and hasattr(msg, 

807 'topic') else 'unknown' 

808 self.log.warning( 

809 'Message failed delivery. Topic %s unknown: %s', 

810 topic, err) 

811 else: 

812 self.log.debug('Message delivered to %s [%d] @ %d', msg.topic(), msg.partition(), msg.offset()) 

813 

814 def _server_check(self): 

815 """Checks if the alert stream credentials are still valid and the 

816 server is contactable. 

817 

818 Raises 

819 ------- 

820 KafkaException 

821 Raised if the server us not contactable. 

822 RuntimeError 

823 Raised if the server is contactable but there are no topics 

824 present. 

825 """ 

826 admin_client = AdminClient(self.kafkaAdminConfig) 

827 topics = admin_client.list_topics(timeout=self.config.maxTimeout).topics 

828 

829 if not topics: 

830 raise RuntimeError() 

831 if self.kafkaTopic not in topics: 

832 raise RuntimeError( 

833 f"Topic '{self.kafkaTopic}' not found on the Kafka server. " 

834 f"Check that the correct topic has been provided and exists " 

835 f"on the Kafka server.") 

836 

837 def _computePsf(self, exposure, pixelCenter, srcId=None): 

838 """Compute the PSF at a location and catch errors. 

839 

840 Parameters 

841 ---------- 

842 exposure : `lsst.afw.image.Exposure` 

843 The image to compute the PSF for. 

844 pixelCenter : `lsst.geom.Point2D` 

845 The location on the image to compute the PSF. 

846 srcId : `int`, optional 

847 Unique id of DiaSource. Used for when an error occurs extracting 

848 a cutout. 

849 

850 Returns 

851 ------- 

852 cutoutPsf : `numpy.array` 

853 Array of the PSF values. 

854 """ 

855 try: 

856 # use exposure.psf.computeKernelImage to provide PSF centered in the array 

857 cutoutPsf = exposure.psf.computeKernelImage(pixelCenter).array 

858 except InvalidParameterError: 

859 if srcId is not None: 

860 msg = "Could not calculate PSF for DiaSource with "\ 

861 "id=%i. InvalidParameterError encountered. Exiting."\ 

862 % srcId 

863 else: 

864 msg = "Could not calculate average PSF for the image" 

865 self.log.warning(msg) 

866 cutoutPsf = None 

867 except InvalidPsfError: 

868 if srcId is not None: 

869 msg = "Could not calculate PSF for DiaSource with "\ 

870 "id=%i. InvalidPsfError encountered. Exiting."\ 

871 % srcId 

872 else: 

873 msg = "Could not calculate average PSF for the image" 

874 self.log.warning(msg) 

875 cutoutPsf = None 

876 # Cast the PSF to float32 to reduce the size of the alert. 

877 if cutoutPsf is not None: 877 ↛ 880line 877 didn't jump to line 880 because the condition on line 877 was always true

878 cutoutPsf = cutoutPsf.astype(np.float32) 

879 

880 return cutoutPsf