Coverage for python/lsst/drp/tasks/gbdesAstrometricFit.py: 10%
534 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-07 15:00 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-02-07 15:00 +0000
1# This file is part of drp_tasks.
2#
3# LSST Data Management System
4# This product includes software developed by the
5# LSST Project (http://www.lsst.org/).
6# See COPYRIGHT file at the top of the source tree.
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <https://www.lsstcorp.org/LegalNotices/>.
21#
22import astropy.coordinates
23import astropy.time
24import astropy.units as u
25import astshim
26import lsst.afw.geom as afwgeom
27import lsst.afw.table
28import lsst.geom
29import lsst.pex.config as pexConfig
30import lsst.pipe.base as pipeBase
31import lsst.sphgeom
32import numpy as np
33import wcsfit
34import yaml
35from lsst.meas.algorithms import (
36 LoadReferenceObjectsConfig,
37 ReferenceObjectLoader,
38 ReferenceSourceSelectorTask,
39)
40from lsst.meas.algorithms.sourceSelector import sourceSelectorRegistry
42__all__ = ["GbdesAstrometricFitConnections", "GbdesAstrometricFitConfig", "GbdesAstrometricFitTask"]
45def _make_ref_covariance_matrix(
46 refCat, inputUnit=u.radian, outputCoordUnit=u.marcsec, outputPMUnit=u.marcsec, version=1
47):
48 """Make a covariance matrix for the reference catalog including proper
49 motion and parallax.
51 The output is flattened to one dimension to match the format expected by
52 `gbdes`.
54 Parameters
55 ----------
56 refCat : `lsst.afw.table.SimpleCatalog`
57 Catalog including proper motion and parallax measurements.
58 inputUnit : `astropy.unit.core.Unit`
59 Units of the input catalog
60 outputCoordUnit : `astropy.unit.core.Unit`
61 Units required for the coordinates in the covariance matrix. `gbdes`
62 expects milliarcseconds.
63 outputPMUnit : `astropy.unit.core.Unit`
64 Units required for the proper motion/parallax in the covariance matrix.
65 `gbdes` expects milliarcseconds.
66 version : `int`
67 Version of the reference catalog. Version 2 includes covariance
68 measurements.
69 Returns
70 -------
71 cov : `list` of `float`
72 Flattened output covariance matrix.
73 """
74 cov = np.zeros((len(refCat), 25))
75 if version == 1:
76 # Here is the standard ordering of components in the cov matrix,
77 # to match the PM enumeration in C++ code of gbdes package's Match.
78 # Each tuple gives: the array holding the 1d error,
79 # the string in Gaia column names for this
80 # the ordering in the Gaia catalog
81 # and the ordering of the tuples is the order we want in our cov matrix
82 raErr = (refCat["coord_raErr"] * inputUnit).to(outputCoordUnit).to_value()
83 decErr = (refCat["coord_decErr"] * inputUnit).to(outputCoordUnit).to_value()
84 raPMErr = (refCat["pm_raErr"] * inputUnit).to(outputPMUnit).to_value()
85 decPMErr = (refCat["pm_decErr"] * inputUnit).to(outputPMUnit).to_value()
86 parallaxErr = (refCat["parallaxErr"] * inputUnit).to(outputPMUnit).to_value()
87 stdOrder = (
88 (raErr, "ra", 0),
89 (decErr, "dec", 1),
90 (raPMErr, "pmra", 3),
91 (decPMErr, "pmdec", 4),
92 (parallaxErr, "parallax", 2),
93 )
95 k = 0
96 for i, pr1 in enumerate(stdOrder):
97 for j, pr2 in enumerate(stdOrder):
98 if pr1[2] < pr2[2]:
99 cov[:, k] = 0
100 elif pr1[2] > pr2[2]:
101 cov[:, k] = 0
102 else:
103 # diagnonal element
104 cov[:, k] = pr1[0] * pr2[0]
105 k = k + 1
107 elif version == 2:
108 positionParameters = ["coord_ra", "coord_dec", "pm_ra", "pm_dec", "parallax"]
109 units = [outputCoordUnit, outputCoordUnit, outputPMUnit, outputPMUnit, outputPMUnit]
110 k = 0
111 for i, pi in enumerate(positionParameters):
112 for j, pj in enumerate(positionParameters):
113 if i == j:
114 cov[:, k] = (refCat[f"{pi}Err"] ** 2 * inputUnit**2).to_value(units[j] * units[j])
115 elif i > j:
116 cov[:, k] = (refCat[f"{pj}_{pi}_Cov"] * inputUnit**2).to_value(units[i] * units[j])
117 else:
118 cov[:, k] = (refCat[f"{pi}_{pj}_Cov"] * inputUnit**2).to_value(units[i] * units[j])
120 k += 1
121 return cov
124def _nCoeffsFromDegree(degree):
125 """Get the number of coefficients for a polynomial of a certain degree with
126 two variables.
128 This uses the general formula that the number of coefficients for a
129 polynomial of degree d with n variables is (n + d) choose d, where in this
130 case n is fixed to 2.
132 Parameters
133 ----------
134 degree : `int`
135 Degree of the polynomial in question.
137 Returns
138 -------
139 nCoeffs : `int`
140 Number of coefficients for the polynomial in question.
141 """
142 nCoeffs = int((degree + 2) * (degree + 1) / 2)
143 return nCoeffs
146def _degreeFromNCoeffs(nCoeffs):
147 """Get the degree for a polynomial with two variables and a certain number
148 of coefficients.
150 This is done by applying the quadratic formula to the
151 formula for calculating the number of coefficients of the polynomial.
153 Parameters
154 ----------
155 nCoeffs : `int`
156 Number of coefficients for the polynomial in question.
158 Returns
159 -------
160 degree : `int`
161 Degree of the polynomial in question.
162 """
163 degree = int(-1.5 + 0.5 * (1 + 8 * nCoeffs) ** 0.5)
164 return degree
167def _convert_to_ast_polymap_coefficients(coefficients):
168 """Convert vector of polynomial coefficients from the format used in
169 `gbdes` into AST format (see Poly2d::vectorIndex(i, j) in
170 gbdes/gbutil/src/Poly2d.cpp). This assumes two input and two output
171 coordinates.
173 Parameters
174 ----------
175 coefficients : `list`
176 Coefficients of the polynomials.
177 degree : `int`
178 Degree of the polynomial.
180 Returns
181 -------
182 astPoly : `astshim.PolyMap`
183 Coefficients in AST polynomial format.
184 """
185 polyArray = np.zeros((len(coefficients), 4))
186 N = len(coefficients) / 2
187 degree = _degreeFromNCoeffs(N)
189 for outVar in [1, 2]:
190 for i in range(degree + 1):
191 for j in range(degree + 1):
192 if (i + j) > degree:
193 continue
194 vectorIndex = int(((i + j) * (i + j + 1)) / 2 + j + N * (outVar - 1))
195 polyArray[vectorIndex, 0] = coefficients[vectorIndex]
196 polyArray[vectorIndex, 1] = outVar
197 polyArray[vectorIndex, 2] = i
198 polyArray[vectorIndex, 3] = j
200 astPoly = astshim.PolyMap(polyArray, 2, options="IterInverse=1,NIterInverse=10,TolInverse=1e-7")
201 return astPoly
204class GbdesAstrometricFitConnections(
205 pipeBase.PipelineTaskConnections, dimensions=("skymap", "tract", "instrument", "physical_filter")
206):
207 """Middleware input/output connections for task data."""
209 inputCatalogRefs = pipeBase.connectionTypes.Input(
210 doc="Source table in parquet format, per visit.",
211 name="preSourceTable_visit",
212 storageClass="DataFrame",
213 dimensions=("instrument", "visit"),
214 deferLoad=True,
215 multiple=True,
216 )
217 inputVisitSummaries = pipeBase.connectionTypes.Input(
218 doc=(
219 "Per-visit consolidated exposure metadata built from calexps. "
220 "These catalogs use detector id for the id and must be sorted for "
221 "fast lookups of a detector."
222 ),
223 name="visitSummary",
224 storageClass="ExposureCatalog",
225 dimensions=("instrument", "visit"),
226 multiple=True,
227 )
228 referenceCatalog = pipeBase.connectionTypes.PrerequisiteInput(
229 doc="The astrometry reference catalog to match to loaded input catalog sources.",
230 name="gaia_dr3_20230707",
231 storageClass="SimpleCatalog",
232 dimensions=("skypix",),
233 deferLoad=True,
234 multiple=True,
235 )
236 outputWcs = pipeBase.connectionTypes.Output(
237 doc=(
238 "Per-tract, per-visit world coordinate systems derived from the fitted model."
239 " These catalogs only contain entries for detectors with an output, and use"
240 " the detector id for the catalog id, sorted on id for fast lookups of a detector."
241 ),
242 name="gbdesAstrometricFitSkyWcsCatalog",
243 storageClass="ExposureCatalog",
244 dimensions=("instrument", "visit", "skymap", "tract"),
245 multiple=True,
246 )
247 outputCatalog = pipeBase.connectionTypes.Output(
248 doc=(
249 "Source table with stars used in fit, along with residuals in pixel coordinates and tangent "
250 "plane coordinates and chisq values."
251 ),
252 name="gbdesAstrometricFit_fitStars",
253 storageClass="ArrowNumpyDict",
254 dimensions=("instrument", "skymap", "tract", "physical_filter"),
255 )
256 starCatalog = pipeBase.connectionTypes.Output(
257 doc="Star catalog.",
258 name="gbdesAstrometricFit_starCatalog",
259 storageClass="ArrowNumpyDict",
260 dimensions=("instrument", "skymap", "tract", "physical_filter"),
261 )
262 modelParams = pipeBase.connectionTypes.Output(
263 doc="WCS parameter covariance.",
264 name="gbdesAstrometricFit_modelParams",
265 storageClass="ArrowNumpyDict",
266 dimensions=("instrument", "skymap", "tract", "physical_filter"),
267 )
269 def getSpatialBoundsConnections(self):
270 return ("inputVisitSummaries",)
272 def __init__(self, *, config=None):
273 super().__init__(config=config)
275 if not self.config.saveModelParams:
276 self.outputs.remove("modelParams")
279class GbdesAstrometricFitConfig(
280 pipeBase.PipelineTaskConfig, pipelineConnections=GbdesAstrometricFitConnections
281):
282 """Configuration for GbdesAstrometricFitTask"""
284 sourceSelector = sourceSelectorRegistry.makeField(
285 doc="How to select sources for cross-matching.", default="science"
286 )
287 referenceSelector = pexConfig.ConfigurableField(
288 target=ReferenceSourceSelectorTask,
289 doc="How to down-select the loaded astrometry reference catalog.",
290 )
291 matchRadius = pexConfig.Field(
292 doc="Matching tolerance between associated objects (arcseconds).", dtype=float, default=1.0
293 )
294 minMatches = pexConfig.Field(
295 doc="Number of matches required to keep a source object.", dtype=int, default=2
296 )
297 allowSelfMatches = pexConfig.Field(
298 doc="Allow multiple sources from the same visit to be associated with the same object.",
299 dtype=bool,
300 default=False,
301 )
302 sourceFluxType = pexConfig.Field(
303 dtype=str,
304 doc="Source flux field to use in source selection and to get fluxes from the catalog.",
305 default="apFlux_12_0",
306 )
307 systematicError = pexConfig.Field(
308 dtype=float,
309 doc=(
310 "Systematic error padding added in quadrature for the science catalogs (marcsec). The default"
311 "value is equivalent to 0.02 pixels for HSC."
312 ),
313 default=0.0034,
314 )
315 referenceSystematicError = pexConfig.Field(
316 dtype=float,
317 doc="Systematic error padding added in quadrature for the reference catalog (marcsec).",
318 default=0.0,
319 )
320 modelComponents = pexConfig.ListField(
321 dtype=str,
322 doc=(
323 "List of mappings to apply to transform from pixels to sky, in order of their application."
324 "Supported options are 'INSTRUMENT/DEVICE' and 'EXPOSURE'."
325 ),
326 default=["INSTRUMENT/DEVICE", "EXPOSURE"],
327 )
328 deviceModel = pexConfig.ListField(
329 dtype=str,
330 doc=(
331 "List of mappings to apply to transform from detector pixels to intermediate frame. Map names"
332 "should match the format 'BAND/DEVICE/<map name>'."
333 ),
334 default=["BAND/DEVICE/poly"],
335 )
336 exposureModel = pexConfig.ListField(
337 dtype=str,
338 doc=(
339 "List of mappings to apply to transform from intermediate frame to sky coordinates. Map names"
340 "should match the format 'EXPOSURE/<map name>'."
341 ),
342 default=["EXPOSURE/poly"],
343 )
344 devicePolyOrder = pexConfig.Field(dtype=int, doc="Order of device polynomial model.", default=4)
345 exposurePolyOrder = pexConfig.Field(dtype=int, doc="Order of exposure polynomial model.", default=6)
346 fitProperMotion = pexConfig.Field(dtype=bool, doc="Fit the proper motions of the objects.", default=False)
347 excludeNonPMObjects = pexConfig.Field(
348 dtype=bool, doc="Exclude reference objects without proper motion/parallax information.", default=True
349 )
350 fitReserveFraction = pexConfig.Field(
351 dtype=float, default=0.2, doc="Fraction of objects to reserve from fit for validation."
352 )
353 fitReserveRandomSeed = pexConfig.Field(
354 dtype=int,
355 doc="Set the random seed for selecting data points to reserve from the fit for validation.",
356 default=1234,
357 )
358 saveModelParams = pexConfig.Field(
359 dtype=bool,
360 doc=(
361 "Save the parameters and covariance of the WCS model. Default to "
362 "false because this can be very large."
363 ),
364 default=False,
365 )
367 def setDefaults(self):
368 # Use only stars because aperture fluxes of galaxies are biased and
369 # depend on seeing.
370 self.sourceSelector["science"].doUnresolved = True
371 self.sourceSelector["science"].unresolved.name = "extendedness"
373 # Use only isolated sources.
374 self.sourceSelector["science"].doIsolated = True
375 self.sourceSelector["science"].isolated.parentName = "parentSourceId"
376 self.sourceSelector["science"].isolated.nChildName = "deblend_nChild"
377 # Do not use either flux or centroid measurements with flags,
378 # chosen from the usual QA flags for stars.
379 self.sourceSelector["science"].doFlags = True
380 badFlags = [
381 "pixelFlags_edge",
382 "pixelFlags_saturated",
383 "pixelFlags_interpolatedCenter",
384 "pixelFlags_interpolated",
385 "pixelFlags_crCenter",
386 "pixelFlags_bad",
387 "hsmPsfMoments_flag",
388 f"{self.sourceFluxType}_flag",
389 ]
390 self.sourceSelector["science"].flags.bad = badFlags
392 # Use only primary sources.
393 self.sourceSelector["science"].doRequirePrimary = True
395 def validate(self):
396 super().validate()
398 # Check if all components of the device and exposure models are
399 # supported.
400 for component in self.deviceModel:
401 if not (("poly" in component.lower()) or ("identity" in component.lower())):
402 raise pexConfig.FieldValidationError(
403 GbdesAstrometricFitConfig.deviceModel,
404 self,
405 f"deviceModel component {component} is not supported.",
406 )
408 for component in self.exposureModel:
409 if not (("poly" in component.lower()) or ("identity" in component.lower())):
410 raise pexConfig.FieldValidationError(
411 GbdesAstrometricFitConfig.exposureModel,
412 self,
413 f"exposureModel component {component} is not supported.",
414 )
417class GbdesAstrometricFitTask(pipeBase.PipelineTask):
418 """Calibrate the WCS across multiple visits of the same field using the
419 GBDES package.
420 """
422 ConfigClass = GbdesAstrometricFitConfig
423 _DefaultName = "gbdesAstrometricFit"
425 def __init__(self, **kwargs):
426 super().__init__(**kwargs)
427 self.makeSubtask("sourceSelector")
428 self.makeSubtask("referenceSelector")
430 def runQuantum(self, butlerQC, inputRefs, outputRefs):
431 # We override runQuantum to set up the refObjLoaders
432 inputs = butlerQC.get(inputRefs)
434 instrumentName = butlerQC.quantum.dataId["instrument"]
436 # Ensure the inputs are in a consistent order
437 inputCatVisits = np.array([inputCat.dataId["visit"] for inputCat in inputs["inputCatalogRefs"]])
438 inputs["inputCatalogRefs"] = [inputs["inputCatalogRefs"][v] for v in inputCatVisits.argsort()]
439 inputSumVisits = np.array([inputSum[0]["visit"] for inputSum in inputs["inputVisitSummaries"]])
440 inputs["inputVisitSummaries"] = [inputs["inputVisitSummaries"][v] for v in inputSumVisits.argsort()]
441 inputRefHtm7s = np.array([inputRefCat.dataId["htm7"] for inputRefCat in inputRefs.referenceCatalog])
442 inputRefCatRefs = [inputRefs.referenceCatalog[htm7] for htm7 in inputRefHtm7s.argsort()]
443 inputRefCats = np.array([inputRefCat.dataId["htm7"] for inputRefCat in inputs["referenceCatalog"]])
444 inputs["referenceCatalog"] = [inputs["referenceCatalog"][v] for v in inputRefCats.argsort()]
446 sampleRefCat = inputs["referenceCatalog"][0].get()
447 refEpoch = sampleRefCat[0]["epoch"]
449 refConfig = LoadReferenceObjectsConfig()
450 refConfig.anyFilterMapsToThis = "phot_g_mean"
451 refConfig.requireProperMotion = True
452 refObjectLoader = ReferenceObjectLoader(
453 dataIds=[ref.datasetRef.dataId for ref in inputRefCatRefs],
454 refCats=inputs.pop("referenceCatalog"),
455 config=refConfig,
456 log=self.log,
457 )
459 output = self.run(
460 **inputs, instrumentName=instrumentName, refEpoch=refEpoch, refObjectLoader=refObjectLoader
461 )
463 wcsOutputRefDict = {outWcsRef.dataId["visit"]: outWcsRef for outWcsRef in outputRefs.outputWcs}
464 for visit, outputWcs in output.outputWCSs.items():
465 butlerQC.put(outputWcs, wcsOutputRefDict[visit])
466 butlerQC.put(output.outputCatalog, outputRefs.outputCatalog)
467 butlerQC.put(output.starCatalog, outputRefs.starCatalog)
468 if self.config.saveModelParams:
469 butlerQC.put(output.modelParams, outputRefs.modelParams)
471 def run(
472 self, inputCatalogRefs, inputVisitSummaries, instrumentName="", refEpoch=None, refObjectLoader=None
473 ):
474 """Run the WCS fit for a given set of visits
476 Parameters
477 ----------
478 inputCatalogRefs : `list`
479 List of `DeferredDatasetHandle`s pointing to visit-level source
480 tables.
481 inputVisitSummaries : `list` of `lsst.afw.table.ExposureCatalog`
482 List of catalogs with per-detector summary information.
483 instrumentName : `str`, optional
484 Name of the instrument used. This is only used for labelling.
485 refEpoch : `float`
486 Epoch of the reference objects in MJD.
487 refObjectLoader : instance of
488 `lsst.meas.algorithms.loadReferenceObjects.ReferenceObjectLoader`
489 Referencef object loader instance.
491 Returns
492 -------
493 result : `lsst.pipe.base.Struct`
494 ``outputWCSs`` : `list` of `lsst.afw.table.ExposureCatalog`
495 List of exposure catalogs (one per visit) with the WCS for each
496 detector set by the new fitted WCS.
497 ``fitModel`` : `wcsfit.WCSFit`
498 Model-fitting object with final model parameters.
499 ``outputCatalog`` : `pyarrow.Table`
500 Catalog with fit residuals of all sources used.
501 """
502 if (len(inputVisitSummaries) == 1) and self.config.deviceModel and self.config.exposureModel:
503 raise RuntimeError(
504 "More than one exposure is necessary to break the degeneracy between the "
505 "device model and the exposure model."
506 )
507 self.log.info("Gathering instrument, exposure, and field info")
508 # Set up an instrument object
509 instrument = wcsfit.Instrument(instrumentName)
511 # Get RA, Dec, MJD, etc., for the input visits
512 exposureInfo, exposuresHelper, extensionInfo = self._get_exposure_info(
513 inputVisitSummaries, instrument
514 )
516 # Get information about the extent of the input visits
517 fields, fieldCenter, fieldRadius = self._prep_sky(inputVisitSummaries, exposureInfo.medianEpoch)
519 self.log.info("Load catalogs and associate sources")
520 # Set up class to associate sources into matches using a
521 # friends-of-friends algorithm
522 associations = wcsfit.FoFClass(
523 fields,
524 [instrument],
525 exposuresHelper,
526 [fieldRadius.asDegrees()],
527 (self.config.matchRadius * u.arcsec).to(u.degree).value,
528 )
530 # Add the reference catalog to the associator
531 medianEpoch = astropy.time.Time(exposureInfo.medianEpoch, format="decimalyear").mjd
532 refObjects, refCovariance = self._load_refcat(
533 associations, refObjectLoader, fieldCenter, fieldRadius, extensionInfo, epoch=medianEpoch
534 )
536 # Add the science catalogs and associate new sources as they are added
537 sourceIndices, usedColumns = self._load_catalogs_and_associate(
538 associations, inputCatalogRefs, extensionInfo
539 )
540 self._check_degeneracies(associations, extensionInfo)
542 self.log.info("Fit the WCSs")
543 # Set up a YAML-type string using the config variables and a sample
544 # visit
545 inputYAML, mapTemplate = self.make_yaml(inputVisitSummaries[0])
547 # Set the verbosity level for WCSFit from the task log level.
548 # TODO: DM-36850, Add lsst.log to gbdes so that log messages are
549 # properly propagated.
550 loglevel = self.log.getEffectiveLevel()
551 if loglevel >= self.log.WARNING:
552 verbose = 0
553 elif loglevel == self.log.INFO:
554 verbose = 1
555 else:
556 verbose = 2
558 # Set up the WCS-fitting class using the results of the FOF associator
559 wcsf = wcsfit.WCSFit(
560 fields,
561 [instrument],
562 exposuresHelper,
563 extensionInfo.visitIndex,
564 extensionInfo.detectorIndex,
565 inputYAML,
566 extensionInfo.wcs,
567 associations.sequence,
568 associations.extn,
569 associations.obj,
570 sysErr=self.config.systematicError,
571 refSysErr=self.config.referenceSystematicError,
572 usePM=self.config.fitProperMotion,
573 verbose=verbose,
574 )
576 # Add the science and reference sources
577 self._add_objects(wcsf, inputCatalogRefs, sourceIndices, extensionInfo, usedColumns)
578 self._add_ref_objects(wcsf, refObjects, refCovariance, extensionInfo)
580 # There must be at least as many sources per visit as the number of
581 # free parameters in the per-visit mapping. Set minFitExposures to be
582 # the number of free parameters, so that visits with fewer visits are
583 # dropped.
584 nCoeffVisitModel = _nCoeffsFromDegree(self.config.exposurePolyOrder)
585 # Do the WCS fit
586 wcsf.fit(
587 reserveFraction=self.config.fitReserveFraction,
588 randomNumberSeed=self.config.fitReserveRandomSeed,
589 minFitExposures=nCoeffVisitModel,
590 )
591 self.log.info("WCS fitting done")
593 outputWCSs = self._make_outputs(wcsf, inputVisitSummaries, exposureInfo, mapTemplate=mapTemplate)
594 outputCatalog = wcsf.getOutputCatalog()
595 starCatalog = wcsf.getStarCatalog()
596 modelParams = self._compute_model_params(wcsf) if self.config.saveModelParams else None
598 return pipeBase.Struct(
599 outputWCSs=outputWCSs,
600 fitModel=wcsf,
601 outputCatalog=outputCatalog,
602 starCatalog=starCatalog,
603 modelParams=modelParams,
604 )
606 def _prep_sky(self, inputVisitSummaries, epoch, fieldName="Field"):
607 """Get center and radius of the input tract. This assumes that all
608 visits will be put into the same `wcsfit.Field` and fit together.
610 Paramaters
611 ----------
612 inputVisitSummaries : `list` of `lsst.afw.table.ExposureCatalog`
613 List of catalogs with per-detector summary information.
614 epoch : float
615 Reference epoch.
616 fieldName : str
617 Name of the field, used internally.
619 Returns
620 -------
621 fields : `wcsfit.Fields`
622 Object with field information.
623 center : `lsst.geom.SpherePoint`
624 Center of the field.
625 radius : `lsst.sphgeom._sphgeom.Angle`
626 Radius of the bounding circle of the tract.
627 """
628 allDetectorCorners = []
629 for visSum in inputVisitSummaries:
630 detectorCorners = [
631 lsst.geom.SpherePoint(ra, dec, lsst.geom.degrees).getVector()
632 for (ra, dec) in zip(visSum["raCorners"].ravel(), visSum["decCorners"].ravel())
633 if (np.isfinite(ra) and (np.isfinite(dec)))
634 ]
635 allDetectorCorners.extend(detectorCorners)
636 boundingCircle = lsst.sphgeom.ConvexPolygon.convexHull(allDetectorCorners).getBoundingCircle()
637 center = lsst.geom.SpherePoint(boundingCircle.getCenter())
638 ra = center.getRa().asDegrees()
639 dec = center.getDec().asDegrees()
640 radius = boundingCircle.getOpeningAngle()
642 # wcsfit.Fields describes a list of fields, but we assume all
643 # observations will be fit together in one field.
644 fields = wcsfit.Fields([fieldName], [ra], [dec], [epoch])
646 return fields, center, radius
648 def _get_exposure_info(
649 self, inputVisitSummaries, instrument, fieldNumber=0, instrumentNumber=0, refEpoch=None
650 ):
651 """Get various information about the input visits to feed to the
652 fitting routines.
654 Parameters
655 ----------
656 inputVisitSummaries : `list` of `lsst.afw.table.ExposureCatalog`
657 Tables for each visit with information for detectors.
658 instrument : `wcsfit.Instrument`
659 Instrument object to which detector information is added.
660 fieldNumber : `int`
661 Index of the field for these visits. Should be zero if all data is
662 being fit together.
663 instrumentNumber : `int`
664 Index of the instrument for these visits. Should be zero if all
665 data comes from the same instrument.
666 refEpoch : `float`
667 Epoch of the reference objects in MJD.
669 Returns
670 -------
671 exposureInfo : `lsst.pipe.base.Struct`
672 Struct containing general properties for the visits:
673 ``visits`` : `list`
674 List of visit names.
675 ``detectors`` : `list`
676 List of all detectors in any visit.
677 ``ras`` : `list` of float
678 List of boresight RAs for each visit.
679 ``decs`` : `list` of float
680 List of borseight Decs for each visit.
681 ``medianEpoch`` : float
682 Median epoch of all visits in decimal-year format.
683 exposuresHelper : `wcsfit.ExposuresHelper`
684 Object containing information about the input visits.
685 extensionInfo : `lsst.pipe.base.Struct`
686 Struct containing properties for each extension:
687 ``visit`` : `np.ndarray`
688 Name of the visit for this extension.
689 ``detector`` : `np.ndarray`
690 Name of the detector for this extension.
691 ``visitIndex` : `np.ndarray` of `int`
692 Index of visit for this extension.
693 ``detectorIndex`` : `np.ndarray` of `int`
694 Index of the detector for this extension.
695 ``wcss`` : `np.ndarray` of `lsst.afw.geom.SkyWcs`
696 Initial WCS for this extension.
697 ``extensionType`` : `np.ndarray` of `str`
698 "SCIENCE" or "REFERENCE".
699 """
700 exposureNames = []
701 ras = []
702 decs = []
703 visits = []
704 detectors = []
705 airmasses = []
706 exposureTimes = []
707 mjds = []
708 observatories = []
709 wcss = []
711 extensionType = []
712 extensionVisitIndices = []
713 extensionDetectorIndices = []
714 extensionVisits = []
715 extensionDetectors = []
716 # Get information for all the science visits
717 for v, visitSummary in enumerate(inputVisitSummaries):
718 visitInfo = visitSummary[0].getVisitInfo()
719 visit = visitSummary[0]["visit"]
720 visits.append(visit)
721 exposureNames.append(str(visit))
722 raDec = visitInfo.getBoresightRaDec()
723 ras.append(raDec.getRa().asRadians())
724 decs.append(raDec.getDec().asRadians())
725 airmasses.append(visitInfo.getBoresightAirmass())
726 exposureTimes.append(visitInfo.getExposureTime())
727 obsDate = visitInfo.getDate()
728 obsMJD = obsDate.get(obsDate.MJD)
729 mjds.append(obsMJD)
730 # Get the observatory ICRS position for use in fitting parallax
731 obsLon = visitInfo.observatory.getLongitude().asDegrees()
732 obsLat = visitInfo.observatory.getLatitude().asDegrees()
733 obsElev = visitInfo.observatory.getElevation()
734 earthLocation = astropy.coordinates.EarthLocation.from_geodetic(obsLon, obsLat, obsElev)
735 observatory_gcrs = earthLocation.get_gcrs(astropy.time.Time(obsMJD, format="mjd"))
736 observatory_icrs = observatory_gcrs.transform_to(astropy.coordinates.ICRS())
737 # We want the position in AU in Cartesian coordinates
738 observatories.append(observatory_icrs.cartesian.xyz.to(u.AU).value)
740 for row in visitSummary:
741 detector = row["id"]
743 wcs = row.getWcs()
744 if wcs is None:
745 self.log.warning(
746 "WCS is None for visit %d, detector %d: this extension (visit/detector) will be "
747 "dropped.",
748 visit,
749 detector,
750 )
751 continue
752 else:
753 wcsRA = wcs.getSkyOrigin().getRa().asRadians()
754 wcsDec = wcs.getSkyOrigin().getDec().asRadians()
755 tangentPoint = wcsfit.Gnomonic(wcsRA, wcsDec)
756 mapping = wcs.getFrameDict().getMapping("PIXELS", "IWC")
757 gbdes_wcs = wcsfit.Wcs(wcsfit.ASTMap(mapping), tangentPoint)
758 wcss.append(gbdes_wcs)
760 if detector not in detectors:
761 detectors.append(detector)
762 detectorBounds = wcsfit.Bounds(
763 row["bbox_min_x"], row["bbox_max_x"], row["bbox_min_y"], row["bbox_max_y"]
764 )
765 instrument.addDevice(str(detector), detectorBounds)
767 detectorIndex = np.flatnonzero(detector == np.array(detectors))[0]
768 extensionVisitIndices.append(v)
769 extensionDetectorIndices.append(detectorIndex)
770 extensionVisits.append(visit)
771 extensionDetectors.append(detector)
772 extensionType.append("SCIENCE")
774 fieldNumbers = list(np.ones(len(exposureNames), dtype=int) * fieldNumber)
775 instrumentNumbers = list(np.ones(len(exposureNames), dtype=int) * instrumentNumber)
777 # Set the reference epoch to be the median of the science visits.
778 # The reference catalog will be shifted to this date.
779 medianMJD = np.median(mjds)
780 medianEpoch = astropy.time.Time(medianMJD, format="mjd").decimalyear
782 # Add information for the reference catalog. Most of the values are
783 # not used.
784 exposureNames.append("REFERENCE")
785 visits.append(-1)
786 fieldNumbers.append(0)
787 if self.config.fitProperMotion:
788 instrumentNumbers.append(-2)
789 else:
790 instrumentNumbers.append(-1)
791 ras.append(0.0)
792 decs.append(0.0)
793 airmasses.append(0.0)
794 exposureTimes.append(0)
795 mjds.append((refEpoch if (refEpoch is not None) else medianMJD))
796 observatories.append(np.array([0, 0, 0]))
797 identity = wcsfit.IdentityMap()
798 icrs = wcsfit.SphericalICRS()
799 refWcs = wcsfit.Wcs(identity, icrs, "Identity", np.pi / 180.0)
800 wcss.append(refWcs)
802 extensionVisitIndices.append(len(exposureNames) - 1)
803 extensionDetectorIndices.append(-1) # REFERENCE device must be -1
804 extensionVisits.append(-1)
805 extensionDetectors.append(-1)
806 extensionType.append("REFERENCE")
808 # Make a table of information to use elsewhere in the class
809 extensionInfo = pipeBase.Struct(
810 visit=np.array(extensionVisits),
811 detector=np.array(extensionDetectors),
812 visitIndex=np.array(extensionVisitIndices),
813 detectorIndex=np.array(extensionDetectorIndices),
814 wcs=np.array(wcss),
815 extensionType=np.array(extensionType),
816 )
818 # Make the exposureHelper object to use in the fitting routines
819 exposuresHelper = wcsfit.ExposuresHelper(
820 exposureNames,
821 fieldNumbers,
822 instrumentNumbers,
823 ras,
824 decs,
825 airmasses,
826 exposureTimes,
827 mjds,
828 observatories,
829 )
831 exposureInfo = pipeBase.Struct(
832 visits=visits, detectors=detectors, ras=ras, decs=decs, medianEpoch=medianEpoch
833 )
835 return exposureInfo, exposuresHelper, extensionInfo
837 def _load_refcat(
838 self, associations, refObjectLoader, center, radius, extensionInfo, epoch=None, fieldIndex=0
839 ):
840 """Load the reference catalog and add reference objects to the
841 `wcsfit.FoFClass` object.
843 Parameters
844 ----------
845 associations : `wcsfit.FoFClass`
846 Object to which to add the catalog of reference objects.
847 refObjectLoader :
848 `lsst.meas.algorithms.loadReferenceObjects.ReferenceObjectLoader`
849 Object set up to load reference catalog objects.
850 center : `lsst.geom.SpherePoint`
851 Center of the circle in which to load reference objects.
852 radius : `lsst.sphgeom._sphgeom.Angle`
853 Radius of the circle in which to load reference objects.
854 extensionInfo : `lsst.pipe.base.Struct`
855 Struct containing properties for each extension.
856 epoch : `float`
857 MJD to which to correct the object positions.
858 fieldIndex : `int`
859 Index of the field. Should be zero if all the data is fit together.
861 Returns
862 -------
863 refObjects : `dict`
864 Position and error information of reference objects.
865 refCovariance : `list` of `float`
866 Flattened output covariance matrix.
867 """
868 formattedEpoch = astropy.time.Time(epoch, format="mjd")
870 refFilter = refObjectLoader.config.anyFilterMapsToThis
871 skyCircle = refObjectLoader.loadSkyCircle(center, radius, refFilter, epoch=formattedEpoch)
873 selected = self.referenceSelector.run(skyCircle.refCat)
874 # Need memory contiguity to get reference filters as a vector.
875 if not selected.sourceCat.isContiguous():
876 refCat = selected.sourceCat.copy(deep=True)
877 else:
878 refCat = selected.sourceCat
880 # In Gaia DR3, missing values are denoted by NaNs.
881 finiteInd = np.isfinite(refCat["coord_ra"]) & np.isfinite(refCat["coord_dec"])
882 refCat = refCat[finiteInd]
884 if self.config.excludeNonPMObjects:
885 # Gaia DR2 has zeros for missing data, while Gaia DR3 has NaNs:
886 hasPM = (
887 (refCat["pm_raErr"] != 0) & np.isfinite(refCat["pm_raErr"]) & np.isfinite(refCat["pm_decErr"])
888 )
889 refCat = refCat[hasPM]
891 ra = (refCat["coord_ra"] * u.radian).to(u.degree).to_value().tolist()
892 dec = (refCat["coord_dec"] * u.radian).to(u.degree).to_value().tolist()
893 raCov = ((refCat["coord_raErr"] * u.radian).to(u.degree).to_value() ** 2).tolist()
894 decCov = ((refCat["coord_decErr"] * u.radian).to(u.degree).to_value() ** 2).tolist()
896 # Get refcat version from refcat metadata
897 refCatMetadata = refObjectLoader.refCats[0].get().getMetadata()
898 refCatVersion = refCatMetadata["REFCAT_FORMAT_VERSION"]
899 if refCatVersion == 2:
900 raDecCov = (refCat["coord_ra_coord_dec_Cov"] * u.radian**2).to(u.degree**2).to_value().tolist()
901 else:
902 raDecCov = np.zeros(len(ra))
904 refObjects = {"ra": ra, "dec": dec, "raCov": raCov, "decCov": decCov, "raDecCov": raDecCov}
905 refCovariance = []
907 if self.config.fitProperMotion:
908 raPM = (refCat["pm_ra"] * u.radian).to(u.marcsec).to_value().tolist()
909 decPM = (refCat["pm_dec"] * u.radian).to(u.marcsec).to_value().tolist()
910 parallax = (refCat["parallax"] * u.radian).to(u.marcsec).to_value().tolist()
911 cov = _make_ref_covariance_matrix(refCat, version=refCatVersion)
912 pmDict = {"raPM": raPM, "decPM": decPM, "parallax": parallax}
913 refObjects.update(pmDict)
914 refCovariance = cov
916 extensionIndex = np.flatnonzero(extensionInfo.extensionType == "REFERENCE")[0]
917 visitIndex = extensionInfo.visitIndex[extensionIndex]
918 detectorIndex = extensionInfo.detectorIndex[extensionIndex]
919 instrumentIndex = -1 # -1 indicates the reference catalog
920 refWcs = extensionInfo.wcs[extensionIndex]
922 associations.addCatalog(
923 refWcs,
924 "STELLAR",
925 visitIndex,
926 fieldIndex,
927 instrumentIndex,
928 detectorIndex,
929 extensionIndex,
930 np.ones(len(refCat), dtype=bool),
931 ra,
932 dec,
933 np.arange(len(ra)),
934 )
936 return refObjects, refCovariance
938 @staticmethod
939 def _find_extension_index(extensionInfo, visit, detector):
940 """Find the index for a given extension from its visit and detector
941 number.
943 If no match is found, None is returned.
945 Parameters
946 ----------
947 extensionInfo : `lsst.pipe.base.Struct`
948 Struct containing properties for each extension.
949 visit : `int`
950 Visit number
951 detector : `int`
952 Detector number
954 Returns
955 -------
956 extensionIndex : `int` or None
957 Index of this extension
958 """
959 findExtension = np.flatnonzero((extensionInfo.visit == visit) & (extensionInfo.detector == detector))
960 if len(findExtension) == 0:
961 extensionIndex = None
962 else:
963 extensionIndex = findExtension[0]
964 return extensionIndex
966 def _load_catalogs_and_associate(
967 self, associations, inputCatalogRefs, extensionInfo, fieldIndex=0, instrumentIndex=0
968 ):
969 """Load the science catalogs and add the sources to the associator
970 class `wcsfit.FoFClass`, associating them into matches as you go.
972 Parameters
973 ----------
974 associations : `wcsfit.FoFClass`
975 Object to which to add the catalog of source and which performs
976 the source association.
977 inputCatalogRefs : `list`
978 List of DeferredDatasetHandles pointing to visit-level source
979 tables.
980 extensionInfo : `lsst.pipe.base.Struct`
981 Struct containing properties for each extension.
982 fieldIndex : `int`
983 Index of the field for these catalogs. Should be zero assuming all
984 data is being fit together.
985 instrumentIndex : `int`
986 Index of the instrument for these catalogs. Should be zero
987 assuming all data comes from the same instrument.
989 Returns
990 -------
991 sourceIndices : `list`
992 List of boolean arrays used to select sources.
993 columns : `list` of `str`
994 List of columns needed from source tables.
995 """
996 columns = [
997 "detector",
998 "sourceId",
999 "x",
1000 "xErr",
1001 "y",
1002 "yErr",
1003 "ixx",
1004 "iyy",
1005 "ixy",
1006 f"{self.config.sourceFluxType}_instFlux",
1007 f"{self.config.sourceFluxType}_instFluxErr",
1008 ]
1009 if self.sourceSelector.config.doFlags:
1010 columns.extend(self.sourceSelector.config.flags.bad)
1011 if self.sourceSelector.config.doUnresolved:
1012 columns.append(self.sourceSelector.config.unresolved.name)
1013 if self.sourceSelector.config.doIsolated:
1014 columns.append(self.sourceSelector.config.isolated.parentName)
1015 columns.append(self.sourceSelector.config.isolated.nChildName)
1016 if self.sourceSelector.config.doRequirePrimary:
1017 columns.append(self.sourceSelector.config.requirePrimary.primaryColName)
1019 sourceIndices = [None] * len(extensionInfo.visit)
1020 for inputCatalogRef in inputCatalogRefs:
1021 visit = inputCatalogRef.dataId["visit"]
1022 inputCatalog = inputCatalogRef.get(parameters={"columns": columns})
1023 # Get a sorted array of detector names
1024 detectors = np.unique(inputCatalog["detector"])
1026 for detector in detectors:
1027 detectorSources = inputCatalog[inputCatalog["detector"] == detector]
1028 xCov = detectorSources["xErr"] ** 2
1029 yCov = detectorSources["yErr"] ** 2
1030 xyCov = (
1031 detectorSources["ixy"] * (xCov + yCov) / (detectorSources["ixx"] + detectorSources["iyy"])
1032 )
1033 # Remove sources with bad shape measurements
1034 goodShapes = xyCov**2 <= (xCov * yCov)
1035 selected = self.sourceSelector.run(detectorSources)
1036 goodInds = selected.selected & goodShapes
1038 isStar = np.ones(goodInds.sum())
1039 extensionIndex = self._find_extension_index(extensionInfo, visit, detector)
1040 if extensionIndex is None:
1041 # This extension does not have information necessary for
1042 # fit. Skip the detections from this detector for this
1043 # visit.
1044 continue
1045 detectorIndex = extensionInfo.detectorIndex[extensionIndex]
1046 visitIndex = extensionInfo.visitIndex[extensionIndex]
1048 sourceIndices[extensionIndex] = goodInds
1050 wcs = extensionInfo.wcs[extensionIndex]
1051 associations.reprojectWCS(wcs, fieldIndex)
1053 associations.addCatalog(
1054 wcs,
1055 "STELLAR",
1056 visitIndex,
1057 fieldIndex,
1058 instrumentIndex,
1059 detectorIndex,
1060 extensionIndex,
1061 isStar,
1062 detectorSources[goodInds]["x"].to_list(),
1063 detectorSources[goodInds]["y"].to_list(),
1064 np.arange(goodInds.sum()),
1065 )
1067 associations.sortMatches(
1068 fieldIndex, minMatches=self.config.minMatches, allowSelfMatches=self.config.allowSelfMatches
1069 )
1071 return sourceIndices, columns
1073 def _check_degeneracies(self, associations, extensionInfo):
1074 """Check that the minimum number of visits and sources needed to
1075 constrain the model are present.
1077 This does not guarantee that the Hessian matrix of the chi-square,
1078 which is used to fit the model, will be positive-definite, but if the
1079 checks here do not pass, the matrix is certain to not be
1080 positive-definite and the model cannot be fit.
1082 Parameters
1083 ----------
1084 associations : `wcsfit.FoFClass`
1085 Object holding the source association information.
1086 extensionInfo : `lsst.pipe.base.Struct`
1087 Struct containing properties for each extension.
1088 """
1089 # As a baseline, need to have more stars per detector than per-detector
1090 # parameters, and more stars per visit than per-visit parameters.
1091 whichExtension = np.array(associations.extn)
1092 whichDetector = np.zeros(len(whichExtension))
1093 whichVisit = np.zeros(len(whichExtension))
1095 for extension, (detector, visit) in enumerate(zip(extensionInfo.detector, extensionInfo.visit)):
1096 ex_ind = whichExtension == extension
1097 whichDetector[ex_ind] = detector
1098 whichVisit[ex_ind] = visit
1100 if "BAND/DEVICE/poly" in self.config.deviceModel:
1101 nCoeffDetectorModel = _nCoeffsFromDegree(self.config.devicePolyOrder)
1102 unconstrainedDetectors = []
1103 for detector in np.unique(extensionInfo.detector):
1104 numSources = (whichDetector == detector).sum()
1105 if numSources < nCoeffDetectorModel:
1106 unconstrainedDetectors.append(str(detector))
1108 if unconstrainedDetectors:
1109 raise RuntimeError(
1110 "The model is not constrained. The following detectors do not have enough "
1111 f"sources ({nCoeffDetectorModel} required): ",
1112 ", ".join(unconstrainedDetectors),
1113 )
1115 def make_yaml(self, inputVisitSummary, inputFile=None):
1116 """Make a YAML-type object that describes the parameters of the fit
1117 model.
1119 Parameters
1120 ----------
1121 inputVisitSummary : `lsst.afw.table.ExposureCatalog`
1122 Catalog with per-detector summary information.
1123 inputFile : `str`
1124 Path to a file that contains a basic model.
1126 Returns
1127 -------
1128 inputYAML : `wcsfit.YAMLCollector`
1129 YAML object containing the model description.
1130 inputDict : `dict` [`str`, `str`]
1131 Dictionary containing the model description.
1132 """
1133 if inputFile is not None:
1134 inputYAML = wcsfit.YAMLCollector(inputFile, "PixelMapCollection")
1135 else:
1136 inputYAML = wcsfit.YAMLCollector("", "PixelMapCollection")
1137 inputDict = {}
1138 modelComponents = ["INSTRUMENT/DEVICE", "EXPOSURE"]
1139 baseMap = {"Type": "Composite", "Elements": modelComponents}
1140 inputDict["EXPOSURE/DEVICE/base"] = baseMap
1142 xMin = str(inputVisitSummary["bbox_min_x"].min())
1143 xMax = str(inputVisitSummary["bbox_max_x"].max())
1144 yMin = str(inputVisitSummary["bbox_min_y"].min())
1145 yMax = str(inputVisitSummary["bbox_max_y"].max())
1147 deviceModel = {"Type": "Composite", "Elements": self.config.deviceModel.list()}
1148 inputDict["INSTRUMENT/DEVICE"] = deviceModel
1149 for component in self.config.deviceModel:
1150 if "poly" in component.lower():
1151 componentDict = {
1152 "Type": "Poly",
1153 "XPoly": {"OrderX": self.config.devicePolyOrder, "SumOrder": True},
1154 "YPoly": {"OrderX": self.config.devicePolyOrder, "SumOrder": True},
1155 "XMin": xMin,
1156 "XMax": xMax,
1157 "YMin": yMin,
1158 "YMax": yMax,
1159 }
1160 elif "identity" in component.lower():
1161 componentDict = {"Type": "Identity"}
1163 inputDict[component] = componentDict
1165 exposureModel = {"Type": "Composite", "Elements": self.config.exposureModel.list()}
1166 inputDict["EXPOSURE"] = exposureModel
1167 for component in self.config.exposureModel:
1168 if "poly" in component.lower():
1169 componentDict = {
1170 "Type": "Poly",
1171 "XPoly": {"OrderX": self.config.exposurePolyOrder, "SumOrder": "true"},
1172 "YPoly": {"OrderX": self.config.exposurePolyOrder, "SumOrder": "true"},
1173 }
1174 elif "identity" in component.lower():
1175 componentDict = {"Type": "Identity"}
1177 inputDict[component] = componentDict
1179 inputYAML.addInput(yaml.dump(inputDict))
1180 inputYAML.addInput("Identity:\n Type: Identity\n")
1182 return inputYAML, inputDict
1184 def _add_objects(self, wcsf, inputCatalogRefs, sourceIndices, extensionInfo, columns):
1185 """Add science sources to the wcsfit.WCSFit object.
1187 Parameters
1188 ----------
1189 wcsf : `wcsfit.WCSFit`
1190 WCS-fitting object.
1191 inputCatalogRefs : `list`
1192 List of DeferredDatasetHandles pointing to visit-level source
1193 tables.
1194 sourceIndices : `list`
1195 List of boolean arrays used to select sources.
1196 extensionInfo : `lsst.pipe.base.Struct`
1197 Struct containing properties for each extension.
1198 columns : `list` of `str`
1199 List of columns needed from source tables.
1200 """
1201 for inputCatalogRef in inputCatalogRefs:
1202 visit = inputCatalogRef.dataId["visit"]
1203 inputCatalog = inputCatalogRef.get(parameters={"columns": columns})
1204 detectors = np.unique(inputCatalog["detector"])
1206 for detector in detectors:
1207 detectorSources = inputCatalog[inputCatalog["detector"] == detector]
1209 extensionIndex = self._find_extension_index(extensionInfo, visit, detector)
1210 if extensionIndex is None:
1211 # This extension does not have information necessary for
1212 # fit. Skip the detections from this detector for this
1213 # visit.
1214 continue
1216 sourceCat = detectorSources[sourceIndices[extensionIndex]]
1218 xCov = sourceCat["xErr"] ** 2
1219 yCov = sourceCat["yErr"] ** 2
1220 xyCov = sourceCat["ixy"] * (xCov + yCov) / (sourceCat["ixx"] + sourceCat["iyy"])
1221 # TODO: add correct xyErr if DM-7101 is ever done.
1223 d = {
1224 "x": sourceCat["x"].to_numpy(),
1225 "y": sourceCat["y"].to_numpy(),
1226 "xCov": xCov.to_numpy(),
1227 "yCov": yCov.to_numpy(),
1228 "xyCov": xyCov.to_numpy(),
1229 }
1231 wcsf.setObjects(extensionIndex, d, "x", "y", ["xCov", "yCov", "xyCov"])
1233 def _add_ref_objects(self, wcsf, refObjects, refCovariance, extensionInfo):
1234 """Add reference sources to the wcsfit.WCSFit object.
1236 Parameters
1237 ----------
1238 wcsf : `wcsfit.WCSFit`
1239 WCS-fitting object.
1240 refObjects : `dict`
1241 Position and error information of reference objects.
1242 refCovariance : `list` of `float`
1243 Flattened output covariance matrix.
1244 extensionInfo : `lsst.pipe.base.Struct`
1245 Struct containing properties for each extension.
1246 """
1247 extensionIndex = np.flatnonzero(extensionInfo.extensionType == "REFERENCE")[0]
1249 if self.config.fitProperMotion:
1250 wcsf.setObjects(
1251 extensionIndex,
1252 refObjects,
1253 "ra",
1254 "dec",
1255 ["raCov", "decCov", "raDecCov"],
1256 pmDecKey="decPM",
1257 pmRaKey="raPM",
1258 parallaxKey="parallax",
1259 pmCovKey="fullCov",
1260 pmCov=refCovariance,
1261 )
1262 else:
1263 wcsf.setObjects(extensionIndex, refObjects, "ra", "dec", ["raCov", "decCov", "raDecCov"])
1265 def _make_afw_wcs(self, mapDict, centerRA, centerDec, doNormalizePixels=False, xScale=1, yScale=1):
1266 """Make an `lsst.afw.geom.SkyWcs` from a dictionary of mappings.
1268 Parameters
1269 ----------
1270 mapDict : `dict`
1271 Dictionary of mapping parameters.
1272 centerRA : `lsst.geom.Angle`
1273 RA of the tangent point.
1274 centerDec : `lsst.geom.Angle`
1275 Declination of the tangent point.
1276 doNormalizePixels : `bool`
1277 Whether to normalize pixels so that range is [-1,1].
1278 xScale : `float`
1279 Factor by which to normalize x-dimension. Corresponds to width of
1280 detector.
1281 yScale : `float`
1282 Factor by which to normalize y-dimension. Corresponds to height of
1283 detector.
1285 Returns
1286 -------
1287 outWCS : `lsst.afw.geom.SkyWcs`
1288 WCS constructed from the input mappings
1289 """
1290 # Set up pixel frames
1291 pixelFrame = astshim.Frame(2, "Domain=PIXELS")
1292 normedPixelFrame = astshim.Frame(2, "Domain=NORMEDPIXELS")
1294 if doNormalizePixels:
1295 # Pixels will need to be rescaled before going into the mappings
1296 normCoefficients = [-1.0, 2.0 / xScale, 0, -1.0, 0, 2.0 / yScale]
1297 normMap = _convert_to_ast_polymap_coefficients(normCoefficients)
1298 else:
1299 normMap = astshim.UnitMap(2)
1301 # All of the detectors for one visit map to the same tangent plane
1302 tangentPoint = lsst.geom.SpherePoint(centerRA, centerDec)
1303 cdMatrix = afwgeom.makeCdMatrix(1.0 * lsst.geom.degrees, 0 * lsst.geom.degrees, True)
1304 iwcToSkyWcs = afwgeom.makeSkyWcs(lsst.geom.Point2D(0, 0), tangentPoint, cdMatrix)
1305 iwcToSkyMap = iwcToSkyWcs.getFrameDict().getMapping("PIXELS", "SKY")
1306 skyFrame = iwcToSkyWcs.getFrameDict().getFrame("SKY")
1308 frameDict = astshim.FrameDict(pixelFrame)
1309 frameDict.addFrame("PIXELS", normMap, normedPixelFrame)
1311 currentFrameName = "NORMEDPIXELS"
1313 # Dictionary values are ordered according to the maps' application.
1314 for m, mapElement in enumerate(mapDict.values()):
1315 mapType = mapElement["Type"]
1317 if mapType == "Poly":
1318 mapCoefficients = mapElement["Coefficients"]
1319 astMap = _convert_to_ast_polymap_coefficients(mapCoefficients)
1320 elif mapType == "Identity":
1321 astMap = astshim.UnitMap(2)
1322 else:
1323 raise ValueError(f"Converting map type {mapType} to WCS is not supported")
1325 if m == len(mapDict) - 1:
1326 newFrameName = "IWC"
1327 else:
1328 newFrameName = "INTERMEDIATE" + str(m)
1329 newFrame = astshim.Frame(2, f"Domain={newFrameName}")
1330 frameDict.addFrame(currentFrameName, astMap, newFrame)
1331 currentFrameName = newFrameName
1332 frameDict.addFrame("IWC", iwcToSkyMap, skyFrame)
1334 outWCS = afwgeom.SkyWcs(frameDict)
1335 return outWCS
1337 def _make_outputs(self, wcsf, visitSummaryTables, exposureInfo, mapTemplate=None):
1338 """Make a WCS object out of the WCS models.
1340 Parameters
1341 ----------
1342 wcsf : `wcsfit.WCSFit`
1343 WCSFit object, assumed to have fit model.
1344 visitSummaryTables : `list` of `lsst.afw.table.ExposureCatalog`
1345 Catalogs with per-detector summary information from which to grab
1346 detector information.
1347 extensionInfo : `lsst.pipe.base.Struct`
1348 Struct containing properties for each extension.
1350 Returns
1351 -------
1352 catalogs : `dict` of [`str`, `lsst.afw.table.ExposureCatalog`]
1353 Dictionary of `lsst.afw.table.ExposureCatalog` objects with the WCS
1354 set to the WCS fit in wcsf, keyed by the visit name.
1355 """
1356 # Get the parameters of the fit models
1357 mapParams = wcsf.mapCollection.getParamDict()
1359 # Set up the schema for the output catalogs
1360 schema = lsst.afw.table.ExposureTable.makeMinimalSchema()
1361 schema.addField("visit", type="L", doc="Visit number")
1363 # Pixels will need to be rescaled before going into the mappings
1364 sampleDetector = visitSummaryTables[0][0]
1365 xscale = sampleDetector["bbox_max_x"] - sampleDetector["bbox_min_x"]
1366 yscale = sampleDetector["bbox_max_y"] - sampleDetector["bbox_min_y"]
1368 catalogs = {}
1369 for v, visitSummary in enumerate(visitSummaryTables):
1370 visit = visitSummary[0]["visit"]
1372 visitMap = wcsf.mapCollection.orderAtoms(f"{visit}")[0]
1373 visitMapType = wcsf.mapCollection.getMapType(visitMap)
1374 if (visitMap not in mapParams) and (visitMapType != "Identity"):
1375 self.log.warning("Visit %d was dropped because of an insufficient number of sources.", visit)
1376 continue
1378 catalog = lsst.afw.table.ExposureCatalog(schema)
1379 catalog.resize(len(exposureInfo.detectors))
1380 catalog["visit"] = visit
1382 for d, detector in enumerate(visitSummary["id"]):
1383 mapName = f"{visit}/{detector}"
1384 if mapName in wcsf.mapCollection.allMapNames():
1385 mapElements = wcsf.mapCollection.orderAtoms(f"{mapName}/base")
1386 else:
1387 # This extension was not fit, but the WCS can be recovered
1388 # using the maps fit from sources on other visits but the
1389 # same detector and from sources on other detectors from
1390 # this visit.
1391 genericElements = mapTemplate["EXPOSURE/DEVICE/base"]["Elements"]
1392 mapElements = []
1393 instrument = visitSummary[0].getVisitInfo().instrumentLabel
1394 # Go through the generic map components to build the names
1395 # of the specific maps for this extension.
1396 for component in genericElements:
1397 elements = mapTemplate[component]["Elements"]
1398 for element in elements:
1399 # TODO: DM-42519, gbdes sets the "BAND" to the
1400 # instrument name currently. This will need to be
1401 # disambiguated if we run on multiple bands at
1402 # once.
1403 element = element.replace("BAND", str(instrument))
1404 element = element.replace("EXPOSURE", str(visit))
1405 element = element.replace("DEVICE", str(detector))
1406 mapElements.append(element)
1407 mapDict = {}
1408 for m, mapElement in enumerate(mapElements):
1409 mapType = wcsf.mapCollection.getMapType(mapElement)
1410 mapDict[mapElement] = {"Type": mapType}
1412 if mapType == "Poly":
1413 mapCoefficients = mapParams[mapElement]
1414 mapDict[mapElement]["Coefficients"] = mapCoefficients
1416 # The RA and Dec of the visit are needed for the last step of
1417 # the mapping from the visit tangent plane to RA and Dec
1418 outWCS = self._make_afw_wcs(
1419 mapDict,
1420 exposureInfo.ras[v] * lsst.geom.radians,
1421 exposureInfo.decs[v] * lsst.geom.radians,
1422 doNormalizePixels=True,
1423 xScale=xscale,
1424 yScale=yscale,
1425 )
1427 catalog[d].setId(detector)
1428 catalog[d].setWcs(outWCS)
1429 catalog.sort()
1430 catalogs[visit] = catalog
1432 return catalogs
1434 def _compute_model_params(self, wcsf):
1435 """Get the WCS model parameters and covariance and convert to a
1436 dictionary that will be readable as a pandas dataframe or other table.
1438 Parameters
1439 ----------
1440 wcsf : `wcsfit.WCSFit`
1441 WCSFit object, assumed to have fit model.
1443 Returns
1444 -------
1445 modelParams : `dict`
1446 Parameters and covariance of the best-fit WCS model.
1447 """
1448 modelParamDict = wcsf.mapCollection.getParamDict()
1449 modelCovariance = wcsf.getModelCovariance()
1451 modelParams = {k: [] for k in ["mapName", "coordinate", "parameter", "coefficientNumber"]}
1452 i = 0
1453 for mapName, params in modelParamDict.items():
1454 nCoeffs = len(params)
1455 # There are an equal number of x and y coordinate parameters
1456 nCoordCoeffs = nCoeffs // 2
1457 modelParams["mapName"].extend([mapName] * nCoeffs)
1458 modelParams["coordinate"].extend(["x"] * nCoordCoeffs)
1459 modelParams["coordinate"].extend(["y"] * nCoordCoeffs)
1460 modelParams["parameter"].extend(params)
1461 modelParams["coefficientNumber"].extend(np.arange(nCoordCoeffs))
1462 modelParams["coefficientNumber"].extend(np.arange(nCoordCoeffs))
1464 for p in range(nCoeffs):
1465 if p < nCoordCoeffs:
1466 coord = "x"
1467 else:
1468 coord = "y"
1469 modelParams[f"{mapName}_{coord}_{p}_cov"] = modelCovariance[i]
1470 i += 1
1472 # Convert the dictionary values from lists to numpy arrays.
1473 for key, value in modelParams.items():
1474 modelParams[key] = np.array(value)
1476 return modelParams