Coverage for python/lsst/obs/subaru/ampOffset.py: 14%
69 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-28 05:14 -0700
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-28 05:14 -0700
1# This file is part of obs_subaru.
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/>.
22import numpy as np
23import warnings
25import lsst.afw.table as afwTable
26import lsst.afw.math as afwMath
27from lsst.ip.isr.ampOffset import (AmpOffsetConfig, AmpOffsetTask)
30class SubaruAmpOffsetConfig(AmpOffsetConfig):
31 """Configuration parameters for SubaruAmpOffsetTask.
32 """
34 def setDefaults(self):
35 # the binSize should be as large as possible, preventing background
36 # subtraction from inadvertently removing the amp offset signature.
37 # Here it's set to the width of an HSC amp, which seems reasonable.
38 self.background.binSize = 512
39 self.background.algorithm = "AKIMA_SPLINE"
40 self.background.useApprox = False
41 self.background.ignoredPixelMask = ["BAD", "SAT", "INTRP", "CR", "EDGE", "DETECTED",
42 "DETECTED_NEGATIVE", "SUSPECT", "NO_DATA"]
43 self.detection.reEstimateBackground = False
46class SubaruAmpOffsetTask(AmpOffsetTask):
47 """Calculate and apply amp offset corrections to an exposure.
48 """
49 ConfigClass = SubaruAmpOffsetConfig
50 _DefaultName = "subaruIsrAmpOffset"
52 def __init__(self, *args, **kwargs):
53 AmpOffsetTask.__init__(self, *args, **kwargs)
55 def run(self, exposure):
56 """Calculate amp offset values, determine corrective pedestals for each
57 amp, and update the input exposure in-place.
59 Parameters
60 ----------
61 exposure: `lsst.afw.image.Exposure`
62 Exposure to be corrected for amp offsets.
63 """
65 # generate a clone to work on and establish the bit mask
66 exp = exposure.clone()
67 bitMask = exp.mask.getPlaneBitMask(self.background.config.ignoredPixelMask)
68 self.log.debug(f"Ignoring mask planes: {', '.join(self.background.config.ignoredPixelMask)}")
70 # fit and subtract background
71 if self.config.doBackground:
72 maskedImage = exp.getMaskedImage()
73 bg = self.background.fitBackground(maskedImage)
74 bgImage = bg.getImageF(self.background.config.algorithm, self.background.config.undersampleStyle)
75 maskedImage -= bgImage
77 # detect sources and update cloned exposure mask planes in-place
78 if self.config.doDetection:
79 schema = afwTable.SourceTable.makeMinimalSchema()
80 table = afwTable.SourceTable.make(schema)
81 # detection sigma, used for smoothing and to grow detections, is
82 # normally measured from the PSF of the exposure. As the PSF hasn't
83 # been measured at this stage of processing, sigma is instead
84 # set to an approximate value here which should be sufficient.
85 _ = self.detection.run(table=table, exposure=exp, sigma=2)
87 # safety check: do any pixels remain for amp offset estimation?
88 if (exp.mask.array & bitMask).all():
89 warnings.warn("All pixels masked: cannot calculate any amp offset corrections.")
90 else:
91 # set up amp offset inputs
92 im = exp.image
93 im.array[(exp.mask.array & bitMask) > 0] = np.nan
94 amps = exp.getDetector().getAmplifiers()
95 ampEdgeOuter = self.config.ampEdgeInset + self.config.ampEdgeWidth
96 sctrl = afwMath.StatisticsControl()
98 # loop over each amp edge boundary to extract amp offset values
99 ampOffsets = []
100 for ii in range(1, len(amps)):
101 ampA = im[amps[ii - 1].getBBox()].array
102 ampB = im[amps[ii].getBBox()].array
103 stripA = ampA[:, -ampEdgeOuter:][:, :self.config.ampEdgeWidth]
104 stripB = ampB[:, :ampEdgeOuter][:, -self.config.ampEdgeWidth:]
105 # catch warnings to prevent all-NaN slice RuntimeWarning
106 with warnings.catch_warnings():
107 warnings.filterwarnings('ignore', r'All-NaN (slice|axis) encountered')
108 edgeA = np.nanmedian(stripA, axis=1)
109 edgeB = np.nanmedian(stripB, axis=1)
110 edgeDiff = edgeB - edgeA
111 # compute rolling averages
112 edgeDiffSum = np.convolve(np.nan_to_num(edgeDiff), np.ones(self.config.ampEdgeWindow), 'same')
113 edgeDiffNum = np.convolve(~np.isnan(edgeDiff), np.ones(self.config.ampEdgeWindow), 'same')
114 edgeDiffAvg = edgeDiffSum / np.clip(edgeDiffNum, 1, None)
115 edgeDiffAvg[np.isnan(edgeDiff)] = np.nan
116 # take clipped mean of rolling average data as amp offset value
117 ampOffset = afwMath.makeStatistics(edgeDiffAvg, afwMath.MEANCLIP, sctrl).getValue()
118 # perform a couple of do-no-harm safety checks:
119 # a) the fraction of unmasked pixel rows is > ampEdgeMinFrac,
120 # b) the absolute offset ADU value is < ampEdgeMaxOffset
121 ampEdgeGoodFrac = 1 - (np.sum(np.isnan(edgeDiffAvg))/len(edgeDiffAvg))
122 minFracFail = ampEdgeGoodFrac < self.config.ampEdgeMinFrac
123 maxOffsetFail = np.abs(ampOffset) > self.config.ampEdgeMaxOffset
124 if minFracFail or maxOffsetFail:
125 ampOffset = 0
126 ampOffsets.append(ampOffset)
127 self.log.debug(f"amp edge {ii}{ii+1} : "
128 f"viable edge frac = {ampEdgeGoodFrac}, "
129 f"edge offset = {ampOffset:.3f}")
131 # solve for pedestal values and update original exposure in-place
132 A = np.array([[-1.0, 1.0, 0.0, 0.0],
133 [1.0, -2.0, 1.0, 0.0],
134 [0.0, 1.0, -2.0, 1.0],
135 [0.0, 0.0, 1.0, -1.0]])
136 B = np.array([ampOffsets[0],
137 ampOffsets[1] - ampOffsets[0],
138 ampOffsets[2] - ampOffsets[1],
139 -ampOffsets[2]])
140 # if least-squares minimization fails, convert NaNs to zeroes,
141 # ensuring that no values are erroneously added/subtracted
142 pedestals = np.nan_to_num(np.linalg.lstsq(A, B, rcond=None)[0])
143 metadata = exposure.getMetadata()
144 for ii, (amp, pedestal) in enumerate(zip(amps, pedestals)):
145 ampIm = exposure.image[amp.getBBox()].array
146 ampIm -= pedestal
147 metadata.set(f"PEDESTAL{ii + 1}",
148 float(pedestal),
149 f"Pedestal level subtracted from amp {ii + 1}")
150 self.log.info(f"amp pedestal values: {', '.join([f'{x:.2f}' for x in pedestals])}")