Loading [MathJax]/extensions/tex2jax.js
LSSTApplications  18.0.0+106,18.0.0+50,19.0.0,19.0.0+1,19.0.0+10,19.0.0+11,19.0.0+13,19.0.0+17,19.0.0+2,19.0.0-1-g20d9b18+6,19.0.0-1-g425ff20,19.0.0-1-g5549ca4,19.0.0-1-g580fafe+6,19.0.0-1-g6fe20d0+1,19.0.0-1-g7011481+9,19.0.0-1-g8c57eb9+6,19.0.0-1-gb5175dc+11,19.0.0-1-gdc0e4a7+9,19.0.0-1-ge272bc4+6,19.0.0-1-ge3aa853,19.0.0-10-g448f008b,19.0.0-12-g6990b2c,19.0.0-2-g0d9f9cd+11,19.0.0-2-g3d9e4fb2+11,19.0.0-2-g5037de4,19.0.0-2-gb96a1c4+3,19.0.0-2-gd955cfd+15,19.0.0-3-g2d13df8,19.0.0-3-g6f3c7dc,19.0.0-4-g725f80e+11,19.0.0-4-ga671dab3b+1,19.0.0-4-gad373c5+3,19.0.0-5-ga2acb9c+2,19.0.0-5-gfe96e6c+2,w.2020.01
LSSTDataManagementBasePackage
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Properties Friends Macros Modules Pages
makeBrighterFatterKernel.py
Go to the documentation of this file.
1 # This file is part of cp_pipe.
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 """Calculation of brighter-fatter effect correlations and kernels."""
23 
24 __all__ = ['MakeBrighterFatterKernelTaskConfig',
25  'MakeBrighterFatterKernelTask',
26  'calcBiasCorr']
27 
28 import os
29 import copy
30 from scipy import stats
31 import numpy as np
32 import matplotlib.pyplot as plt
33 # the following import is required for 3d projection
34 from mpl_toolkits.mplot3d import axes3d # noqa: F401
35 from matplotlib.backends.backend_pdf import PdfPages
36 from dataclasses import dataclass
37 
38 import lsstDebug
39 import lsst.afw.image as afwImage
40 import lsst.afw.math as afwMath
41 import lsst.afw.display as afwDisp
42 from lsst.ip.isr import IsrTask
43 import lsst.log as lsstLog
44 import lsst.pex.config as pexConfig
45 import lsst.pipe.base as pipeBase
46 from .utils import PairedVisitListTaskRunner, checkExpLengthEqual
47 import lsst.daf.persistence.butlerExceptions as butlerExceptions
48 from lsst.cp.pipe.ptc import (MeasurePhotonTransferCurveTaskConfig, MeasurePhotonTransferCurveTask,
49  PhotonTransferCurveDataset)
50 
51 
52 class MakeBrighterFatterKernelTaskConfig(pexConfig.Config):
53  """Config class for bright-fatter effect coefficient calculation."""
54 
55  isr = pexConfig.ConfigurableField(
56  target=IsrTask,
57  doc="""Task to perform instrumental signature removal""",
58  )
59  isrMandatorySteps = pexConfig.ListField(
60  dtype=str,
61  doc="isr operations that must be performed for valid results. Raises if any of these are False",
62  default=['doAssembleCcd']
63  )
64  isrForbiddenSteps = pexConfig.ListField(
65  dtype=str,
66  doc="isr operations that must NOT be performed for valid results. Raises if any of these are True",
67  default=['doFlat', 'doFringe', 'doAddDistortionModel', 'doBrighterFatter', 'doUseOpticsTransmission',
68  'doUseFilterTransmission', 'doUseSensorTransmission', 'doUseAtmosphereTransmission']
69  )
70  isrDesirableSteps = pexConfig.ListField(
71  dtype=str,
72  doc="isr operations that it is advisable to perform, but are not mission-critical." +
73  " WARNs are logged for any of these found to be False.",
74  default=['doBias', 'doDark', 'doCrosstalk', 'doDefect', 'doLinearize']
75  )
76  doCalcGains = pexConfig.Field(
77  dtype=bool,
78  doc="Measure the per-amplifier gains (using the photon transfer curve method)?",
79  default=True,
80  )
81  doPlotPtcs = pexConfig.Field(
82  dtype=bool,
83  doc="Plot the PTCs and butler.put() them as defined by the plotBrighterFatterPtc template",
84  default=False,
85  )
86  forceZeroSum = pexConfig.Field(
87  dtype=bool,
88  doc="Force the correlation matrix to have zero sum by adjusting the (0,0) value?",
89  default=False,
90  )
91  correlationQuadraticFit = pexConfig.Field(
92  dtype=bool,
93  doc="Use a quadratic fit to find the correlations instead of simple averaging?",
94  default=False,
95  )
96  correlationModelRadius = pexConfig.Field(
97  dtype=int,
98  doc="Build a model of the correlation coefficients for radii larger than this value in pixels?",
99  default=100,
100  )
101  correlationModelSlope = pexConfig.Field(
102  dtype=float,
103  doc="Slope of the correlation model for radii larger than correlationModelRadius",
104  default=-1.35,
105  )
106  ccdKey = pexConfig.Field(
107  dtype=str,
108  doc="The key by which to pull a detector from a dataId, e.g. 'ccd' or 'detector'",
109  default='ccd',
110  )
111  minMeanSignal = pexConfig.Field(
112  dtype=float,
113  doc="Minimum value of mean signal (in ADU) to consider.",
114  default=0,
115  )
116  maxMeanSignal = pexConfig.Field(
117  dtype=float,
118  doc="Maximum value to of mean signal (in ADU) to consider.",
119  default=9e6,
120  )
121  maxIterRegression = pexConfig.Field(
122  dtype=int,
123  doc="Maximum number of iterations for the regression fitter",
124  default=2
125  )
126  nSigmaClipGainCalc = pexConfig.Field(
127  dtype=int,
128  doc="Number of sigma to clip the pixel value distribution to during gain calculation",
129  default=5
130  )
131  nSigmaClipRegression = pexConfig.Field(
132  dtype=int,
133  doc="Number of sigma to clip outliers from the line of best fit to during iterative regression",
134  default=4
135  )
136  xcorrCheckRejectLevel = pexConfig.Field(
137  dtype=float,
138  doc="Sanity check level for the sum of the input cross-correlations. Arrays which " +
139  "sum to greater than this are discarded before the clipped mean is calculated.",
140  default=2.0
141  )
142  maxIterSuccessiveOverRelaxation = pexConfig.Field(
143  dtype=int,
144  doc="The maximum number of iterations allowed for the successive over-relaxation method",
145  default=10000
146  )
147  eLevelSuccessiveOverRelaxation = pexConfig.Field(
148  dtype=float,
149  doc="The target residual error for the successive over-relaxation method",
150  default=5.0e-14
151  )
152  nSigmaClipKernelGen = pexConfig.Field(
153  dtype=float,
154  doc="Number of sigma to clip to during pixel-wise clipping when generating the kernel. See " +
155  "the generateKernel docstring for more info.",
156  default=4
157  )
158  nSigmaClipXCorr = pexConfig.Field(
159  dtype=float,
160  doc="Number of sigma to clip when calculating means for the cross-correlation",
161  default=5
162  )
163  maxLag = pexConfig.Field(
164  dtype=int,
165  doc="The maximum lag (in pixels) to use when calculating the cross-correlation/kernel",
166  default=8
167  )
168  nPixBorderGainCalc = pexConfig.Field(
169  dtype=int,
170  doc="The number of border pixels to exclude when calculating the gain",
171  default=10
172  )
173  nPixBorderXCorr = pexConfig.Field(
174  dtype=int,
175  doc="The number of border pixels to exclude when calculating the cross-correlation and kernel",
176  default=10
177  )
178  biasCorr = pexConfig.Field(
179  dtype=float,
180  doc="An empirically determined correction factor, used to correct for the sigma-clipping of" +
181  " a non-Gaussian distribution. Post DM-15277, code will exist here to calculate appropriate values",
182  default=0.9241
183  )
184  backgroundBinSize = pexConfig.Field(
185  dtype=int,
186  doc="Size of the background bins",
187  default=128
188  )
189  fixPtcThroughOrigin = pexConfig.Field(
190  dtype=bool,
191  doc="Constrain the fit of the photon transfer curve to go through the origin when measuring" +
192  "the gain?",
193  default=True
194  )
195  level = pexConfig.ChoiceField(
196  doc="The level at which to calculate the brighter-fatter kernels",
197  dtype=str,
198  default="DETECTOR",
199  allowed={
200  "AMP": "Every amplifier treated separately",
201  "DETECTOR": "One kernel per detector",
202  }
203  )
204  ignoreAmpsForAveraging = pexConfig.ListField(
205  dtype=str,
206  doc="List of amp names to ignore when averaging the amplifier kernels into the detector" +
207  " kernel. Only relevant for level = AMP",
208  default=[]
209  )
210  backgroundWarnLevel = pexConfig.Field(
211  dtype=float,
212  doc="Log warnings if the mean of the fitted background is found to be above this level after " +
213  "differencing image pair.",
214  default=0.1
215  )
216 
217 
218 class BrighterFatterKernelTaskDataIdContainer(pipeBase.DataIdContainer):
219  """A DataIdContainer for the MakeBrighterFatterKernelTask."""
220 
221  def makeDataRefList(self, namespace):
222  """Compute refList based on idList.
223 
224  This method must be defined as the dataset does not exist before this
225  task is run.
226 
227  Parameters
228  ----------
229  namespace
230  Results of parsing the command-line.
231 
232  Notes
233  -----
234  Not called if ``add_id_argument`` called
235  with ``doMakeDataRefList=False``.
236  Note that this is almost a copy-and-paste of the vanilla implementation,
237  but without checking if the datasets already exist,
238  as this task exists to make them.
239  """
240  if self.datasetType is None:
241  raise RuntimeError("Must call setDatasetType first")
242  butler = namespace.butler
243  for dataId in self.idList:
244  refList = list(butler.subset(datasetType=self.datasetType, level=self.level, dataId=dataId))
245  # exclude nonexistent data
246  # this is a recursive test, e.g. for the sake of "raw" data
247  if not refList:
248  namespace.log.warn("No data found for dataId=%s", dataId)
249  continue
250  self.refList += refList
251 
252 
254  """A simple class to hold the kernel(s) generated and the intermediate
255  data products.
256 
257  kernel.ampwiseKernels are the kernels for each amplifier in the detector,
258  as generated by having LEVEL == 'AMP'
259 
260  kernel.detectorKernel is the kernel generated for the detector as a whole,
261  as generated by having LEVEL == 'DETECTOR'
262 
263  kernel.detectorKernelFromAmpKernels is the kernel for the detector,
264  generated by averaging together the amps in the detector
265 
266  The originalLevel is the level for which the kernel(s) were generated,
267  i.e. the level at which the task was originally run.
268  """
269 
270  def __init__(self, originalLevel, **kwargs):
271  self.__dict__["originalLevel"] = originalLevel
272  self.__dict__["ampwiseKernels"] = {}
273  self.__dict__["detectorKernel"] = {}
274  self.__dict__["detectorKernelFromAmpKernels"] = {}
275  self.__dict__["means"] = []
276  self.__dict__["rawMeans"] = []
277  self.__dict__["rawXcorrs"] = []
278  self.__dict__["xCorrs"] = []
279  self.__dict__["meanXcorrs"] = []
280  self.__dict__["gain"] = None # will be a dict keyed by amp if set
281  self.__dict__["gainErr"] = None # will be a dict keyed by amp if set
282  self.__dict__["noise"] = None # will be a dict keyed by amp if set
283  self.__dict__["noiseErr"] = None # will be a dict keyed by amp if set
284 
285  for key, value in kwargs.items():
286  if hasattr(self, key):
287  setattr(self, key, value)
288 
289  def __setattr__(self, attribute, value):
290  """Protect class attributes"""
291  if attribute not in self.__dict__:
292  print(f"Cannot set {attribute}")
293  else:
294  self.__dict__[attribute] = value
295 
296  def replaceDetectorKernelWithAmpKernel(self, ampName, detectorName):
297  self.detectorKernel[detectorName] = self.ampwiseKernels[ampName]
298 
299  def makeDetectorKernelFromAmpwiseKernels(self, detectorName, ampsToExclude=[], overwrite=False):
300  if detectorName not in self.detectorKernelFromAmpKernels.keys():
301  self.detectorKernelFromAmpKernels[detectorName] = {}
302 
303  if self.detectorKernelFromAmpKernels[detectorName] != {} and overwrite is False:
304  raise RuntimeError('Was told to replace existing detector kernel with overwrite==False')
305 
306  ampNames = self.ampwiseKernels.keys()
307  ampsToAverage = [amp for amp in ampNames if amp not in ampsToExclude]
308  avgKernel = np.zeros_like(self.ampwiseKernels[ampsToAverage[0]])
309  for ampName in ampsToAverage:
310  avgKernel += self.ampwiseKernels[ampName]
311  avgKernel /= len(ampsToAverage)
312 
313  self.detectorKernelFromAmpKernels[detectorName] = avgKernel
314 
315 
316 @dataclass
318  """The gains and the results of the PTC fits."""
319  gains: dict
320  ptcResults: dict
321 
322 
323 class MakeBrighterFatterKernelTask(pipeBase.CmdLineTask):
324  """Brighter-fatter effect correction-kernel calculation task.
325 
326  A command line task for calculating the brighter-fatter correction
327  kernel from pairs of flat-field images (with the same exposure length).
328 
329  The following operations are performed:
330 
331  - The configurable isr task is called, which unpersists and assembles the
332  raw images, and performs the selected instrument signature removal tasks.
333  For the purpose of brighter-fatter coefficient calculation is it
334  essential that certain components of isr are *not* performed, and
335  recommended that certain others are. The task checks the selected isr
336  configuration before it is run, and if forbidden components have been
337  selected task will raise, and if recommended ones have not been selected,
338  warnings are logged.
339 
340  - The gain of the each amplifier in the detector is calculated using
341  the photon transfer curve (PTC) method and used to correct the images
342  so that all calculations are done in units of electrons, and so that the
343  level across amplifier boundaries is continuous.
344  Outliers in the PTC are iteratively rejected
345  before fitting, with the nSigma rejection level set by
346  config.nSigmaClipRegression. Individual pixels are ignored in the input
347  images the image based on config.nSigmaClipGainCalc.
348 
349  - Each image is then cross-correlated with the one it's paired with
350  (with the pairing defined by the --visit-pairs command line argument),
351  which is done either the whole-image to whole-image,
352  or amplifier-by-amplifier, depending on config.level.
353 
354  - Once the cross-correlations have been calculated for each visit pair,
355  these are used to generate the correction kernel.
356  The maximum lag used, in pixels, and hence the size of the half-size
357  of the kernel generated, is given by config.maxLag,
358  i.e. a value of 10 will result in a kernel of size 2n-1 = 19x19 pixels.
359  Outlier values in these cross-correlations are rejected by using a
360  pixel-wise sigma-clipped thresholding to each cross-correlation in
361  the visit-pairs-length stack of cross-correlations.
362  The number of sigma clipped to is set by config.nSigmaClipKernelGen.
363 
364  - Once DM-15277 has been completed, a method will exist to calculate the
365  empirical correction factor, config.biasCorr.
366  TODO: DM-15277 update this part of the docstring once the ticket is done.
367  """
368 
369  RunnerClass = PairedVisitListTaskRunner
370  ConfigClass = MakeBrighterFatterKernelTaskConfig
371  _DefaultName = "makeBrighterFatterKernel"
372 
373  def __init__(self, *args, **kwargs):
374  pipeBase.CmdLineTask.__init__(self, *args, **kwargs)
375  self.makeSubtask("isr")
376 
377  self.debug = lsstDebug.Info(__name__)
378  if self.debug.enabled:
379  self.log.info("Running with debug enabled...")
380  # If we're displaying, test it works and save displays for later.
381  # It's worth testing here as displays are flaky and sometimes
382  # can't be contacted, and given processing takes a while,
383  # it's a shame to fail late due to display issues.
384  if self.debug.display:
385  try:
386  afwDisp.setDefaultBackend(self.debug.displayBackend)
387  afwDisp.Display.delAllDisplays()
388  self.disp1 = afwDisp.Display(0, open=True)
389  self.disp2 = afwDisp.Display(1, open=True)
390 
391  im = afwImage.ImageF(1, 1)
392  im.array[:] = [[1]]
393  self.disp1.mtv(im)
394  self.disp1.erase()
395  except NameError:
396  self.debug.display = False
397  self.log.warn('Failed to setup/connect to display! Debug display has been disabled')
398 
399  plt.interactive(False) # stop windows popping up when plotting. When headless, use 'agg' backend too
400  self.validateIsrConfig()
401  self.config.validate()
402  self.config.freeze()
403 
404  @classmethod
405  def _makeArgumentParser(cls):
406  """Augment argument parser for the MakeBrighterFatterKernelTask."""
407  parser = pipeBase.ArgumentParser(name=cls._DefaultName)
408  parser.add_argument("--visit-pairs", dest="visitPairs", nargs="*",
409  help="Visit pairs to use. Each pair must be of the form INT,INT e.g. 123,456")
410  parser.add_id_argument("--id", datasetType="brighterFatterKernel",
411  ContainerClass=BrighterFatterKernelTaskDataIdContainer,
412  help="The ccds to use, e.g. --id ccd=0..100")
413  return parser
414 
415  def validateIsrConfig(self):
416  """Check that appropriate ISR settings are being used
417  for brighter-fatter kernel calculation."""
418 
419  # How should we handle saturation/bad regions?
420  # 'doSaturationInterpolation': True
421  # 'doNanInterpAfterFlat': False
422  # 'doSaturation': True
423  # 'doSuspect': True
424  # 'doWidenSaturationTrails': True
425  # 'doSetBadRegions': True
426 
427  configDict = self.config.isr.toDict()
428 
429  for configParam in self.config.isrMandatorySteps:
430  if configDict[configParam] is False:
431  raise RuntimeError('Must set config.isr.%s to True '
432  'for brighter-fatter kernel calculation' % configParam)
433 
434  for configParam in self.config.isrForbiddenSteps:
435  if configDict[configParam] is True:
436  raise RuntimeError('Must set config.isr.%s to False '
437  'for brighter-fatter kernel calculation' % configParam)
438 
439  for configParam in self.config.isrDesirableSteps:
440  if configParam not in configDict:
441  self.log.info('Failed to find key %s in the isr config dict. You probably want ' +
442  'to set the equivalent for your obs_package to True.' % configParam)
443  continue
444  if configDict[configParam] is False:
445  self.log.warn('Found config.isr.%s set to False for brighter-fatter kernel calculation. '
446  'It is probably desirable to have this set to True' % configParam)
447 
448  # subtask settings
449  if not self.config.isr.assembleCcd.doTrim:
450  raise RuntimeError('Must trim when assembling CCDs. Set config.isr.assembleCcd.doTrim to True')
451 
452  @pipeBase.timeMethod
453  def runDataRef(self, dataRef, visitPairs):
454  """Run the brighter-fatter measurement task.
455 
456  For a dataRef (which is each detector here),
457  and given a list of visit pairs, calculate the
458  brighter-fatter kernel for the detector.
459 
460  Parameters
461  ----------
462  dataRef : `list` of `lsst.daf.persistence.ButlerDataRef`
463  dataRef for the detector for the visits to be fit.
464  visitPairs : `iterable` of `tuple` of `int`
465  Pairs of visit numbers to be processed together
466  """
467  np.random.seed(0) # used in the PTC fit bootstrap
468 
469  # setup necessary objects
470  # NB: don't use dataRef.get('raw_detector')
471  # this currently doesn't work for composites because of the way
472  # composite objects (i.e. LSST images) are handled/constructed
473  # these need to be retrieved from the camera and dereferenced
474  # rather than accessed directly
475  detNum = dataRef.dataId[self.config.ccdKey]
476  detector = dataRef.get('camera')[dataRef.dataId[self.config.ccdKey]]
477  amps = detector.getAmplifiers()
478  ampNames = [amp.getName() for amp in amps]
479 
480  if self.config.level == 'DETECTOR':
481  kernels = {detNum: []}
482  means = {detNum: []}
483  xcorrs = {detNum: []}
484  meanXcorrs = {detNum: []}
485  elif self.config.level == 'AMP':
486  kernels = {key: [] for key in ampNames}
487  means = {key: [] for key in ampNames}
488  xcorrs = {key: [] for key in ampNames}
489  meanXcorrs = {key: [] for key in ampNames}
490  else:
491  raise RuntimeError("Unsupported level: {}".format(self.config.level))
492 
493  # we must be able to get the gains one way or the other, so check early
494  if not self.config.doCalcGains:
495  deleteMe = None
496  try:
497  deleteMe = dataRef.get('photonTransferCurveDataset')
498  except butlerExceptions.NoResults:
499  try:
500  deleteMe = dataRef.get('brighterFatterGain')
501  except butlerExceptions.NoResults:
502  pass
503  if not deleteMe:
504  raise RuntimeError("doCalcGains == False and gains could not be got from butler") from None
505  else:
506  del deleteMe
507 
508  # if the level is DETECTOR we need to have the gains first so that each
509  # amp can be gain corrected in order to treat the detector as a single
510  # imaging area. However, if the level is AMP we can wait, calculate
511  # the correlations and correct for the gains afterwards
512  if self.config.level == 'DETECTOR':
513  if self.config.doCalcGains:
514  self.log.info('Computing gains for detector %s' % detNum)
515  gains, nomGains = self.estimateGains(dataRef, visitPairs)
516  dataRef.put(gains, datasetType='brighterFatterGain')
517  self.log.debug('Finished gain estimation for detector %s' % detNum)
518  else:
519  gains = dataRef.get('brighterFatterGain')
520  if not gains:
521  raise RuntimeError('Failed to retrieved gains for detector %s' % detNum)
522  self.log.info('Retrieved stored gain for detector %s' % detNum)
523  self.log.debug('Detector %s has gains %s' % (detNum, gains))
524  else: # we fake the gains as 1 for now, and correct later
525  gains = BrighterFatterGain({}, {})
526  for ampName in ampNames:
527  gains.gains[ampName] = 1.0
528  # We'll use the ptc.py code to calculate the gains, so we set this up
530  ptcConfig.isrForbiddenSteps = []
531  ptcConfig.doFitBootstrap = True
532  ptcConfig.minMeanSignal = self.config.minMeanSignal
533  ptcConfig.maxMeanSignal = self.config.maxMeanSignal
534  ptcTask = MeasurePhotonTransferCurveTask(config=ptcConfig)
535  ptcDataset = PhotonTransferCurveDataset(ampNames)
536 
537  # Loop over pairs of visits
538  # calculating the cross-correlations at the required level
539  for (v1, v2) in visitPairs:
540  dataRef.dataId['visit'] = v1
541  exp1 = self.isr.runDataRef(dataRef).exposure
542  dataRef.dataId['visit'] = v2
543  exp2 = self.isr.runDataRef(dataRef).exposure
544  del dataRef.dataId['visit']
545  checkExpLengthEqual(exp1, exp2, v1, v2, raiseWithMessage=True)
546 
547  self.log.info('Preparing images for cross-correlation calculation for detector %s' % detNum)
548  # note the shape of these returns depends on level
549  _scaledMaskedIms1, _means1 = self._makeCroppedExposures(exp1, gains, self.config.level)
550  _scaledMaskedIms2, _means2 = self._makeCroppedExposures(exp2, gains, self.config.level)
551 
552  # Compute the cross-correlation and means
553  # at the appropriate config.level:
554  # - "DETECTOR": one key, so compare the two visits to each other
555  # - "AMP": n_amp keys, comparing each amplifier of one visit
556  # to the same amplifier in the visit its paired with
557  for det_object in _scaledMaskedIms1.keys(): # det_object is ampName or detName depending on level
558  self.log.debug("Calculating correlations for %s" % det_object)
559  _xcorr, _mean = self._crossCorrelate(_scaledMaskedIms1[det_object],
560  _scaledMaskedIms2[det_object])
561  xcorrs[det_object].append(_xcorr)
562  means[det_object].append([_means1[det_object], _means2[det_object]])
563  if self.config.level != 'DETECTOR':
564  # Populate the ptcDataset for running fitting in the PTC task
565  expTime = exp1.getInfo().getVisitInfo().getExposureTime()
566  ptcDataset.rawExpTimes[det_object].append(expTime)
567  ptcDataset.rawMeans[det_object].append((_means1[det_object] + _means2[det_object]) / 2.0)
568  ptcDataset.rawVars[det_object].append(_xcorr[0, 0] / 2.0)
569 
570  # TODO: DM-15305 improve debug functionality here.
571  # This is position 1 for the removed code.
572 
573  # Save the raw means and xcorrs so we can look at them before any modifications
574  rawMeans = copy.deepcopy(means)
575  rawXcorrs = copy.deepcopy(xcorrs)
576 
577  # gains are always and only pre-applied for DETECTOR
578  # so for all other levels we now calculate them from the correlations
579  # and apply them
580  if self.config.level != 'DETECTOR':
581  if self.config.doCalcGains: # Run the PTC task for calculating the gains, put results
582  self.log.info('Calculating gains for detector %s using PTC task' % detNum)
583  ptcDataset = ptcTask.fitPtcAndNl(ptcDataset, ptcConfig.ptcFitType)
584  dataRef.put(ptcDataset, datasetType='photonTransferCurveDataset')
585  self.log.debug('Finished gain estimation for detector %s' % detNum)
586  else: # load results - confirmed to work much earlier on, so can be relied upon here
587  ptcDataset = dataRef.get('photonTransferCurveDataset')
588 
589  self._applyGains(means, xcorrs, ptcDataset)
590 
591  if self.config.doPlotPtcs:
592  dirname = dataRef.getUri(datasetType='cpPipePlotRoot', write=True)
593  if not os.path.exists(dirname):
594  os.makedirs(dirname)
595  detNum = dataRef.dataId[self.config.ccdKey]
596  filename = f"PTC_det{detNum}.pdf"
597  filenameFull = os.path.join(dirname, filename)
598  with PdfPages(filenameFull) as pdfPages:
599  ptcTask._plotPtc(ptcDataset, ptcConfig.ptcFitType, pdfPages)
600 
601  # having calculated and applied the gains for all code-paths we can now
602  # generate the kernel(s)
603  self.log.info('Generating kernel(s) for %s' % detNum)
604  for det_object in xcorrs.keys(): # looping over either detectors or amps
605  if self.config.level == 'DETECTOR':
606  objId = 'detector %s' % det_object
607  elif self.config.level == 'AMP':
608  objId = 'detector %s AMP %s' % (detNum, det_object)
609 
610  try:
611  meanXcorr, kernel = self.generateKernel(xcorrs[det_object], means[det_object], objId)
612  kernels[det_object] = kernel
613  meanXcorrs[det_object] = meanXcorr
614  except RuntimeError:
615  # bad amps will cause failures here which we want to ignore
616  self.log.warn('RuntimeError during kernel generation for %s' % objId)
617  continue
618 
619  bfKernel = BrighterFatterKernel(self.config.level)
620  bfKernel.means = means
621  bfKernel.rawMeans = rawMeans
622  bfKernel.rawXcorrs = rawXcorrs
623  bfKernel.xCorrs = xcorrs
624  bfKernel.meanXcorrs = meanXcorrs
625  bfKernel.originalLevel = self.config.level
626  try:
627  bfKernel.gain = ptcDataset.gain
628  bfKernel.gainErr = ptcDataset.gainErr
629  bfKernel.noise = ptcDataset.noise
630  bfKernel.noiseErr = ptcDataset.noiseErr
631  except NameError: # we don't have a ptcDataset to store results from
632  pass
633 
634  if self.config.level == 'AMP':
635  bfKernel.ampwiseKernels = kernels
636  ex = self.config.ignoreAmpsForAveraging
637  bfKernel.detectorKernel = bfKernel.makeDetectorKernelFromAmpwiseKernels(detNum, ampsToExclude=ex)
638 
639  elif self.config.level == 'DETECTOR':
640  bfKernel.detectorKernel = kernels
641  else:
642  raise RuntimeError('Invalid level for kernel calculation; this should not be possible.')
643 
644  dataRef.put(bfKernel)
645 
646  self.log.info('Finished generating kernel(s) for %s' % detNum)
647  return pipeBase.Struct(exitStatus=0)
648 
649  def _applyGains(self, means, xcorrs, ptcData):
650  """Apply the gains calculated by the PtcTask.
651 
652  It also removes datapoints that were thrown out in the PTC algorithm.
653 
654  Parameters
655  ----------
656  means : `dict` [`str`, `list` of `tuple`]
657  Dictionary, keyed by ampName, containing a list of the means for
658  each visit pair.
659 
660  xcorrs : `dict` [`str`, `list` of `np.array`]
661  Dictionary, keyed by ampName, containing a list of the
662  cross-correlations for each visit pair.
663 
664  ptcDataset : `lsst.cp.pipe.ptc.PhotonTransferCurveDataset`
665  The results of running the ptcTask.
666  """
667  ampNames = means.keys()
668  assert set(xcorrs.keys()) == set(ampNames) == set(ptcData.ampNames)
669 
670  for ampName in ampNames:
671  mask = ptcData.visitMask[ampName]
672  gain = ptcData.gain[ampName]
673 
674  fitType = ptcData.ptcFitType
675  if fitType != 'POLYNOMIAL':
676  raise RuntimeError("Only polynomial fit types supported currently")
677  ptcFitPars = ptcData.ptcFitPars[ampName]
678  # polynomial params go in ascending order, so this is safe w.r.t.
679  # the polynomial order, as the constant term is always first,
680  # the linear term second etc
681 
682  # Adjust xcorrs[0,0] to remove the linear gain part, leaving just the second order part
683  for i in range(len(means[ampName])):
684  ampMean = np.mean(means[ampName][i])
685  xcorrs[ampName][i][0, 0] -= 2.0 * (ampMean * ptcFitPars[1] + ptcFitPars[0])
686 
687  # Now adjust the means and xcorrs for the calculated gain and remove the bad indices
688  means[ampName] = [[value*gain for value in pair] for pair in np.array(means[ampName])[mask]]
689  xcorrs[ampName] = [arr*gain*gain for arr in np.array(xcorrs[ampName])[mask]]
690  return
691 
692  def _makeCroppedExposures(self, exp, gains, level):
693  """Prepare exposure for cross-correlation calculation.
694 
695  For each amp, crop by the border amount, specified by
696  config.nPixBorderXCorr, then rescale by the gain
697  and subtract the sigma-clipped mean.
698  If the level is 'DETECTOR' then this is done
699  to the whole image so that it can be cross-correlated, with a copy
700  being returned.
701  If the level is 'AMP' then this is done per-amplifier,
702  and a copy of each prepared amp-image returned.
703 
704  Parameters:
705  -----------
706  exp : `lsst.afw.image.exposure.ExposureF`
707  The exposure to prepare
708  gains : `lsst.cp.pipe.makeBrighterFatterKernel.BrighterFatterGain`
709  The object holding the amplifier gains, essentially a
710  dictionary of the amplifier gain values, keyed by amplifier name
711  level : `str`
712  Either `AMP` or `DETECTOR`
713 
714  Returns:
715  --------
716  scaledMaskedIms : `dict` [`str`, `lsst.afw.image.maskedImage.MaskedImageF`]
717  Depending on level, this is either one item, or n_amp items,
718  keyed by detectorId or ampName
719 
720  Notes:
721  ------
722  This function is controlled by the following config parameters:
723  nPixBorderXCorr : `int`
724  The number of border pixels to exclude
725  nSigmaClipXCorr : `float`
726  The number of sigma to be clipped to
727  """
728  assert(isinstance(exp, afwImage.ExposureF))
729 
730  local_exp = exp.clone() # we don't want to modify the image passed in
731  del exp # ensure we don't make mistakes!
732 
733  border = self.config.nPixBorderXCorr
734  sigma = self.config.nSigmaClipXCorr
735 
736  sctrl = afwMath.StatisticsControl()
737  sctrl.setNumSigmaClip(sigma)
738 
739  means = {}
740  returnAreas = {}
741 
742  detector = local_exp.getDetector()
743  amps = detector.getAmplifiers()
744 
745  mi = local_exp.getMaskedImage() # makeStatistics does not seem to take exposures
746  temp = mi.clone()
747 
748  # Rescale each amp by the appropriate gain and subtract the mean.
749  # NB these are views modifying the image in-place
750  for amp in amps:
751  ampName = amp.getName()
752  rescaleIm = mi[amp.getBBox()] # the soon-to-be scaled, mean subtracted, amp image
753  rescaleTemp = temp[amp.getBBox()]
754  mean = afwMath.makeStatistics(rescaleIm, afwMath.MEANCLIP, sctrl).getValue()
755  gain = gains.gains[ampName]
756  rescaleIm *= gain
757  rescaleTemp *= gain
758  self.log.debug("mean*gain = %s, clipped mean = %s" %
759  (mean*gain, afwMath.makeStatistics(rescaleIm, afwMath.MEANCLIP,
760  sctrl).getValue()))
761  rescaleIm -= mean*gain
762 
763  if level == 'AMP': # build the dicts if doing amp-wise
764  means[ampName] = afwMath.makeStatistics(rescaleTemp[border: -border, border: -border,
765  afwImage.LOCAL], afwMath.MEANCLIP, sctrl).getValue()
766  returnAreas[ampName] = rescaleIm
767 
768  if level == 'DETECTOR': # else just average the whole detector
769  detName = local_exp.getDetector().getId()
770  means[detName] = afwMath.makeStatistics(temp[border: -border, border: -border, afwImage.LOCAL],
771  afwMath.MEANCLIP, sctrl).getValue()
772  returnAreas[detName] = rescaleIm
773 
774  return returnAreas, means
775 
776  def _crossCorrelate(self, maskedIm0, maskedIm1, runningBiasCorrSim=False, frameId=None, detId=None):
777  """Calculate the cross-correlation of an area.
778 
779  If the area in question contains multiple amplifiers then they must
780  have been gain corrected.
781 
782  Parameters:
783  -----------
784  maskedIm0 : `lsst.afw.image.MaskedImageF`
785  The first image area
786  maskedIm1 : `lsst.afw.image.MaskedImageF`
787  The first image area
788  frameId : `str`, optional
789  The frame identifier for use in the filename
790  if writing debug outputs.
791  detId : `str`, optional
792  The detector identifier (detector, or detector+amp,
793  depending on config.level) for use in the filename
794  if writing debug outputs.
795  runningBiasCorrSim : `bool`
796  Set to true when using this function to calculate the amount of bias
797  introduced by the sigma clipping. If False, the biasCorr parameter
798  is divided by to remove the bias, but this is, of course, not
799  appropriate when this is the parameter being measured.
800 
801  Returns:
802  --------
803  xcorr : `np.ndarray`
804  The quarter-image cross-correlation
805  mean : `float`
806  The sum of the means of the input images,
807  sigma-clipped, and with borders applied.
808  This is used when using this function with simulations to calculate
809  the biasCorr parameter.
810 
811  Notes:
812  ------
813  This function is controlled by the following config parameters:
814  maxLag : `int`
815  The maximum lag to use in the cross-correlation calculation
816  nPixBorderXCorr : `int`
817  The number of border pixels to exclude
818  nSigmaClipXCorr : `float`
819  The number of sigma to be clipped to
820  biasCorr : `float`
821  Parameter used to correct from the bias introduced
822  by the sigma cuts.
823  """
824  maxLag = self.config.maxLag
825  border = self.config.nPixBorderXCorr
826  sigma = self.config.nSigmaClipXCorr
827  biasCorr = self.config.biasCorr
828 
829  sctrl = afwMath.StatisticsControl()
830  sctrl.setNumSigmaClip(sigma)
831 
832  mean = afwMath.makeStatistics(maskedIm0.getImage()[border: -border, border: -border, afwImage.LOCAL],
833  afwMath.MEANCLIP, sctrl).getValue()
834  mean += afwMath.makeStatistics(maskedIm1.getImage()[border: -border, border: -border, afwImage.LOCAL],
835  afwMath.MEANCLIP, sctrl).getValue()
836 
837  # Diff the images, and apply border
838  diff = maskedIm0.clone()
839  diff -= maskedIm1.getImage()
840  diff = diff[border: -border, border: -border, afwImage.LOCAL]
841 
842  if self.debug.writeDiffImages:
843  filename = '_'.join(['diff', 'detector', detId, frameId, '.fits'])
844  diff.writeFits(os.path.join(self.debug.debugDataPath, filename))
845 
846  # Subtract background. It should be a constant, but it isn't always
847  binsize = self.config.backgroundBinSize
848  nx = diff.getWidth()//binsize
849  ny = diff.getHeight()//binsize
850  bctrl = afwMath.BackgroundControl(nx, ny, sctrl, afwMath.MEANCLIP)
851  bkgd = afwMath.makeBackground(diff, bctrl)
852  bgImg = bkgd.getImageF(afwMath.Interpolate.CUBIC_SPLINE, afwMath.REDUCE_INTERP_ORDER)
853  bgMean = np.mean(bgImg.getArray())
854  if abs(bgMean) >= self.config.backgroundWarnLevel:
855  self.log.warn('Mean of background = %s > config.maxBackground' % bgMean)
856 
857  diff -= bgImg
858 
859  if self.debug.writeDiffImages:
860  filename = '_'.join(['bgSub', 'diff', 'detector', detId, frameId, '.fits'])
861  diff.writeFits(os.path.join(self.debug.debugDataPath, filename))
862  if self.debug.display:
863  self.disp1.mtv(diff, title=frameId)
864 
865  self.log.debug("Median and variance of diff:")
866  self.log.debug("%s" % afwMath.makeStatistics(diff, afwMath.MEDIAN, sctrl).getValue())
867  self.log.debug("%s, %s" % (afwMath.makeStatistics(diff, afwMath.VARIANCECLIP, sctrl).getValue(),
868  np.var(diff.getImage().getArray())))
869 
870  # Measure the correlations
871  dim0 = diff[0: -maxLag, : -maxLag, afwImage.LOCAL]
872  dim0 -= afwMath.makeStatistics(dim0, afwMath.MEANCLIP, sctrl).getValue()
873  width, height = dim0.getDimensions()
874  xcorr = np.zeros((maxLag + 1, maxLag + 1), dtype=np.float64)
875 
876  for xlag in range(maxLag + 1):
877  for ylag in range(maxLag + 1):
878  dim_xy = diff[xlag:xlag + width, ylag: ylag + height, afwImage.LOCAL].clone()
879  dim_xy -= afwMath.makeStatistics(dim_xy, afwMath.MEANCLIP, sctrl).getValue()
880  dim_xy *= dim0
881  xcorr[xlag, ylag] = afwMath.makeStatistics(dim_xy, afwMath.MEANCLIP, sctrl).getValue()
882  if not runningBiasCorrSim:
883  xcorr[xlag, ylag] /= biasCorr
884 
885  # TODO: DM-15305 improve debug functionality here.
886  # This is position 2 for the removed code.
887 
888  return xcorr, mean
889 
890  def estimateGains(self, dataRef, visitPairs):
891  """Estimate the amplifier gains using the specified visits.
892 
893  Given a dataRef and list of flats of varying intensity,
894  calculate the gain for each amplifier in the detector
895  using the photon transfer curve (PTC) method.
896 
897  The config.fixPtcThroughOrigin option determines whether the iterative
898  fitting is forced to go through the origin or not.
899  This defaults to True, fitting var=1/gain * mean.
900  If set to False then var=1/g * mean + const is fitted.
901 
902  This is really a photo transfer curve (PTC) gain measurement task.
903  See DM-14063 for results from of a comparison between
904  this task's numbers and the gain values in the HSC camera model,
905  and those measured by the PTC task in eotest.
906 
907  Parameters
908  ----------
909  dataRef : `lsst.daf.persistence.butler.Butler.dataRef`
910  dataRef for the detector for the flats to be used
911  visitPairs : `list` of `tuple`
912  List of visit-pairs to use, as [(v1,v2), (v3,v4)...]
913 
914  Returns
915  -------
916  gains : `lsst.cp.pipe.makeBrighterFatterKernel.BrighterFatterGain`
917  Object holding the per-amplifier gains, essentially a
918  dict of the as-calculated amplifier gain values, keyed by amp name
919  nominalGains : `dict` [`str`, `float`]
920  Dict of the amplifier gains, as reported by the `detector` object,
921  keyed by amplifier name
922  """
923  # NB: don't use dataRef.get('raw_detector') due to composites
924  detector = dataRef.get('camera')[dataRef.dataId[self.config.ccdKey]]
925  amps = detector.getAmplifiers()
926  ampNames = [amp.getName() for amp in amps]
927 
928  ampMeans = {key: [] for key in ampNames} # these get turned into np.arrays later
929  ampCoVariances = {key: [] for key in ampNames}
930  ampVariances = {key: [] for key in ampNames}
931 
932  # Loop over the amps in the detector,
933  # calculating a PTC for each amplifier.
934  # The amplifier iteration is performed in _calcMeansAndVars()
935  # NB: no gain correction is applied
936  for visPairNum, visPair in enumerate(visitPairs):
937  _means, _vars, _covars = self._calcMeansAndVars(dataRef, visPair[0], visPair[1])
938 
939  # Do sanity checks; if these are failed more investigation is needed
940  breaker = 0
941  for amp in detector:
942  ampName = amp.getName()
943  if _means[ampName]*10 < _vars[ampName] or _means[ampName]*10 < _covars[ampName]:
944  msg = 'Sanity check failed; check visit pair %s amp %s' % (visPair, ampName)
945  self.log.warn(msg)
946  breaker += 1
947  if breaker:
948  continue
949 
950  # having made sanity checks
951  # pull the values out into the respective dicts
952  for k in _means.keys(): # keys are necessarily the same
953  if _vars[k]*1.3 < _covars[k] or _vars[k]*0.7 > _covars[k]:
954  self.log.warn('Dropped a value')
955  continue
956  ampMeans[k].append(_means[k])
957  ampVariances[k].append(_vars[k])
958  ampCoVariances[k].append(_covars[k])
959 
960  gains = {}
961  nomGains = {}
962  ptcResults = {}
963  for amp in detector:
964  ampName = amp.getName()
965  if ampMeans[ampName] == []: # all the data was dropped, amp is presumed bad
966  gains[ampName] = 1.0
967  ptcResults[ampName] = (0, 0, 1, 0)
968  continue
969 
970  nomGains[ampName] = amp.getGain()
971  slopeRaw, interceptRaw, rVal, pVal, stdErr = \
972  stats.linregress(np.asarray(ampMeans[ampName]), np.asarray(ampCoVariances[ampName]))
973  slopeFix, _ = self._iterativeRegression(np.asarray(ampMeans[ampName]),
974  np.asarray(ampCoVariances[ampName]),
975  fixThroughOrigin=True)
976  slopeUnfix, intercept = self._iterativeRegression(np.asarray(ampMeans[ampName]),
977  np.asarray(ampCoVariances[ampName]),
978  fixThroughOrigin=False)
979  self.log.info("Slope of raw fit: %s, intercept: %s p value: %s" % (slopeRaw,
980  interceptRaw, pVal))
981  self.log.info("slope of fixed fit: %s, difference vs raw:%s" % (slopeFix,
982  slopeFix - slopeRaw))
983  self.log.info("slope of unfixed fit: %s, difference vs fix:%s" % (slopeUnfix,
984  slopeFix - slopeUnfix))
985  if self.config.fixPtcThroughOrigin:
986  slopeToUse = slopeFix
987  else:
988  slopeToUse = slopeUnfix
989 
990  if self.debug.enabled:
991  fig = plt.figure()
992  ax = fig.add_subplot(111)
993  ax.plot(np.asarray(ampMeans[ampName]),
994  np.asarray(ampCoVariances[ampName]), linestyle='None', marker='x', label='data')
995  if self.config.fixPtcThroughOrigin:
996  ax.plot(np.asarray(ampMeans[ampName]),
997  np.asarray(ampMeans[ampName])*slopeToUse, label='Fit through origin')
998  else:
999  ax.plot(np.asarray(ampMeans[ampName]),
1000  np.asarray(ampMeans[ampName])*slopeToUse + intercept,
1001  label='Fit (intercept unconstrained')
1002 
1003  dataRef.put(fig, "plotBrighterFatterPtc", amp=ampName)
1004  self.log.info('Saved PTC for detector %s amp %s' % (detector.getId(), ampName))
1005  gains[ampName] = 1.0/slopeToUse
1006  # change the fit to use a cubic and match parameters with Lage method
1007  # or better, use the PTC task here too
1008  ptcResults[ampName] = (0, 0, 1, 0)
1009 
1010  return BrighterFatterGain(gains, ptcResults), nomGains
1011 
1012  def _calcMeansAndVars(self, dataRef, v1, v2):
1013  """Calculate the means, vars, covars, and retrieve the nominal gains,
1014  for each amp in each detector.
1015 
1016  This code runs using two visit numbers, and for the detector specified.
1017  It calculates the correlations in the individual amps without
1018  rescaling any gains. This allows a photon transfer curve
1019  to be generated and the gains measured.
1020 
1021  Images are assembled with use the isrTask, and basic isr is performed.
1022 
1023  Parameters:
1024  -----------
1025  dataRef : `lsst.daf.persistence.butler.Butler.dataRef`
1026  dataRef for the detector for the repo containing the flats to be used
1027  v1 : `int`
1028  First visit of the visit pair
1029  v2 : `int`
1030  Second visit of the visit pair
1031 
1032  Returns
1033  -------
1034  means, vars, covars : `tuple` of `dict`
1035  Three dicts, keyed by ampName,
1036  containing the sum of the image-means,
1037  the variance, and the quarter-image of the xcorr.
1038  """
1039  sigma = self.config.nSigmaClipGainCalc
1040  maxLag = self.config.maxLag
1041  border = self.config.nPixBorderGainCalc
1042  biasCorr = self.config.biasCorr
1043 
1044  # NB: don't use dataRef.get('raw_detector') due to composites
1045  detector = dataRef.get('camera')[dataRef.dataId[self.config.ccdKey]]
1046 
1047  ampMeans = {}
1048 
1049  # manipulate the dataId to get a postISR exposure for each visit
1050  # from the detector obj, restoring its original state afterwards
1051  originalDataId = dataRef.dataId.copy()
1052  dataRef.dataId['visit'] = v1
1053  exp1 = self.isr.runDataRef(dataRef).exposure
1054  dataRef.dataId['visit'] = v2
1055  exp2 = self.isr.runDataRef(dataRef).exposure
1056  dataRef.dataId = originalDataId
1057  exps = [exp1, exp2]
1058  checkExpLengthEqual(exp1, exp2, v1, v2, raiseWithMessage=True)
1059 
1060  detector = exps[0].getDetector()
1061  ims = [self._convertImagelikeToFloatImage(exp) for exp in exps]
1062 
1063  if self.debug.display:
1064  self.disp1.mtv(ims[0], title=str(v1))
1065  self.disp2.mtv(ims[1], title=str(v2))
1066 
1067  sctrl = afwMath.StatisticsControl()
1068  sctrl.setNumSigmaClip(sigma)
1069  for imNum, im in enumerate(ims):
1070 
1071  # calculate the sigma-clipped mean, excluding the borders
1072  # safest to apply borders to all amps regardless of edges
1073  # easier, camera-agnostic, and mitigates potentially dodgy
1074  # overscan-biases around edges as well
1075  for amp in detector:
1076  ampName = amp.getName()
1077  ampIm = im[amp.getBBox()]
1078  mean = afwMath.makeStatistics(ampIm[border: -border, border: -border, afwImage.LOCAL],
1079  afwMath.MEANCLIP, sctrl).getValue()
1080  if ampName not in ampMeans.keys():
1081  ampMeans[ampName] = []
1082  ampMeans[ampName].append(mean)
1083  ampIm -= mean
1084 
1085  diff = ims[0].clone()
1086  diff -= ims[1]
1087 
1088  temp = diff[border: -border, border: -border, afwImage.LOCAL]
1089 
1090  # Subtract background. It should be a constant,
1091  # but it isn't always (e.g. some SuprimeCam flats)
1092  # TODO: Check how this looks, and if this is the "right" way to do this
1093  binsize = self.config.backgroundBinSize
1094  nx = temp.getWidth()//binsize
1095  ny = temp.getHeight()//binsize
1096  bctrl = afwMath.BackgroundControl(nx, ny, sctrl, afwMath.MEANCLIP)
1097  bkgd = afwMath.makeBackground(temp, bctrl)
1098 
1099  box = diff.getBBox()
1100  box.grow(-border)
1101  diff[box, afwImage.LOCAL] -= bkgd.getImageF(afwMath.Interpolate.CUBIC_SPLINE,
1102  afwMath.REDUCE_INTERP_ORDER)
1103 
1104  variances = {}
1105  coVars = {}
1106  for amp in detector:
1107  ampName = amp.getName()
1108  diffAmpIm = diff[amp.getBBox()].clone()
1109  diffAmpImCrop = diffAmpIm[border: -border - maxLag, border: -border - maxLag, afwImage.LOCAL]
1110  diffAmpImCrop -= afwMath.makeStatistics(diffAmpImCrop, afwMath.MEANCLIP, sctrl).getValue()
1111  w, h = diffAmpImCrop.getDimensions()
1112  xcorr = np.zeros((maxLag + 1, maxLag + 1), dtype=np.float64)
1113 
1114  # calculate the cross-correlation
1115  for xlag in range(maxLag + 1):
1116  for ylag in range(maxLag + 1):
1117  dim_xy = diffAmpIm[border + xlag: border + xlag + w,
1118  border + ylag: border + ylag + h,
1119  afwImage.LOCAL].clone()
1120  dim_xy -= afwMath.makeStatistics(dim_xy, afwMath.MEANCLIP, sctrl).getValue()
1121  dim_xy *= diffAmpImCrop
1122  xcorr[xlag, ylag] = afwMath.makeStatistics(dim_xy,
1123  afwMath.MEANCLIP, sctrl).getValue()/(biasCorr)
1124 
1125  variances[ampName] = xcorr[0, 0]
1126  xcorr_full = self._tileArray(xcorr)
1127  coVars[ampName] = np.sum(xcorr_full)
1128 
1129  msg = "M1: " + str(ampMeans[ampName][0])
1130  msg += " M2 " + str(ampMeans[ampName][1])
1131  msg += " M_sum: " + str((ampMeans[ampName][0]) + ampMeans[ampName][1])
1132  msg += " Var " + str(variances[ampName])
1133  msg += " coVar: " + str(coVars[ampName])
1134  self.log.debug(msg)
1135 
1136  means = {}
1137  for amp in detector:
1138  ampName = amp.getName()
1139  means[ampName] = ampMeans[ampName][0] + ampMeans[ampName][1]
1140 
1141  return means, variances, coVars
1142 
1143  def _plotXcorr(self, xcorr, mean, zmax=0.05, title=None, fig=None, saveToFileName=None):
1144  """Plot the correlation functions."""
1145  try:
1146  xcorr = xcorr.getArray()
1147  except Exception:
1148  pass
1149 
1150  xcorr /= float(mean)
1151  # xcorr.getArray()[0,0]=abs(xcorr.getArray()[0,0]-1)
1152 
1153  if fig is None:
1154  fig = plt.figure()
1155  else:
1156  fig.clf()
1157 
1158  ax = fig.add_subplot(111, projection='3d')
1159  ax.azim = 30
1160  ax.elev = 20
1161 
1162  nx, ny = np.shape(xcorr)
1163 
1164  xpos, ypos = np.meshgrid(np.arange(nx), np.arange(ny))
1165  xpos = xpos.flatten()
1166  ypos = ypos.flatten()
1167  zpos = np.zeros(nx*ny)
1168  dz = xcorr.flatten()
1169  dz[dz > zmax] = zmax
1170 
1171  ax.bar3d(xpos, ypos, zpos, 1, 1, dz, color='b', zsort='max', sort_zpos=100)
1172  if xcorr[0, 0] > zmax:
1173  ax.bar3d([0], [0], [zmax], 1, 1, 1e-4, color='c')
1174 
1175  ax.set_xlabel("row")
1176  ax.set_ylabel("column")
1177  ax.set_zlabel(r"$\langle{(F_i - \bar{F})(F_i - \bar{F})}\rangle/\bar{F}$")
1178 
1179  if title:
1180  fig.suptitle(title)
1181  if saveToFileName:
1182  fig.savefig(saveToFileName)
1183 
1184  def _iterativeRegression(self, x, y, fixThroughOrigin=False, nSigmaClip=None, maxIter=None):
1185  """Use linear regression to fit a line, iteratively removing outliers.
1186 
1187  Useful when you have a sufficiently large numbers of points on your PTC.
1188  This function iterates until either there are no outliers of
1189  config.nSigmaClip magnitude, or until the specified maximum number
1190  of iterations has been performed.
1191 
1192  Parameters:
1193  -----------
1194  x : `numpy.array`
1195  The independent variable. Must be a numpy array, not a list.
1196  y : `numpy.array`
1197  The dependent variable. Must be a numpy array, not a list.
1198  fixThroughOrigin : `bool`, optional
1199  Whether to fix the PTC through the origin or allow an y-intercept.
1200  nSigmaClip : `float`, optional
1201  The number of sigma to clip to.
1202  Taken from the task config if not specified.
1203  maxIter : `int`, optional
1204  The maximum number of iterations allowed.
1205  Taken from the task config if not specified.
1206 
1207  Returns:
1208  --------
1209  slope : `float`
1210  The slope of the line of best fit
1211  intercept : `float`
1212  The y-intercept of the line of best fit
1213  """
1214  if not maxIter:
1215  maxIter = self.config.maxIterRegression
1216  if not nSigmaClip:
1217  nSigmaClip = self.config.nSigmaClipRegression
1218 
1219  nIter = 0
1220  sctrl = afwMath.StatisticsControl()
1221  sctrl.setNumSigmaClip(nSigmaClip)
1222 
1223  if fixThroughOrigin:
1224  while nIter < maxIter:
1225  nIter += 1
1226  self.log.debug("Origin fixed, iteration # %s using %s elements:" % (nIter, np.shape(x)[0]))
1227  TEST = x[:, np.newaxis]
1228  slope, _, _, _ = np.linalg.lstsq(TEST, y)
1229  slope = slope[0]
1230  res = (y - slope * x) / x
1231  resMean = afwMath.makeStatistics(res, afwMath.MEANCLIP, sctrl).getValue()
1232  resStd = np.sqrt(afwMath.makeStatistics(res, afwMath.VARIANCECLIP, sctrl).getValue())
1233  index = np.where((res > (resMean + nSigmaClip*resStd)) |
1234  (res < (resMean - nSigmaClip*resStd)))
1235  self.log.debug("%.3f %.3f %.3f %.3f" % (resMean, resStd, np.max(res), nSigmaClip))
1236  if np.shape(np.where(index))[1] == 0 or (nIter >= maxIter): # run out of points or iters
1237  break
1238  x = np.delete(x, index)
1239  y = np.delete(y, index)
1240 
1241  return slope, 0
1242 
1243  while nIter < maxIter:
1244  nIter += 1
1245  self.log.debug("Iteration # %s using %s elements:" % (nIter, np.shape(x)[0]))
1246  xx = np.vstack([x, np.ones(len(x))]).T
1247  ret, _, _, _ = np.linalg.lstsq(xx, y)
1248  slope, intercept = ret
1249  res = y - slope*x - intercept
1250  resMean = afwMath.makeStatistics(res, afwMath.MEANCLIP, sctrl).getValue()
1251  resStd = np.sqrt(afwMath.makeStatistics(res, afwMath.VARIANCECLIP, sctrl).getValue())
1252  index = np.where((res > (resMean + nSigmaClip * resStd)) | (res < resMean - nSigmaClip * resStd))
1253  self.log.debug("%.3f %.3f %.3f %.3f" % (resMean, resStd, np.max(res), nSigmaClip))
1254  if np.shape(np.where(index))[1] == 0 or (nIter >= maxIter): # run out of points, or iterations
1255  break
1256  x = np.delete(x, index)
1257  y = np.delete(y, index)
1258 
1259  return slope, intercept
1260 
1261  def generateKernel(self, corrs, means, objId, rejectLevel=None):
1262  """Generate the full kernel from a list of cross-correlations and means.
1263 
1264  Taking a list of quarter-image, gain-corrected cross-correlations,
1265  do a pixel-wise sigma-clipped mean of each,
1266  and tile into the full-sized kernel image.
1267 
1268  Each corr in corrs is one quarter of the full cross-correlation,
1269  and has been gain-corrected. Each mean in means is a tuple of the means
1270  of the two individual images, corresponding to that corr.
1271 
1272  Parameters:
1273  -----------
1274  corrs : `list` of `numpy.ndarray`, (Ny, Nx)
1275  A list of the quarter-image cross-correlations
1276  means : `list` of `tuples` of `floats`
1277  The means of the input images for each corr in corrs
1278  rejectLevel : `float`, optional
1279  This is essentially is a sanity check parameter.
1280  If this condition is violated there is something unexpected
1281  going on in the image, and it is discarded from the stack before
1282  the clipped-mean is calculated.
1283  If not provided then config.xcorrCheckRejectLevel is used
1284 
1285  Returns:
1286  --------
1287  kernel : `numpy.ndarray`, (Ny, Nx)
1288  The output kernel
1289  """
1290  self.log.info('Calculating kernel for %s'%objId)
1291 
1292  if not rejectLevel:
1293  rejectLevel = self.config.xcorrCheckRejectLevel
1294 
1295  if self.config.correlationQuadraticFit:
1296  xcorrList = []
1297  fluxList = []
1298 
1299  for corrNum, ((mean1, mean2), corr) in enumerate(zip(means, corrs)):
1300  msg = 'For item %s, initial corr[0,0] = %g, corr[1,0] = %g'%(corrNum, corr[0, 0], corr[1, 0])
1301  self.log.info(msg)
1302  if self.config.level == 'DETECTOR':
1303  # This is now done in _applyGains() but only if level is not DETECTOR
1304  corr[0, 0] -= (mean1 + mean2)
1305  fullCorr = self._tileArray(corr)
1306 
1307  # Craig Lage says he doesn't understand the negative sign, but it needs to be there
1308  xcorrList.append(-fullCorr / 2.0)
1309  flux = (mean1 + mean2) / 2.0
1310  fluxList.append(flux * flux)
1311  # We're using the linear fit algorithm to find a quadratic fit,
1312  # so we square the x-axis.
1313  # The step below does not need to be done, but is included
1314  # so that correlations can be compared
1315  # directly to existing code. We might want to take it out.
1316  corr /= -1.0*(mean1**2 + mean2**2)
1317 
1318  if not xcorrList:
1319  raise RuntimeError("Cannot generate kernel because all inputs were discarded. "
1320  "Either the data is bad, or config.xcorrCheckRejectLevel is too low")
1321 
1322  # This method fits a quadratic vs flux and keeps only the quadratic term.
1323  meanXcorr = np.zeros_like(fullCorr)
1324  xcorrList = np.asarray(xcorrList)
1325 
1326  for i in range(np.shape(meanXcorr)[0]):
1327  for j in range(np.shape(meanXcorr)[1]):
1328  # Note the i,j inversion. This serves the same function as the transpose step in
1329  # the base code. I don't understand why it is there, but I put it in to be consistent.
1330  slopeRaw, interceptRaw, rVal, pVal, stdErr = stats.linregress(np.asarray(fluxList),
1331  xcorrList[:, j, i])
1332  try:
1333  slope, intercept = self._iterativeRegression(np.asarray(fluxList),
1334  xcorrList[:, j, i],
1335  fixThroughOrigin=True)
1336  msg = "(%s,%s):Slope of raw fit: %s, intercept: %s p value: %s" % (i, j, slopeRaw,
1337  interceptRaw, pVal)
1338  self.log.debug(msg)
1339  self.log.debug("(%s,%s):Slope of fixed fit: %s" % (i, j, slope))
1340 
1341  meanXcorr[i, j] = slope
1342  except ValueError:
1343  meanXcorr[i, j] = slopeRaw
1344 
1345  msg = f"i={i}, j={j}, slope = {slope:.6g}, slopeRaw = {slopeRaw:.6g}"
1346  self.log.debug(msg)
1347  self.log.info('Quad Fit meanXcorr[0,0] = %g, meanXcorr[1,0] = %g'%(meanXcorr[8, 8],
1348  meanXcorr[9, 8]))
1349 
1350  else:
1351  # Try to average over a set of possible inputs.
1352  # This generates a simple function of the kernel that
1353  # should be constant across the images, and averages that.
1354  xcorrList = []
1355  sctrl = afwMath.StatisticsControl()
1356  sctrl.setNumSigmaClip(self.config.nSigmaClipKernelGen)
1357 
1358  for corrNum, ((mean1, mean2), corr) in enumerate(zip(means, corrs)):
1359  corr[0, 0] -= (mean1 + mean2)
1360  if corr[0, 0] > 0:
1361  self.log.warn('Skipped item %s due to unexpected value of (variance-mean)' % corrNum)
1362  continue
1363  corr /= -1.0*(mean1**2 + mean2**2)
1364 
1365  fullCorr = self._tileArray(corr)
1366 
1367  xcorrCheck = np.abs(np.sum(fullCorr))/np.sum(np.abs(fullCorr))
1368  if xcorrCheck > rejectLevel:
1369  self.log.warn("Sum of the xcorr is unexpectedly high. Investigate item num %s for %s. \n"
1370  "value = %s" % (corrNum, objId, xcorrCheck))
1371  continue
1372  xcorrList.append(fullCorr)
1373 
1374  if not xcorrList:
1375  raise RuntimeError("Cannot generate kernel because all inputs were discarded. "
1376  "Either the data is bad, or config.xcorrCheckRejectLevel is too low")
1377 
1378  # stack the individual xcorrs and apply a per-pixel clipped-mean
1379  meanXcorr = np.zeros_like(fullCorr)
1380  xcorrList = np.transpose(xcorrList)
1381  for i in range(np.shape(meanXcorr)[0]):
1382  for j in range(np.shape(meanXcorr)[1]):
1383  meanXcorr[i, j] = afwMath.makeStatistics(xcorrList[i, j],
1384  afwMath.MEANCLIP, sctrl).getValue()
1385 
1386  if self.config.correlationModelRadius < (meanXcorr.shape[0] - 1) / 2:
1387  sumToInfinity = self._buildCorrelationModel(meanXcorr, self.config.correlationModelRadius,
1388  self.config.correlationModelSlope)
1389  self.log.info("SumToInfinity = %s" % sumToInfinity)
1390  else:
1391  sumToInfinity = 0.0
1392  if self.config.forceZeroSum:
1393  self.log.info("Forcing sum of correlation matrix to zero")
1394  meanXcorr = self._forceZeroSum(meanXcorr, sumToInfinity)
1395 
1396  return meanXcorr, self.successiveOverRelax(meanXcorr)
1397 
1398  def successiveOverRelax(self, source, maxIter=None, eLevel=None):
1399  """An implementation of the successive over relaxation (SOR) method.
1400 
1401  A numerical method for solving a system of linear equations
1402  with faster convergence than the Gauss-Seidel method.
1403 
1404  Parameters:
1405  -----------
1406  source : `numpy.ndarray`
1407  The input array.
1408  maxIter : `int`, optional
1409  Maximum number of iterations to attempt before aborting.
1410  eLevel : `float`, optional
1411  The target error level at which we deem convergence to have
1412  occurred.
1413 
1414  Returns:
1415  --------
1416  output : `numpy.ndarray`
1417  The solution.
1418  """
1419  if not maxIter:
1420  maxIter = self.config.maxIterSuccessiveOverRelaxation
1421  if not eLevel:
1422  eLevel = self.config.eLevelSuccessiveOverRelaxation
1423 
1424  assert source.shape[0] == source.shape[1], "Input array must be square"
1425  # initialize, and set boundary conditions
1426  func = np.zeros([source.shape[0] + 2, source.shape[1] + 2])
1427  resid = np.zeros([source.shape[0] + 2, source.shape[1] + 2])
1428  rhoSpe = np.cos(np.pi/source.shape[0]) # Here a square grid is assumed
1429 
1430  # Calculate the initial error
1431  for i in range(1, func.shape[0] - 1):
1432  for j in range(1, func.shape[1] - 1):
1433  resid[i, j] = (func[i, j - 1] + func[i, j + 1] + func[i - 1, j] +
1434  func[i + 1, j] - 4*func[i, j] - source[i - 1, j - 1])
1435  inError = np.sum(np.abs(resid))
1436 
1437  # Iterate until convergence
1438  # We perform two sweeps per cycle,
1439  # updating 'odd' and 'even' points separately
1440  nIter = 0
1441  omega = 1.0
1442  dx = 1.0
1443  while nIter < maxIter*2:
1444  outError = 0
1445  if nIter%2 == 0:
1446  for i in range(1, func.shape[0] - 1, 2):
1447  for j in range(1, func.shape[1] - 1, 2):
1448  resid[i, j] = float(func[i, j-1] + func[i, j + 1] + func[i - 1, j] +
1449  func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
1450  func[i, j] += omega*resid[i, j]*.25
1451  for i in range(2, func.shape[0] - 1, 2):
1452  for j in range(2, func.shape[1] - 1, 2):
1453  resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j] +
1454  func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
1455  func[i, j] += omega*resid[i, j]*.25
1456  else:
1457  for i in range(1, func.shape[0] - 1, 2):
1458  for j in range(2, func.shape[1] - 1, 2):
1459  resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j] +
1460  func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
1461  func[i, j] += omega*resid[i, j]*.25
1462  for i in range(2, func.shape[0] - 1, 2):
1463  for j in range(1, func.shape[1] - 1, 2):
1464  resid[i, j] = float(func[i, j - 1] + func[i, j + 1] + func[i - 1, j] +
1465  func[i + 1, j] - 4.0*func[i, j] - dx*dx*source[i - 1, j - 1])
1466  func[i, j] += omega*resid[i, j]*.25
1467  outError = np.sum(np.abs(resid))
1468  if outError < inError*eLevel:
1469  break
1470  if nIter == 0:
1471  omega = 1.0/(1 - rhoSpe*rhoSpe/2.0)
1472  else:
1473  omega = 1.0/(1 - rhoSpe*rhoSpe*omega/4.0)
1474  nIter += 1
1475 
1476  if nIter >= maxIter*2:
1477  self.log.warn("Failure: SuccessiveOverRelaxation did not converge in %s iterations."
1478  "\noutError: %s, inError: %s," % (nIter//2, outError, inError*eLevel))
1479  else:
1480  self.log.info("Success: SuccessiveOverRelaxation converged in %s iterations."
1481  "\noutError: %s, inError: %s", nIter//2, outError, inError*eLevel)
1482  return func[1: -1, 1: -1]
1483 
1484  @staticmethod
1485  def _tileArray(in_array):
1486  """Given an input quarter-image, tile/mirror it and return full image.
1487 
1488  Given a square input of side-length n, of the form
1489 
1490  input = array([[1, 2, 3],
1491  [4, 5, 6],
1492  [7, 8, 9]])
1493 
1494  return an array of size 2n-1 as
1495 
1496  output = array([[ 9, 8, 7, 8, 9],
1497  [ 6, 5, 4, 5, 6],
1498  [ 3, 2, 1, 2, 3],
1499  [ 6, 5, 4, 5, 6],
1500  [ 9, 8, 7, 8, 9]])
1501 
1502  Parameters:
1503  -----------
1504  input : `np.array`
1505  The square input quarter-array
1506 
1507  Returns:
1508  --------
1509  output : `np.array`
1510  The full, tiled array
1511  """
1512  assert(in_array.shape[0] == in_array.shape[1])
1513  length = in_array.shape[0] - 1
1514  output = np.zeros((2*length + 1, 2*length + 1))
1515 
1516  for i in range(length + 1):
1517  for j in range(length + 1):
1518  output[i + length, j + length] = in_array[i, j]
1519  output[-i + length, j + length] = in_array[i, j]
1520  output[i + length, -j + length] = in_array[i, j]
1521  output[-i + length, -j + length] = in_array[i, j]
1522  return output
1523 
1524  @staticmethod
1525  def _forceZeroSum(inputArray, sumToInfinity):
1526  """Given an array of correlations, adjust the
1527  central value to force the sum of the array to be zero.
1528 
1529  Parameters:
1530  -----------
1531  input : `np.array`
1532  The square input array, assumed square and with
1533  shape (2n+1) x (2n+1)
1534 
1535  Returns:
1536  --------
1537  output : `np.array`
1538  The same array, with the value of the central value
1539  inputArray[n,n] adjusted to force the array sum to be zero.
1540  """
1541  assert(inputArray.shape[0] == inputArray.shape[1])
1542  assert(inputArray.shape[0] % 2 == 1)
1543  center = int((inputArray.shape[0] - 1) / 2)
1544  outputArray = np.copy(inputArray)
1545  outputArray[center, center] -= inputArray.sum() - sumToInfinity
1546  return outputArray
1547 
1548  @staticmethod
1549  def _buildCorrelationModel(array, replacementRadius, slope):
1550  """Given an array of correlations, build a model
1551  for correlations beyond replacementRadius pixels from the center
1552  and replace the measured values with the model.
1553 
1554  Parameters:
1555  -----------
1556  input : `np.array`
1557  The square input array, assumed square and with
1558  shape (2n+1) x (2n+1)
1559 
1560  Returns:
1561  --------
1562  output : `np.array`
1563  The same array, with the outer values
1564  replaced with a smoothed model.
1565  """
1566  assert(array.shape[0] == array.shape[1])
1567  assert(array.shape[0] % 2 == 1)
1568  assert(replacementRadius > 1)
1569  center = int((array.shape[0] - 1) / 2)
1570  # First we check if either the [0,1] or [1,0] correlation is positive.
1571  # If so, the data is seriously messed up. This has happened in some bad amplifiers.
1572  # In this case, we just return the input array unchanged.
1573  if (array[center, center + 1] >= 0.0) or (array[center + 1, center] >= 0.0):
1574  return 0.0
1575 
1576  intercept = (np.log10(-array[center, center + 1]) + np.log10(-array[center + 1, center])) / 2.0
1577  preFactor = 10**intercept
1578  slopeFactor = 2.0*abs(slope) - 2.0
1579  sumToInfinity = 2.0*np.pi*preFactor / (slopeFactor*(float(center)+0.5)**slopeFactor)
1580  # sum_to_ininity is the integral of the model beyond what is measured.
1581  # It is used to adjust C00 in the case of forcing zero sum
1582 
1583  # Now replace the pixels beyond replacementRadius with the model values
1584  for i in range(array.shape[0]):
1585  for j in range(array.shape[1]):
1586  r2 = float((i-center)*(i-center) + (j-center)*(j-center))
1587  if abs(i-center) < replacementRadius and abs(j-center) < replacementRadius:
1588  continue
1589  else:
1590  newCvalue = -preFactor * r2**slope
1591  array[i, j] = newCvalue
1592  return sumToInfinity
1593 
1594  @staticmethod
1595  def _convertImagelikeToFloatImage(imagelikeObject):
1596  """Turn an exposure or masked image of any type into an ImageF."""
1597  for attr in ("getMaskedImage", "getImage"):
1598  if hasattr(imagelikeObject, attr):
1599  imagelikeObject = getattr(imagelikeObject, attr)()
1600  try:
1601  floatImage = imagelikeObject.convertF()
1602  except AttributeError:
1603  raise RuntimeError("Failed to convert image to float")
1604  return floatImage
1605 
1606 
1607 def calcBiasCorr(fluxLevels, imageShape, repeats=1, seed=0, addCorrelations=False,
1608  correlationStrength=0.1, maxLag=10, nSigmaClip=5, border=10, logger=None):
1609  """Calculate the bias induced when sigma-clipping non-Gaussian distributions.
1610 
1611  Fill image-pairs of the specified size with Poisson-distributed values,
1612  adding correlations as necessary. Then calculate the cross correlation,
1613  and calculate the bias induced using the cross-correlation image
1614  and the image means.
1615 
1616  Parameters:
1617  -----------
1618  fluxLevels : `list` of `int`
1619  The mean flux levels at which to simulate.
1620  Nominal values might be something like [70000, 90000, 110000]
1621  imageShape : `tuple` of `int`
1622  The shape of the image array to simulate, nx by ny pixels.
1623  repeats : `int`, optional
1624  Number of repeats to perform so that results
1625  can be averaged to improve SNR.
1626  seed : `int`, optional
1627  The random seed to use for the Poisson points.
1628  addCorrelations : `bool`, optional
1629  Whether to add brighter-fatter-like correlations to the simulated images
1630  If true, a correlation between x_{i,j} and x_{i+1,j+1} is introduced
1631  by adding a*x_{i,j} to x_{i+1,j+1}
1632  correlationStrength : `float`, optional
1633  The strength of the correlations.
1634  This is the value of the coefficient `a` in the above definition.
1635  maxLag : `int`, optional
1636  The maximum lag to work to in pixels
1637  nSigmaClip : `float`, optional
1638  Number of sigma to clip to when calculating the sigma-clipped mean.
1639  border : `int`, optional
1640  Number of border pixels to mask
1641  logger : `lsst.log.Log`, optional
1642  Logger to use. Instantiated anew if not provided.
1643 
1644  Returns:
1645  --------
1646  biases : `dict` [`float`, `list` of `float`]
1647  A dictionary, keyed by flux level, containing a list of the biases
1648  for each repeat at that flux level
1649  means : `dict` [`float`, `list` of `float`]
1650  A dictionary, keyed by flux level, containing a list of the average
1651  mean fluxes (average of the mean of the two images)
1652  for the image pairs at that flux level
1653  xcorrs : `dict` [`float`, `list` of `np.ndarray`]
1654  A dictionary, keyed by flux level, containing a list of the xcorr
1655  images for the image pairs at that flux level
1656  """
1657  if logger is None:
1658  logger = lsstLog.Log.getDefaultLogger()
1659 
1660  means = {f: [] for f in fluxLevels}
1661  xcorrs = {f: [] for f in fluxLevels}
1662  biases = {f: [] for f in fluxLevels}
1663 
1665  config.isrMandatorySteps = [] # no isr but the validation routine is still run
1666  config.isrForbiddenSteps = []
1667  config.nSigmaClipXCorr = nSigmaClip
1668  config.nPixBorderXCorr = border
1669  config.maxLag = maxLag
1670  task = MakeBrighterFatterKernelTask(config=config)
1671 
1672  im0 = afwImage.maskedImage.MaskedImageF(imageShape[1], imageShape[0])
1673  im1 = afwImage.maskedImage.MaskedImageF(imageShape[1], imageShape[0])
1674 
1675  random = np.random.RandomState(seed)
1676 
1677  for rep in range(repeats):
1678  for flux in fluxLevels:
1679  data0 = random.poisson(flux, (imageShape)).astype(float)
1680  data1 = random.poisson(flux, (imageShape)).astype(float)
1681  if addCorrelations:
1682  data0[1:, 1:] += correlationStrength*data0[: -1, : -1]
1683  data1[1:, 1:] += correlationStrength*data1[: -1, : -1]
1684  im0.image.array[:, :] = data0
1685  im1.image.array[:, :] = data1
1686 
1687  _xcorr, _means = task._crossCorrelate(im0, im1, runningBiasCorrSim=True)
1688 
1689  means[flux].append(_means)
1690  xcorrs[flux].append(_xcorr)
1691  if addCorrelations:
1692  bias = xcorrs[flux][-1][1, 1]/means[flux][-1]*(1 + correlationStrength)/correlationStrength
1693  msg = f"Simulated/expected avg. flux: {flux:.1f}, {(means[flux][-1]/2):.1f}"
1694  logger.info(msg)
1695  logger.info(f"Bias: {bias:.6f}")
1696  else:
1697  bias = xcorrs[flux][-1][0, 0]/means[flux][-1]
1698  msg = f"Simulated/expected avg. flux: {flux:.1f}, {(means[flux][-1]/2):.1f}"
1699  logger.info(msg)
1700  logger.info(f"Bias: {bias:.6f}")
1701  biases[flux].append(bias)
1702 
1703  return biases, means, xcorrs
def format(config, name=None, writeSourceLine=True, prefix="", verbose=False)
Definition: history.py:174
Angle abs(Angle const &a)
Definition: Angle.h:106
def _iterativeRegression(self, x, y, fixThroughOrigin=False, nSigmaClip=None, maxIter=None)
def erase(frame=None)
Definition: ds9.py:97
std::shared_ptr< Background > makeBackground(ImageT const &img, BackgroundControl const &bgCtrl)
A convenience function that uses function overloading to make the correct type of Background...
Definition: Background.h:594
std::shared_ptr< FrameSet > append(FrameSet const &first, FrameSet const &second)
Construct a FrameSet that performs two transformations in series.
Definition: functional.cc:33
def checkExpLengthEqual(exp1, exp2, v1=None, v2=None, raiseWithMessage=False)
Definition: utils.py:162
daf::base::PropertySet * set
Definition: fits.cc:902
Pass parameters to a Background object.
Definition: Background.h:56
Definition: Log.h:706
Statistics makeStatistics(lsst::afw::math::MaskedVector< EntryT > const &mv, std::vector< WeightPixel > const &vweights, int const flags, StatisticsControl const &sctrl=StatisticsControl())
The makeStatistics() overload to handle lsst::afw::math::MaskedVector<>
Definition: Statistics.h:520
Pass parameters to a Statistics object.
Definition: Statistics.h:93
def _crossCorrelate(self, maskedIm0, maskedIm1, runningBiasCorrSim=False, frameId=None, detId=None)
def calcBiasCorr(fluxLevels, imageShape, repeats=1, seed=0, addCorrelations=False, correlationStrength=0.1, maxLag=10, nSigmaClip=5, border=10, logger=None)
def mtv(data, frame=None, title="", wcs=None, args, kwargs)
Definition: ds9.py:93
def makeDetectorKernelFromAmpwiseKernels(self, detectorName, ampsToExclude=[], overwrite=False)
Backwards-compatibility support for depersisting the old Calib (FluxMag0/FluxMag0Err) objects...
daf::base::PropertyList * list
Definition: fits.cc:903