Hide keyboard shortcuts

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'] 

22 

23import uuid 

24 

25import astropy.units as u 

26from astropy.tests.helper import quantity_allclose 

27 

28from .blob import Blob 

29from .datum import Datum 

30from .jsonmixin import JsonSerializationMixin 

31from .metric import Metric 

32from .naming import Name 

33 

34 

35class Measurement(JsonSerializationMixin): 

36 r"""A measurement of a single `~lsst.verify.Metric`. 

37 

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`). 

42 

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. 

68 

69 Raises 

70 ------ 

71 TypeError 

72 Raised if arguments are not valid types. 

73 """ 

74 

75 blobs = None 

76 r"""`dict` of `lsst.verify.Blob`\ s associated with this measurement. 

77 

78 See also 

79 -------- 

80 Measurement.link_blob 

81 """ 

82 

83 extras = None 

84 r"""`Blob` associated solely to this measurement. 

85 

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 """ 

95 

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 

102 

103 try: 

104 self.metric = metric 

105 except TypeError: 

106 # must be a name 

107 self._metric = None 

108 self.metric_name = metric 

109 

110 self.quantity = quantity 

111 

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 

119 

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 

135 

136 self._notes = MeasurementNotes(self.metric_name) 

137 if notes is not None: 

138 self.notes.update(notes) 

139 

140 @property 

141 def metric(self): 

142 """Metric associated with the measurement (`lsst.verify.Metric` or 

143 `None`, mutable). 

144 """ 

145 return self._metric 

146 

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)) 

152 

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)) 

159 

160 self._metric = value 

161 

162 # Reset metric_name for consistency 

163 self.metric_name = value.name 

164 

165 @property 

166 def metric_name(self): 

167 """Name of the corresponding metric (`lsst.verify.Name`, mutable). 

168 """ 

169 return self._metric_name 

170 

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 

181 

182 @property 

183 def quantity(self): 

184 """`astropy.units.Quantity` component of the measurement (mutable). 

185 """ 

186 return self._quantity 

187 

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)) 

198 

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)) 

207 

208 self._quantity = q 

209 

210 @property 

211 def identifier(self): 

212 """Unique UUID4-based identifier for this measurement (`str`, 

213 immutable).""" 

214 return self._id 

215 

216 def __str__(self): 

217 return "{self.metric_name!s}: {self.quantity!s}".format(self=self) 

218 

219 def _repr_latex_(self): 

220 """Get a LaTeX-formatted string representation of the measurement 

221 quantity (used in Jupyter notebooks). 

222 

223 Returns 

224 ------- 

225 rep : `str` 

226 String representation. 

227 """ 

228 return '{0.value:0.1f} {0.unit:latex_inline}'.format(self.quantity) 

229 

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 

239 

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) 

246 

247 def link_blob(self, blob): 

248 """Link a `Blob` to this measurement. 

249 

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. 

253 

254 Parameters 

255 ---------- 

256 blob : `lsst.verify.Blob` 

257 A `~lsst.verify.Blob` instance. 

258 

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 

268 

269 @property 

270 def notes(self): 

271 r"""Measurement annotations as key-value pairs (`dict`). 

272 

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 

279 

280 @property 

281 def json(self): 

282 r"""A `dict` that can be serialized as semantic SQUASH JSON. 

283 

284 Fields: 

285 

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. 

293 

294 .. note:: 

295 

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. 

299 

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) 

314 

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) 

319 

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 

327 

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. 

332 

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. 

350 

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 

366 

367 # Resolve quantity 

368 _quantity = u.Quantity(value, u.Unit(unit)) 

369 

370 instance = cls(metric, quantity=_quantity, blobs=_blobs) 

371 instance._id = identifier # re-wire id from serialization 

372 return instance 

373 

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) 

378 

379 def __ne__(self, other): 

380 return not self.__eq__(other) 

381 

382 

383class MeasurementNotes(object): 

384 """Container for annotations (notes) associated with a single 

385 `lsst.verify.Measurement`. 

386 

387 Typically you will use pre-instantiate ``MeasurementNotes`` objects 

388 through the `lsst.verify.Measurement.notes` attribute. 

389 

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. 

395 

396 See also 

397 -------- 

398 lsst.verify.Measurement.notes 

399 lsst.verify.Metadata 

400 

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`). 

407 

408 Users of `MeasurementNotes`, typically though `Measurement.notes`, do 

409 not need to use this prefix. Keys are prefixed behind the scenes. 

410 

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 """ 

420 

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 = {} 

427 

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 

433 

434 def __getitem__(self, key): 

435 key = self._format_key(key) 

436 return self._data[key] 

437 

438 def __setitem__(self, key, value): 

439 key = self._format_key(key) 

440 self._data[key] = value 

441 

442 def __delitem__(self, key): 

443 key = self._format_key(key) 

444 del self._data[key] 

445 

446 def __contains__(self, key): 

447 key = self._format_key(key) 

448 return key in self._data 

449 

450 def __len__(self): 

451 return len(self._data) 

452 

453 def __eq__(self, other): 

454 return (self._metric_name == other._metric_name) and \ 

455 (self._data == other._data) 

456 

457 def __ne__(self, other): 

458 return not self.__eq__(other) 

459 

460 def __iter__(self): 

461 for key in self._data: 

462 yield key 

463 

464 def __str__(self): 

465 return str(self._data) 

466 

467 def __repr__(self): 

468 return repr(self._data) 

469 

470 def keys(self): 

471 """Get key names. 

472 

473 Returns 

474 ------- 

475 keys : `list` of `str` 

476 List of key names. 

477 """ 

478 return [key for key in self] 

479 

480 def items(self): 

481 """Iterate over note key-value pairs. 

482 

483 Yields 

484 ------ 

485 item : key-value pair 

486 Each items is tuple of: 

487 

488 - Key name (`str`). 

489 - Note value (object). 

490 """ 

491 for item in self._data.items(): 

492 yield item 

493 

494 def update(self, data): 

495 """Update the notes with key-value pairs from a `dict`-like object. 

496 

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