Coverage for python/lsst/verify/measurement.py : 21%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of verify.
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__all__ = ['Measurement', 'MeasurementNotes']
23import uuid
25import astropy.units as u
26from astropy.tests.helper import quantity_allclose
28from .blob import Blob
29from .datum import Datum
30from .jsonmixin import JsonSerializationMixin
31from .metric import Metric
32from .naming import Name
35class Measurement(JsonSerializationMixin):
36 r"""A measurement of a single `~lsst.verify.Metric`.
38 A measurement is associated with a single `Metric` and consists of
39 a `astropy.units.Quantity` value. In addition, a measurement can be
40 augmented with `Blob`\ s (either shared, or directly associated with the
41 measurement's `Measurement.extras`) and metadata (`Measurement.notes`).
43 Parameters
44 ----------
45 metric : `str`, `lsst.verify.Name`, or `lsst.verify.Metric`
46 The name of this metric or the corresponding `~lsst.verify.Metric`
47 instance. If a `~lsst.verify.Metric` is provided then the units of
48 the ``quantity`` argument are automatically validated.
49 quantity : `astropy.units.Quantity`, optional
50 The measured value as an Astropy `~astropy.units.Quantity`.
51 If a `~lsst.verify.Metric` instance is provided, the units of
52 ``quantity`` are compared to the `~lsst.verify.Metric`\ 's units
53 for compatibility. The ``quantity`` can also be set, updated, or
54 read with the `Measurement.quantity` attribute.
55 blobs : `list` of `~lsst.verify.Blob`\ s, optional
56 List of `lsst.verify.Blob` instances that are associated with a
57 measurement. Blobs are datasets that can be associated with many
58 measurements and provide context to a measurement.
59 extras : `dict` of `lsst.verify.Datum` instances, optional
60 `~lsst.verify.Datum` instances can be attached to a measurement.
61 Extras can be accessed from the `Measurement.extras` attribute.
62 notes : `dict`, optional
63 Measurement annotations. These key-value pairs are automatically
64 available from `Job.meta`, though keys are prefixed with the
65 metric's name. This metadata can be queried by specifications,
66 so that specifications can be written to test only certain types
67 of measurements.
69 Raises
70 ------
71 TypeError
72 Raised if arguments are not valid types.
73 """
75 blobs = None
76 r"""`dict` of `lsst.verify.Blob`\ s associated with this measurement.
78 See also
79 --------
80 Measurement.link_blob
81 """
83 extras = None
84 r"""`Blob` associated solely to this measurement.
86 Notes
87 -----
88 ``extras`` work just like `Blob`\ s, but they're automatically created with
89 each `Measurement`. Add `~lsst.verify.Datum`\ s to ``extras`` if those
90 `~lsst.verify.Datum`\ s only make sense in the context of that
91 `Measurement`. If `Datum`\ s are relevant to multiple measurements, add
92 them to an external `Blob` instance and attach them to each measurements's
93 `Measurement.blobs` attribute through the `Measurement.link_blob` method.
94 """
96 def __init__(self, metric, quantity=None, blobs=None, extras=None,
97 notes=None):
98 # Internal attributes
99 self._quantity = None
100 # every instance gets a unique identifier, useful for serialization
101 self._id = uuid.uuid4().hex
103 try:
104 self.metric = metric
105 except TypeError:
106 # must be a name
107 self._metric = None
108 self.metric_name = metric
110 self.quantity = quantity
112 self.blobs = {}
113 if blobs is not None:
114 for blob in blobs:
115 if not isinstance(blob, Blob):
116 message = 'Blob {0} is not a Blob-type'
117 raise TypeError(message.format(blob))
118 self.blobs[blob.name] = blob
120 # extras is a blob automatically created for a measurement.
121 # by attaching extras to the self.blobs we ensure it is serialized
122 # with other blobs.
123 if str(self.metric_name) not in self.blobs:
124 self.extras = Blob(str(self.metric_name))
125 self.blobs[str(self.metric_name)] = self.extras
126 else:
127 # pre-existing Blobs; such as from a deserialization
128 self.extras = self.blobs[str(self.metric_name)]
129 if extras is not None:
130 for key, extra in extras.items():
131 if not isinstance(extra, Datum):
132 message = 'Extra {0} is not a Datum-type'
133 raise TypeError(message.format(extra))
134 self.extras[key] = extra
136 self._notes = MeasurementNotes(self.metric_name)
137 if notes is not None:
138 self.notes.update(notes)
140 @property
141 def metric(self):
142 """Metric associated with the measurement (`lsst.verify.Metric` or
143 `None`, mutable).
144 """
145 return self._metric
147 @metric.setter
148 def metric(self, value):
149 if not isinstance(value, Metric):
150 message = '{0} must be an lsst.verify.Metric-type'
151 raise TypeError(message.format(value))
153 # Ensure the existing quantity has compatible units
154 if self.quantity is not None:
155 if not value.check_unit(self.quantity):
156 message = ('Cannot assign metric {0} with units incompatible '
157 'with existing quantity {1}')
158 raise TypeError(message.format(value, self.quantity))
160 self._metric = value
162 # Reset metric_name for consistency
163 self.metric_name = value.name
165 @property
166 def metric_name(self):
167 """Name of the corresponding metric (`lsst.verify.Name`, mutable).
168 """
169 return self._metric_name
171 @metric_name.setter
172 def metric_name(self, value):
173 if not isinstance(value, Name):
174 self._metric_name = Name(metric=value)
175 else:
176 if not value.is_metric:
177 message = "Expected {0} to be a metric's name".format(value)
178 raise TypeError(message)
179 else:
180 self._metric_name = value
182 @property
183 def quantity(self):
184 """`astropy.units.Quantity` component of the measurement (mutable).
185 """
186 return self._quantity
188 @quantity.setter
189 def quantity(self, q):
190 # a quantity can be None or a Quantity
191 if not isinstance(q, u.Quantity) and q is not None:
192 try:
193 q = q*u.dimensionless_unscaled
194 except (TypeError, ValueError):
195 message = ('{0} cannot be coerced into an '
196 'astropy.units.dimensionless_unscaled')
197 raise TypeError(message.format(q))
199 if self.metric is not None and q is not None:
200 # check unit consistency
201 if not self.metric.check_unit(q):
202 message = ("The quantity's units {0} are incompatible with "
203 "{1}'s units {2}")
204 raise TypeError(message.format(q.unit,
205 self.metric_name,
206 self.metric.unit))
208 self._quantity = q
210 @property
211 def identifier(self):
212 """Unique UUID4-based identifier for this measurement (`str`,
213 immutable)."""
214 return self._id
216 def __str__(self):
217 return "{self.metric_name!s}: {self.quantity!s}".format(self=self)
219 def _repr_latex_(self):
220 """Get a LaTeX-formatted string representation of the measurement
221 quantity (used in Jupyter notebooks).
223 Returns
224 -------
225 rep : `str`
226 String representation.
227 """
228 return '{0.value:0.1f} {0.unit:latex_inline}'.format(self.quantity)
230 @property
231 def description(self):
232 """Description of the metric (`str`, or `None` if
233 `Measurement.metric` is not set).
234 """
235 if self._metric is not None:
236 return self._metric.description
237 else:
238 return None
240 @property
241 def datum(self):
242 """Representation of this measurement as a `Datum`."""
243 return Datum(self.quantity,
244 label=str(self.metric_name),
245 description=self.description)
247 def link_blob(self, blob):
248 """Link a `Blob` to this measurement.
250 Blobs can be linked to a measurement so that they can be retrieved
251 by analysis and visualization tools post-serialization. Blob data
252 is not copied, and one blob can be linked to multiple measurements.
254 Parameters
255 ----------
256 blob : `lsst.verify.Blob`
257 A `~lsst.verify.Blob` instance.
259 Notes
260 -----
261 After linking, the `Blob` instance can be accessed by name
262 (`Blob.name`) through the `Measurement.blobs` `dict`.
263 """
264 if not isinstance(blob, Blob):
265 message = 'Blob {0} is not a Blob-type'.format(blob)
266 raise TypeError(message)
267 self.blobs[blob.name] = blob
269 @property
270 def notes(self):
271 r"""Measurement annotations as key-value pairs (`dict`).
273 These key-value pairs are automatically available from `Job.meta`,
274 though keys are prefixed with the `Metric`\ 's name. This metadata can
275 be queried by `Specification`\ s, so that `Specification`\ s can be
276 written to test only certain types of `Measurement`\ s.
277 """
278 return self._notes
280 @property
281 def json(self):
282 r"""A `dict` that can be serialized as semantic SQUASH JSON.
284 Fields:
286 - ``metric`` (`str`) Name of the metric the measurement measures.
287 - ``identifier`` (`str`) Unique identifier for this measurement.
288 - ``value`` (`float`) Value of the measurement.
289 - ``unit`` (`str`) Units of the ``value``, as an
290 `astropy.units`-compatible string.
291 - ``blob_refs`` (`list` of `str`) List of `Blob.identifier`\ s for
292 Blobs associated with this measurement.
294 .. note::
296 `Blob`\ s are not serialized with a measurement, only their
297 identifiers. The `lsst.verify.Job` class handles serialization of
298 blobs alongside measurements.
300 Likewise, `Measurement.notes` are not serialized with the
301 measurement. They are included with `lsst.verify.Job`\ 's
302 serialization, alongside job-level metadata.
303 """
304 if self.quantity is None:
305 _normalized_value = None
306 _normalized_unit_str = None
307 elif self.metric is not None:
308 # ensure metrics are normalized to metric definition's units
309 _normalized_value = self.quantity.to(self.metric.unit).value
310 _normalized_unit_str = self.metric.unit_str
311 else:
312 _normalized_value = self.quantity.value
313 _normalized_unit_str = str(self.quantity.unit)
315 blob_refs = [b.identifier for k, b in self.blobs.items()]
316 # Remove any reference to an empty extras blob
317 if len(self.extras) == 0:
318 blob_refs.remove(self.extras.identifier)
320 object_doc = {'metric': str(self.metric_name),
321 'identifier': self.identifier,
322 'value': _normalized_value,
323 'unit': _normalized_unit_str,
324 'blob_refs': blob_refs}
325 json_doc = JsonSerializationMixin.jsonify_dict(object_doc)
326 return json_doc
328 @classmethod
329 def deserialize(cls, metric=None, identifier=None, value=None, unit=None,
330 blob_refs=None, blobs=None, **kwargs):
331 r"""Create a Measurement instance from a parsed YAML/JSON document.
333 Parameters
334 ----------
335 metric : `str`
336 Name of the metric the measurement measures.
337 identifier : `str`
338 Unique identifier for this measurement.
339 value : `float`
340 Value of the measurement.
341 unit : `str`
342 Units of the ``value``, as an `astropy.units`-compatible string.
343 blob_refs : `list` of `str`
344 List of `Blob.identifier`\ s for Blob associated with this
345 measurement.
346 blobs : `BlobSet`
347 `BlobSet` containing all `Blob`\ s referenced by the measurement's
348 ``blob_refs`` field. Note that the `BlobSet` must be created
349 separately, prior to deserializing measurement objects.
351 Returns
352 -------
353 measurement : `Measurement`
354 Measurement instance.
355 """
356 # Resolve blobs from references:
357 if blob_refs is not None and blobs is not None:
358 # get only referenced blobs
359 _blobs = [blob for blob_identifier, blob in blobs.items()
360 if blob_identifier in blob_refs]
361 elif blobs is not None:
362 # use all the blobs if none were specifically referenced
363 _blobs = blobs
364 else:
365 _blobs = None
367 # Resolve quantity
368 _quantity = u.Quantity(value, u.Unit(unit))
370 instance = cls(metric, quantity=_quantity, blobs=_blobs)
371 instance._id = identifier # re-wire id from serialization
372 return instance
374 def __eq__(self, other):
375 return quantity_allclose(self.quantity, other.quantity) and \
376 (self.metric_name == other.metric_name) and \
377 (self.notes == other.notes)
379 def __ne__(self, other):
380 return not self.__eq__(other)
383class MeasurementNotes(object):
384 """Container for annotations (notes) associated with a single
385 `lsst.verify.Measurement`.
387 Typically you will use pre-instantiate ``MeasurementNotes`` objects
388 through the `lsst.verify.Measurement.notes` attribute.
390 Parameters
391 ----------
392 metric_name : `Name` or `str`
393 Fully qualified name of the measurement's metric. The metric's name
394 is used as a prefix for key names.
396 See also
397 --------
398 lsst.verify.Measurement.notes
399 lsst.verify.Metadata
401 Examples
402 --------
403 ``MeasurementNotes`` implements a `dict`-like interface. The only
404 difference is that, internally, keys are always prefixed with the name of
405 a metric. This allows measurement annotations to mesh `lsst.verify.Job`
406 metadata keys (`lsst.verify.Job.meta`).
408 Users of `MeasurementNotes`, typically though `Measurement.notes`, do
409 not need to use this prefix. Keys are prefixed behind the scenes.
411 >>> notes = MeasurementNotes('validate_drp')
412 >>> notes['filter_name'] = 'r'
413 >>> notes['filter_name']
414 'r'
415 >>> notes['validate_drp.filter_name']
416 'r'
417 >>> print(notes)
418 {'validate_drp.filter_name': 'r'}
419 """
421 def __init__(self, metric_name):
422 # cast Name to str form to deal with prefixes
423 self._metric_name = str(metric_name)
424 # Enforced key prefix for all notes
425 self._prefix = '{self._metric_name}.'.format(self=self)
426 self._data = {}
428 def _format_key(self, key):
429 """Ensures the key includes the metric name prefix."""
430 if not key.startswith(self._prefix):
431 key = self._prefix + key
432 return key
434 def __getitem__(self, key):
435 key = self._format_key(key)
436 return self._data[key]
438 def __setitem__(self, key, value):
439 key = self._format_key(key)
440 self._data[key] = value
442 def __delitem__(self, key):
443 key = self._format_key(key)
444 del self._data[key]
446 def __contains__(self, key):
447 key = self._format_key(key)
448 return key in self._data
450 def __len__(self):
451 return len(self._data)
453 def __eq__(self, other):
454 return (self._metric_name == other._metric_name) and \
455 (self._data == other._data)
457 def __ne__(self, other):
458 return not self.__eq__(other)
460 def __iter__(self):
461 for key in self._data:
462 yield key
464 def __str__(self):
465 return str(self._data)
467 def __repr__(self):
468 return repr(self._data)
470 def keys(self):
471 """Get key names.
473 Returns
474 -------
475 keys : `list` of `str`
476 List of key names.
477 """
478 return [key for key in self]
480 def items(self):
481 """Iterate over note key-value pairs.
483 Yields
484 ------
485 item : key-value pair
486 Each items is tuple of:
488 - Key name (`str`).
489 - Note value (object).
490 """
491 for item in self._data.items():
492 yield item
494 def update(self, data):
495 """Update the notes with key-value pairs from a `dict`-like object.
497 Parameters
498 ----------
499 data : `dict`-like
500 `dict`-like object that has an ``items`` method for iteration.
501 The key-value pairs of ``data`` are added to the
502 ``MeasurementNotes`` instance. If key-value pairs already exist
503 in the ``MeasurementNotes`` instance, they are overwritten with
504 values from ``data``.
505 """
506 for key, value in data.items():
507 self[key] = value