Coverage for python/lsst/verify/measurement.py: 24%

197 statements  

« prev     ^ index     » next       coverage.py v6.4.4, created at 2022-09-03 03:58 -0700

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 numpy as np 

26import astropy.units as u 

27from astropy.tests.helper import quantity_allclose 

28 

29from .blob import Blob 

30from .datum import Datum 

31from .jsonmixin import JsonSerializationMixin 

32from .metric import Metric 

33from .naming import Name 

34 

35 

36class Measurement(JsonSerializationMixin): 

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

38 

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

43 

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. 

69 

70 Raises 

71 ------ 

72 TypeError 

73 Raised if arguments are not valid types. 

74 """ 

75 

76 blobs = None 

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

78 

79 See also 

80 -------- 

81 Measurement.link_blob 

82 """ 

83 

84 extras = None 

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

86 

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

96 

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 

103 

104 try: 

105 self.metric = metric 

106 except TypeError: 

107 # must be a name 

108 self._metric = None 

109 self.metric_name = metric 

110 

111 self.quantity = quantity 

112 

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 

120 

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 

136 

137 self._notes = MeasurementNotes(self.metric_name) 

138 if notes is not None: 

139 self.notes.update(notes) 

140 

141 @property 

142 def metric(self): 

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

144 `None`, mutable). 

145 """ 

146 return self._metric 

147 

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

153 

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

160 

161 self._metric = value 

162 

163 # Reset metric_name for consistency 

164 self.metric_name = value.name 

165 

166 @property 

167 def metric_name(self): 

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

169 """ 

170 return self._metric_name 

171 

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 

182 

183 @property 

184 def quantity(self): 

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

186 """ 

187 return self._quantity 

188 

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

199 

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

208 

209 self._quantity = q 

210 

211 @property 

212 def identifier(self): 

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

214 immutable).""" 

215 return self._id 

216 

217 def __str__(self): 

218 return f"{self.metric_name!s}: {self.quantity!s}" 

219 

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 ] 

226 

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

232 

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

234 if self.extras: 

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

236 

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

238 if self.notes: 

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

240 

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

242 

243 def _repr_latex_(self): 

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

245 quantity (used in Jupyter notebooks). 

246 

247 Returns 

248 ------- 

249 rep : `str` 

250 String representation. 

251 """ 

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

253 

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 

263 

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) 

270 

271 def link_blob(self, blob): 

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

273 

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. 

277 

278 Parameters 

279 ---------- 

280 blob : `lsst.verify.Blob` 

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

282 

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 

292 

293 @property 

294 def notes(self): 

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

296 

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 

303 

304 @property 

305 def json(self): 

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

307 

308 Fields: 

309 

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. 

317 

318 .. note:: 

319 

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. 

323 

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) 

338 

339 # Represent NaN, positive infinity, or negative infinity as None 

340 if _normalized_value and not np.isfinite(_normalized_value): 

341 _normalized_value = None 

342 

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) 

347 

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 

355 

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. 

360 

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. 

378 

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 

394 

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

399 

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

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

402 return instance 

403 

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) 

408 

409 def __ne__(self, other): 

410 return not self.__eq__(other) 

411 

412 

413class MeasurementNotes(object): 

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

415 `lsst.verify.Measurement`. 

416 

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

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

419 

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. 

425 

426 See also 

427 -------- 

428 lsst.verify.Measurement.notes 

429 lsst.verify.Metadata 

430 

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

437 

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

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

440 

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

450 

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

457 

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 

463 

464 def __getitem__(self, key): 

465 key = self._format_key(key) 

466 return self._data[key] 

467 

468 def __setitem__(self, key, value): 

469 key = self._format_key(key) 

470 self._data[key] = value 

471 

472 def __delitem__(self, key): 

473 key = self._format_key(key) 

474 del self._data[key] 

475 

476 def __contains__(self, key): 

477 key = self._format_key(key) 

478 return key in self._data 

479 

480 def __len__(self): 

481 return len(self._data) 

482 

483 def __eq__(self, other): 

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

485 (self._data == other._data) 

486 

487 def __ne__(self, other): 

488 return not self.__eq__(other) 

489 

490 def __iter__(self): 

491 for key in self._data: 

492 yield key 

493 

494 def __str__(self): 

495 return str(self._data) 

496 

497 def __repr__(self): 

498 return repr(self._data) 

499 

500 def keys(self): 

501 """Get key names. 

502 

503 Returns 

504 ------- 

505 keys : `list` of `str` 

506 List of key names. 

507 """ 

508 return [key for key in self] 

509 

510 def items(self): 

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

512 

513 Yields 

514 ------ 

515 item : key-value pair 

516 Each items is tuple of: 

517 

518 - Key name (`str`). 

519 - Note value (object). 

520 """ 

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

522 yield item 

523 

524 def update(self, data): 

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

526 

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