Coverage for python/lsst/verify/measurement.py: 23%
195 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-01 20:30 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-01 20:30 +0000
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 numpy as np
26import astropy.units as u
27from astropy.tests.helper import quantity_allclose
29from .blob import Blob
30from .datum import Datum
31from .jsonmixin import JsonSerializationMixin
32from .metric import Metric
33from .naming import Name
36class Measurement(JsonSerializationMixin):
37 r"""A measurement of a single `~lsst.verify.Metric`.
39 A measurement is associated with a single `Metric` and consists of
40 a `astropy.units.Quantity` value. In addition, a measurement can be
41 augmented with `Blob`\ s (either shared, or directly associated with the
42 measurement's `Measurement.extras`) and metadata (`Measurement.notes`).
44 Parameters
45 ----------
46 metric : `str`, `lsst.verify.Name`, or `lsst.verify.Metric`
47 The name of this metric or the corresponding `~lsst.verify.Metric`
48 instance. If a `~lsst.verify.Metric` is provided then the units of
49 the ``quantity`` argument are automatically validated.
50 quantity : `astropy.units.Quantity`, optional
51 The measured value as an Astropy `~astropy.units.Quantity`.
52 If a `~lsst.verify.Metric` instance is provided, the units of
53 ``quantity`` are compared to the `~lsst.verify.Metric`\ 's units
54 for compatibility. The ``quantity`` can also be set, updated, or
55 read with the `Measurement.quantity` attribute.
56 blobs : `list` of `~lsst.verify.Blob`\ s, optional
57 List of `lsst.verify.Blob` instances that are associated with a
58 measurement. Blobs are datasets that can be associated with many
59 measurements and provide context to a measurement.
60 extras : `dict` of `lsst.verify.Datum` instances, optional
61 `~lsst.verify.Datum` instances can be attached to a measurement.
62 Extras can be accessed from the `Measurement.extras` attribute.
63 notes : `dict`, optional
64 Measurement annotations. These key-value pairs are automatically
65 available from `Job.meta`, though keys are prefixed with the
66 metric's name. This metadata can be queried by specifications,
67 so that specifications can be written to test only certain types
68 of measurements.
70 Raises
71 ------
72 TypeError
73 Raised if arguments are not valid types.
74 """
76 blobs = None
77 r"""`dict` of `lsst.verify.Blob`\ s associated with this measurement.
79 See also
80 --------
81 Measurement.link_blob
82 """
84 extras = None
85 r"""`Blob` associated solely to this measurement.
87 Notes
88 -----
89 ``extras`` work just like `Blob`\ s, but they're automatically created with
90 each `Measurement`. Add `~lsst.verify.Datum`\ s to ``extras`` if those
91 `~lsst.verify.Datum`\ s only make sense in the context of that
92 `Measurement`. If `Datum`\ s are relevant to multiple measurements, add
93 them to an external `Blob` instance and attach them to each measurements's
94 `Measurement.blobs` attribute through the `Measurement.link_blob` method.
95 """
97 def __init__(self, metric, quantity=None, blobs=None, extras=None,
98 notes=None):
99 # Internal attributes
100 self._quantity = None
101 # every instance gets a unique identifier, useful for serialization
102 self._id = uuid.uuid4().hex
104 try:
105 self.metric = metric
106 except TypeError:
107 # must be a name
108 self._metric = None
109 self.metric_name = metric
111 self.quantity = quantity
113 self.blobs = {}
114 if blobs is not None:
115 for blob in blobs:
116 if not isinstance(blob, Blob):
117 message = 'Blob {0} is not a Blob-type'
118 raise TypeError(message.format(blob))
119 self.blobs[blob.name] = blob
121 # extras is a blob automatically created for a measurement.
122 # by attaching extras to the self.blobs we ensure it is serialized
123 # with other blobs.
124 if str(self.metric_name) not in self.blobs:
125 self.extras = Blob(str(self.metric_name))
126 self.blobs[str(self.metric_name)] = self.extras
127 else:
128 # pre-existing Blobs; such as from a deserialization
129 self.extras = self.blobs[str(self.metric_name)]
130 if extras is not None:
131 for key, extra in extras.items():
132 if not isinstance(extra, Datum):
133 message = 'Extra {0} is not a Datum-type'
134 raise TypeError(message.format(extra))
135 self.extras[key] = extra
137 self._notes = MeasurementNotes(self.metric_name)
138 if notes is not None:
139 self.notes.update(notes)
141 @property
142 def metric(self):
143 """Metric associated with the measurement (`lsst.verify.Metric` or
144 `None`, mutable).
145 """
146 return self._metric
148 @metric.setter
149 def metric(self, value):
150 if not isinstance(value, Metric):
151 message = '{0} must be an lsst.verify.Metric-type'
152 raise TypeError(message.format(value))
154 # Ensure the existing quantity has compatible units
155 if self.quantity is not None:
156 if not value.check_unit(self.quantity):
157 message = ('Cannot assign metric {0} with units incompatible '
158 'with existing quantity {1}')
159 raise TypeError(message.format(value, self.quantity))
161 self._metric = value
163 # Reset metric_name for consistency
164 self.metric_name = value.name
166 @property
167 def metric_name(self):
168 """Name of the corresponding metric (`lsst.verify.Name`, mutable).
169 """
170 return self._metric_name
172 @metric_name.setter
173 def metric_name(self, value):
174 if not isinstance(value, Name):
175 self._metric_name = Name(metric=value)
176 else:
177 if not value.is_metric:
178 message = "Expected {0} to be a metric's name".format(value)
179 raise TypeError(message)
180 else:
181 self._metric_name = value
183 @property
184 def quantity(self):
185 """`astropy.units.Quantity` component of the measurement (mutable).
186 """
187 return self._quantity
189 @quantity.setter
190 def quantity(self, q):
191 # a quantity can be None or a Quantity
192 if not isinstance(q, u.Quantity) and q is not None:
193 try:
194 q = q*u.dimensionless_unscaled
195 except (TypeError, ValueError):
196 message = ('{0} cannot be coerced into an '
197 'astropy.units.dimensionless_unscaled')
198 raise TypeError(message.format(q))
200 if self.metric is not None and q is not None:
201 # check unit consistency
202 if not self.metric.check_unit(q):
203 message = ("The quantity's units {0} are incompatible with "
204 "{1}'s units {2}")
205 raise TypeError(message.format(q.unit,
206 self.metric_name,
207 self.metric.unit))
209 self._quantity = q
211 @property
212 def identifier(self):
213 """Unique UUID4-based identifier for this measurement (`str`,
214 immutable)."""
215 return self._id
217 def __str__(self):
218 return f"{self.metric_name!s}: {self.quantity!s}"
220 def __repr__(self):
221 metricString = str(self.metric_name)
222 # For readability, don't print rarely-used components if they're None
223 args = [repr(str(metricString)),
224 repr(self.quantity),
225 ]
227 # invariant: self.blobs always exists and contains at least extras
228 if self.blobs.keys() - {metricString}:
229 pureBlobs = self.blobs.copy()
230 del pureBlobs[metricString]
231 args.append(f"blobs={list(pureBlobs.values())!r}")
233 # invariant: self.extras always exists, but may be empty
234 if self.extras:
235 args.append(f"extras={dict(self.extras)!r}")
237 # invariant: self.notes always exists, but may be empty
238 if self.notes:
239 args.append(f"notes={dict(self.notes)!r}")
241 return f"Measurement({', '.join(args)})"
243 def _repr_latex_(self):
244 """Get a LaTeX-formatted string representation of the measurement
245 quantity (used in Jupyter notebooks).
247 Returns
248 -------
249 rep : `str`
250 String representation.
251 """
252 return '{0.value:0.1f} {0.unit:latex_inline}'.format(self.quantity)
254 @property
255 def description(self):
256 """Description of the metric (`str`, or `None` if
257 `Measurement.metric` is not set).
258 """
259 if self._metric is not None:
260 return self._metric.description
261 else:
262 return None
264 @property
265 def datum(self):
266 """Representation of this measurement as a `Datum`."""
267 return Datum(self.quantity,
268 label=str(self.metric_name),
269 description=self.description)
271 def link_blob(self, blob):
272 """Link a `Blob` to this measurement.
274 Blobs can be linked to a measurement so that they can be retrieved
275 by analysis and visualization tools post-serialization. Blob data
276 is not copied, and one blob can be linked to multiple measurements.
278 Parameters
279 ----------
280 blob : `lsst.verify.Blob`
281 A `~lsst.verify.Blob` instance.
283 Notes
284 -----
285 After linking, the `Blob` instance can be accessed by name
286 (`Blob.name`) through the `Measurement.blobs` `dict`.
287 """
288 if not isinstance(blob, Blob):
289 message = 'Blob {0} is not a Blob-type'.format(blob)
290 raise TypeError(message)
291 self.blobs[blob.name] = blob
293 @property
294 def notes(self):
295 r"""Measurement annotations as key-value pairs (`dict`).
297 These key-value pairs are automatically available from `Job.meta`,
298 though keys are prefixed with the `Metric`\ 's name. This metadata can
299 be queried by `Specification`\ s, so that `Specification`\ s can be
300 written to test only certain types of `Measurement`\ s.
301 """
302 return self._notes
304 @property
305 def json(self):
306 r"""A `dict` that can be serialized as semantic SQUASH JSON.
308 Fields:
310 - ``metric`` (`str`) Name of the metric the measurement measures.
311 - ``identifier`` (`str`) Unique identifier for this measurement.
312 - ``value`` (`float`) Value of the measurement.
313 - ``unit`` (`str`) Units of the ``value``, as an
314 `astropy.units`-compatible string.
315 - ``blob_refs`` (`list` of `str`) List of `Blob.identifier`\ s for
316 Blobs associated with this measurement.
318 .. note::
320 `Blob`\ s are not serialized with a measurement, only their
321 identifiers. The `lsst.verify.Job` class handles serialization of
322 blobs alongside measurements.
324 Likewise, `Measurement.notes` are not serialized with the
325 measurement. They are included with `lsst.verify.Job`\ 's
326 serialization, alongside job-level metadata.
327 """
328 if self.quantity is None:
329 _normalized_value = None
330 _normalized_unit_str = None
331 elif self.metric is not None:
332 # ensure metrics are normalized to metric definition's units
333 _normalized_value = self.quantity.to(self.metric.unit).value
334 _normalized_unit_str = self.metric.unit_str
335 else:
336 _normalized_value = self.quantity.value
337 _normalized_unit_str = str(self.quantity.unit)
339 # Represent NaN, positive infinity, or negative infinity as None
340 if _normalized_value and not np.isfinite(_normalized_value):
341 _normalized_value = None
343 blob_refs = [b.identifier for k, b in self.blobs.items()]
344 # Remove any reference to an empty extras blob
345 if len(self.extras) == 0:
346 blob_refs.remove(self.extras.identifier)
348 object_doc = {'metric': str(self.metric_name),
349 'identifier': self.identifier,
350 'value': _normalized_value,
351 'unit': _normalized_unit_str,
352 'blob_refs': blob_refs}
353 json_doc = JsonSerializationMixin.jsonify_dict(object_doc)
354 return json_doc
356 @classmethod
357 def deserialize(cls, metric=None, identifier=None, value=None, unit=None,
358 blob_refs=None, blobs=None, **kwargs):
359 r"""Create a Measurement instance from a parsed YAML/JSON document.
361 Parameters
362 ----------
363 metric : `str`
364 Name of the metric the measurement measures.
365 identifier : `str`
366 Unique identifier for this measurement.
367 value : `float`
368 Value of the measurement.
369 unit : `str`
370 Units of the ``value``, as an `astropy.units`-compatible string.
371 blob_refs : `list` of `str`
372 List of `Blob.identifier`\ s for Blob associated with this
373 measurement.
374 blobs : `BlobSet`
375 `BlobSet` containing all `Blob`\ s referenced by the measurement's
376 ``blob_refs`` field. Note that the `BlobSet` must be created
377 separately, prior to deserializing measurement objects.
379 Returns
380 -------
381 measurement : `Measurement`
382 Measurement instance.
383 """
384 # Resolve blobs from references:
385 if blob_refs is not None and blobs is not None:
386 # get only referenced blobs
387 _blobs = [blob for blob_identifier, blob in blobs.items()
388 if blob_identifier in blob_refs]
389 elif blobs is not None:
390 # use all the blobs if none were specifically referenced
391 _blobs = blobs
392 else:
393 _blobs = None
395 # Resolve quantity, represent None values as np.nan
396 if value is None:
397 value = np.nan
398 _quantity = u.Quantity(value, u.Unit(unit))
400 instance = cls(metric, quantity=_quantity, blobs=_blobs)
401 instance._id = identifier # re-wire id from serialization
402 return instance
404 def __eq__(self, other):
405 return quantity_allclose(self.quantity, other.quantity) and \
406 (self.metric_name == other.metric_name) and \
407 (self.notes == other.notes)
409 def __ne__(self, other):
410 return not self.__eq__(other)
413class MeasurementNotes(object):
414 """Container for annotations (notes) associated with a single
415 `lsst.verify.Measurement`.
417 Typically you will use pre-instantiate ``MeasurementNotes`` objects
418 through the `lsst.verify.Measurement.notes` attribute.
420 Parameters
421 ----------
422 metric_name : `Name` or `str`
423 Fully qualified name of the measurement's metric. The metric's name
424 is used as a prefix for key names.
426 See also
427 --------
428 lsst.verify.Measurement.notes
429 lsst.verify.Metadata
431 Examples
432 --------
433 ``MeasurementNotes`` implements a `dict`-like interface. The only
434 difference is that, internally, keys are always prefixed with the name of
435 a metric. This allows measurement annotations to mesh `lsst.verify.Job`
436 metadata keys (`lsst.verify.Job.meta`).
438 Users of `MeasurementNotes`, typically though `Measurement.notes`, do
439 not need to use this prefix. Keys are prefixed behind the scenes.
441 >>> notes = MeasurementNotes('validate_drp')
442 >>> notes['filter_name'] = 'r'
443 >>> notes['filter_name']
444 'r'
445 >>> notes['validate_drp.filter_name']
446 'r'
447 >>> print(notes)
448 {'validate_drp.filter_name': 'r'}
449 """
451 def __init__(self, metric_name):
452 # cast Name to str form to deal with prefixes
453 self._metric_name = str(metric_name)
454 # Enforced key prefix for all notes
455 self._prefix = '{self._metric_name}.'.format(self=self)
456 self._data = {}
458 def _format_key(self, key):
459 """Ensures the key includes the metric name prefix."""
460 if not key.startswith(self._prefix):
461 key = self._prefix + key
462 return key
464 def __getitem__(self, key):
465 key = self._format_key(key)
466 return self._data[key]
468 def __setitem__(self, key, value):
469 key = self._format_key(key)
470 self._data[key] = value
472 def __delitem__(self, key):
473 key = self._format_key(key)
474 del self._data[key]
476 def __contains__(self, key):
477 key = self._format_key(key)
478 return key in self._data
480 def __len__(self):
481 return len(self._data)
483 def __eq__(self, other):
484 return (self._metric_name == other._metric_name) and \
485 (self._data == other._data)
487 def __ne__(self, other):
488 return not self.__eq__(other)
490 def __iter__(self):
491 for key in self._data:
492 yield key
494 def __str__(self):
495 return str(self._data)
497 def __repr__(self):
498 return repr(self._data)
500 def keys(self):
501 """Get key names.
503 Returns
504 -------
505 keys : `list` of `str`
506 List of key names.
507 """
508 return [key for key in self]
510 def items(self):
511 """Iterate over note key-value pairs.
513 Yields
514 ------
515 item : key-value pair
516 Each items is tuple of:
518 - Key name (`str`).
519 - Note value (object).
520 """
521 for item in self._data.items():
522 yield item
524 def update(self, data):
525 """Update the notes with key-value pairs from a `dict`-like object.
527 Parameters
528 ----------
529 data : `dict`-like
530 `dict`-like object that has an ``items`` method for iteration.
531 The key-value pairs of ``data`` are added to the
532 ``MeasurementNotes`` instance. If key-value pairs already exist
533 in the ``MeasurementNotes`` instance, they are overwritten with
534 values from ``data``.
535 """
536 for key, value in data.items():
537 self[key] = value