Coverage for python/lsst/verify/metricset.py: 77%

161 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-14 18:37 +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__ = ['MetricSet'] 

22 

23from astropy.table import Table 

24 

25from lsst.resources import ResourcePath 

26from .jsonmixin import JsonSerializationMixin 

27from .metric import Metric 

28from .naming import Name 

29from .yamlutils import load_ordered_yaml 

30 

31 

32class MetricSet(JsonSerializationMixin): 

33 r"""A collection of `Metric`\ s. 

34 

35 Parameters 

36 ---------- 

37 metrics : sequence of `Metric` instances, optional 

38 `Metric`\ s to be contained within the ``MetricSet``. 

39 """ 

40 

41 def __init__(self, metrics=None): 

42 # Internal dict of Metrics. The MetricSet manages access through its 

43 # own mapping API. 

44 self._metrics = {} 

45 

46 if metrics is not None: 

47 for metric in metrics: 

48 if not isinstance(metric, Metric): 48 ↛ 49line 48 didn't jump to line 49 because the condition on line 48 was never true

49 message = '{0!r} is not a Metric-type'.format(metric) 

50 raise TypeError(message) 

51 self._metrics[metric.name] = metric 

52 

53 @classmethod 

54 def load_metrics_package(cls, package_name_or_path='verify_metrics', 

55 subset=None): 

56 """Create a MetricSet from a Verification Framework metrics package. 

57 

58 Parameters 

59 ---------- 

60 package_name_or_path : `str`, optional 

61 Name of an EUPS package that hosts metric and specification 

62 definition YAML files **or** the file path to a metrics package. 

63 ``'verify_metrics'`` is the default package, and is where metrics 

64 and specifications are defined for most packages. 

65 subset : `str`, optional 

66 If set, only metrics for this package are loaded. For example, if 

67 ``subset='validate_drp'``, only ``validate_drp`` metrics are 

68 included in the `MetricSet`. This argument is equivalent to the 

69 `MetricSet.subset` method. Default is `None`. 

70 

71 Returns 

72 ------- 

73 metric_set : `MetricSet` 

74 A `MetricSet` containing `Metric` instances. 

75 

76 See also 

77 -------- 

78 lsst.verify.MetricSet.load_single_package 

79 

80 Notes 

81 ----- 

82 EUPS packages that host metrics and specification definitions for the 

83 Verification Framework have top-level directories named ``'metrics'`` 

84 and ``'specs'``. The metrics package chosen with the 

85 ``package_name_or_path`` argument. The default metric package for 

86 LSST Science Pipelines is ``verify_metrics``. 

87 

88 To make a `MetricSet` from a single package's YAML metric definition 

89 file that **is not** contained in a metrics package, 

90 use `load_single_package` instead. 

91 """ 

92 # A bare name (no "/") may be an EUPS package, expressed as an 

93 # ``eups://`` URI so that resolution is handled by lsst.resources 

94 # without a direct EUPS lookup here. Anything containing a "/" is a 

95 # filesystem path or URI; such values would produce an empty netloc 

96 # (and a ValueError) if forced into an ``eups://`` URI, so skip the 

97 # EUPS attempt for them. 

98 package_dir = None 

99 if "/" not in package_name_or_path: 

100 eups_dir = ResourcePath(f"eups://{package_name_or_path}/", 

101 forceDirectory=True) 

102 if eups_dir.exists(): 

103 package_dir = eups_dir 

104 if package_dir is None: 

105 # Fall back to a filesystem path (or URI) instead. 

106 package_dir = ResourcePath(package_name_or_path, 

107 forceDirectory=True, 

108 forceAbsolute=True) 

109 

110 metrics_dirname = package_dir.join('metrics', forceDirectory=True) 

111 if not metrics_dirname.exists(): 

112 message = 'Metrics directory {0} not found' 

113 raise OSError(message.format(metrics_dirname)) 

114 

115 metrics = [] 

116 

117 if subset is not None: 

118 # Load only a single package's YAML file 

119 metrics_yaml_paths = [metrics_dirname.join(f"{subset}.yaml")] 

120 else: 

121 # Load all package's YAML files (top level only) 

122 _, _, filenames = next( 

123 metrics_dirname.walk(file_filter=r'.*\.yaml$')) 

124 metrics_yaml_paths = [metrics_dirname.join(name) 

125 for name in filenames] 

126 

127 for metrics_yaml_path in metrics_yaml_paths: 

128 new_metrics = MetricSet._load_metrics_yaml(metrics_yaml_path) 

129 metrics.extend(new_metrics) 

130 

131 return cls(metrics) 

132 

133 @classmethod 

134 def load_single_package(cls, metrics_yaml_path): 

135 """Create a MetricSet from a single YAML file containing metric 

136 definitions for a single package. 

137 

138 Returns 

139 ------- 

140 metric_set : `MetricSet` 

141 A `MetricSet` containing `Metric` instances found in the YAML 

142 file. 

143 

144 See also 

145 -------- 

146 lsst.verify.MetricSet.load_metrics_package 

147 

148 Notes 

149 ----- 

150 The YAML file's name, without extension, is taken as the package 

151 name for all metrics. 

152 

153 For example, ``validate_drp.yaml`` contains metrics that are 

154 identified as belonging to the ``validate_drp`` package. 

155 """ 

156 metrics = MetricSet._load_metrics_yaml(metrics_yaml_path) 

157 return cls(metrics) 

158 

159 @staticmethod 

160 def _load_metrics_yaml(metrics_yaml_path): 

161 # package name is inferred from YAML file name (by definition) 

162 metrics_yaml_path = ResourcePath(metrics_yaml_path, forceAbsolute=True) 

163 package_name = metrics_yaml_path.updatedExtension('').basename() 

164 

165 metrics = [] 

166 with metrics_yaml_path.open() as f: 

167 yaml_doc = load_ordered_yaml(f) 

168 for metric_name, metric_doc in yaml_doc.items(): 

169 name = Name(package=package_name, metric=metric_name) 

170 # throw away a 'name' field if there happens to be one 

171 metric_doc.pop('name', None) 

172 # Create metric instance 

173 metric = Metric.deserialize(name=name, **metric_doc) 

174 metrics.append(metric) 

175 return metrics 

176 

177 @classmethod 

178 def deserialize(cls, metrics=None): 

179 """Deserialize metric JSON objects into a MetricSet instance. 

180 

181 Parameters 

182 ---------- 

183 metrics : `list` 

184 List of metric JSON serializations (typically created by 

185 `MetricSet.json`). 

186 

187 Returns 

188 ------- 

189 metric_set : `MetricSet` 

190 `MetricSet` instance. 

191 """ 

192 instance = cls() 

193 for metric_doc in metrics: 

194 metric = Metric.deserialize(**metric_doc) 

195 instance.insert(metric) 

196 return instance 

197 

198 @property 

199 def json(self): 

200 """A JSON-serializable object (`list`).""" 

201 doc = JsonSerializationMixin._jsonify_list( 

202 [metric for name, metric in self.items()] 

203 ) 

204 return doc 

205 

206 def __getitem__(self, key): 

207 if not isinstance(key, Name): 

208 key = Name(metric=key) 

209 return self._metrics[key] 

210 

211 def __setitem__(self, key, value): 

212 if not isinstance(key, Name): 

213 key = Name(metric=key) 

214 

215 # Key name must be for a metric 

216 if not key.is_metric: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 message = 'Key {0!r} is not a metric name'.format(key) 

218 raise KeyError(message) 

219 

220 # value must be a metric type 

221 if not isinstance(value, Metric): 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 message = 'Expected {0!s}={1!r} to be a Metric-type'.format( 

223 key, value) 

224 raise TypeError(message) 

225 

226 # Metric name and key name must be consistent 

227 if value.name != key: 

228 message = 'Key {0!s} inconsistent with Metric {0!s}' 

229 raise KeyError(message.format(key, value)) 

230 

231 self._metrics[key] = value 

232 

233 def __delitem__(self, key): 

234 if not isinstance(key, Name): 234 ↛ 236line 234 didn't jump to line 236 because the condition on line 234 was always true

235 key = Name(metric=key) 

236 del self._metrics[key] 

237 

238 def __len__(self): 

239 return len(self._metrics) 

240 

241 def __contains__(self, key): 

242 if not isinstance(key, Name): 

243 key = Name(metric=key) 

244 return key in self._metrics 

245 

246 def __iter__(self): 

247 for key in self._metrics: 

248 yield key 

249 

250 def __str__(self): 

251 count = len(self) 

252 if count == 0: 

253 count_str = 'empty' 

254 elif count == 1: 

255 count_str = '1 Metric' 

256 else: 

257 count_str = '{count:d} Metrics'.format(count=count) 

258 return '<MetricSet: {0}>'.format(count_str) 

259 

260 def __eq__(self, other): 

261 if len(self) != len(other): 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

262 return False 

263 

264 for name, metric in self.items(): 

265 try: 

266 if metric != other[name]: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 return False 

268 except KeyError: 

269 return False 

270 

271 return True 

272 

273 def __ne__(self, other): 

274 return not self.__eq__(other) 

275 

276 def __iadd__(self, other): 

277 """Merge another `MetricSet` into this one. 

278 

279 Parameters 

280 --------- 

281 other : `MetricSet` 

282 Another `MetricSet`. Metrics in ``other`` that do exist in this 

283 set are added to this one. Metrics in ``other`` replace metrics of 

284 the same name in this one. 

285 

286 Returns 

287 ------- 

288 self : `MetricSet` 

289 This `MetricSet`. 

290 

291 Notes 

292 ----- 

293 Equivalent to `update`. 

294 """ 

295 self.update(other) 

296 return self 

297 

298 def insert(self, metric): 

299 """Insert a `Metric` into the set. 

300 

301 Any pre-existing metric with the same name is replaced 

302 

303 Parameters 

304 ---------- 

305 metric : `Metric` 

306 A metric. 

307 """ 

308 self[metric.name] = metric 

309 

310 def keys(self): 

311 r"""Get a list of metric names included in the set 

312 

313 Returns 

314 ------- 

315 keys : `list` of `Name` 

316 List of `Name`\ s included in the set. 

317 """ 

318 return self._metrics.keys() 

319 

320 def items(self): 

321 """Iterate over ``(name, metric)`` pairs in the set. 

322 

323 Yields 

324 ------ 

325 item : tuple 

326 Tuple containing: 

327 

328 - `Name` of the `Metric` 

329 - `Metric` instance 

330 """ 

331 for item in self._metrics.items(): 

332 yield item 

333 

334 def subset(self, package=None, tags=None): 

335 """Create a new `MetricSet` with metrics belonging to a single 

336 package and/or tag. 

337 

338 Parameters 

339 ---------- 

340 package : `str` or `lsst.verify.Name`, optional 

341 Name of the package to subset metrics by. If the package name 

342 is ``'pkg_a'``, then metric ``'pkg_a.metric_1'`` would be 

343 **included** in the subset, while ``'pkg_b.metric_2'`` would be 

344 **excluded**. 

345 tags : sequence of `str`, optional 

346 Tags to select metrics by. These tags must be a subset (``<=``) 

347 of the `Metric.tags` for the metric to be selected. 

348 

349 Returns 

350 ------- 

351 metric_subset : `MetricSet` 

352 Subset of this metric set containing only metrics belonging 

353 to the specified package and/or tag. 

354 

355 Notes 

356 ----- 

357 If both ``package`` and ``tag`` are provided then the resulting 

358 `MetricSet` contains the **intersection** of the package-based and 

359 tag-based selections. That is, metrics will belong to ``package`` 

360 and posess the tag ``tag``. 

361 """ 

362 if package is not None and not isinstance(package, Name): 

363 package = Name(package=package) 

364 

365 if tags is not None: 

366 tags = set(tags) 

367 

368 if package is not None and tags is None: 

369 metrics = [metric for metric_name, metric in self._metrics.items() 

370 if metric_name in package] 

371 

372 elif package is not None and tags is not None: 

373 metrics = [metric for metric_name, metric in self._metrics.items() 

374 if metric_name in package 

375 if tags <= metric.tags] 

376 

377 elif package is None and tags is not None: 377 ↛ 382line 377 didn't jump to line 382 because the condition on line 377 was always true

378 metrics = [metric for metric_name, metric in self._metrics.items() 

379 if tags <= metric.tags] 

380 

381 else: 

382 metrics = [] 

383 

384 return MetricSet(metrics) 

385 

386 def update(self, other): 

387 """Merge another `MetricSet` into this one. 

388 

389 Parameters 

390 ---------- 

391 other : `MetricSet` 

392 Another `MetricSet`. Metrics in ``other`` that do exist in this 

393 set are added to this one. Metrics in ``other`` replace metrics of 

394 the same name in this one. 

395 """ 

396 for _, metric in other.items(): 

397 self.insert(metric) 

398 

399 def _repr_html_(self): 

400 """Make an HTML representation of metrics for Jupyter notebooks. 

401 """ 

402 name_col = [] 

403 tags_col = [] 

404 units_col = [] 

405 description_col = [] 

406 reference_col = [] 

407 

408 metric_names = list(self.keys()) 

409 metric_names.sort() 

410 

411 for metric_name in metric_names: 

412 metric = self[metric_name] 

413 

414 name_col.append(str(metric_name)) 

415 

416 tags = list(metric.tags) 

417 tags.sort() 

418 tags_col.append(', '.join(tags)) 

419 

420 units_col.append("{0:latex}".format(metric.unit)) 

421 

422 description_col.append(metric.description) 

423 

424 reference_col.append(metric.reference) 

425 

426 table = Table([name_col, description_col, units_col, reference_col, 

427 tags_col], 

428 names=['Name', 'Description', 'Units', 'Reference', 

429 'Tags']) 

430 return table._repr_html_()