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

199 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-19 04:18 -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 # 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) 

257 

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 

267 

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) 

274 

275 def link_blob(self, blob): 

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

277 

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. 

281 

282 Parameters 

283 ---------- 

284 blob : `lsst.verify.Blob` 

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

286 

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 

296 

297 @property 

298 def notes(self): 

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

300 

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 

307 

308 @property 

309 def json(self): 

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

311 

312 Fields: 

313 

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. 

321 

322 .. note:: 

323 

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. 

327 

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) 

342 

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

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

345 _normalized_value = None 

346 

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) 

351 

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 

359 

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. 

364 

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. 

382 

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 

398 

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

403 

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

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

406 return instance 

407 

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) 

412 

413 def __ne__(self, other): 

414 return not self.__eq__(other) 

415 

416 

417class MeasurementNotes(object): 

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

419 `lsst.verify.Measurement`. 

420 

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

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

423 

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. 

429 

430 See also 

431 -------- 

432 lsst.verify.Measurement.notes 

433 lsst.verify.Metadata 

434 

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

441 

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

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

444 

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

454 

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

461 

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 

467 

468 def __getitem__(self, key): 

469 key = self._format_key(key) 

470 return self._data[key] 

471 

472 def __setitem__(self, key, value): 

473 key = self._format_key(key) 

474 self._data[key] = value 

475 

476 def __delitem__(self, key): 

477 key = self._format_key(key) 

478 del self._data[key] 

479 

480 def __contains__(self, key): 

481 key = self._format_key(key) 

482 return key in self._data 

483 

484 def __len__(self): 

485 return len(self._data) 

486 

487 def __eq__(self, other): 

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

489 (self._data == other._data) 

490 

491 def __ne__(self, other): 

492 return not self.__eq__(other) 

493 

494 def __iter__(self): 

495 for key in self._data: 

496 yield key 

497 

498 def __str__(self): 

499 return str(self._data) 

500 

501 def __repr__(self): 

502 return repr(self._data) 

503 

504 def keys(self): 

505 """Get key names. 

506 

507 Returns 

508 ------- 

509 keys : `list` of `str` 

510 List of key names. 

511 """ 

512 return [key for key in self] 

513 

514 def items(self): 

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

516 

517 Yields 

518 ------ 

519 item : key-value pair 

520 Each items is tuple of: 

521 

522 - Key name (`str`). 

523 - Note value (object). 

524 """ 

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

526 yield item 

527 

528 def update(self, data): 

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

530 

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