Coverage for python/lsst/analysis/tools/tasks/metadataAnalysis.py: 40%

118 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 10:03 +0000

1# This file is part of analysis_tools. 

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/>. 

21from __future__ import annotations 

22 

23__all__ = ( 

24 "MetadataAnalysisConfig", 

25 "DatasetMetadataAnalysisTask", 

26 "TaskMetadataAnalysisTask", 

27 "AggregatedTaskMetadataAnalysisConfig", 

28 "AggregatedTaskMetadataAnalysisTask", 

29) 

30 

31from lsst.pex.config import Field, ListField 

32from lsst.pipe.base import UpstreamFailureNoWorkFound, connectionTypes 

33 

34from ..interfaces import AnalysisBaseConfig, AnalysisBaseConnections, AnalysisPipelineTask 

35 

36 

37class MetadataAnalysisConnections( 

38 AnalysisBaseConnections, 

39 dimensions={}, 

40 defaultTemplates={"inputName": "", "outputName": "", "datasetType": "", "storageClass": ""}, 

41): 

42 def __init__(self, *, config=None): 

43 """Customize the connections for a specific task's or dataset's 

44 metadata. 

45 

46 Parameters 

47 ---------- 

48 config : `MetadataAnalysisConfig` 

49 A config for analyzing task or dataset metadata with this 

50 connection. 

51 """ 

52 # The following must come before super().__init__ so the input 

53 # dimensions are propagated into the output metric bundle by 

54 # the base class __init__ 

55 self.dimensions = frozenset(config.inputDimensions) 

56 super().__init__(config=config) 

57 

58 self.data = connectionTypes.Input( 

59 doc="Input dataset to extract metadata from.", 

60 name=config.connections.inputName, 

61 storageClass=config.connections.storageClass, 

62 deferLoad=True, 

63 dimensions=self.dimensions, 

64 ) 

65 

66 

67class MetadataAnalysisConfig( 

68 AnalysisBaseConfig, 

69 pipelineConnections=MetadataAnalysisConnections, 

70): 

71 inputDimensions = ListField( 

72 # Sort to ensure default order is consistent between runs 

73 default=sorted(MetadataAnalysisConnections.dimensions), 

74 dtype=str, 

75 doc="The dimensions of the input dataset.", 

76 ) 

77 raiseNoWorkFoundOnEmptyMetadata = Field( 

78 dtype=bool, 

79 default=False, 

80 doc="Raise a NoWorkFound error if none of the configured metrics are in the task metadata.", 

81 ) 

82 raiseNoWorkFoundOnIncompleteMetadata = Field( 

83 dtype=bool, 

84 default=False, 

85 doc="Raise NoWorkFound if any of the configured metrics are not in the task metadata.", 

86 ) 

87 

88 def validate(self): 

89 super().validate() 

90 if self.raiseNoWorkFoundOnEmptyMetadata and self.raiseNoWorkFoundOnIncompleteMetadata: 

91 raise ValueError("At most one 'raiseNoWorkFound' option may be True at a time.") 

92 

93 

94class DatasetMetadataAnalysisConfig(MetadataAnalysisConfig): 

95 

96 def setDefaults(self): 

97 super().setDefaults() 

98 self.raiseNoWorkFoundOnIncompleteMetadata = True 

99 

100 

101class DatasetMetadataAnalysisTask(AnalysisPipelineTask): 

102 ConfigClass = DatasetMetadataAnalysisConfig 

103 _DefaultName = "datasetMetadataAnalysis" 

104 

105 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

106 dataId = butlerQC.quantum.dataId 

107 inputs = butlerQC.get(inputRefs) 

108 plotInfo = self.parsePlotInfo(inputs, dataId) 

109 data = inputs["data"].get() 

110 

111 # Collect all the metrics that are configured to run. 

112 metadata = {} 

113 for name in self.config.atools.fieldNames: 

114 atool = getattr(self.config.atools, name) 

115 if hasattr(atool, "metrics"): 

116 for metric in atool.metrics: 

117 # Check if the metric uses base key prefixes. 

118 if atool.metricsPrefixedWithBaseKeys.get(metric): 

119 # Find all keys prefixed with the base key and collect 

120 # them in a dictionary. 

121 metadata[metric] = {k: v for k, v in data.metadata.items() if k.startswith(metric)} 

122 else: 

123 # Retrieve the metric directly if it's not prefixed. 

124 metadata[metric] = data.metadata.get(metric) 

125 # Check if the retrieved metadata is empty. 

126 if not metadata[metric] and self.config.raiseNoWorkFoundOnIncompleteMetadata: 

127 raise UpstreamFailureNoWorkFound( 

128 f"Metadata entry '{metric}' is empty for {inputRefs.data.datasetType.name}, " 

129 f"or it is not one of {data.metadata.getOrderedNames()}." 

130 ) 

131 if self.config.raiseNoWorkFoundOnEmptyMetadata and not any(v for v in metadata.values()): 

132 raise UpstreamFailureNoWorkFound( 

133 "All configured metadata entries are missing from " 

134 f"{inputRefs.data.datasetType.name}." 

135 ) 

136 

137 outputs = self.run(data={"metadata_metrics": metadata}, plotInfo=plotInfo) 

138 butlerQC.put(outputs, outputRefs) 

139 

140 

141class TaskMetadataAnalysisTask(AnalysisPipelineTask): 

142 ConfigClass = MetadataAnalysisConfig 

143 _DefaultName = "taskMetadataAnalysis" 

144 

145 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

146 dataId = butlerQC.quantum.dataId 

147 inputs = butlerQC.get(inputRefs) 

148 plotInfo = self.parsePlotInfo(inputs, dataId) 

149 metadata = inputs["data"].get().to_dict() 

150 taskName = inputRefs.data.datasetType.name 

151 taskName = taskName[: taskName.find("_")] 

152 if not metadata: 

153 raise UpstreamFailureNoWorkFound(f"No metadata entries for {taskName}.") 

154 if self.config.raiseNoWorkFoundOnEmptyMetadata or self.config.raiseNoWorkFoundOnIncompleteMetadata: 

155 self.validateMetrics(metadata, taskName) 

156 outputs = self.run(data=metadata, plotInfo=plotInfo) 

157 butlerQC.put(outputs, outputRefs) 

158 

159 def validateMetrics(self, metadata, taskName): 

160 """Raise NoWorkFound if there are insufficent metrics in the task 

161 metadata. 

162 

163 Parameters 

164 ---------- 

165 metadata : `dict` 

166 The task metadata converted to a dict. 

167 taskName : `str` 

168 The name of the task to extract metadata from 

169 

170 Raises 

171 ------ 

172 NoWorkFound 

173 If none of the metrics are in the metadata. 

174 """ 

175 for fieldName in self.config.atools.fieldNames: 

176 atool = getattr(self.config.atools, fieldName) 

177 subTaskNames = getattr(atool, "subTaskNames", None) or {} 

178 for key in atool.metrics.keys(): 

179 if key in metadata[taskName].keys(): 

180 if self.config.raiseNoWorkFoundOnEmptyMetadata: 

181 return 

182 elif subtaskName := subTaskNames.get(key): 

183 if f"{taskName}:{subtaskName}" not in metadata: 

184 raise UpstreamFailureNoWorkFound( 

185 f"Subtask {subtaskName!r} was not found in {taskName} metadata" 

186 ) 

187 if key in metadata[f"{taskName}:{subtaskName}"]: 

188 if self.config.raiseNoWorkFoundOnEmptyMetadata: 

189 return 

190 else: 

191 if self.config.raiseNoWorkFoundOnIncompleteMetadata: 

192 raise UpstreamFailureNoWorkFound( 

193 f"Metric {key!r} was not found in {taskName} metadata" 

194 ) 

195 if self.config.raiseNoWorkFoundOnEmptyMetadata: 

196 raise UpstreamFailureNoWorkFound( 

197 f"None of the specified metrics were found in the {taskName} metadata" 

198 ) 

199 

200 

201class AggregatedTaskMetadataAnalysisConnections( 

202 AnalysisBaseConnections, 

203 dimensions={}, 

204 defaultTemplates={"inputName": "", "outputName": "", "storageClass": "TaskMetadata"}, 

205): 

206 def __init__(self, *, config=None): 

207 """Allow for the dimensions of the input and output data and task 

208 to be changed from the defaults via the corresponding config parameter. 

209 This enables the task to work with any TaskMetadata. 

210 

211 Parameters 

212 ---------- 

213 config : `AggregatedTaskMetadataAnalysisConfig` 

214 Configuration for this task. 

215 """ 

216 super().__init__(config=config) 

217 

218 self.data = connectionTypes.Input( 

219 doc="Task metadata to aggregate across additional dimensions.", 

220 name=config.connections.inputName, 

221 storageClass=config.connections.storageClass, 

222 deferLoad=True, 

223 dimensions=frozenset(config.inputDataDimensions), 

224 multiple=True, 

225 ) 

226 

227 self.dimensions.update(frozenset(config.outputDimensions)) 

228 

229 

230class AggregatedTaskMetadataAnalysisConfig( 

231 AnalysisBaseConfig, 

232 pipelineConnections=AggregatedTaskMetadataAnalysisConnections, 

233): 

234 inputDataDimensions = ListField( 

235 default=["instrument", "visit", "detector"], 

236 dtype=str, 

237 doc="Dimensions of the input task metadata datasets.", 

238 ) 

239 outputDimensions = ListField( 

240 default=["instrument", "visit"], 

241 dtype=str, 

242 doc="Dimensions of the output metric bundle. Also this task's dimensions.", 

243 ) 

244 

245 

246class AggregatedTaskMetadataAnalysisTask(AnalysisPipelineTask): 

247 ConfigClass = AggregatedTaskMetadataAnalysisConfig 

248 _DefaultName = "aggregatedTaskMetadataAnalysis" 

249 

250 def _collectData(self, handles, taskName): 

251 """Collect metric vectors from a list of deferred dataset handles. 

252 

253 Parameters 

254 ---------- 

255 handles : `list` 

256 Deferred dataset handles whose ``.get().to_dict()`` returns the 

257 task metadata as a nested dict. 

258 taskName : `str` 

259 The name of the task, used as the top-level key in the metadata 

260 dict (subtask keys are of the form ``taskName:subTaskName``). 

261 

262 Returns 

263 ------- 

264 data : `dict` [`str`, `list` [`float`]] 

265 Mapping from metric name to list of per-input values. 

266 

267 Raises 

268 ------ 

269 lsst.pipe.base.UpstreamFailureNoWorkFound 

270 If no data could be collected from any input. 

271 """ 

272 data: dict[str, list[float]] = {} 

273 for i, handle in enumerate(handles): 

274 metadata = handle.get().to_dict() 

275 if not metadata: 

276 continue 

277 for atool in self.config.atools: 

278 if not hasattr(atool, "metrics"): 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true

279 continue 

280 subTaskNames = getattr(atool, "subTaskNames", None) or {} 

281 for metric in atool.metrics.keys(): 

282 if metric in subTaskNames: 

283 taskFullName = f"{taskName}:{subTaskNames[metric]}" 

284 else: 

285 taskFullName = taskName 

286 value = metadata.get(taskFullName, {}).get(metric) 

287 if value is not None: 

288 if metric not in data.keys(): 

289 data[metric] = [float("nan")] * len(handles) 

290 data[metric][i] = float(value) 

291 

292 if not data: 

293 raise UpstreamFailureNoWorkFound(f"No metadata entries found for {taskName}.") 

294 

295 return data 

296 

297 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

298 inputs = butlerQC.get(inputRefs) 

299 

300 # Collecting data from [taskName]_metedata datasetType. 

301 # Extract taskName from the datasetType name. 

302 taskName = inputRefs.data[0].datasetType.name 

303 taskName = taskName[: taskName.find("_")] 

304 

305 data = self._collectData(inputs["data"], taskName) 

306 outputs = self.run(data=data) 

307 butlerQC.put(outputs, outputRefs)