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

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 f"{self.metric_name!s}: {self.quantity!s}"
219 def __repr__(self):
220 metricString = str(self.metric_name)
221 # For readability, don't print rarely-used components if they're None
222 args = [repr(str(metricString)),
223 repr(self.quantity),
224 ]
226 # invariant: self.blobs always exists and contains at least extras
227 if self.blobs.keys() - {metricString}:
228 pureBlobs = self.blobs.copy()
229 del pureBlobs[metricString]
230 args.append(f"blobs={list(pureBlobs.values())!r}")
232 # invariant: self.extras always exists, but may be empty
233 if self.extras:
234 args.append(f"extras={dict(self.extras)!r}")
236 # invariant: self.notes always exists, but may be empty
237 if self.notes:
238 args.append(f"notes={dict(self.notes)!r}")
240 return f"Measurement({', '.join(args)})"
242 def _repr_latex_(self):
243 """Get a LaTeX-formatted string representation of the measurement
244 quantity (used in Jupyter notebooks).
246 Returns
247 -------
248 rep : `str`
249 String representation.
250 """
251 return '{0.value:0.1f} {0.unit:latex_inline}'.format(self.quantity)
253 @property
254 def description(self):
255 """Description of the metric (`str`, or `None` if
256 `Measurement.metric` is not set).
257 """
258 if self._metric is not None:
259 return self._metric.description
260 else:
261 return None
263 @property
264 def datum(self):
265 """Representation of this measurement as a `Datum`."""
266 return Datum(self.quantity,
267 label=str(self.metric_name),
268 description=self.description)
270 def link_blob(self, blob):
271 """Link a `Blob` to this measurement.
273 Blobs can be linked to a measurement so that they can be retrieved
274 by analysis and visualization tools post-serialization. Blob data
275 is not copied, and one blob can be linked to multiple measurements.
277 Parameters
278 ----------
279 blob : `lsst.verify.Blob`
280 A `~lsst.verify.Blob` instance.
282 Notes
283 -----
284 After linking, the `Blob` instance can be accessed by name
285 (`Blob.name`) through the `Measurement.blobs` `dict`.
286 """
287 if not isinstance(blob, Blob):
288 message = 'Blob {0} is not a Blob-type'.format(blob)
289 raise TypeError(message)
290 self.blobs[blob.name] = blob
292 @property
293 def notes(self):
294 r"""Measurement annotations as key-value pairs (`dict`).
296 These key-value pairs are automatically available from `Job.meta`,
297 though keys are prefixed with the `Metric`\ 's name. This metadata can
298 be queried by `Specification`\ s, so that `Specification`\ s can be
299 written to test only certain types of `Measurement`\ s.
300 """
301 return self._notes
303 @property
304 def json(self):
305 r"""A `dict` that can be serialized as semantic SQUASH JSON.
307 Fields:
309 - ``metric`` (`str`) Name of the metric the measurement measures.
310 - ``identifier`` (`str`) Unique identifier for this measurement.
311 - ``value`` (`float`) Value of the measurement.
312 - ``unit`` (`str`) Units of the ``value``, as an
313 `astropy.units`-compatible string.
314 - ``blob_refs`` (`list` of `str`) List of `Blob.identifier`\ s for
315 Blobs associated with this measurement.
317 .. note::
319 `Blob`\ s are not serialized with a measurement, only their
320 identifiers. The `lsst.verify.Job` class handles serialization of
321 blobs alongside measurements.
323 Likewise, `Measurement.notes` are not serialized with the
324 measurement. They are included with `lsst.verify.Job`\ 's
325 serialization, alongside job-level metadata.
326 """
327 if self.quantity is None:
328 _normalized_value = None
329 _normalized_unit_str = None
330 elif self.metric is not None:
331 # ensure metrics are normalized to metric definition's units
332 _normalized_value = self.quantity.to(self.metric.unit).value
333 _normalized_unit_str = self.metric.unit_str
334 else:
335 _normalized_value = self.quantity.value
336 _normalized_unit_str = str(self.quantity.unit)
338 blob_refs = [b.identifier for k, b in self.blobs.items()]
339 # Remove any reference to an empty extras blob
340 if len(self.extras) == 0:
341 blob_refs.remove(self.extras.identifier)
343 object_doc = {'metric': str(self.metric_name),
344 'identifier': self.identifier,
345 'value': _normalized_value,
346 'unit': _normalized_unit_str,
347 'blob_refs': blob_refs}
348 json_doc = JsonSerializationMixin.jsonify_dict(object_doc)
349 return json_doc
351 @classmethod
352 def deserialize(cls, metric=None, identifier=None, value=None, unit=None,
353 blob_refs=None, blobs=None, **kwargs):
354 r"""Create a Measurement instance from a parsed YAML/JSON document.
356 Parameters
357 ----------
358 metric : `str`
359 Name of the metric the measurement measures.
360 identifier : `str`
361 Unique identifier for this measurement.
362 value : `float`
363 Value of the measurement.
364 unit : `str`
365 Units of the ``value``, as an `astropy.units`-compatible string.
366 blob_refs : `list` of `str`
367 List of `Blob.identifier`\ s for Blob associated with this
368 measurement.
369 blobs : `BlobSet`
370 `BlobSet` containing all `Blob`\ s referenced by the measurement's
371 ``blob_refs`` field. Note that the `BlobSet` must be created
372 separately, prior to deserializing measurement objects.
374 Returns
375 -------
376 measurement : `Measurement`
377 Measurement instance.
378 """
379 # Resolve blobs from references:
380 if blob_refs is not None and blobs is not None:
381 # get only referenced blobs
382 _blobs = [blob for blob_identifier, blob in blobs.items()
383 if blob_identifier in blob_refs]
384 elif blobs is not None:
385 # use all the blobs if none were specifically referenced
386 _blobs = blobs
387 else:
388 _blobs = None
390 # Resolve quantity
391 _quantity = u.Quantity(value, u.Unit(unit))
393 instance = cls(metric, quantity=_quantity, blobs=_blobs)
394 instance._id = identifier # re-wire id from serialization
395 return instance
397 def __eq__(self, other):
398 return quantity_allclose(self.quantity, other.quantity) and \
399 (self.metric_name == other.metric_name) and \
400 (self.notes == other.notes)
402 def __ne__(self, other):
403 return not self.__eq__(other)
406class MeasurementNotes(object):
407 """Container for annotations (notes) associated with a single
408 `lsst.verify.Measurement`.
410 Typically you will use pre-instantiate ``MeasurementNotes`` objects
411 through the `lsst.verify.Measurement.notes` attribute.
413 Parameters
414 ----------
415 metric_name : `Name` or `str`
416 Fully qualified name of the measurement's metric. The metric's name
417 is used as a prefix for key names.
419 See also
420 --------
421 lsst.verify.Measurement.notes
422 lsst.verify.Metadata
424 Examples
425 --------
426 ``MeasurementNotes`` implements a `dict`-like interface. The only
427 difference is that, internally, keys are always prefixed with the name of
428 a metric. This allows measurement annotations to mesh `lsst.verify.Job`
429 metadata keys (`lsst.verify.Job.meta`).
431 Users of `MeasurementNotes`, typically though `Measurement.notes`, do
432 not need to use this prefix. Keys are prefixed behind the scenes.
434 >>> notes = MeasurementNotes('validate_drp')
435 >>> notes['filter_name'] = 'r'
436 >>> notes['filter_name']
437 'r'
438 >>> notes['validate_drp.filter_name']
439 'r'
440 >>> print(notes)
441 {'validate_drp.filter_name': 'r'}
442 """
444 def __init__(self, metric_name):
445 # cast Name to str form to deal with prefixes
446 self._metric_name = str(metric_name)
447 # Enforced key prefix for all notes
448 self._prefix = '{self._metric_name}.'.format(self=self)
449 self._data = {}
451 def _format_key(self, key):
452 """Ensures the key includes the metric name prefix."""
453 if not key.startswith(self._prefix):
454 key = self._prefix + key
455 return key
457 def __getitem__(self, key):
458 key = self._format_key(key)
459 return self._data[key]
461 def __setitem__(self, key, value):
462 key = self._format_key(key)
463 self._data[key] = value
465 def __delitem__(self, key):
466 key = self._format_key(key)
467 del self._data[key]
469 def __contains__(self, key):
470 key = self._format_key(key)
471 return key in self._data
473 def __len__(self):
474 return len(self._data)
476 def __eq__(self, other):
477 return (self._metric_name == other._metric_name) and \
478 (self._data == other._data)
480 def __ne__(self, other):
481 return not self.__eq__(other)
483 def __iter__(self):
484 for key in self._data:
485 yield key
487 def __str__(self):
488 return str(self._data)
490 def __repr__(self):
491 return repr(self._data)
493 def keys(self):
494 """Get key names.
496 Returns
497 -------
498 keys : `list` of `str`
499 List of key names.
500 """
501 return [key for key in self]
503 def items(self):
504 """Iterate over note key-value pairs.
506 Yields
507 ------
508 item : key-value pair
509 Each items is tuple of:
511 - Key name (`str`).
512 - Note value (object).
513 """
514 for item in self._data.items():
515 yield item
517 def update(self, data):
518 """Update the notes with key-value pairs from a `dict`-like object.
520 Parameters
521 ----------
522 data : `dict`-like
523 `dict`-like object that has an ``items`` method for iteration.
524 The key-value pairs of ``data`` are added to the
525 ``MeasurementNotes`` instance. If key-value pairs already exist
526 in the ``MeasurementNotes`` instance, they are overwritten with
527 values from ``data``.
528 """
529 for key, value in data.items():
530 self[key] = value