Coverage for python/lsst/analysis/tools/interfaces/_analysisTools.py: 22%

137 statements  

« prev     ^ index     » next       coverage.py v7.2.4, created at 2023-04-30 03:04 -0700

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

21 

22from __future__ import annotations 

23 

24__all__ = ("AnalysisTool",) 

25 

26from collections.abc import Mapping 

27from functools import wraps 

28from typing import Callable, Iterable, Protocol, runtime_checkable 

29 

30from lsst.pex.config import Field, ListField 

31from lsst.pex.config.configurableActions import ConfigurableActionField 

32from lsst.verify import Measurement 

33 

34from ._actions import AnalysisAction, JointAction, JointResults, NoPlot, PlotAction 

35from ._interfaces import KeyedData, KeyedDataSchema, KeyedResults, PlotTypes 

36from ._stages import BasePrep, BaseProcess, BaseProduce 

37 

38 

39@runtime_checkable 

40class _HasOutputNames(Protocol): 

41 def getOutputNames(self) -> Iterable[str]: 

42 ... 

43 

44 

45def _finalizeWrapper( 

46 f: Callable[[AnalysisTool], None], cls: type[AnalysisTool] 

47) -> Callable[[AnalysisTool], None]: 

48 """Wrap a classes finalize function to ensure the base classes special 

49 finalize method only fires after the most derived finalize method. 

50 

51 Parameters 

52 ---------- 

53 f : `Callable` 

54 Function that is being wrapped 

55 cls : `type` of `AnalysisTool` 

56 The class which is having its function wrapped 

57 

58 Returns 

59 ------- 

60 function : `Callable` 

61 The new function which wraps the old 

62 """ 

63 

64 @wraps(f) 

65 def wrapper(self: AnalysisTool) -> None: 

66 # call the wrapped finalize function 

67 f(self) 

68 # get the method resolution order for the self variable 

69 mro = self.__class__.mro() 

70 

71 # Find which class in the mro that last defines a finalize method 

72 # note that this is in the reverse order from the mro, because the 

73 # last class in an inheritance stack is the first in the mro (aka you 

74 # walk from the furthest child first. 

75 # 

76 # Also note that the most derived finalize method need not be the same 

77 # as the type of self, as that might inherit from a parent somewhere 

78 # between it and the furthest parent. 

79 mostDerived: type | None = None 

80 for klass in mro: 

81 # inspect the classes dictionary to see if it specifically defines 

82 # finalize. This is needed because normal lookup will go through 

83 # the mro, but this needs to be restricted to each class. 

84 if "finalize" in dir(klass): 

85 mostDerived = klass 

86 break 

87 

88 # Find what stage in the MRO walking process the recursive function 

89 # call is in. 

90 this = super(cls, self).__thisclass__ 

91 

92 # If the current place in the MRO walking is also the class that 

93 # defines the most derived instance of finalize, then call the base 

94 # classes private finalize that must be called after everything else. 

95 if mostDerived is not None and this == mostDerived: 

96 self._baseFinalize() 

97 

98 return wrapper 

99 

100 

101class AnalysisTool(AnalysisAction): 

102 r"""A tool which which calculates a single type of analysis on input data, 

103 though it may return more than one result. 

104 

105 Although `AnalysisTool`\ s are considered a single type of analysis, the 

106 classes themselves can be thought of as a container. `AnalysisTool`\ s 

107 are aggregations of `AnalysisAction`\ s to form prep, process, and 

108 produce stages. These stages allow better reuse of individual 

109 `AnalysisActions` and easier introspection in contexts such as a notebook 

110 or interpreter. 

111 

112 An `AnalysisTool` can be thought of an an individual configuration that 

113 specifies which `AnalysisAction` should run for each stage. 

114 

115 The stages themselves are also configurable, allowing control over various 

116 aspects of the individual `AnalysisAction`\ s. 

117 """ 

118 prep = ConfigurableActionField[AnalysisAction](doc="Action to run to prepare inputs", default=BasePrep) 

119 process = ConfigurableActionField[AnalysisAction]( 

120 doc="Action to process data into intended form", default=BaseProcess 

121 ) 

122 produce = ConfigurableActionField[AnalysisAction]( 

123 doc="Action to perform any finalization steps", default=BaseProduce 

124 ) 

125 metric_tags = ListField[str]( 

126 doc="List of tags which will be associated with metric measurement(s)", default=[] 

127 ) 

128 dataset_identifier = Field[str](doc="An identifier to be associated with output Metrics", optional=True) 

129 reference_package = Field[str]( 

130 doc="A package who's version, at the time of metric upload to a " 

131 "time series database, will be converted to a timestamp of when " 

132 "that version was produced", 

133 default="lsst_distrib", 

134 ) 

135 timestamp_version = Field[str]( 135 ↛ exitline 135 didn't jump to the function exit

136 doc="Which time stamp should be used as the reference timestamp for a " 

137 "metric in a time series database, valid values are; " 

138 "reference_package_timestamp, run_timestamp, current_timestamp, " 

139 "and dataset_timestamp", 

140 default="run_timestamp", 

141 check=lambda x: x 

142 in ("reference_package_timestamp", "run_timestamp", "current_timestamp", "dataset_timestamp"), 

143 ) 

144 

145 def __init_subclass__(cls: type[AnalysisTool], **kwargs): 

146 super().__init_subclass__(**kwargs) 

147 # Wrap all definitions of the finalize method in a special wrapper that 

148 # ensures that the bases classes private finalize is called last. 

149 if "finalize" in dir(cls): 149 ↛ exitline 149 didn't return from function '__init_subclass__', because the condition on line 149 was never false

150 cls.finalize = _finalizeWrapper(cls.finalize, cls) 

151 

152 parameterizedBand: bool | Field[bool] = True 

153 """Specifies if an `AnalysisTool` may parameterize a band within any field 

154 in any stage, or if the set of bands is already uniquely determined though 

155 configuration. I.e. can this `AnalysisTool` be automatically looped over to 

156 produce a result for multiple bands. 

157 """ 

158 

159 def __call__(self, data: KeyedData, **kwargs) -> KeyedResults: 

160 bands = kwargs.pop("bands", None) 

161 if "plotInfo" in kwargs and kwargs.get("plotInfo") is not None: 

162 kwargs["plotInfo"]["plotName"] = self.identity 

163 if not self.parameterizedBand or bands is None: 

164 if "band" not in kwargs: 

165 # Some tasks require a "band" key for naming. This shouldn't 

166 # affect the results. DM-35813 should make this unnecessary. 

167 kwargs["band"] = "analysisTools" 

168 return self._call_single(data, **kwargs) 

169 results: KeyedResults = {} 

170 for band in bands: 

171 kwargs["band"] = band 

172 subResult = self._call_single(data, **kwargs) 

173 for key, value in subResult.items(): 

174 match value: 

175 case PlotTypes(): 

176 results[f"{band}_{key}"] = value 

177 case Measurement(): 

178 results[key] = value 

179 return results 

180 

181 def _call_single(self, data: KeyedData, **kwargs) -> KeyedResults: 

182 # create a shallow copy of kwargs 

183 kwargs = dict(**kwargs) 

184 kwargs["metric_tags"] = list(self.metric_tags or ()) 

185 prepped: KeyedData = self.prep(data, **kwargs) # type: ignore 

186 processed: KeyedData = self.process(prepped, **kwargs) # type: ignore 

187 finalized: Mapping[str, PlotTypes] | PlotTypes | Mapping[ 

188 str, Measurement 

189 ] | Measurement | JointResults = self.produce( 

190 processed, **kwargs 

191 ) # type: ignore 

192 return self._process_single_results(finalized) 

193 

194 def _getPlotType(self) -> str: 

195 match self.produce: 

196 case PlotAction(): 

197 return type(self.produce).__name__ 

198 case JointAction(plot=NoPlot()): 

199 pass 

200 case JointAction(plot=plotter): 

201 return type(plotter).__name__ 

202 

203 return "" 

204 

205 def _process_single_results( 

206 self, 

207 results: Mapping[str, PlotTypes] | PlotTypes | Mapping[str, Measurement] | Measurement | JointResults, 

208 ) -> KeyedResults: 

209 accumulation = {} 

210 suffix = self._getPlotType() 

211 predicate = f"{self.identity}" if self.identity else "" 

212 match results: 

213 case Mapping(): 

214 for key, value in results.items(): 

215 match value: 

216 case PlotTypes(): 

217 iterable = (predicate, key, suffix) 

218 case Measurement(): 

219 iterable = (predicate, key) 

220 refKey = "_".join(x for x in iterable if x) 

221 accumulation[refKey] = value 

222 case PlotTypes(): 

223 refKey = "_".join(x for x in (predicate, suffix) if x) 

224 accumulation[refKey] = results 

225 case Measurement(): 

226 accumulation[f"{predicate}"] = results 

227 case JointResults(plot=plotResults, metric=metricResults): 

228 if plotResults is not None: 

229 subResult = self._process_single_results(plotResults) 

230 accumulation.update(subResult) 

231 if metricResults is not None: 

232 subResult = self._process_single_results(metricResults) 

233 accumulation.update(subResult) 

234 return accumulation 

235 

236 def getInputSchema(self) -> KeyedDataSchema: 

237 return self.prep.getInputSchema() 

238 

239 def populatePrepFromProcess(self): 

240 """Add additional inputs to the prep stage if supported. 

241 

242 If the configured prep action supports adding to it's input schema, 

243 attempt to add the required inputs schema from the process stage to the 

244 prep stage. 

245 

246 This method will be a no-op if the prep action does not support this 

247 feature. 

248 """ 

249 self.prep.addInputSchema(self.process.getInputSchema()) 

250 

251 def getOutputNames(self) -> Iterable[str]: 

252 """Return the names of the plots produced by this analysis tool. 

253 

254 If there is a `PlotAction` defined in the produce action, these names 

255 will either come from the `PlotAction` if it defines a 

256 ``getOutputNames`` method (likely if it returns a mapping of figures), 

257 or a default value is used and a single figure is assumed. 

258 

259 Returns 

260 ------- 

261 result : `tuple` of `str` 

262 Names for each plot produced by this action. 

263 """ 

264 match self.produce: 

265 case JointAction(plot=NoPlot()): 

266 return tuple() 

267 case _HasOutputNames(): 

268 outNames = tuple(self.produce.getOutputNames()) 

269 case _: 

270 raise ValueError(f"Unsupported Action type {type(self.produce)} for getting output names") 

271 

272 results = [] 

273 suffix = self._getPlotType() 

274 if self.parameterizedBand: 

275 prefix = "_".join(x for x in ("{band}", self.identity) if x) 

276 else: 

277 prefix = f"{self.identity}" if self.identity else "" 

278 

279 if outNames: 

280 for name in outNames: 

281 results.append("_".join(x for x in (prefix, name, suffix) if x)) 

282 else: 

283 results.append("_".join(x for x in (prefix, suffix) if x)) 

284 return results 

285 

286 def finalize(self) -> None: 

287 """Run any finalization code that depends on configuration being 

288 complete. 

289 """ 

290 pass 

291 

292 def _baseFinalize(self) -> None: 

293 self.populatePrepFromProcess() 

294 

295 def freeze(self): 

296 if not self.__dict__.get("_finalizeRun"): 

297 self.finalize() 

298 self.__dict__["_finalizeRun"] = True 

299 super().freeze() 

300 

301 

302# explicitly wrap the finalize of the base class 

303AnalysisTool.finalize = _finalizeWrapper(AnalysisTool.finalize, AnalysisTool)