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 f"{self.metric_name!s}: {self.quantity!s}" 

218 

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 ] 

225 

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

231 

232 # invariant: self.extras always exists, but may be empty 

233 if self.extras: 

234 args.append(f"extras={dict(self.extras)!r}") 

235 

236 # invariant: self.notes always exists, but may be empty 

237 if self.notes: 

238 args.append(f"notes={dict(self.notes)!r}") 

239 

240 return f"Measurement({', '.join(args)})" 

241 

242 def _repr_latex_(self): 

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

244 quantity (used in Jupyter notebooks). 

245 

246 Returns 

247 ------- 

248 rep : `str` 

249 String representation. 

250 """ 

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

252 

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 

262 

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) 

269 

270 def link_blob(self, blob): 

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

272 

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. 

276 

277 Parameters 

278 ---------- 

279 blob : `lsst.verify.Blob` 

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

281 

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 

291 

292 @property 

293 def notes(self): 

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

295 

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 

302 

303 @property 

304 def json(self): 

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

306 

307 Fields: 

308 

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. 

316 

317 .. note:: 

318 

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. 

322 

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) 

337 

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) 

342 

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 

350 

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. 

355 

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. 

373 

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 

389 

390 # Resolve quantity 

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

392 

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

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

395 return instance 

396 

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) 

401 

402 def __ne__(self, other): 

403 return not self.__eq__(other) 

404 

405 

406class MeasurementNotes(object): 

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

408 `lsst.verify.Measurement`. 

409 

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

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

412 

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. 

418 

419 See also 

420 -------- 

421 lsst.verify.Measurement.notes 

422 lsst.verify.Metadata 

423 

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

430 

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

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

433 

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

443 

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

450 

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 

456 

457 def __getitem__(self, key): 

458 key = self._format_key(key) 

459 return self._data[key] 

460 

461 def __setitem__(self, key, value): 

462 key = self._format_key(key) 

463 self._data[key] = value 

464 

465 def __delitem__(self, key): 

466 key = self._format_key(key) 

467 del self._data[key] 

468 

469 def __contains__(self, key): 

470 key = self._format_key(key) 

471 return key in self._data 

472 

473 def __len__(self): 

474 return len(self._data) 

475 

476 def __eq__(self, other): 

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

478 (self._data == other._data) 

479 

480 def __ne__(self, other): 

481 return not self.__eq__(other) 

482 

483 def __iter__(self): 

484 for key in self._data: 

485 yield key 

486 

487 def __str__(self): 

488 return str(self._data) 

489 

490 def __repr__(self): 

491 return repr(self._data) 

492 

493 def keys(self): 

494 """Get key names. 

495 

496 Returns 

497 ------- 

498 keys : `list` of `str` 

499 List of key names. 

500 """ 

501 return [key for key in self] 

502 

503 def items(self): 

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

505 

506 Yields 

507 ------ 

508 item : key-value pair 

509 Each items is tuple of: 

510 

511 - Key name (`str`). 

512 - Note value (object). 

513 """ 

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

515 yield item 

516 

517 def update(self, data): 

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

519 

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