Coverage for python/lsst/verify/measurement.py: 23%
199 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-22 10:04 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-22 10:04 +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 # If the value is less than 0.01, represent in scientific notation
253 if (self.quantity.value < 1e-2):
254 return '{0.value:0.2e} {0.unit:latex_inline}'.format(self.quantity)
255 else:
256 return '{0.value:0.2f} {0.unit:latex_inline}'.format(self.quantity)
258 @property
259 def description(self):
260 """Description of the metric (`str`, or `None` if
261 `Measurement.metric` is not set).
262 """
263 if self._metric is not None:
264 return self._metric.description
265 else:
266 return None
268 @property
269 def datum(self):
270 """Representation of this measurement as a `Datum`."""
271 return Datum(self.quantity,
272 label=str(self.metric_name),
273 description=self.description)
275 def link_blob(self, blob):
276 """Link a `Blob` to this measurement.
278 Blobs can be linked to a measurement so that they can be retrieved
279 by analysis and visualization tools post-serialization. Blob data
280 is not copied, and one blob can be linked to multiple measurements.
282 Parameters
283 ----------
284 blob : `lsst.verify.Blob`
285 A `~lsst.verify.Blob` instance.
287 Notes
288 -----
289 After linking, the `Blob` instance can be accessed by name
290 (`Blob.name`) through the `Measurement.blobs` `dict`.
291 """
292 if not isinstance(blob, Blob):
293 message = 'Blob {0} is not a Blob-type'.format(blob)
294 raise TypeError(message)
295 self.blobs[blob.name] = blob
297 @property
298 def notes(self):
299 r"""Measurement annotations as key-value pairs (`dict`).
301 These key-value pairs are automatically available from `Job.meta`,
302 though keys are prefixed with the `Metric`\ 's name. This metadata can
303 be queried by `Specification`\ s, so that `Specification`\ s can be
304 written to test only certain types of `Measurement`\ s.
305 """
306 return self._notes
308 @property
309 def json(self):
310 r"""A `dict` that can be serialized as semantic SQUASH JSON.
312 Fields:
314 - ``metric`` (`str`) Name of the metric the measurement measures.
315 - ``identifier`` (`str`) Unique identifier for this measurement.
316 - ``value`` (`float`) Value of the measurement.
317 - ``unit`` (`str`) Units of the ``value``, as an
318 `astropy.units`-compatible string.
319 - ``blob_refs`` (`list` of `str`) List of `Blob.identifier`\ s for
320 Blobs associated with this measurement.
322 .. note::
324 `Blob`\ s are not serialized with a measurement, only their
325 identifiers. The `lsst.verify.Job` class handles serialization of
326 blobs alongside measurements.
328 Likewise, `Measurement.notes` are not serialized with the
329 measurement. They are included with `lsst.verify.Job`\ 's
330 serialization, alongside job-level metadata.
331 """
332 if self.quantity is None:
333 _normalized_value = None
334 _normalized_unit_str = None
335 elif self.metric is not None:
336 # ensure metrics are normalized to metric definition's units
337 _normalized_value = self.quantity.to(self.metric.unit).value
338 _normalized_unit_str = self.metric.unit_str
339 else:
340 _normalized_value = self.quantity.value
341 _normalized_unit_str = str(self.quantity.unit)
343 # Represent NaN, positive infinity, or negative infinity as None
344 if _normalized_value and not np.isfinite(_normalized_value):
345 _normalized_value = None
347 blob_refs = [b.identifier for k, b in self.blobs.items()]
348 # Remove any reference to an empty extras blob
349 if len(self.extras) == 0:
350 blob_refs.remove(self.extras.identifier)
352 object_doc = {'metric': str(self.metric_name),
353 'identifier': self.identifier,
354 'value': _normalized_value,
355 'unit': _normalized_unit_str,
356 'blob_refs': blob_refs}
357 json_doc = JsonSerializationMixin.jsonify_dict(object_doc)
358 return json_doc
360 @classmethod
361 def deserialize(cls, metric=None, identifier=None, value=None, unit=None,
362 blob_refs=None, blobs=None, **kwargs):
363 r"""Create a Measurement instance from a parsed YAML/JSON document.
365 Parameters
366 ----------
367 metric : `str`
368 Name of the metric the measurement measures.
369 identifier : `str`
370 Unique identifier for this measurement.
371 value : `float`
372 Value of the measurement.
373 unit : `str`
374 Units of the ``value``, as an `astropy.units`-compatible string.
375 blob_refs : `list` of `str`
376 List of `Blob.identifier`\ s for Blob associated with this
377 measurement.
378 blobs : `BlobSet`
379 `BlobSet` containing all `Blob`\ s referenced by the measurement's
380 ``blob_refs`` field. Note that the `BlobSet` must be created
381 separately, prior to deserializing measurement objects.
383 Returns
384 -------
385 measurement : `Measurement`
386 Measurement instance.
387 """
388 # Resolve blobs from references:
389 if blob_refs is not None and blobs is not None:
390 # get only referenced blobs
391 _blobs = [blob for blob_identifier, blob in blobs.items()
392 if blob_identifier in blob_refs]
393 elif blobs is not None:
394 # use all the blobs if none were specifically referenced
395 _blobs = blobs
396 else:
397 _blobs = None
399 # Resolve quantity, represent None values as np.nan
400 if value is None:
401 value = np.nan
402 _quantity = u.Quantity(value, u.Unit(unit))
404 instance = cls(metric, quantity=_quantity, blobs=_blobs)
405 instance._id = identifier # re-wire id from serialization
406 return instance
408 def __eq__(self, other):
409 return quantity_allclose(self.quantity, other.quantity) and \
410 (self.metric_name == other.metric_name) and \
411 (self.notes == other.notes)
413 def __ne__(self, other):
414 return not self.__eq__(other)
417class MeasurementNotes(object):
418 """Container for annotations (notes) associated with a single
419 `lsst.verify.Measurement`.
421 Typically you will use pre-instantiate ``MeasurementNotes`` objects
422 through the `lsst.verify.Measurement.notes` attribute.
424 Parameters
425 ----------
426 metric_name : `Name` or `str`
427 Fully qualified name of the measurement's metric. The metric's name
428 is used as a prefix for key names.
430 See also
431 --------
432 lsst.verify.Measurement.notes
433 lsst.verify.Metadata
435 Examples
436 --------
437 ``MeasurementNotes`` implements a `dict`-like interface. The only
438 difference is that, internally, keys are always prefixed with the name of
439 a metric. This allows measurement annotations to mesh `lsst.verify.Job`
440 metadata keys (`lsst.verify.Job.meta`).
442 Users of `MeasurementNotes`, typically though `Measurement.notes`, do
443 not need to use this prefix. Keys are prefixed behind the scenes.
445 >>> notes = MeasurementNotes('validate_drp')
446 >>> notes['filter_name'] = 'r'
447 >>> notes['filter_name']
448 'r'
449 >>> notes['validate_drp.filter_name']
450 'r'
451 >>> print(notes)
452 {'validate_drp.filter_name': 'r'}
453 """
455 def __init__(self, metric_name):
456 # cast Name to str form to deal with prefixes
457 self._metric_name = str(metric_name)
458 # Enforced key prefix for all notes
459 self._prefix = '{self._metric_name}.'.format(self=self)
460 self._data = {}
462 def _format_key(self, key):
463 """Ensures the key includes the metric name prefix."""
464 if not key.startswith(self._prefix):
465 key = self._prefix + key
466 return key
468 def __getitem__(self, key):
469 key = self._format_key(key)
470 return self._data[key]
472 def __setitem__(self, key, value):
473 key = self._format_key(key)
474 self._data[key] = value
476 def __delitem__(self, key):
477 key = self._format_key(key)
478 del self._data[key]
480 def __contains__(self, key):
481 key = self._format_key(key)
482 return key in self._data
484 def __len__(self):
485 return len(self._data)
487 def __eq__(self, other):
488 return (self._metric_name == other._metric_name) and \
489 (self._data == other._data)
491 def __ne__(self, other):
492 return not self.__eq__(other)
494 def __iter__(self):
495 for key in self._data:
496 yield key
498 def __str__(self):
499 return str(self._data)
501 def __repr__(self):
502 return repr(self._data)
504 def keys(self):
505 """Get key names.
507 Returns
508 -------
509 keys : `list` of `str`
510 List of key names.
511 """
512 return [key for key in self]
514 def items(self):
515 """Iterate over note key-value pairs.
517 Yields
518 ------
519 item : key-value pair
520 Each items is tuple of:
522 - Key name (`str`).
523 - Note value (object).
524 """
525 for item in self._data.items():
526 yield item
528 def update(self, data):
529 """Update the notes with key-value pairs from a `dict`-like object.
531 Parameters
532 ----------
533 data : `dict`-like
534 `dict`-like object that has an ``items`` method for iteration.
535 The key-value pairs of ``data`` are added to the
536 ``MeasurementNotes`` instance. If key-value pairs already exist
537 in the ``MeasurementNotes`` instance, they are overwritten with
538 values from ``data``.
539 """
540 for key, value in data.items():
541 self[key] = value