lsst.ip.isr g77367d4607+66658255f0
Loading...
Searching...
No Matches
intrinsicZernikes.py
Go to the documentation of this file.
1# This file is part of ip_isr.
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"""
22Intrinsic Zernikes storage class.
23"""
24
25__all__ = ["IntrinsicZernikes"]
26
27import numpy as np
28from astropy import units as u
29from astropy.table import Table
30from scipy.interpolate import LinearNDInterpolator
31
32from lsst.ip.isr import IsrCalib
33
34
36 """Intrinsic Zernike coefficients.
37
38 Stores Zernike wavefront-error coefficients sampled at a set of
39 focal-plane field angles. At query time the coefficients are
40 interpolated to an arbitrary field position provided in CCS.
41
42 The coefficients are stored in two coordinate systems, each as an
43 independent set of sample points and values:
44
45 - the Camera Coordinate System (CCS), which corresponds to the
46 focal plane heights and any other CCS contribution and
47 - the Optical Coordinate System (OCS), corresponding to
48 the intrinsics, which are by nature defined in the optical
49 coordinates.
50
51 See `LSE-349 <https://ls.st/LSE-349>`_ for the definitions.
52
53 The CCS sample points and values are stored on the un-suffixed
54 ``field_x``, ``field_y`` and ``values`` attributes (and serialized
55 under those same keys). These names are kept unchanged from version
56 1, which only stored the CCS system, so that version 1 calibrations
57 round-trip unchanged. The OCS system is stored alongside on the
58 ``*_ocs`` attributes/keys.
59
60 Parameters
61 ----------
62 table : `astropy.table.Table`, optional
63 Source table in the CCS. Must contain columns:
64
65 ``"x"``
66 Field x positions with angular units (e.g. ``u.deg``).
67 ``"y"``
68 Field y positions with angular units (e.g. ``u.deg``).
69 ``"Z{j}"``
70 One column per Noll index *j*, with length units
71 (e.g. ``u.um``).
72 table_ocs : `astropy.table.Table`, optional
73 Source table in the OCS, with the same column layout as
74 ``table``.
75
76 Attributes
77 ----------
78 field_x, field_y : `numpy.ndarray`
79 CCS x/y field positions in degrees for all sample points,
80 shape ``(n_points_ccs,)``.
81 field_x_ocs, field_y_ocs : `numpy.ndarray`
82 OCS x/y field positions in degrees for all sample points,
83 shape ``(n_points_ocs,)``.
84 noll_indices : `numpy.ndarray`
85 Noll indices of the stored Zernike terms, shape ``(n_zernikes,)``.
86 values, values_ocs : `numpy.ndarray`
87 Zernike coefficients in microns for the CCS and OCS sample
88 points, shape ``(n_points, n_zernikes)``.
89 interpolator, interpolator_ocs : `scipy.interpolate.LinearNDInterpolator`
90 or `None`
91 Interpolators built from the CCS and OCS sample points and
92 values. ``None`` until the corresponding system is populated.
93
94 Version 1.1 adds the OCS coordinate system alongside the CCS system
95 stored by version 1.
96 """
97
98 _OBSTYPE = "INTRINSIC_ZERNIKES"
99 _SCHEMA = "Intrinsic Zernikes"
100 _VERSION = 1.1
101
102 def __init__(self, table=None, table_ocs=None, **kwargs):
103 # CCS uses the un-suffixed names (field_x/field_y/values/
104 # interpolator) for backwards compatibility with version 1, which
105 # only stored the CCS system under those names.
106 self.field_x = np.array([])
107 self.field_y = np.array([])
108 self.values = np.array([])
109 self.field_x_ocs = np.array([])
110 self.field_y_ocs = np.array([])
111 self.values_ocs = np.array([])
112 self.noll_indices = np.array([])
113 self.interpolator = None
115
116 super().__init__(**kwargs)
117
118 if table is not None:
119 (self.field_x, self.field_y, self.values, self.noll_indices) = (
120 self._unpackTable(table)
121 )
123 self.field_x, self.field_y, self.values
124 )
125 if table_ocs is not None:
126 (self.field_x_ocs, self.field_y_ocs, self.values_ocs, noll_indices_ocs) = (
127 self._unpackTable(table_ocs)
128 )
129 # The CCS and OCS systems are summed term-by-term at query time, so
130 # they must describe the same Noll indices. Store a single shared
131 # ``noll_indices`` and reject tables that disagree.
132 if table is not None and not np.array_equal(noll_indices_ocs, self.noll_indices):
133 raise ValueError(
134 "CCS and OCS tables must share the same Noll indices; got "
135 f"{self.noll_indices.tolist()} (CCS) and "
136 f"{noll_indices_ocs.tolist()} (OCS)."
137 )
139 self.field_x_ocs, self.field_y_ocs, self.values_ocs
140 )
141
142 self.requiredAttributes.update(
143 [
144 "field_x",
145 "field_y",
146 "values",
147 "field_x_ocs",
148 "field_y_ocs",
149 "values_ocs",
150 "noll_indices",
151 ]
152 )
153
154 @staticmethod
155 def _unpackTable(table):
156 """Unpack a source table into field positions, values, and Noll
157 indices.
158
159 Parameters
160 ----------
161 table : `astropy.table.Table`
162 Source table with ``"x"``, ``"y"``, and ``"Z{j}"`` columns.
163
164 Returns
165 -------
166 field_x, field_y : `numpy.ndarray`
167 Field positions in degrees.
168 values : `numpy.ndarray`
169 Zernike coefficients in microns, shape
170 ``(n_points, n_zernikes)`` ordered by ascending Noll index.
171 noll_indices : `numpy.ndarray`
172 Sorted Noll indices.
173 """
174 field_x = table["x"].to("deg").value
175 field_y = table["y"].to("deg").value
176 zcols = [col for col in table.colnames if col.startswith("Z")]
177 noll_indices = np.array(sorted(int(col[1:]) for col in zcols))
178 values = np.column_stack([table[f"Z{j}"].to("um").value for j in noll_indices])
179 return field_x, field_y, values, noll_indices
180
181 @staticmethod
182 def _makeInterpolator(field_x, field_y, values):
183 """Build a field-position interpolator, or `None` if there are no
184 sample points."""
185 if np.asarray(field_x).size == 0:
186 return None
187 return LinearNDInterpolator(np.column_stack((field_x, field_y)), values)
188
189 @classmethod
190 def fromDict(cls, dictionary):
191 """Construct an IntrinsicZernikes from dictionary of properties.
192
193 Parameters
194 ----------
195 dictionary : `dict`
196 Dictionary of properties.
197
198 Returns
199 -------
200 calib : `lsst.ip.isr.IntrinsicZernikes`
201 Constructed calibration.
202
203 Raises
204 ------
205 RuntimeError
206 Raised if the supplied dictionary is for a different
207 calibration type.
208 """
209 calib = cls()
210
211 if calib._OBSTYPE != dictionary["metadata"]["OBSTYPE"]:
212 raise RuntimeError(
213 f"Incorrect intrinsic zernikes supplied. "
214 f"Expected {calib._OBSTYPE}, found {dictionary['metadata']['OBSTYPE']}"
215 )
216
217 calib.setMetadata(dictionary["metadata"])
218 # CCS keys (field_x/field_y/values) are always present, including
219 # in version 1 dictionaries. The OCS keys are optional, so version
220 # 1 dictionaries (CCS only) load with an empty OCS system.
221 calib.field_x = np.array(dictionary["field_x"])
222 calib.field_y = np.array(dictionary["field_y"])
223 calib.values = np.array(dictionary["values"])
224 calib.field_x_ocs = np.array(dictionary.get("field_x_ocs", []))
225 calib.field_y_ocs = np.array(dictionary.get("field_y_ocs", []))
226 calib.values_ocs = np.array(dictionary.get("values_ocs", []))
227 calib.noll_indices = np.array(dictionary["noll_indices"])
228 calib.interpolator = cls._makeInterpolator(
229 calib.field_x, calib.field_y, calib.values
230 )
231 calib.interpolator_ocs = cls._makeInterpolator(
232 calib.field_x_ocs, calib.field_y_ocs, calib.values_ocs
233 )
234
235 calib.updateMetadata()
236 return calib
237
238 def toDict(self):
239 """Return a dictionary containing the calibration properties.
240
241 The dictionary should be able to be round-tripped through
242 `fromDict`.
243
244 Returns
245 -------
246 dictionary : `dict`
247 Dictionary of properties.
248 """
249 self.updateMetadata()
250
251 outDict = {}
252 outDict["metadata"] = self.getMetadata()
253 outDict["field_x"] = self.field_x.tolist()
254 outDict["field_y"] = self.field_y.tolist()
255 outDict["values"] = self.values.tolist()
256 outDict["field_x_ocs"] = self.field_x_ocs.tolist()
257 outDict["field_y_ocs"] = self.field_y_ocs.tolist()
258 outDict["values_ocs"] = self.values_ocs.tolist()
259 outDict["noll_indices"] = self.noll_indices.tolist()
260
261 return outDict
262
263 @classmethod
264 def fromTable(cls, tableList):
265 """Construct calibration from a list of tables.
266
267 Parameters
268 ----------
269 tableList : `list` [`astropy.table.Table`]
270 List of tables to use to construct the intrinsic zernikes
271 calibration. Each table is dispatched to the CCS or OCS
272 coordinate system according to its ``coord_sys`` metadata
273 entry (defaulting to ``"CCS"``). A version 1 single-table
274 calibration, which has no ``coord_sys`` entry, is therefore
275 read as the CCS system.
276
277 Returns
278 -------
279 calib : `lsst.ip.isr.IntrinsicZernikes`
280 The calibration defined in the tables.
281 """
282 tables = {}
283 for table in tableList:
284 coord_sys = table.meta.get("coord_sys", "CCS")
285 if coord_sys not in ("CCS", "OCS"):
286 raise RuntimeError(
287 f"Invalid coordinate system {coord_sys} in table metadata; "
288 f"expected 'CCS' or 'OCS'"
289 )
290 tables[coord_sys] = table
291
292 calib = cls(table=tables.get("CCS"), table_ocs=tables.get("OCS", None))
293 # ``coord_sys`` is a per-table annotation used only to dispatch each
294 # table above; drop it so it does not leak into the calibration
295 # metadata (which must match across a toTable/fromTable round-trip).
296 meta = dict(tableList[0].meta)
297 meta.pop("coord_sys", None)
298 calib.setMetadata(meta)
299 calib.updateMetadata()
300 return calib
301
302 def toTable(self):
303 """Construct a list of tables containing the information in this
304 calibration.
305
306 One table is produced per populated coordinate system. The CCS
307 table is always emitted; the OCS table is only emitted when the
308 OCS system holds sample points, so a CCS-only calibration (e.g.
309 one read from a version 1 file) round-trips to a single table,
310 exactly as in version 1. The list of tables should be able to be
311 round-tripped through `fromTable`.
312
313 Returns
314 -------
315 tableList : `list` [`astropy.table.Table`]
316 List of tables containing the intrinsic zernikes calibration
317 information, one per populated coordinate system.
318 """
319 self.updateMetadata()
320
321 inMeta = self.getMetadata().toDict()
322 baseMeta = {k: v for k, v in inMeta.items() if v is not None}
323 baseMeta.update({k: "" for k, v in inMeta.items() if v is None})
324
325 systems = [("CCS", self.field_x, self.field_y, self.values)]
326 if np.asarray(self.field_x_ocs).size > 0:
327 systems.append(("OCS", self.field_x_ocs, self.field_y_ocs, self.values_ocs))
328
329 tableList = []
330 for coord_sys, field_x, field_y, values in systems:
331 data = {
332 "x": field_x * u.deg,
333 "y": field_y * u.deg,
334 }
335 for i, j in enumerate(self.noll_indices):
336 column = values[:, i] if values.ndim == 2 else np.array([])
337 data[f"Z{j}"] = column * u.um
338
339 table = Table(data)
340 meta = dict(baseMeta)
341 meta["coord_sys"] = coord_sys
342 table.meta = meta
343 tableList.append(table)
344
345 return tableList
346
347 def writeText(self, filename, format="auto"):
348 raise NotImplementedError("Text output not implemented for IntrinsicZernikes")
349
350 def readText(self, filename, format="auto"):
351 raise NotImplementedError("Text input not implemented for IntrinsicZernikes")
352
354 self, field_x, field_y, rotTelPos=0.0, noll_indices=None
355 ):
356 """
357 Get the intrinsic Zernike coefficients at a given field position.
358
359 The returned coefficients are the sum of the CCS contribution
360 (heights_ccs), interpolated at the requested field position,
361 and the OCS contribution (measured_intrinsics),
362 interpolated at the field position rotated by
363 ``rotTelPos``. For calibrations that only store one
364 coordinate system (e.g. version 1 files, which only carry CCS),
365 the missing OCS contribution is simply omitted from the sum.
366
367 Parameters
368 ----------
369 field_x : `array-like`
370 x-field positions in degrees (CCS).
371 field_y : `array-like`
372 y-field positions in degrees (CCS).
373 rotTelPos : `float`, optional
374 Rotation angle in degrees applied to the query point before
375 interpolating the OCS contribution. Defaults to 0.
376 noll_indices : `list` [`int`], optional
377 List of Noll indices to return. If None, return all.
378
379 Returns
380 -------
381 zernikes : `array-like`
382 Array of Zernike coefficient values in microns corresponding to the
383 requested Noll indices and field positions.
384 """
385 if noll_indices is None:
386 # Default to all stored terms. The CCS and OCS systems share these
387 # indices, so the output shape is the same whether or not an OCS
388 # system is present (an absent OCS simply adds nothing).
389 noll_indices = self.noll_indices
390 noll_indices = np.array(noll_indices)
391 noll_mask = np.isin(self.noll_indices, noll_indices)
392
393 field_x = np.asarray(field_x)
394 field_y = np.asarray(field_y)
395
396 point = np.array([field_x, field_y]).T
397 total = self.interpolator(point)
398
399 if self.interpolator_ocs is not None:
400 # Rotate the query point into the OCS frame before interpolating.
401 theta = np.deg2rad(rotTelPos)
402 cos_a, sin_a = np.cos(theta), np.sin(theta)
403 x_ocs = cos_a * field_x - sin_a * field_y
404 y_ocs = sin_a * field_x + cos_a * field_y
405 point_ocs = np.array([x_ocs, y_ocs]).T
406 # CCS and OCS share the same Noll indices (enforced in __init__),
407 # so the two interpolator outputs line up column-for-column and add
408 # element-wise, preserving the CCS output shape.
409 total = total + self.interpolator_ocs(point_ocs)
410
411 return total[..., noll_mask]
updateMetadata(self, camera=None, detector=None, filterName=None, setCalibId=False, setCalibInfo=False, setDate=False, **kwargs)
Definition calibType.py:210
getIntrinsicZernikes(self, field_x, field_y, rotTelPos=0.0, noll_indices=None)
__init__(self, table=None, table_ocs=None, **kwargs)