LSSTApplications  16.0-10-g0ee56ad+5,16.0-11-ga33d1f2+5,16.0-12-g3ef5c14+3,16.0-12-g71e5ef5+18,16.0-12-gbdf3636+3,16.0-13-g118c103+3,16.0-13-g8f68b0a+3,16.0-15-gbf5c1cb+4,16.0-16-gfd17674+3,16.0-17-g7c01f5c+3,16.0-18-g0a50484+1,16.0-20-ga20f992+8,16.0-21-g0e05fd4+6,16.0-21-g15e2d33+4,16.0-22-g62d8060+4,16.0-22-g847a80f+4,16.0-25-gf00d9b8+1,16.0-28-g3990c221+4,16.0-3-gf928089+3,16.0-32-g88a4f23+5,16.0-34-gd7987ad+3,16.0-37-gc7333cb+2,16.0-4-g10fc685+2,16.0-4-g18f3627+26,16.0-4-g5f3a788+26,16.0-5-gaf5c3d7+4,16.0-5-gcc1f4bb+1,16.0-6-g3b92700+4,16.0-6-g4412fcd+3,16.0-6-g7235603+4,16.0-69-g2562ce1b+2,16.0-8-g14ebd58+4,16.0-8-g2df868b+1,16.0-8-g4cec79c+6,16.0-8-gadf6c7a+1,16.0-8-gfc7ad86,16.0-82-g59ec2a54a+1,16.0-9-g5400cdc+2,16.0-9-ge6233d7+5,master-g2880f2d8cf+3,v17.0.rc1
LSSTDataManagementBasePackage
jointcal.py
Go to the documentation of this file.
1 # This file is part of jointcal.
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 import collections
23 import numpy as np
24 import astropy.units as u
25 
26 import lsst.utils
27 import lsst.pex.config as pexConfig
28 import lsst.pipe.base as pipeBase
29 import lsst.afw.image as afwImage
30 from lsst.afw.image import fluxErrFromABMagErr
31 import lsst.afw.geom as afwGeom
32 import lsst.pex.exceptions as pexExceptions
33 import lsst.afw.table
35 from lsst.pipe.tasks.colorterms import ColortermLibrary
36 from lsst.verify import Job, Measurement
37 
38 from lsst.meas.algorithms import LoadIndexedReferenceObjectsTask
39 from lsst.meas.algorithms.sourceSelector import sourceSelectorRegistry
40 
41 from .dataIds import PerTractCcdDataIdContainer
42 
43 import lsst.jointcal
44 from lsst.jointcal import MinimizeResult
45 
46 __all__ = ["JointcalConfig", "JointcalRunner", "JointcalTask"]
47 
48 Photometry = collections.namedtuple('Photometry', ('fit', 'model'))
49 Astrometry = collections.namedtuple('Astrometry', ('fit', 'model', 'sky_to_tan_projection'))
50 
51 
52 # TODO: move this to MeasurementSet in lsst.verify per DM-12655.
53 def add_measurement(job, name, value):
54  meas = Measurement(job.metrics[name], value)
55  job.measurements.insert(meas)
56 
57 
58 class JointcalRunner(pipeBase.ButlerInitializedTaskRunner):
59  """Subclass of TaskRunner for jointcalTask
60 
61  jointcalTask.runDataRef() takes a number of arguments, one of which is a list of dataRefs
62  extracted from the command line (whereas most CmdLineTasks' runDataRef methods take
63  single dataRef, are are called repeatedly). This class transforms the processed
64  arguments generated by the ArgumentParser into the arguments expected by
65  Jointcal.runDataRef().
66 
67  See pipeBase.TaskRunner for more information.
68  """
69 
70  @staticmethod
71  def getTargetList(parsedCmd, **kwargs):
72  """
73  Return a list of tuples per tract, each containing (dataRefs, kwargs).
74 
75  Jointcal operates on lists of dataRefs simultaneously.
76  """
77  kwargs['profile_jointcal'] = parsedCmd.profile_jointcal
78  kwargs['butler'] = parsedCmd.butler
79 
80  # organize data IDs by tract
81  refListDict = {}
82  for ref in parsedCmd.id.refList:
83  refListDict.setdefault(ref.dataId["tract"], []).append(ref)
84  # we call runDataRef() once with each tract
85  result = [(refListDict[tract], kwargs) for tract in sorted(refListDict.keys())]
86  return result
87 
88  def __call__(self, args):
89  """
90  Parameters
91  ----------
92  args
93  Arguments for Task.runDataRef()
94 
95  Returns
96  -------
97  pipe.base.Struct
98  if self.doReturnResults is False:
99 
100  - ``exitStatus``: 0 if the task completed successfully, 1 otherwise.
101 
102  if self.doReturnResults is True:
103 
104  - ``result``: the result of calling jointcal.runDataRef()
105  - ``exitStatus``: 0 if the task completed successfully, 1 otherwise.
106  """
107  exitStatus = 0 # exit status for shell
108 
109  # NOTE: cannot call self.makeTask because that assumes args[0] is a single dataRef.
110  dataRefList, kwargs = args
111  butler = kwargs.pop('butler')
112  task = self.TaskClass(config=self.config, log=self.log, butler=butler)
113  result = None
114  try:
115  result = task.runDataRef(dataRefList, **kwargs)
116  exitStatus = result.exitStatus
117  job_path = butler.get('verify_job_filename')
118  result.job.write(job_path[0])
119  except Exception as e: # catch everything, sort it out later.
120  if self.doRaise:
121  raise e
122  else:
123  exitStatus = 1
124  eName = type(e).__name__
125  tract = dataRefList[0].dataId['tract']
126  task.log.fatal("Failed processing tract %s, %s: %s", tract, eName, e)
127 
128  if self.doReturnResults:
129  return pipeBase.Struct(result=result, exitStatus=exitStatus)
130  else:
131  return pipeBase.Struct(exitStatus=exitStatus)
132 
133 
134 class JointcalConfig(pexConfig.Config):
135  """Config for JointcalTask"""
136 
137  doAstrometry = pexConfig.Field(
138  doc="Fit astrometry and write the fitted result.",
139  dtype=bool,
140  default=True
141  )
142  doPhotometry = pexConfig.Field(
143  doc="Fit photometry and write the fitted result.",
144  dtype=bool,
145  default=True
146  )
147  coaddName = pexConfig.Field(
148  doc="Type of coadd, typically deep or goodSeeing",
149  dtype=str,
150  default="deep"
151  )
152  positionErrorPedestal = pexConfig.Field(
153  doc="Systematic term to apply to the measured position error (pixels)",
154  dtype=float,
155  default=0.02,
156  )
157  photometryErrorPedestal = pexConfig.Field(
158  doc="Systematic term to apply to the measured error on flux or magnitude as a "
159  "fraction of source flux or magnitude delta (e.g. 0.05 is 5% of flux or +50 millimag).",
160  dtype=float,
161  default=0.0,
162  )
163  # TODO: DM-6885 matchCut should be an afw.geom.Angle
164  matchCut = pexConfig.Field(
165  doc="Matching radius between fitted and reference stars (arcseconds)",
166  dtype=float,
167  default=3.0,
168  )
169  minMeasurements = pexConfig.Field(
170  doc="Minimum number of associated measured stars for a fitted star to be included in the fit",
171  dtype=int,
172  default=2,
173  )
174  minMeasuredStarsPerCcd = pexConfig.Field(
175  doc="Minimum number of measuredStars per ccdImage before printing warnings",
176  dtype=int,
177  default=100,
178  )
179  minRefStarsPerCcd = pexConfig.Field(
180  doc="Minimum number of measuredStars per ccdImage before printing warnings",
181  dtype=int,
182  default=30,
183  )
184  allowLineSearch = pexConfig.Field(
185  doc="Allow a line search during minimization, if it is reasonable for the model"
186  " (models with a significant non-linear component, e.g. constrainedPhotometry).",
187  dtype=bool,
188  default=False
189  )
190  astrometrySimpleOrder = pexConfig.Field(
191  doc="Polynomial order for fitting the simple astrometry model.",
192  dtype=int,
193  default=3,
194  )
195  astrometryChipOrder = pexConfig.Field(
196  doc="Order of the per-chip transform for the constrained astrometry model.",
197  dtype=int,
198  default=1,
199  )
200  astrometryVisitOrder = pexConfig.Field(
201  doc="Order of the per-visit transform for the constrained astrometry model.",
202  dtype=int,
203  default=5,
204  )
205  useInputWcs = pexConfig.Field(
206  doc="Use the input calexp WCSs to initialize a SimpleAstrometryModel.",
207  dtype=bool,
208  default=True,
209  )
210  astrometryModel = pexConfig.ChoiceField(
211  doc="Type of model to fit to astrometry",
212  dtype=str,
213  default="constrained",
214  allowed={"simple": "One polynomial per ccd",
215  "constrained": "One polynomial per ccd, and one polynomial per visit"}
216  )
217  photometryModel = pexConfig.ChoiceField(
218  doc="Type of model to fit to photometry",
219  dtype=str,
220  default="constrainedMagnitude",
221  allowed={"simpleFlux": "One constant zeropoint per ccd and visit, fitting in flux space.",
222  "constrainedFlux": "Constrained zeropoint per ccd, and one polynomial per visit,"
223  " fitting in flux space.",
224  "simpleMagnitude": "One constant zeropoint per ccd and visit,"
225  " fitting in magnitude space.",
226  "constrainedMagnitude": "Constrained zeropoint per ccd, and one polynomial per visit,"
227  " fitting in magnitude space.",
228  }
229  )
230  applyColorTerms = pexConfig.Field(
231  doc="Apply photometric color terms to reference stars?"
232  "Requires that colorterms be set to a ColortermLibrary",
233  dtype=bool,
234  default=False
235  )
236  colorterms = pexConfig.ConfigField(
237  doc="Library of photometric reference catalog name to color term dict.",
238  dtype=ColortermLibrary,
239  )
240  photometryVisitOrder = pexConfig.Field(
241  doc="Order of the per-visit polynomial transform for the constrained photometry model.",
242  dtype=int,
243  default=7,
244  )
245  photometryDoRankUpdate = pexConfig.Field(
246  doc="Do the rank update step during minimization. "
247  "Skipping this can help deal with models that are too non-linear.",
248  dtype=bool,
249  default=True,
250  )
251  astrometryDoRankUpdate = pexConfig.Field(
252  doc="Do the rank update step during minimization (should not change the astrometry fit). "
253  "Skipping this can help deal with models that are too non-linear.",
254  dtype=bool,
255  default=True,
256  )
257  outlierRejectSigma = pexConfig.Field(
258  doc="How many sigma to reject outliers at during minimization.",
259  dtype=float,
260  default=5.0,
261  )
262  maxPhotometrySteps = pexConfig.Field(
263  doc="Maximum number of minimize iterations to take when fitting photometry.",
264  dtype=int,
265  default=20,
266  )
267  maxAstrometrySteps = pexConfig.Field(
268  doc="Maximum number of minimize iterations to take when fitting photometry.",
269  dtype=int,
270  default=20,
271  )
272  astrometryRefObjLoader = pexConfig.ConfigurableField(
273  target=LoadIndexedReferenceObjectsTask,
274  doc="Reference object loader for astrometric fit",
275  )
276  photometryRefObjLoader = pexConfig.ConfigurableField(
277  target=LoadIndexedReferenceObjectsTask,
278  doc="Reference object loader for photometric fit",
279  )
280  sourceSelector = sourceSelectorRegistry.makeField(
281  doc="How to select sources for cross-matching",
282  default="astrometry"
283  )
284  writeInitMatrix = pexConfig.Field(
285  dtype=bool,
286  doc="Write the pre/post-initialization Hessian and gradient to text files, for debugging."
287  "The output files will be of the form 'astrometry_preinit-mat.txt', in the current directory."
288  "Note that these files are the dense versions of the matrix, and so may be very large.",
289  default=False
290  )
291  writeChi2ContributionFiles = pexConfig.Field(
292  dtype=bool,
293  doc="Write initial/final fit files containing the contributions to chi2.",
294  default=False
295  )
296  sourceFluxType = pexConfig.Field(
297  dtype=str,
298  doc="Source flux field to use in source selection and to get fluxes from the catalog.",
299  default='Calib'
300  )
301 
302  def validate(self):
303  super().validate()
304  if self.applyColorTerms and len(self.colorterms.data) == 0:
305  msg = "applyColorTerms=True requires the `colorterms` field be set to a ColortermLibrary."
306  raise pexConfig.FieldValidationError(JointcalConfig.colorterms, self, msg)
307 
308  def setDefaults(self):
309  sourceSelector = self.sourceSelector["astrometry"]
310  sourceSelector.setDefaults()
311  # don't want to lose existing flags, just add to them.
312  sourceSelector.badFlags.extend(["slot_Shape_flag"])
313  # This should be used to set the FluxField value in jointcal::JointcalControl
314  sourceSelector.sourceFluxType = self.sourceFluxType
315 
316 
317 class JointcalTask(pipeBase.CmdLineTask):
318  """Jointly astrometrically and photometrically calibrate a group of images."""
319 
320  ConfigClass = JointcalConfig
321  RunnerClass = JointcalRunner
322  _DefaultName = "jointcal"
323 
324  def __init__(self, butler=None, profile_jointcal=False, **kwargs):
325  """
326  Instantiate a JointcalTask.
327 
328  Parameters
329  ----------
330  butler : lsst.daf.persistence.Butler
331  The butler is passed to the refObjLoader constructor in case it is
332  needed. Ignored if the refObjLoader argument provides a loader directly.
333  Used to initialize the astrometry and photometry refObjLoaders.
334  profile_jointcal : bool
335  set to True to profile different stages of this jointcal run.
336  """
337  pipeBase.CmdLineTask.__init__(self, **kwargs)
338  self.profile_jointcal = profile_jointcal
339  self.makeSubtask("sourceSelector")
340  if self.config.doAstrometry:
341  self.makeSubtask('astrometryRefObjLoader', butler=butler)
342  else:
344  if self.config.doPhotometry:
345  self.makeSubtask('photometryRefObjLoader', butler=butler)
346  else:
348 
349  # To hold various computed metrics for use by tests
350  self.job = Job.load_metrics_package(subset='jointcal')
351 
352  # We don't need to persist config and metadata at this stage.
353  # In this way, we don't need to put a specific entry in the camera mapper policy file
354  def _getConfigName(self):
355  return None
356 
357  def _getMetadataName(self):
358  return None
359 
360  @classmethod
361  def _makeArgumentParser(cls):
362  """Create an argument parser"""
363  parser = pipeBase.ArgumentParser(name=cls._DefaultName)
364  parser.add_argument("--profile_jointcal", default=False, action="store_true",
365  help="Profile steps of jointcal separately.")
366  parser.add_id_argument("--id", "calexp", help="data ID, e.g. --id visit=6789 ccd=0..9",
367  ContainerClass=PerTractCcdDataIdContainer)
368  return parser
369 
370  def _build_ccdImage(self, dataRef, associations, jointcalControl):
371  """
372  Extract the necessary things from this dataRef to add a new ccdImage.
373 
374  Parameters
375  ----------
376  dataRef : lsst.daf.persistence.ButlerDataRef
377  dataRef to extract info from.
378  associations : lsst.jointcal.Associations
379  object to add the info to, to construct a new CcdImage
380  jointcalControl : jointcal.JointcalControl
381  control object for associations management
382 
383  Returns
384  ------
385  namedtuple
386  wcs : lsst.afw.geom.SkyWcs
387  the TAN WCS of this image, read from the calexp
388  key : namedtuple
389  a key to identify this dataRef by its visit and ccd ids
390  filter : str
391  this calexp's filter
392  """
393  if "visit" in dataRef.dataId.keys():
394  visit = dataRef.dataId["visit"]
395  else:
396  visit = dataRef.getButler().queryMetadata("calexp", ("visit"), dataRef.dataId)[0]
397 
398  src = dataRef.get("src", flags=lsst.afw.table.SOURCE_IO_NO_FOOTPRINTS, immediate=True)
399 
400  visitInfo = dataRef.get('calexp_visitInfo')
401  detector = dataRef.get('calexp_detector')
402  ccdId = detector.getId()
403  calib = dataRef.get('calexp_calib')
404  tanWcs = dataRef.get('calexp_wcs')
405  bbox = dataRef.get('calexp_bbox')
406  filt = dataRef.get('calexp_filter')
407  filterName = filt.getName()
408  fluxMag0 = calib.getFluxMag0()
409  # TODO: need to scale these until DM-10153 is completed and PhotoCalib has replaced Calib entirely
410  referenceFlux = 1e23 * 10**(48.6 / -2.5) * 1e9
411  photoCalib = afwImage.PhotoCalib(referenceFlux/fluxMag0[0],
412  referenceFlux*fluxMag0[1]/fluxMag0[0]**2, bbox)
413 
414  goodSrc = self.sourceSelector.run(src)
415 
416  if len(goodSrc.sourceCat) == 0:
417  self.log.warn("No sources selected in visit %s ccd %s", visit, ccdId)
418  else:
419  self.log.info("%d sources selected in visit %d ccd %d", len(goodSrc.sourceCat), visit, ccdId)
420  associations.createCcdImage(goodSrc.sourceCat,
421  tanWcs,
422  visitInfo,
423  bbox,
424  filterName,
425  photoCalib,
426  detector,
427  visit,
428  ccdId,
429  jointcalControl)
430 
431  Result = collections.namedtuple('Result_from_build_CcdImage', ('wcs', 'key', 'filter'))
432  Key = collections.namedtuple('Key', ('visit', 'ccd'))
433  return Result(tanWcs, Key(visit, ccdId), filterName)
434 
435  @pipeBase.timeMethod
436  def runDataRef(self, dataRefs, profile_jointcal=False):
437  """
438  Jointly calibrate the astrometry and photometry across a set of images.
439 
440  Parameters
441  ----------
442  dataRefs : list of lsst.daf.persistence.ButlerDataRef
443  List of data references to the exposures to be fit.
444  profile_jointcal : bool
445  Profile the individual steps of jointcal.
446 
447  Returns
448  -------
449  pipe.base.Struct
450  struct containing:
451  * dataRefs: the provided data references that were fit (with updated WCSs)
452  * oldWcsList: the original WCS from each dataRef
453  * metrics: dictionary of internally-computed metrics for testing/validation.
454  """
455  if len(dataRefs) == 0:
456  raise ValueError('Need a non-empty list of data references!')
457 
458  exitStatus = 0 # exit status for shell
459 
460  sourceFluxField = "slot_%sFlux" % (self.config.sourceFluxType,)
461  jointcalControl = lsst.jointcal.JointcalControl(sourceFluxField)
462  associations = lsst.jointcal.Associations()
463 
464  visit_ccd_to_dataRef = {}
465  oldWcsList = []
466  filters = []
467  load_cat_prof_file = 'jointcal_build_ccdImage.prof' if profile_jointcal else ''
468  with pipeBase.cmdLineTask.profile(load_cat_prof_file):
469  # We need the bounding-box of the focal plane for photometry visit models.
470  # NOTE: we only need to read it once, because its the same for all exposures of a camera.
471  camera = dataRefs[0].get('camera', immediate=True)
472  self.focalPlaneBBox = camera.getFpBBox()
473  for ref in dataRefs:
474  result = self._build_ccdImage(ref, associations, jointcalControl)
475  oldWcsList.append(result.wcs)
476  visit_ccd_to_dataRef[result.key] = ref
477  filters.append(result.filter)
478  filters = collections.Counter(filters)
479 
480  associations.computeCommonTangentPoint()
481 
482  # Use external reference catalogs handled by LSST stack mechanism
483  # Get the bounding box overlapping all associated images
484  # ==> This is probably a bad idea to do it this way <== To be improved
485  bbox = associations.getRaDecBBox()
486  bboxCenter = bbox.getCenter()
487  center = afwGeom.SpherePoint(bboxCenter[0], bboxCenter[1], afwGeom.degrees)
488  bboxMax = bbox.getMax()
489  corner = afwGeom.SpherePoint(bboxMax[0], bboxMax[1], afwGeom.degrees)
490  radius = center.separation(corner).asRadians()
491 
492  # Get astrometry_net_data path
493  anDir = lsst.utils.getPackageDir('astrometry_net_data')
494  if anDir is None:
495  raise RuntimeError("astrometry_net_data is not setup")
496 
497  # Determine a default filter associated with the catalog. See DM-9093
498  defaultFilter = filters.most_common(1)[0][0]
499  self.log.debug("Using %s band for reference flux", defaultFilter)
500 
501  # TODO: need a better way to get the tract.
502  tract = dataRefs[0].dataId['tract']
503 
504  if self.config.doAstrometry:
505  astrometry = self._do_load_refcat_and_fit(associations, defaultFilter, center, radius,
506  name="astrometry",
507  refObjLoader=self.astrometryRefObjLoader,
508  fit_function=self._fit_astrometry,
509  profile_jointcal=profile_jointcal,
510  tract=tract)
511  self._write_astrometry_results(associations, astrometry.model, visit_ccd_to_dataRef)
512  else:
513  astrometry = Astrometry(None, None, None)
514 
515  if self.config.doPhotometry:
516  photometry = self._do_load_refcat_and_fit(associations, defaultFilter, center, radius,
517  name="photometry",
518  refObjLoader=self.photometryRefObjLoader,
519  fit_function=self._fit_photometry,
520  profile_jointcal=profile_jointcal,
521  tract=tract,
522  filters=filters,
523  reject_bad_fluxes=True)
524  self._write_photometry_results(associations, photometry.model, visit_ccd_to_dataRef)
525  else:
526  photometry = Photometry(None, None)
527 
528  return pipeBase.Struct(dataRefs=dataRefs,
529  oldWcsList=oldWcsList,
530  job=self.job,
531  astrometryRefObjLoader=self.astrometryRefObjLoader,
532  photometryRefObjLoader=self.photometryRefObjLoader,
533  defaultFilter=defaultFilter,
534  exitStatus=exitStatus)
535 
536  def _do_load_refcat_and_fit(self, associations, defaultFilter, center, radius,
537  name="", refObjLoader=None, filters=[], fit_function=None,
538  tract=None, profile_jointcal=False, match_cut=3.0,
539  reject_bad_fluxes=False):
540  """Load reference catalog, perform the fit, and return the result.
541 
542  Parameters
543  ----------
544  associations : lsst.jointcal.Associations
545  The star/reference star associations to fit.
546  defaultFilter : str
547  filter to load from reference catalog.
548  center : lsst.afw.geom.SpherePoint
549  ICRS center of field to load from reference catalog.
550  radius : lsst.afw.geom.Angle
551  On-sky radius to load from reference catalog.
552  name : str
553  Name of thing being fit: "Astrometry" or "Photometry".
554  refObjLoader : lsst.meas.algorithms.LoadReferenceObjectsTask
555  Reference object loader to load from for fit.
556  filters : list of str, optional
557  List of filters to load from the reference catalog.
558  fit_function : function
559  function to call to perform fit (takes associations object).
560  tract : str
561  Name of tract currently being fit.
562  profile_jointcal : bool, optional
563  Separately profile the fitting step.
564  match_cut : float, optional
565  Radius in arcseconds to find cross-catalog matches to during
566  associations.associateCatalogs.
567  reject_bad_fluxes : bool, optional
568  Reject refCat sources with NaN/inf flux or NaN/0 fluxErr.
569 
570  Returns
571  -------
572  Result of `fit_function()`
573  """
574  self.log.info("====== Now processing %s...", name)
575  # TODO: this should not print "trying to invert a singular transformation:"
576  # if it does that, something's not right about the WCS...
577  associations.associateCatalogs(match_cut)
578  add_measurement(self.job, 'jointcal.associated_%s_fittedStars' % name,
579  associations.fittedStarListSize())
580 
581  applyColorterms = False if name == "Astrometry" else self.config.applyColorTerms
582  refCat, fluxField = self._load_reference_catalog(refObjLoader, center, radius, defaultFilter,
583  applyColorterms=applyColorterms)
584 
585  associations.collectRefStars(refCat, self.config.matchCut*afwGeom.arcseconds,
586  fluxField, reject_bad_fluxes)
587  add_measurement(self.job, 'jointcal.collected_%s_refStars' % name,
588  associations.refStarListSize())
589 
590  associations.prepareFittedStars(self.config.minMeasurements)
591 
592  self._check_star_lists(associations, name)
593  add_measurement(self.job, 'jointcal.selected_%s_refStars' % name,
594  associations.nFittedStarsWithAssociatedRefStar())
595  add_measurement(self.job, 'jointcal.selected_%s_fittedStars' % name,
596  associations.fittedStarListSize())
597  add_measurement(self.job, 'jointcal.selected_%s_ccdImages' % name,
598  associations.nCcdImagesValidForFit())
599 
600  load_cat_prof_file = 'jointcal_fit_%s.prof'%name if profile_jointcal else ''
601  dataName = "{}_{}".format(tract, defaultFilter)
602  with pipeBase.cmdLineTask.profile(load_cat_prof_file):
603  result = fit_function(associations, dataName)
604  # TODO DM-12446: turn this into a "butler save" somehow.
605  # Save reference and measurement chi2 contributions for this data
606  if self.config.writeChi2ContributionFiles:
607  baseName = "{}_final_chi2-{}.csv".format(name, dataName)
608  result.fit.saveChi2Contributions(baseName)
609 
610  return result
611 
612  def _load_reference_catalog(self, refObjLoader, center, radius, filterName,
613  applyColorterms=False):
614  """Load the necessary reference catalog sources, convert fluxes to
615  correct units, and apply color term corrections if requested.
616 
617  Parameters
618  ----------
619  refObjLoader : `lsst.meas.algorithms.LoadReferenceObjectsTask`
620  The reference catalog loader to use to get the data.
621  center : `lsst.geom.SpherePoint`
622  The center around which to load sources.
623  radius : `lsst.geom.Angle`
624  The radius around ``center`` to load sources in.
625  filterName : `str`
626  The name of the camera filter to load fluxes for.
627  applyColorterms : `bool`
628  Apply colorterm corrections to the refcat for ``filterName``?
629 
630  Returns
631  -------
632  refCat : `lsst.afw.table.SimpleCatalog`
633  The loaded reference catalog.
634  fluxField : `str`
635  The name of the reference catalog flux field appropriate for ``filterName``.
636  """
637  skyCircle = refObjLoader.loadSkyCircle(center,
638  afwGeom.Angle(radius, afwGeom.radians),
639  filterName)
640 
641  # Need memory contiguity to get reference filters as a vector.
642  if not skyCircle.refCat.isContiguous():
643  refCat = skyCircle.refCat.copy(deep=True)
644  else:
645  refCat = skyCircle.refCat
646 
647  if applyColorterms:
648  try:
649  refCatName = refObjLoader.ref_dataset_name
650  except AttributeError:
651  # NOTE: we need this try:except: block in place until we've completely removed a.net support.
652  raise RuntimeError("Cannot perform colorterm corrections with a.net refcats.")
653  self.log.info("Applying color terms for filterName=%r reference catalog=%s",
654  filterName, refCatName)
655  colorterm = self.config.colorterms.getColorterm(
656  filterName=filterName, photoCatName=refCatName, doRaise=True)
657 
658  refMag, refMagErr = colorterm.getCorrectedMagnitudes(refCat, filterName)
659  refCat[skyCircle.fluxField] = u.Magnitude(refMag, u.ABmag).to_value(u.nJy)
660  # TODO: I didn't want to use this, but I'll deal with it in DM-16903
661  refCat[skyCircle.fluxField+'Err'] = fluxErrFromABMagErr(refMagErr, refMag) * 1e9
662  else:
663  # TODO: need to scale these until RFC-549 is completed and refcats return nanojansky
664  refCat[skyCircle.fluxField] *= 1e9
665  try:
666  refCat[skyCircle.fluxField+'Err'] *= 1e9
667  except KeyError:
668  # not all existing refcats have an error field.
669  pass
670 
671  return refCat, skyCircle.fluxField
672 
673  def _check_star_lists(self, associations, name):
674  # TODO: these should be len(blah), but we need this properly wrapped first.
675  if associations.nCcdImagesValidForFit() == 0:
676  raise RuntimeError('No images in the ccdImageList!')
677  if associations.fittedStarListSize() == 0:
678  raise RuntimeError('No stars in the {} fittedStarList!'.format(name))
679  if associations.refStarListSize() == 0:
680  raise RuntimeError('No stars in the {} reference star list!'.format(name))
681 
682  def _logChi2AndValidate(self, associations, fit, model, chi2Label="Model"):
683  """Compute chi2, log it, validate the model, and return chi2."""
684  chi2 = fit.computeChi2()
685  self.log.info("%s %s", chi2Label, chi2)
686  self._check_stars(associations)
687  if not np.isfinite(chi2.chi2):
688  raise FloatingPointError('%s chi2 is invalid: %s', chi2Label, chi2)
689  if not model.validate(associations.getCcdImageList()):
690  raise ValueError("Model is not valid: check log messages for warnings.")
691  return chi2
692 
693  def _fit_photometry(self, associations, dataName=None):
694  """
695  Fit the photometric data.
696 
697  Parameters
698  ----------
699  associations : lsst.jointcal.Associations
700  The star/reference star associations to fit.
701  dataName : str
702  Name of the data being processed (e.g. "1234_HSC-Y"), for
703  identifying debugging files.
704 
705  Returns
706  -------
707  namedtuple
708  fit : lsst.jointcal.PhotometryFit
709  The photometric fitter used to perform the fit.
710  model : lsst.jointcal.PhotometryModel
711  The photometric model that was fit.
712  """
713  self.log.info("=== Starting photometric fitting...")
714 
715  # TODO: should use pex.config.RegistryField here (see DM-9195)
716  if self.config.photometryModel == "constrainedFlux":
717  model = lsst.jointcal.ConstrainedFluxModel(associations.getCcdImageList(),
718  self.focalPlaneBBox,
719  visitOrder=self.config.photometryVisitOrder,
720  errorPedestal=self.config.photometryErrorPedestal)
721  # potentially nonlinear problem, so we may need a line search to converge.
722  doLineSearch = self.config.allowLineSearch
723  elif self.config.photometryModel == "constrainedMagnitude":
724  model = lsst.jointcal.ConstrainedMagnitudeModel(associations.getCcdImageList(),
725  self.focalPlaneBBox,
726  visitOrder=self.config.photometryVisitOrder,
727  errorPedestal=self.config.photometryErrorPedestal)
728  # potentially nonlinear problem, so we may need a line search to converge.
729  doLineSearch = self.config.allowLineSearch
730  elif self.config.photometryModel == "simpleFlux":
731  model = lsst.jointcal.SimpleFluxModel(associations.getCcdImageList(),
732  errorPedestal=self.config.photometryErrorPedestal)
733  doLineSearch = False # purely linear in model parameters, so no line search needed
734  elif self.config.photometryModel == "simpleMagnitude":
735  model = lsst.jointcal.SimpleMagnitudeModel(associations.getCcdImageList(),
736  errorPedestal=self.config.photometryErrorPedestal)
737  doLineSearch = False # purely linear in model parameters, so no line search needed
738 
739  fit = lsst.jointcal.PhotometryFit(associations, model)
740  self._logChi2AndValidate(associations, fit, model, "Initialized")
741 
742  # TODO DM-12446: turn this into a "butler save" somehow.
743  # Save reference and measurement chi2 contributions for this data
744  if self.config.writeChi2ContributionFiles:
745  baseName = "photometry_initial_chi2-{}.csv".format(dataName)
746  fit.saveChi2Contributions(baseName)
747 
748  # The constrained model needs the visit transform fit first; the chip
749  # transform is initialized from the singleFrame PhotoCalib, so it's close.
750  dumpMatrixFile = "photometry_preinit" if self.config.writeInitMatrix else ""
751  if self.config.photometryModel.startswith("constrained"):
752  # no line search: should be purely (or nearly) linear,
753  # and we want a large step size to initialize with.
754  fit.minimize("ModelVisit", dumpMatrixFile=dumpMatrixFile)
755  self._logChi2AndValidate(associations, fit, model)
756  dumpMatrixFile = "" # so we don't redo the output on the next step
757 
758  fit.minimize("Model", doLineSearch=doLineSearch, dumpMatrixFile=dumpMatrixFile)
759  self._logChi2AndValidate(associations, fit, model)
760 
761  fit.minimize("Fluxes") # no line search: always purely linear.
762  self._logChi2AndValidate(associations, fit, model)
763 
764  fit.minimize("Model Fluxes", doLineSearch=doLineSearch)
765  self._logChi2AndValidate(associations, fit, model, "Fit prepared")
766 
767  model.freezeErrorTransform()
768  self.log.debug("Photometry error scales are frozen.")
769 
770  chi2 = self._iterate_fit(associations,
771  fit,
772  self.config.maxPhotometrySteps,
773  "photometry",
774  "Model Fluxes",
775  doRankUpdate=self.config.photometryDoRankUpdate,
776  doLineSearch=doLineSearch,
777  dataName=dataName)
778 
779  add_measurement(self.job, 'jointcal.photometry_final_chi2', chi2.chi2)
780  add_measurement(self.job, 'jointcal.photometry_final_ndof', chi2.ndof)
781  return Photometry(fit, model)
782 
783  def _fit_astrometry(self, associations, dataName=None):
784  """
785  Fit the astrometric data.
786 
787  Parameters
788  ----------
789  associations : lsst.jointcal.Associations
790  The star/reference star associations to fit.
791  dataName : str
792  Name of the data being processed (e.g. "1234_HSC-Y"), for
793  identifying debugging files.
794 
795  Returns
796  -------
797  namedtuple
798  fit : lsst.jointcal.AstrometryFit
799  The astrometric fitter used to perform the fit.
800  model : lsst.jointcal.AstrometryModel
801  The astrometric model that was fit.
802  sky_to_tan_projection : lsst.jointcal.ProjectionHandler
803  The model for the sky to tangent plane projection that was used in the fit.
804  """
805 
806  self.log.info("=== Starting astrometric fitting...")
807 
808  associations.deprojectFittedStars()
809 
810  # NOTE: need to return sky_to_tan_projection so that it doesn't get garbage collected.
811  # TODO: could we package sky_to_tan_projection and model together so we don't have to manage
812  # them so carefully?
813  sky_to_tan_projection = lsst.jointcal.OneTPPerVisitHandler(associations.getCcdImageList())
814 
815  if self.config.astrometryModel == "constrained":
816  model = lsst.jointcal.ConstrainedAstrometryModel(associations.getCcdImageList(),
817  sky_to_tan_projection,
818  chipOrder=self.config.astrometryChipOrder,
819  visitOrder=self.config.astrometryVisitOrder)
820  elif self.config.astrometryModel == "simple":
821  model = lsst.jointcal.SimpleAstrometryModel(associations.getCcdImageList(),
822  sky_to_tan_projection,
823  self.config.useInputWcs,
824  nNotFit=0,
825  order=self.config.astrometrySimpleOrder)
826 
827  fit = lsst.jointcal.AstrometryFit(associations, model, self.config.positionErrorPedestal)
828  self._logChi2AndValidate(associations, fit, model, "Initial")
829 
830  # TODO DM-12446: turn this into a "butler save" somehow.
831  # Save reference and measurement chi2 contributions for this data
832  if self.config.writeChi2ContributionFiles:
833  baseName = "astrometry_initial_chi2-{}.csv".format(dataName)
834  fit.saveChi2Contributions(baseName)
835 
836  dumpMatrixFile = "astrometry_preinit" if self.config.writeInitMatrix else ""
837  # The constrained model needs the visit transform fit first; the chip
838  # transform is initialized from the detector's cameraGeom, so it's close.
839  if self.config.astrometryModel == "constrained":
840  fit.minimize("DistortionsVisit", dumpMatrixFile=dumpMatrixFile)
841  self._logChi2AndValidate(associations, fit, model)
842  dumpMatrixFile = "" # so we don't redo the output on the next step
843 
844  fit.minimize("Distortions", dumpMatrixFile=dumpMatrixFile)
845  self._logChi2AndValidate(associations, fit, model)
846 
847  fit.minimize("Positions")
848  self._logChi2AndValidate(associations, fit, model)
849 
850  fit.minimize("Distortions Positions")
851  self._logChi2AndValidate(associations, fit, model, "Fit prepared")
852 
853  chi2 = self._iterate_fit(associations,
854  fit,
855  self.config.maxAstrometrySteps,
856  "astrometry",
857  "Distortions Positions",
858  doRankUpdate=self.config.astrometryDoRankUpdate,
859  dataName=dataName)
860 
861  add_measurement(self.job, 'jointcal.astrometry_final_chi2', chi2.chi2)
862  add_measurement(self.job, 'jointcal.astrometry_final_ndof', chi2.ndof)
863 
864  return Astrometry(fit, model, sky_to_tan_projection)
865 
866  def _check_stars(self, associations):
867  """Count measured and reference stars per ccd and warn/log them."""
868  for ccdImage in associations.getCcdImageList():
869  nMeasuredStars, nRefStars = ccdImage.countStars()
870  self.log.debug("ccdImage %s has %s measured and %s reference stars",
871  ccdImage.getName(), nMeasuredStars, nRefStars)
872  if nMeasuredStars < self.config.minMeasuredStarsPerCcd:
873  self.log.warn("ccdImage %s has only %s measuredStars (desired %s)",
874  ccdImage.getName(), nMeasuredStars, self.config.minMeasuredStarsPerCcd)
875  if nRefStars < self.config.minRefStarsPerCcd:
876  self.log.warn("ccdImage %s has only %s RefStars (desired %s)",
877  ccdImage.getName(), nRefStars, self.config.minRefStarsPerCcd)
878 
879  def _iterate_fit(self, associations, fitter, max_steps, name, whatToFit,
880  dataName="",
881  doRankUpdate=True,
882  doLineSearch=False):
883  """Run fitter.minimize up to max_steps times, returning the final chi2.
884 
885  Parameters
886  ----------
887  associations : `lsst.jointcal.Associations`
888  The star/reference star associations to fit.
889  fitter : `lsst.jointcal.FitterBase`
890  The fitter to use for minimization.
891  max_steps : `int`
892  Maximum number of steps to run outlier rejection before declaring
893  convergence failure.
894  name : {'photometry' or 'astrometry'}
895  What type of data are we fitting (for logs and debugging files).
896  whatToFit : `str`
897  Passed to ``fitter.minimize()`` to define the parameters to fit.
898  dataName : str, optional
899  Descriptive name for this dataset (e.g. tract and filter),
900  for debugging.
901  doRankUpdate : bool, optional
902  Do an Eigen rank update during minimization, or recompute the full
903  matrix and gradient?
904  doLineSearch : bool, optional
905  Do a line search for the optimum step during minimization?
906 
907  Returns
908  -------
909  chi2: `lsst.jointcal.Chi2Statistic`
910  The final chi2 after the fit converges, or is forced to end.
911 
912  Raises
913  ------
914  FloatingPointError
915  Raised if the fitter fails with a non-finite value.
916  RuntimeError
917  Raised if the fitter fails for some other reason;
918  log messages will provide further details.
919  """
920  dumpMatrixFile = "%s_postinit" % name if self.config.writeInitMatrix else ""
921  for i in range(max_steps):
922  result = fitter.minimize(whatToFit,
923  self.config.outlierRejectSigma,
924  doRankUpdate=doRankUpdate,
925  doLineSearch=doLineSearch,
926  dumpMatrixFile=dumpMatrixFile)
927  dumpMatrixFile = "" # clear it so we don't write the matrix again.
928  chi2 = self._logChi2AndValidate(associations, fitter, fitter.getModel())
929 
930  if result == MinimizeResult.Converged:
931  if doRankUpdate:
932  self.log.debug("fit has converged - no more outliers - redo minimization "
933  "one more time in case we have lost accuracy in rank update.")
934  # Redo minimization one more time in case we have lost accuracy in rank update
935  result = fitter.minimize(whatToFit, self.config.outlierRejectSigma)
936  chi2 = self._logChi2AndValidate(associations, fitter, fitter.getModel(), "Fit completed")
937 
938  # log a message for a large final chi2, TODO: DM-15247 for something better
939  if chi2.chi2/chi2.ndof >= 4.0:
940  self.log.error("Potentially bad fit: High chi-squared/ndof.")
941 
942  break
943  elif result == MinimizeResult.Chi2Increased:
944  self.log.warn("still some outliers but chi2 increases - retry")
945  elif result == MinimizeResult.NonFinite:
946  filename = "{}_failure-nonfinite_chi2-{}.csv".format(name, dataName)
947  # TODO DM-12446: turn this into a "butler save" somehow.
948  fitter.saveChi2Contributions(filename)
949  msg = "Nonfinite value in chi2 minimization, cannot complete fit. Dumped star tables to: {}"
950  raise FloatingPointError(msg.format(filename))
951  elif result == MinimizeResult.Failed:
952  raise RuntimeError("Chi2 minimization failure, cannot complete fit.")
953  else:
954  raise RuntimeError("Unxepected return code from minimize().")
955  else:
956  self.log.error("%s failed to converge after %d steps"%(name, max_steps))
957 
958  return chi2
959 
960  def _write_astrometry_results(self, associations, model, visit_ccd_to_dataRef):
961  """
962  Write the fitted astrometric results to a new 'jointcal_wcs' dataRef.
963 
964  Parameters
965  ----------
966  associations : lsst.jointcal.Associations
967  The star/reference star associations to fit.
968  model : lsst.jointcal.AstrometryModel
969  The astrometric model that was fit.
970  visit_ccd_to_dataRef : dict of Key: lsst.daf.persistence.ButlerDataRef
971  dict of ccdImage identifiers to dataRefs that were fit
972  """
973 
974  ccdImageList = associations.getCcdImageList()
975  for ccdImage in ccdImageList:
976  # TODO: there must be a better way to identify this ccdImage than a visit,ccd pair?
977  ccd = ccdImage.ccdId
978  visit = ccdImage.visit
979  dataRef = visit_ccd_to_dataRef[(visit, ccd)]
980  self.log.info("Updating WCS for visit: %d, ccd: %d", visit, ccd)
981  skyWcs = model.makeSkyWcs(ccdImage)
982  try:
983  dataRef.put(skyWcs, 'jointcal_wcs')
984  except pexExceptions.Exception as e:
985  self.log.fatal('Failed to write updated Wcs: %s', str(e))
986  raise e
987 
988  def _write_photometry_results(self, associations, model, visit_ccd_to_dataRef):
989  """
990  Write the fitted photometric results to a new 'jointcal_photoCalib' dataRef.
991 
992  Parameters
993  ----------
994  associations : lsst.jointcal.Associations
995  The star/reference star associations to fit.
996  model : lsst.jointcal.PhotometryModel
997  The photoometric model that was fit.
998  visit_ccd_to_dataRef : dict of Key: lsst.daf.persistence.ButlerDataRef
999  dict of ccdImage identifiers to dataRefs that were fit
1000  """
1001 
1002  ccdImageList = associations.getCcdImageList()
1003  for ccdImage in ccdImageList:
1004  # TODO: there must be a better way to identify this ccdImage than a visit,ccd pair?
1005  ccd = ccdImage.ccdId
1006  visit = ccdImage.visit
1007  dataRef = visit_ccd_to_dataRef[(visit, ccd)]
1008  self.log.info("Updating PhotoCalib for visit: %d, ccd: %d", visit, ccd)
1009  photoCalib = model.toPhotoCalib(ccdImage)
1010  try:
1011  dataRef.put(photoCalib, 'jointcal_photoCalib')
1012  except pexExceptions.Exception as e:
1013  self.log.fatal('Failed to write updated PhotoCalib: %s', str(e))
1014  raise e
def runDataRef(self, dataRefs, profile_jointcal=False)
Definition: jointcal.py:436
double fluxErrFromABMagErr(double magErr, double mag) noexcept
Compute flux error in Janskys from AB magnitude error and AB magnitude.
Definition: Calib.h:69
def _build_ccdImage(self, dataRef, associations, jointcalControl)
Definition: jointcal.py:370
def _fit_photometry(self, associations, dataName=None)
Definition: jointcal.py:693
The photometric calibration of an exposure.
Definition: PhotoCalib.h:111
def getTargetList(parsedCmd, kwargs)
Definition: jointcal.py:71
def _fit_astrometry(self, associations, dataName=None)
Definition: jointcal.py:783
Provides consistent interface for LSST exceptions.
Definition: Exception.h:107
std::shared_ptr< FrameSet > append(FrameSet const &first, FrameSet const &second)
Construct a FrameSet that performs two transformations in series.
Definition: functional.cc:33
Fit spatial kernel using approximate fluxes for candidates, and solving a linear system of equations...
def _check_star_lists(self, associations, name)
Definition: jointcal.py:673
def _load_reference_catalog(self, refObjLoader, center, radius, filterName, applyColorterms=False)
Definition: jointcal.py:613
The class that implements the relations between MeasuredStar and FittedStar.
Definition: Associations.h:53
A class representing an angle.
Definition: Angle.h:127
A projection handler in which all CCDs from the same visit have the same tangent point.
std::string getPackageDir(std::string const &packageName)
return the root directory of a setup package
Definition: packaging.cc:33
def _logChi2AndValidate(self, associations, fit, model, chi2Label="Model")
Definition: jointcal.py:682
this is the model used to fit independent CCDs, meaning that there is no instrument model...
table::Key< int > type
Definition: Detector.cc:164
def _iterate_fit(self, associations, fitter, max_steps, name, whatToFit, dataName="", doRankUpdate=True, doLineSearch=False)
Definition: jointcal.py:882
def format(config, name=None, writeSourceLine=True, prefix="", verbose=False)
Definition: history.py:168
def _write_photometry_results(self, associations, model, visit_ccd_to_dataRef)
Definition: jointcal.py:988
def _check_stars(self, associations)
Definition: jointcal.py:866
Class that handles the photometric least squares problem.
Definition: PhotometryFit.h:45
Point in an unspecified spherical coordinate system.
Definition: SpherePoint.h:57
Class that handles the astrometric least squares problem.
Definition: AstrometryFit.h:78
def add_measurement(job, name, value)
Definition: jointcal.py:53
def _do_load_refcat_and_fit(self, associations, defaultFilter, center, radius, name="", refObjLoader=None, filters=[], fit_function=None, tract=None, profile_jointcal=False, match_cut=3.0, reject_bad_fluxes=False)
Definition: jointcal.py:539
This is the model used to fit mappings as the combination of a transformation depending on the chip n...
def _write_astrometry_results(self, associations, model, visit_ccd_to_dataRef)
Definition: jointcal.py:960
def __init__(self, butler=None, profile_jointcal=False, kwargs)
Definition: jointcal.py:324