Source code for lsst.verify.measurement

#
# LSST Data Management System
#
# This product includes software developed by the
# LSST Project (http://www.lsst.org/).
#
# See COPYRIGHT file at the top of the source tree.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the LSST License Statement and
# the GNU General Public License along with this program.  If not,
# see <https://www.lsstcorp.org/LegalNotices/>.
#
from __future__ import print_function, division

__all__ = ['Measurement', 'MeasurementNotes']

import uuid

import astropy.units as u
from astropy.tests.helper import quantity_allclose

from .blob import Blob
from .datum import Datum
from .jsonmixin import JsonSerializationMixin
from .metric import Metric
from .naming import Name


[docs]class Measurement(JsonSerializationMixin): """A measurement of a single `~lsst.verify.Metric`. A measurement is associated with a single `Metric` and consists of a `astropy.units.Quantity` value. In addition, a measurement can be augmented with `Blob`\ s (either shared, or directly associated with the measurement's `Measurement.extras`) and metadata (`Measurement.notes`). Parameters ---------- metric : `str`, `lsst.verify.Name`, or `lsst.verify.Metric` The name of this metric or the corresponding `~lsst.verify.Metric` instance. If a `~lsst.verify.Metric` is provided then the units of the ``quantity`` argument are automatically validated. quantity : `astropy.units.Quantity`, optional The measured value as an Astropy `~astropy.units.Quantity`. If a `~lsst.verify.Metric` instance is provided, the units of ``quantity`` are compared to the `~lsst.verify.Metric`\ 's units for compatibility. The ``quantity`` can also be set, updated, or read with the `Measurement.quantity` attribute. blobs : `list` of `~lsst.verify.Blob`\ s, optional List of `lsst.verify.Blob` instances that are associated with a measurement. Blobs are datasets that can be associated with many measurements and provide context to a measurement. extras : `dict` of `lsst.verify.Datum` instances, optional `~lsst.verify.Datum` instances can be attached to a measurement. Extras can be accessed from the `Measurement.extras` attribute. notes : `dict`, optional Measurement annotations. These key-value pairs are automatically available from `Job.meta`, though keys are prefixed with the metric's name. This metadata can be queried by specifications, so that specifications can be written to test only certain types of measurements. Raises ------ TypeError Raised if arguments are not valid types. """ blobs = None """`dict` of `lsst.verify.Blob`\ s associated with this measurement. See also -------- Measurement.link_blob """ extras = None """`Blob` associated solely to this measurement. Notes ----- ``extras`` work just like `Blob`\ s, but they're automatically created with each `Measurement`. Add `~lsst.verify.Datum`\ s to ``extras`` if those `~lsst.verify.Datum`\ s only make sense in the context of that `Measurement`. If `Datum`\ s are relevant to multiple measurements, add them to an external `Blob` instance and attach them to each measurements's `Measurement.blobs` attribute through the `Measurement.link_blob` method. """ def __init__(self, metric, quantity=None, blobs=None, extras=None, notes=None): # Internal attributes self._quantity = None # every instance gets a unique identifier, useful for serialization self._id = uuid.uuid4().hex try: self.metric = metric except TypeError: # must be a name self._metric = None self.metric_name = metric self.quantity = quantity self.blobs = {} if blobs is not None: for blob in blobs: if not isinstance(blob, Blob): message = 'Blob {0} is not a Blob-type' raise TypeError(message.format(blob)) self.blobs[blob.name] = blob # extras is a blob automatically created for a measurement. # by attaching extras to the self.blobs we ensure it is serialized # with other blobs. if str(self.metric_name) not in self.blobs: self.extras = Blob(str(self.metric_name)) self.blobs[str(self.metric_name)] = self.extras else: # pre-existing Blobs; such as from a deserialization self.extras = self.blobs[str(self.metric_name)] if extras is not None: for key, extra in extras.items(): if not isinstance(extra, Datum): message = 'Extra {0} is not a Datum-type' raise TypeError(message.format(extra)) self.extras[key] = extra self._notes = MeasurementNotes(self.metric_name) if notes is not None: self.notes.update(notes) @property def metric(self): """Metric associated with the measurement (`lsst.verify.Metric` or `None`, mutable). """ return self._metric @metric.setter def metric(self, value): if not isinstance(value, Metric): message = '{0} must be an lsst.verify.Metric-type' raise TypeError(message.format(value)) # Ensure the existing quantity has compatible units if self.quantity is not None: if not value.check_unit(self.quantity): message = ('Cannot assign metric {0} with units incompatible ' 'with existing quantity {1}') raise TypeError(message.format(value, self.quantity)) self._metric = value # Reset metric_name for consistency self.metric_name = value.name @property def metric_name(self): """Name of the corresponding metric (`lsst.verify.Name`, mutable). """ return self._metric_name @metric_name.setter def metric_name(self, value): if not isinstance(value, Name): self._metric_name = Name(metric=value) else: if not value.is_metric: message = "Expected {0} to be a metric's name".format(value) raise TypeError(message) else: self._metric_name = value @property def quantity(self): """`astropy.units.Quantity` component of the measurement (mutable). """ return self._quantity @quantity.setter def quantity(self, q): # a quantity can be None or a Quantity if not isinstance(q, u.Quantity) and q is not None: try: q = q*u.dimensionless_unscaled except (TypeError, ValueError): message = ('{0} cannot be coerced into an ' 'astropy.units.dimensionless_unscaled') raise TypeError(message.format(q)) if self.metric is not None and q is not None: # check unit consistency if not self.metric.check_unit(q): message = ("The quantity's units {0} are incompatible with " "the metric's units {1}") raise TypeError(message.format(q.unit, self.metric.unit)) self._quantity = q @property def identifier(self): """Unique UUID4-based identifier for this measurement (`str`, immutable).""" return self._id def __str__(self): return "{self.metric_name!s}: {self.quantity!s}".format(self=self) def _repr_latex_(self): """Get a LaTeX-formatted string representation of the measurement quantity (used in Jupyter notebooks). Returns ------- rep : `str` String representation. """ return '{0.value:0.1f} {0.unit:latex_inline}'.format(self.quantity) @property def description(self): """Description of the metric (`str`, or `None` if `Measurement.metric` is not set). """ if self._metric is not None: return self._metric.description else: return None @property def datum(self): """Representation of this measurement as a `Datum`.""" return Datum(self.quantity, label=str(self.metric_name), description=self.description) @property def notes(self): """Measurement annotations as key-value pairs (`dict`). These key-value pairs are automatically available from `Job.meta`, though keys are prefixed with the `Metric`\ 's name. This metadata can be queried by `Specification`\ s, so that `Specification`\ s can be written to test only certain types of `Measurement`\ s. """ return self._notes @property def json(self): """A `dict` that can be serialized as semantic SQUASH JSON. Fields: - ``metric`` (`str`) Name of the metric the measurement measures. - ``identifier`` (`str`) Unique identifier for this measurement. - ``value`` (`float`) Value of the measurement. - ``unit`` (`str`) Units of the ``value``, as an `astropy.units`-compatible string. - ``blob_refs`` (`list` of `str`) List of `Blob.identifier`\ s for Blobs associated with this measurement. .. note:: `Blob`\ s are not serialized with a measurement, only their identifiers. The `lsst.verify.Job` class handles serialization of blobs alongside measurements. Likewise, `Measurement.notes` are not serialized with the measurement. They are included with `lsst.verify.Job`\ 's serialization, alongside job-level metadata. """ if self.quantity is None: _normalized_value = None _normalized_unit_str = None elif self.metric is not None: # ensure metrics are normalized to metric definition's units _normalized_value = self.quantity.to(self.metric.unit).value _normalized_unit_str = self.metric.unit_str else: _normalized_value = self.quantity.value _normalized_unit_str = str(self.quantity.unit) blob_refs = [b.identifier for k, b in self.blobs.items()] # Remove any reference to an empty extras blob if len(self.extras) == 0: blob_refs.remove(self.extras.identifier) object_doc = {'metric': str(self.metric_name), 'identifier': self.identifier, 'value': _normalized_value, 'unit': _normalized_unit_str, 'blob_refs': blob_refs} json_doc = JsonSerializationMixin.jsonify_dict(object_doc) return json_doc @classmethod
[docs] def deserialize(cls, metric=None, identifier=None, value=None, unit=None, blob_refs=None, blobs=None, **kwargs): """Create a Measurement instance from a parsed YAML/JSON document. Parameters ---------- metric : `str` Name of the metric the measurement measures. identifier : `str` Unique identifier for this measurement. value : `float` Value of the measurement. unit : `str` Units of the ``value``, as an `astropy.units`-compatible string. blob_refs : `list` of `str` List of `Blob.identifier`\ s for Blob associated with this measurement. blobs : `BlobSet` `BlobSet` containing all `Blob`\ s referenced by the measurement's ``blob_refs`` field. Note that the `BlobSet` must be created separately, prior to deserializing measurement objects. Returns ------- measurement : `Measurement` Measurement instance. """ # Resolve blobs from references: if blob_refs is not None and blobs is not None: # get only referenced blobs _blobs = [blob for blob_identifier, blob in blobs.items() if blob_identifier in blob_refs] elif blobs is not None: # use all the blobs if none were specifically referenced _blobs = blobs else: _blobs = None # Resolve quantity _quantity = u.Quantity(value, u.Unit(unit)) instance = cls(metric, quantity=_quantity, blobs=_blobs) instance._identifer = identifier # re-wire id from serialization return instance
def __eq__(self, other): return quantity_allclose(self.quantity, other.quantity) and \ (self.metric_name == other.metric_name) and \ (self.notes == other.notes) def __ne__(self, other): return not self.__eq__(other)
[docs]class MeasurementNotes(object): """Container for annotations (notes) associated with a single `lsst.verify.Measurement`. Typically you will use pre-instantiate ``MeasurementNotes`` objects through the `lsst.verify.Measurement.notes` attribute. Parameters ---------- metric_name : `Name` or `str` Fully qualified name of the measurement's metric. The metric's name is used as a prefix for key names. See also -------- lsst.verify.Measurement.notes lsst.verify.Metadata Examples -------- ``MeasurementNotes`` implements a `dict`-like interface. The only difference is that, internally, keys are always prefixed with the name of a metric. This allows measurement annotations to mesh `lsst.verify.Job` metadata keys (`lsst.verify.Job.meta`). Users of `MeasurementNotes`, typically though `Measurement.notes`, do not need to use this prefix. Keys are prefixed behind the scenes. >>> notes = MeasurementNotes('validate_drp') >>> notes['filter_name'] = 'r' >>> notes['filter_name'] 'r' >>> notes['validate_drp.filter_name'] 'r' >>> print(notes) {'validate_drp.filter_name': 'r'} """ def __init__(self, metric_name): # cast Name to str form to deal with prefixes self._metric_name = str(metric_name) # Enforced key prefix for all notes self._prefix = '{self._metric_name}.'.format(self=self) self._data = {} def _format_key(self, key): """Ensures the key includes the metric name prefix.""" if not key.startswith(self._prefix): key = self._prefix + key return key def __getitem__(self, key): key = self._format_key(key) return self._data[key] def __setitem__(self, key, value): key = self._format_key(key) self._data[key] = value def __delitem__(self, key): key = self._format_key(key) del self._data[key] def __contains__(self, key): key = self._format_key(key) return key in self._data def __len__(self): return len(self._data) def __eq__(self, other): return (self._metric_name == other._metric_name) and \ (self._data == other._data) def __ne__(self, other): return not self.__eq__(other) def __iter__(self): for key in self._data: yield key def __str__(self): return str(self._data) def __repr__(self): return repr(self._data)
[docs] def keys(self): """Get key names. Returns ------- keys : `list` of `str` List of key names. """ return [key for key in self]
[docs] def items(self): """Iterate over note key-value pairs. Yields ------ item : key-value pair Each items is tuple of: - Key name (`str`). - Note value (object). """ for item in self._data.items(): yield item
[docs] def update(self, data): """Update the notes with key-value pairs from a `dict`-like object. Parameters ---------- data : `dict`-like `dict`-like object that has an ``items`` method for iteration. The key-value pairs of ``data`` are added to the ``MeasurementNotes`` instance. If key-value pairs already exist in the ``MeasurementNotes`` instance, they are overwritten with values from ``data``. """ for key, value in data.items(): self[key] = value